mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-12 05:49:01 +00:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4412ba1adf | |||
| 2ad29b77f9 | |||
| 9307ab5e45 | |||
| 2f4e84b54b | |||
| c403673f4d | |||
| 5c4859743e | |||
| 0bf0052909 | |||
| 0e1455b947 | |||
| 63e2481b9d | |||
| ade7c5d007 | |||
| fc3d03f932 | |||
| 9de2b64acf | |||
| ef206635f6 | |||
| b8b8481fb4 | |||
| 71f3385842 | |||
| cf35720093 | |||
| 3ef150add9 | |||
| 626b532d2e | |||
| e5dfd74967 | |||
| cb8f02af4f | |||
| 996dd11af9 | |||
| 9a33837ffd | |||
| 7b24da0688 | |||
| 009d252651 | |||
| 3c7733e5b0 | |||
| 2fa8bd5227 | |||
| 37039278c4 | |||
| 3020e41b3a | |||
| 8887d5e3e1 | |||
| 0925547f8d | |||
| c6b769a476 | |||
| 3c49941693 | |||
| 1d79dad5c8 | |||
| 245adf204f | |||
| 08da93bd40 | |||
| 317b32edea | |||
| b24ab9b352 | |||
| b0482d438f | |||
| 8298125080 | |||
| 47492637b4 | |||
| 1c25d1a1bd | |||
| f24536313c | |||
| 2a56c7c666 | |||
| fd39c5303a | |||
| 123733e44d | |||
| 38d4e39818 | |||
| 70a321b418 | |||
| ed248c42cd | |||
| 40f24c7933 | |||
| ef4601d4a9 | |||
| 24341951cd | |||
| b6ba0fbf9b | |||
| 1064b88902 | |||
| b2eab9ec61 | |||
| 3d0d3eca47 | |||
| 55e5ae4335 | |||
| 3c456edbd8 | |||
| dc25a02ff2 | |||
| 5c200c2704 | |||
| 5149e7101e | |||
| 471a82b9f5 | |||
| 072ad5d629 | |||
| dc5fd90bca | |||
| 55f6347784 | |||
| 165c1b2444 | |||
| b3cc983965 | |||
| a14c332586 | |||
| aa91934f19 | |||
| aa8e6e2da6 | |||
| c4c9a16a09 | |||
| 293340c253 | |||
| 04bb545c25 | |||
| aa235c10ea | |||
| 45e4a11605 | |||
| 55b42dfecb | |||
| a620c39b50 | |||
| 8edb2d50d3 | |||
| 19093598a0 | |||
| 9091d576e1 | |||
| 7d9e4a811a | |||
| 1b03e61d2b | |||
| 0936f51a24 | |||
| c4c6d91456 | |||
| 1ba13eef45 | |||
| 8a67296673 | |||
| 8fe3353c5f | |||
| 61debcda11 | |||
| 615a47eafd | |||
| 4705055062 | |||
| 7426f72257 | |||
| 64ddf4acfa | |||
| d843008091 | |||
| 95cbd330b1 | |||
| cf7055ce60 |
@@ -28,11 +28,11 @@ jobs:
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN_CLA }}
|
||||
with:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
path-to-document: 'https://github.com/Gimanh/test-cla1/blob/main/CLA/CLA.md'
|
||||
path-to-document: 'https://github.com/Gimanh/taskview-community/blob/main/CLA/CLA.md'
|
||||
branch: 'cla-signatures'
|
||||
allowlist: bot*,dependabot[bot]
|
||||
custom-notsigned-prcomment: >
|
||||
Thank you for your contribution!
|
||||
Please read the CLA: $pathToCLADocument
|
||||
Please read the CLA: https://github.com/Gimanh/taskview-community/blob/main/CLA/CLA.md
|
||||
Then comment exactly:
|
||||
"I have read the CLA Document and I hereby sign the CLA"
|
||||
|
||||
+5
-1
@@ -35,4 +35,8 @@ tsconfig.app.tsbuildinfo
|
||||
customer-license
|
||||
!build-dockers.sh
|
||||
!build-docker-api.sh
|
||||
!build-docker-web.sh
|
||||
!build-docker-web.sh
|
||||
!entrypoint.sh
|
||||
!build-docker-migrations.sh
|
||||
*.private
|
||||
*.private*
|
||||
@@ -1,6 +1,6 @@
|
||||
# TaskView Source-Available License
|
||||
|
||||
Version 1.0 – Draft (tailored for TaskView)
|
||||
Version 1.0
|
||||
|
||||
---
|
||||
|
||||
@@ -113,9 +113,14 @@ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMA
|
||||
|
||||
For commercial licensing inquiries, contact:
|
||||
|
||||
**[licensing@taskview.tech](mailto:licensing@taskview.tech)**
|
||||
**[support@taskview.tech](mailto:support@taskview.tech)**
|
||||
|
||||
## 10. Termination
|
||||
|
||||
- Any violation of Sections 4 or 5 automatically terminates the rights granted under this License.
|
||||
- Rights may be reinstated if You cure the violation within 21 days and cease all prohibited use.
|
||||
|
||||
---
|
||||
|
||||
Copyright (c) 2026 Nikolai Giman
|
||||
All rights reserved.
|
||||
@@ -5,6 +5,12 @@
|
||||
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
|
||||
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
|
||||
|
||||
## Apps
|
||||
* [Docs](https://taskview.tech/docs/)
|
||||
* [Web](https://app.taskview.tech/)
|
||||
* [iOS](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
|
||||
* [Android](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
|
||||
|
||||
It is designed for teams and individuals who want:
|
||||
- full control over their data
|
||||
- transparent architecture
|
||||
@@ -127,14 +133,18 @@ docker-compose up
|
||||
Make sure the image versions match the version defined in the root package.json.
|
||||
|
||||
## Roadmap
|
||||
- Migrate api-server to BunJS
|
||||
|
||||
- Plugin / extension system
|
||||
- Migrate to NuxtUI or similar ui library
|
||||
- [X] Migrate to NuxtUI or similar ui library
|
||||
- Enterprise SSO and identity integrations
|
||||
- Redesign
|
||||
- [X] Redesign
|
||||
- Desktop version
|
||||
|
||||
|
||||
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
|
||||
|
||||
© TaskView
|
||||
---
|
||||
|
||||
TaskView is developed and maintained by Nikolai Giman.
|
||||
|
||||
Copyright © 2026 Nikolai Giman
|
||||
|
||||
+33
-1
@@ -11,6 +11,7 @@ DB_PORT=5432
|
||||
# Application
|
||||
APP_PORT=1401
|
||||
APP_URL=http://localhost:3000
|
||||
API_URL=http://localhost:1401
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SIGN=your_jwt_secret_here
|
||||
@@ -25,4 +26,35 @@ SMTP_USERNAME=your_email@example.com
|
||||
SMTP_PASSWORD=your_smtp_password_here
|
||||
SMTP_ENCRYPTION=ssl
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
|
||||
# Encryption (32-byte hex key for AES-256-GCM)
|
||||
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# GitHub Integration OAuth (separate from login OAuth)
|
||||
GITHUB_INTEGRATION_CLIENT_ID=
|
||||
GITHUB_INTEGRATION_CLIENT_SECRET=
|
||||
GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/github/callback
|
||||
# For GitHub Enterprise, override these:
|
||||
# GITHUB_BASE_URL=https://github.yourcompany.com
|
||||
# GITHUB_API_URL=https://github.yourcompany.com/api/v3
|
||||
|
||||
# GitLab Integration OAuth
|
||||
GITLAB_INTEGRATION_CLIENT_ID=
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
|
||||
# For self-hosted GitLab, override these:
|
||||
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
# Centrifugo (real-time notifications, optional)
|
||||
# CENTRIFUGO_API_URL=http://localhost:8000
|
||||
# CENTRIFUGO_API_KEY=your_centrifugo_api_key_here
|
||||
# CENTRIFUGO_TOKEN_SECRET=your_centrifugo_token_secret_here
|
||||
# Public port that clients use to connect to Centrifugo (exposed port, not internal docker port)
|
||||
# CENTRIFUGO_PUBLIC_PORT=8000
|
||||
+15
-10
@@ -5,14 +5,16 @@
|
||||
|
||||
VERSION=$1
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: ./build-docker-api.sh <version>"
|
||||
echo "Example: ./build-docker-api.sh 1.17.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node -v
|
||||
|
||||
# Build the server scripts for the docker container (obfuscated scripts)
|
||||
npm run vite-build-docker
|
||||
|
||||
# Create the taskview-server.js file for running the server
|
||||
# (taskview-server-api.jsc is bytecode file that will be created by bytenode when docker file is run)
|
||||
echo "const bytenode = require('bytenode'); require('./taskview-server-api.jsc');" > ./dist/taskview-server.js
|
||||
# Build the server scripts for the docker container
|
||||
npm run build:docker
|
||||
|
||||
# Copy the production package.json to the dist folder
|
||||
cp ./production.package.json ./dist/package.json
|
||||
@@ -20,14 +22,17 @@ cp ./production.package.json ./dist/package.json
|
||||
# Copy the production ecosystem.config.js to the dist folder
|
||||
cp ./production.ecosystem.config.js ./dist/ecosystem.config.js
|
||||
|
||||
# Build the migration scripts for the docker container (obfuscated scripts)
|
||||
npm run vite-build-migration
|
||||
mkdir ./dist-migration/taskview
|
||||
# Build the migration scripts for the docker container
|
||||
npm run build:migration
|
||||
mkdir -p ./dist-migration/taskview
|
||||
cp -R ./src/migrations/taskview/* ./dist-migration/taskview
|
||||
node ./commands/copy-migration-files.js
|
||||
|
||||
echo "Building docker image..."
|
||||
docker buildx build --platform=linux/amd64,linux/arm64 -t gimanhead/taskview-ce-api-server:$VERSION -t gimanhead/taskview-ce-api-server:latest . --load
|
||||
|
||||
cd postgresql
|
||||
bash ./build-docker-migrations.sh $VERSION
|
||||
cd ..
|
||||
cd ..
|
||||
|
||||
echo "Build complete! Image: gimanhead/taskview-ce-api-server:$VERSION"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
CENTRIFUGO_TOKEN_HMAC_SECRET_KEY=taskview-centrifugo-secret-change-me
|
||||
CENTRIFUGO_API_KEY=taskview-centrifugo-api-key-change-me
|
||||
CENTRIFUGO_ALLOWED_ORIGINS=*
|
||||
CENTRIFUGO_ADMIN=true
|
||||
CENTRIFUGO_ADMIN_PASSWORD=admin
|
||||
CENTRIFUGO_ADMIN_SECRET=admin-secret-change-me
|
||||
@@ -16,6 +16,7 @@ SMTP_PASSWORD="smtp-password"
|
||||
SMTP_ENCRYPTION="ssl"
|
||||
SMTP_FROM_NAME="TaskViewApiServer"
|
||||
SMTP_FROM_EMAIL="smtp"
|
||||
CORS_ALLOWED_ORIGINS="http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://localhost:8888,http://127.0.0.1:3000,http://127.0.0.1:8888"
|
||||
|
||||
# Constant value
|
||||
APP_URL="https://taskview.handscream.com"
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"allow_subscribe_for_client": true,
|
||||
"user_personal_channel_namespace": "personal",
|
||||
"namespaces": [
|
||||
{
|
||||
"name": "personal",
|
||||
"presence": false,
|
||||
"join_leave": false,
|
||||
"history_size": 10,
|
||||
"history_ttl": "300s",
|
||||
"force_recovery": true,
|
||||
"allow_subscribe_for_client": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
centrifugo:
|
||||
image: centrifugo/centrifugo:v5
|
||||
restart: unless-stopped
|
||||
command: centrifugo -c config.json
|
||||
env_file:
|
||||
- ./.env.centrifugo
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./centrifugo/config.json:/centrifugo/config.json
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
migration:
|
||||
image: gimanhead/taskview-ce-db-migration:1.18.0
|
||||
image: gimanhead/taskview-ce-db-migration:latest
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
@@ -23,12 +23,12 @@ services:
|
||||
- ./.env.taskview
|
||||
|
||||
taskview-ce-webapp:
|
||||
image: gimanhead/taskview-ce-webapp:1.18.0
|
||||
image: gimanhead/taskview-ce-webapp:latest
|
||||
restart: "no"
|
||||
ports:
|
||||
- "8888:80"
|
||||
taskview-api-server:
|
||||
image: gimanhead/taskview-ce-api-server:1.18.0
|
||||
image: gimanhead/taskview-ce-api-server:latest
|
||||
restart: "no"
|
||||
ports:
|
||||
- "1725:1401"
|
||||
|
||||
+3
-10
@@ -1,10 +1,6 @@
|
||||
FROM node:20-bullseye-slim
|
||||
FROM node:24.12.0-alpine
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
qemu-user-static \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
RUN npm install pm2 -g
|
||||
|
||||
@@ -12,10 +8,7 @@ WORKDIR /usr/src/app
|
||||
|
||||
COPY ./dist .
|
||||
|
||||
RUN npm install
|
||||
RUN npm run byte
|
||||
RUN rm ./taskview-server-api.js
|
||||
RUN rm ./taskview-server-docker.js
|
||||
RUN npm install --omit=dev
|
||||
|
||||
EXPOSE 1401
|
||||
|
||||
|
||||
+24
-14
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.17.0",
|
||||
"version": "1.32.0",
|
||||
"scripts": {
|
||||
"byte": "bytenode --compile ./dist/taskview-server-api.js",
|
||||
"vite-build": "vite build && javascript-obfuscator ./dist/taskview-server.js --output ./dist/taskview-server-api.js && npm run byte",
|
||||
"vite-build-docker": "vite build --config ./vite.config.docker.mts && javascript-obfuscator ./dist/taskview-server-docker.js --output ./dist/taskview-server-api.js",
|
||||
"vite-build-migration": "vite build --config ./vite.config-migration.mts",
|
||||
"start": "bun run --watch ./server.ts",
|
||||
"build": "bun build ./server.ts --outdir=dist --target=node",
|
||||
"build-all": "bun build ./server.ts --outdir=dist --target=node --minify && javascript-obfuscator ./dist/server.js --output ./dist/taskview.js",
|
||||
"start-prod": "NODE_ENV=production bun run --watch ./server.ts",
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
"build": "vite build",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
|
||||
"build:docker": "pnpm run build:packages && vite build --config ./vite.config.docker.mts",
|
||||
"build:migration": "vite build --config ./vite.config-migration.mts",
|
||||
"build:all": "pnpm run build:docker && pnpm run build:migration",
|
||||
"test": "vitest",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome lint .",
|
||||
@@ -21,18 +20,16 @@
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bun": "latest",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.3",
|
||||
"@types/passport-apple": "^2.0.3",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"aws-sdk": "^2.1691.0",
|
||||
"bytenode": "1.5.6",
|
||||
"javascript-obfuscator": "^4.1.1",
|
||||
"mock-aws-s3": "^4.0.2",
|
||||
"nexe": "^4.0.0-rc.6",
|
||||
"nock": "^13.5.5",
|
||||
"vite": "^5.4.9",
|
||||
"vite-plugin-node": "^4.0.0",
|
||||
@@ -45,23 +42,36 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-github2": "^1.2.9",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@vitejs/plugin-legacy": "^5.4.2",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"axios": "^1.7.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.44.4",
|
||||
"emailjs": "^4.0.3",
|
||||
"express": "4.21.0",
|
||||
"firebase-admin": "^12.7.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"pg-boss": "^12.14.0",
|
||||
"pino": "^9.4.0",
|
||||
"rotating-file-stream": "^3.2.5",
|
||||
"semver": "^7.6.3",
|
||||
"taskview-db-schemas": "workspace:^",
|
||||
"terser": "^5.36.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
VERSION=$1
|
||||
|
||||
docker buildx build --platform=linux/amd64,linux/arm64 -t gimanhead/taskview-ce-db-migration:$VERSION -t gimanhead/taskview-ce-db-migration:latest . --load
|
||||
@@ -1,5 +1,5 @@
|
||||
FROM node:20-slim
|
||||
RUN apt-get update && apt-get install -y postgresql-client && apt-get clean
|
||||
FROM node:24.12.0-alpine
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
# ENV DB_HOST=localhost
|
||||
# ENV DB_USER=tvdbuser
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# entrypoint.sh
|
||||
|
||||
echo "Waiting for the database service to be ready..."
|
||||
|
||||
# Wait for the database service to be ready
|
||||
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q'; do
|
||||
>&2 echo "Postgres is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
>&2 echo "Postgres is up - executing command"
|
||||
|
||||
# Check if tables exist in the 'tasks' schema using SQL query.
|
||||
# 'count(*)' will return 0 if there are no tables.
|
||||
# We read this result and make a decision.
|
||||
TABLE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'tasks';" | xargs)
|
||||
|
||||
if [ "$TABLE_COUNT" -eq 0 ]; then
|
||||
echo "No tables found in the 'tasks' schema. Running migration with --create."
|
||||
node /app-migration/taskview-db-migration.js --create
|
||||
else
|
||||
echo "Tables already exist in the 'tasks' schema ($TABLE_COUNT tables found). Running standard migration."
|
||||
node /app-migration/taskview-db-migration.js
|
||||
fi
|
||||
|
||||
# Check the exit code of the migration script
|
||||
if [ $? -ne 0 ]; then
|
||||
>&2 echo "Migration failed! Exiting with an error."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Migration successful!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.17.0",
|
||||
"scripts": {
|
||||
"byte": "bytenode --compile ./taskview-server-api.js",
|
||||
"start": "pm2-runtime start ecosystem.config.js --env production"
|
||||
},
|
||||
"dependencies": {
|
||||
"bytenode": "1.5.6"
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
+45
-5
@@ -4,8 +4,21 @@ import helmet from 'helmet';
|
||||
import { appUserMiddleware } from './middlewares/app-user-middleware';
|
||||
import errorHandler from './middlewares/error-handler';
|
||||
import routes from './routes';
|
||||
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
|
||||
|
||||
const VRS = '1.18.0';
|
||||
const allow = new Set([
|
||||
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
|
||||
// default allowed origins for official TaskView apps
|
||||
"https://app.taskview.tech",
|
||||
"https://taskview.handscream.com",
|
||||
"capacitor://taskview.handscream.com",
|
||||
"capacitor://app.taskview.tech",
|
||||
"https://appleid.apple.com"
|
||||
]),
|
||||
...(process.env.CORS_ALLOWED_ORIGINS?.split(',') || []),
|
||||
]);
|
||||
|
||||
export default class App {
|
||||
public app: express.Application;
|
||||
@@ -15,11 +28,21 @@ export default class App {
|
||||
this.app = express();
|
||||
this.port = port;
|
||||
|
||||
this.extendApp();
|
||||
|
||||
this.initializeMiddlewares();
|
||||
registerAllEventHandlers();
|
||||
this.initializeRoutes();
|
||||
this.app.use(errorHandler);
|
||||
this.app.use(passport.initialize());
|
||||
initPassportLogin();
|
||||
|
||||
this.extendMiddlewares();
|
||||
}
|
||||
|
||||
protected extendApp(): void { }
|
||||
protected extendMiddlewares(): void { }
|
||||
|
||||
private initializeMiddlewares() {
|
||||
//add tvJson method, clien need response format like {response: data}
|
||||
this.app.use((_req: Request, res: Response, next) => {
|
||||
@@ -29,10 +52,27 @@ export default class App {
|
||||
next();
|
||||
});
|
||||
|
||||
this.app.use(cookieParser());
|
||||
this.app.use(appUserMiddleware);
|
||||
this.app.use(cors());
|
||||
|
||||
this.app.use(cors({
|
||||
credentials: true,
|
||||
origin(origin, cb) {
|
||||
if (!origin) return cb(null, true);
|
||||
if (allow.has(origin)) return cb(null, true);
|
||||
return cb(new Error(`CORS blocked origin: ${origin}`), false);
|
||||
},
|
||||
}));
|
||||
|
||||
this.app.use(helmet());
|
||||
this.app.use(express.json());
|
||||
this.app.use(express.json({
|
||||
verify: (req: any, _res, buf) => {
|
||||
// Store raw body for webhook signature verification github and gitlab integrations
|
||||
if (req.url?.includes('/webhook/')) {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
}
|
||||
|
||||
@@ -43,9 +83,9 @@ export default class App {
|
||||
}
|
||||
|
||||
public listen() {
|
||||
return this.app.listen(this.port, '0.0.0.0', () => {
|
||||
return this.app.listen(this.port, '0.0.0.0', async () => {
|
||||
console.log(`Server is running on port ${this.port}`);
|
||||
console.log(`Server version is ${VRS}`);
|
||||
await startAllWorkers();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { KanbanManager } from '../tv-modules/kanban/KanbanManager';
|
||||
import { GoalListManager } from '../tv-modules/lists/GoalListManager';
|
||||
import { StartManager } from '../tv-modules/start/StartManager';
|
||||
import { TagsManager } from '../tv-modules/tags/TagsManager';
|
||||
import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsManager';
|
||||
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
|
||||
import { TasksManager } from '../tv-modules/tasks/TasksManager';
|
||||
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
|
||||
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
|
||||
@@ -26,6 +28,8 @@ export class AppUser {
|
||||
public readonly startManager: StartManager;
|
||||
public readonly kanbanManager: KanbanManager;
|
||||
public readonly graphManager: GraphManager;
|
||||
public readonly integrationsManager: IntegrationsManager;
|
||||
public readonly notificationsManager: NotificationsManager;
|
||||
|
||||
constructor(userData?: UserJwtPayload) {
|
||||
this.userData = userData;
|
||||
@@ -40,6 +44,8 @@ export class AppUser {
|
||||
this.startManager = new StartManager(this);
|
||||
this.kanbanManager = new KanbanManager(this);
|
||||
this.graphManager = new GraphManager(this);
|
||||
this.integrationsManager = new IntegrationsManager(this);
|
||||
this.notificationsManager = new NotificationsManager(this);
|
||||
}
|
||||
|
||||
getTokenId(): number | undefined {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
interface CentrifugoPublishPayload {
|
||||
channel: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CentrifugoClient {
|
||||
private readonly apiUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
const url = process.env.CENTRIFUGO_API_URL;
|
||||
const key = process.env.CENTRIFUGO_API_KEY;
|
||||
this.enabled = !!(url && key);
|
||||
this.apiUrl = url || '';
|
||||
this.apiKey = key || '';
|
||||
|
||||
if (!this.enabled) {
|
||||
$logger.warn('[Centrifugo] Not configured — real-time notifications disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async publish(channel: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
if (!this.enabled) return false;
|
||||
|
||||
try {
|
||||
const payload: CentrifugoPublishPayload = { channel, data };
|
||||
const response = await fetch(`${this.apiUrl}/api/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `apikey ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
$logger.error({ status: response.status }, '[Centrifugo] Publish failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
$logger.error({ err }, '[Centrifugo] Publish error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async publishToUser(userId: number, event: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
return this.publish(`personal:#${userId}`, { event, ...data });
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
static generateConnectionToken(userId: number): string {
|
||||
const secret = process.env.CENTRIFUGO_TOKEN_SECRET || process.env.JWT_SIGN || '';
|
||||
return jwt.sign(
|
||||
{ sub: String(userId) },
|
||||
secret,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: CentrifugoClient | null = null;
|
||||
|
||||
export function getCentrifugoClient(): CentrifugoClient {
|
||||
if (!_instance) {
|
||||
_instance = new CentrifugoClient();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Dispatcher {
|
||||
register(): void;
|
||||
registerWorkers(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export interface AppEvents {
|
||||
'task.created': { task: TasksSchemaTypeForSelect; initiatorId: number };
|
||||
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
|
||||
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
|
||||
}
|
||||
|
||||
type EventName = keyof AppEvents;
|
||||
type EventHandler<T extends EventName> = (data: AppEvents[T]) => void | Promise<void>;
|
||||
|
||||
class AppEventBus {
|
||||
private emitter = new EventEmitter();
|
||||
|
||||
on<T extends EventName>(event: T, handler: EventHandler<T>) {
|
||||
this.emitter.on(event, (data: AppEvents[T]) => {
|
||||
try {
|
||||
const result = handler(data);
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => {
|
||||
$logger.error(err, `EventBus handler error [${event}]`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
$logger.error(err, `EventBus handler error [${event}]`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emit<T extends EventName>(event: T, data: AppEvents[T]) {
|
||||
this.emitter.emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new AppEventBus();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { PgBoss } from 'pg-boss';
|
||||
import { $logger } from '../modules/logget';
|
||||
import { Database } from '../modules/db';
|
||||
|
||||
let boss: PgBoss | null = null;
|
||||
|
||||
export async function startJobQueue(): Promise<PgBoss> {
|
||||
boss = new PgBoss({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: +process.env.DB_PORT!,
|
||||
schema: 'pgboss',
|
||||
});
|
||||
|
||||
boss.on('error', (err) => {
|
||||
$logger.error(err, '[JobQueue] Error');
|
||||
});
|
||||
|
||||
await boss.start();
|
||||
$logger.info('[JobQueue] Started');
|
||||
|
||||
return boss;
|
||||
}
|
||||
|
||||
export function getJobQueue(): PgBoss {
|
||||
if (!boss) {
|
||||
throw new Error('JobQueue not started. Call startJobQueue() first.');
|
||||
}
|
||||
return boss;
|
||||
}
|
||||
|
||||
/** Cancel jobs by singletonKey — finds and deletes matching queued jobs */
|
||||
export async function cancelJobBySingletonKey(queueName: string, singletonKey: string): Promise<void> {
|
||||
if (!boss) return;
|
||||
const db = Database.getInstance();
|
||||
const result = await db.query<{ id: string }>(
|
||||
`SELECT id FROM pgboss.job WHERE name = $1 AND singleton_key = $2 AND state IN ('created', 'retry')`,
|
||||
[queueName, singletonKey],
|
||||
);
|
||||
|
||||
const count = result?.rows?.length ?? 0;
|
||||
if (count === 0) {
|
||||
$logger.info(`[JobQueue] Cancel: no jobs found for key="${singletonKey}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of result!.rows) {
|
||||
await boss.deleteJob(queueName, row.id);
|
||||
$logger.info(`[JobQueue] Deleted job id=${row.id} key="${singletonKey}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { startJobQueue } from './JobQueue';
|
||||
import type { Dispatcher } from './Dispatcher';
|
||||
import { NotificationDispatcher } from '../tv-modules/notifications/NotificationDispatcher';
|
||||
import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
new WebhooksDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
dispatchers.forEach((d) => d.register());
|
||||
}
|
||||
|
||||
export async function startAllWorkers() {
|
||||
await startJobQueue();
|
||||
for (const d of dispatchers) {
|
||||
await d.registerWorkers();
|
||||
}
|
||||
}
|
||||
@@ -282,5 +282,95 @@
|
||||
"description": [
|
||||
"Release 1.16.0"
|
||||
]
|
||||
},
|
||||
"22": {
|
||||
"version": "1.18.2",
|
||||
"name": "Release 1.18.2",
|
||||
"releaseDate": "20260108",
|
||||
"scripts": [
|
||||
"/1.18.2/0.1.18.2.sql",
|
||||
"/1.18.2/all-triggers.sql"
|
||||
],
|
||||
"description": [
|
||||
"Release 1.18.2"
|
||||
]
|
||||
},
|
||||
"23": {
|
||||
"version": "1.20.0",
|
||||
"name": "Release 1.20.0",
|
||||
"releaseDate": "20260221",
|
||||
"scripts": [
|
||||
"/1.20.0/all-triggers.sql"
|
||||
],
|
||||
"description": [
|
||||
"Validate tag and task belong to the same project on insert into tasks_to_tags"
|
||||
]
|
||||
},
|
||||
"24": {
|
||||
"version": "1.21.0",
|
||||
"name": "Release 1.21.0",
|
||||
"releaseDate": "20260302",
|
||||
"scripts": [
|
||||
"/1.21.0/0.1.21.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added integrations table for GitHub/GitLab integration",
|
||||
"Added integration_task_map table for issue-task mapping"
|
||||
]
|
||||
},
|
||||
"25": {
|
||||
"version": "1.22.0",
|
||||
"name": "Release 1.22.0",
|
||||
"releaseDate": "20260304",
|
||||
"scripts": [
|
||||
"/1.22.0/0.1.22.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added last_synced_at column to integrations for incremental sync"
|
||||
]
|
||||
},
|
||||
"26": {
|
||||
"version": "1.23.0",
|
||||
"name": "Release 1.23.0",
|
||||
"releaseDate": "20260314",
|
||||
"scripts": [
|
||||
"/1.23.0/0.1.23.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added source_url column to tasks for external issue links"
|
||||
]
|
||||
},
|
||||
"27": {
|
||||
"version": "1.24.0",
|
||||
"name": "Release 1.24.0",
|
||||
"releaseDate": "20260315",
|
||||
"scripts": [
|
||||
"/1.24.0/0.1.24.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added reminders, notifications, and push_subscriptions tables"
|
||||
]
|
||||
},
|
||||
"28": {
|
||||
"version": "1.25.0",
|
||||
"name": "Release 1.25.0",
|
||||
"releaseDate": "20260317",
|
||||
"scripts": [
|
||||
"/1.25.0/0.1.25.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added type column to notifications table"
|
||||
]
|
||||
},
|
||||
"29": {
|
||||
"version": "1.26.0",
|
||||
"name": "Release 1.26.0",
|
||||
"releaseDate": "20260320",
|
||||
"scripts": [
|
||||
"/1.26.0/0.1.26.0.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added notification_preferences table with JSONB settings"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
--Delete orphaned records from history.tasks_tasks table
|
||||
delete from history.tasks_tasks h
|
||||
where not exists (
|
||||
select 1
|
||||
from tasks.tasks t
|
||||
where t.id = h.task_id
|
||||
);
|
||||
|
||||
--Delete orphaned records from history.tasks_goal_lists table
|
||||
delete from history.tasks_goal_lists h
|
||||
where not exists (
|
||||
select 1
|
||||
from tasks.goal_lists gl
|
||||
where gl.id = h.goal_list_id
|
||||
);
|
||||
|
||||
--Delete orphaned records from history.tasks_goals table
|
||||
delete from history.tasks_goals h
|
||||
where not exists (
|
||||
select 1
|
||||
from tasks.goals g
|
||||
where g.id = h.goal_id
|
||||
);
|
||||
|
||||
alter table history.tasks_tasks
|
||||
add constraint fk_history_tasks_tasks_task_id
|
||||
foreign key (task_id)
|
||||
references tasks.tasks (id) on delete cascade;
|
||||
|
||||
alter table history.tasks_goal_lists
|
||||
add constraint fk_history_tasks_goal_lists_goal_list_id
|
||||
foreign key (goal_list_id)
|
||||
references tasks.goal_lists (id) on delete cascade;
|
||||
|
||||
alter table history.tasks_goals
|
||||
add constraint fk_history_tasks_goals_goal_id
|
||||
foreign key (goal_id)
|
||||
references tasks.goals (id) on delete cascade;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
--1.
|
||||
--Trigger set previous version
|
||||
create or replace function app.trigger_set_previous_version()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.prev_version = old.version;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_previous_version on app.version;
|
||||
create trigger trigger_set_previous_version
|
||||
before insert
|
||||
on app.version
|
||||
for each row
|
||||
execute procedure app.trigger_set_previous_version();
|
||||
|
||||
--2.
|
||||
--Trigger for adding owner for taskList from goal
|
||||
create or replace function tasks.trigger_set_owner_for_component()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.owner = (select owner from tasks.goals where id = new.goal_id);
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
|
||||
create trigger trigger_set_owner_for_component
|
||||
before insert
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.trigger_set_owner_for_component();
|
||||
|
||||
--3.
|
||||
--Trigger for updating date_complete for task
|
||||
create or replace function tasks.update_date_complete()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
if new.complete != old.complete
|
||||
then
|
||||
if new.complete = true
|
||||
then
|
||||
update tasks.tasks set date_complete = now() where id = old.id;
|
||||
else
|
||||
update tasks.tasks set date_complete = null where id = old.id;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists tr_update_date_complete on tasks.tasks;
|
||||
create trigger tr_update_date_complete
|
||||
after update
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.update_date_complete();
|
||||
|
||||
--4.
|
||||
-- Delete user from collaboration if not assigned to any goal
|
||||
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
count int;
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from collaboration.users_to_goals
|
||||
where user_id = old.user_id
|
||||
limit 1
|
||||
) then
|
||||
delete from collaboration.users where id = old.user_id;
|
||||
end if;
|
||||
|
||||
return old;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
|
||||
create trigger trigger_delete_user_if_not_assigned_to_goal
|
||||
after delete
|
||||
on collaboration.users_to_goals
|
||||
for each row
|
||||
execute function collaboration.delete_user_if_not_assigned_to_goal();
|
||||
|
||||
--5.
|
||||
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
|
||||
create or replace function tasks.check_task_graph_relation_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
from_goal int;
|
||||
to_goal int;
|
||||
begin
|
||||
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
|
||||
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
|
||||
|
||||
if from_goal is null or to_goal is null then
|
||||
raise exception 'Invalid task reference in relation';
|
||||
end if;
|
||||
|
||||
if from_goal <> to_goal then
|
||||
raise exception 'Relation goal_id must match both tasks'' goal_id';
|
||||
end if;
|
||||
|
||||
new.goal_id := from_goal;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
|
||||
create trigger trigger_task_relation_goal
|
||||
before insert or update on tasks.task_relations
|
||||
for each row execute function tasks.check_task_graph_relation_goal();
|
||||
|
||||
--6.
|
||||
--Trigger for logging changes in taskList to history table
|
||||
create or replace function tasks.log_changes_tasks_goal_lists()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
|
||||
create trigger trigger_log_changes_tasks_goal_lists
|
||||
before update or delete
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goal_lists();
|
||||
|
||||
--7.
|
||||
--Trigger for logging changes in goal to history table
|
||||
create or replace function tasks.log_changes_tasks_goals()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
|
||||
create trigger trigger_log_changes_tasks_goals
|
||||
before update or delete
|
||||
on tasks.goals
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goals();
|
||||
|
||||
--8.
|
||||
--Trigger for logging changes in task to history table
|
||||
create or replace function tasks.log_changes_tasks_tasks()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
|
||||
create trigger trigger_log_changes_tasks_tasks
|
||||
before update or delete
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_tasks();
|
||||
|
||||
--9.
|
||||
--Trigger for setting goal_id default for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
goal_id INT;
|
||||
BEGIN
|
||||
|
||||
SELECT gl.goal_id
|
||||
INTO goal_id
|
||||
FROM tasks.goal_lists gl
|
||||
WHERE gl.id = NEW.goal_list_id;
|
||||
|
||||
IF goal_id IS NOT NULL THEN
|
||||
NEW.goal_id := goal_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER before_insert_set_goal_id_for_task
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
|
||||
|
||||
|
||||
--10.
|
||||
--Trigger for adding default roles and permissions for goal
|
||||
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
editor_role_id INTEGER;
|
||||
executor_role_id INTEGER;
|
||||
BEGIN
|
||||
-- 1. Create role "editor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('editor', NEW.id)
|
||||
RETURNING id INTO editor_role_id;
|
||||
|
||||
-- 2. Create role "executor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('executor', NEW.id)
|
||||
RETURNING id INTO executor_role_id;
|
||||
|
||||
-- 3. Add permissions for role "editor"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT editor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'goal_can_edit',
|
||||
'goal_can_add_task_list',
|
||||
'goal_can_manage_users',
|
||||
'component_can_watch_content',
|
||||
'component_can_edit',
|
||||
'component_can_delete',
|
||||
'component_can_add_tasks',
|
||||
'task_can_edit_deadline',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_recovery_history',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_edit_priority',
|
||||
'task_can_delete',
|
||||
'task_can_watch_details',
|
||||
'task_can_assign_users',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'task_can_access_history',
|
||||
'task_can_edit_tags',
|
||||
'task_can_edit_description',
|
||||
'task_can_edit_status',
|
||||
'task_can_edit_note',
|
||||
'kanban_can_manage',
|
||||
'kanban_can_view',
|
||||
'graph_can_manage',
|
||||
'graph_can_view'
|
||||
);
|
||||
|
||||
-- 4. Add permissions for role "viewver"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT executor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'component_can_watch_content',
|
||||
'component_can_add_tasks',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_watch_details',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority'
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists add_roles_after_insert on tasks.goals;
|
||||
|
||||
CREATE TRIGGER add_roles_after_insert
|
||||
AFTER INSERT
|
||||
ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.add_roles_and_permissions();
|
||||
|
||||
|
||||
--11.
|
||||
--Trigger for adjusting start and end dates for task
|
||||
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
start_timestamp TIMESTAMPTZ;
|
||||
end_timestamp TIMESTAMPTZ;
|
||||
BEGIN
|
||||
-- If start_date is NULL, then start_time should be NULL
|
||||
IF NEW.start_date IS NULL THEN
|
||||
NEW.start_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If end_date is NULL, then end_time should be NULL
|
||||
IF NEW.end_date IS NULL THEN
|
||||
NEW.end_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If both dates are set
|
||||
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
|
||||
-- Adjust dates
|
||||
IF NEW.start_date > NEW.end_date THEN
|
||||
-- If start_date is greater than end_date, set end_date to start_date
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- end_time remains unchanged
|
||||
ELSIF NEW.end_date < NEW.start_date THEN
|
||||
-- If end_date is less than start_date, set start_date to end_date
|
||||
NEW.start_date := NEW.end_date;
|
||||
-- start_time remains unchanged
|
||||
END IF;
|
||||
|
||||
-- Prepare timestamps for comparison
|
||||
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
|
||||
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
|
||||
|
||||
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
|
||||
IF start_timestamp > end_timestamp THEN
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- Assign end_time only if start_time is not NULL
|
||||
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
|
||||
NEW.end_time := NEW.start_time;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
|
||||
CREATE TRIGGER adjust_dates_and_times_trigger
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
|
||||
|
||||
--12.
|
||||
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
|
||||
create or replace function tasks.add_self_to_collaboration()
|
||||
returns trigger as $$
|
||||
DECLARE
|
||||
owner_email TEXT;
|
||||
BEGIN
|
||||
|
||||
select email into owner_email
|
||||
from tv_auth.users
|
||||
where id = NEW.owner;
|
||||
|
||||
if owner_email is not null then
|
||||
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
|
||||
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
|
||||
end if;
|
||||
|
||||
return NEW;
|
||||
END;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
|
||||
|
||||
create trigger add_selt_to_collaboration_trg
|
||||
after insert on tasks.goals
|
||||
for each row
|
||||
execute function tasks.add_self_to_collaboration();
|
||||
|
||||
--13.
|
||||
--Trigger for adding default kanban columns for new goal
|
||||
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Add default columns for new goal
|
||||
INSERT INTO tasks.statuses (name, goal_id, view_order)
|
||||
VALUES
|
||||
('TODO', NEW.id, 1),
|
||||
('In Progress', NEW.id, 2),
|
||||
('Done', NEW.id, 3);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
|
||||
|
||||
CREATE TRIGGER kanban_add_default_columns_trg
|
||||
AFTER INSERT ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.kanban_add_default_columns();
|
||||
|
||||
--14.
|
||||
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if there is a record in tasks.statuses with the same goal_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tasks.statuses s
|
||||
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists enforce_task_status_goal on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER enforce_task_status_goal
|
||||
BEFORE INSERT OR UPDATE ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status_id IS NOT NULL)
|
||||
EXECUTE FUNCTION tasks.check_task_status_goal();
|
||||
|
||||
--15.
|
||||
--Trigger for setting default orders value for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_order_value()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.task_order IS NULL THEN
|
||||
NEW.task_order := NEW.id;
|
||||
END IF;
|
||||
IF NEW.kanban_order IS NULL THEN
|
||||
NEW.kanban_order := NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_order_trigger on tasks.tasks;
|
||||
CREATE TRIGGER set_order_trigger
|
||||
BEFORE INSERT ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_order_value();
|
||||
|
||||
--16.
|
||||
--Trigger for setting default view order for new status
|
||||
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_view_order INT;
|
||||
BEGIN
|
||||
-- Determine the next view_order for the given goal_id
|
||||
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
|
||||
FROM tasks.statuses
|
||||
WHERE goal_id = NEW.goal_id;
|
||||
|
||||
-- Assign the calculated value to the view_order field
|
||||
NEW.view_order := new_view_order;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_default_status_view_order on tasks.statuses;
|
||||
|
||||
CREATE TRIGGER set_default_status_view_order
|
||||
BEFORE INSERT ON tasks.statuses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.status_set_default_view_order();
|
||||
|
||||
--17.
|
||||
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks.tasks tt
|
||||
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
|
||||
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
|
||||
) INTO user_exists;
|
||||
|
||||
IF NOT user_exists THEN
|
||||
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
|
||||
|
||||
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
|
||||
BEFORE INSERT ON tasks_auth.task_assignee
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
|
||||
|
||||
--18.
|
||||
--Trigger for adding owner for task, extend owner from goal or taskList
|
||||
|
||||
--delete old function with wrong name
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
drop function if exists tasks.trigger_set_owner_for_task();
|
||||
|
||||
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$body$
|
||||
BEGIN
|
||||
NEW.owner := COALESCE(
|
||||
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
|
||||
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
|
||||
);
|
||||
|
||||
IF NEW.owner IS NULL THEN
|
||||
RAISE EXCEPTION 'Can not insert task without owner';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$body$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
create trigger trigger_set_owner_for_task
|
||||
before insert
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.fn_set_owner_for_task();
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
--1.
|
||||
--Trigger set previous version
|
||||
create or replace function app.trigger_set_previous_version()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.prev_version = old.version;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_previous_version on app.version;
|
||||
create trigger trigger_set_previous_version
|
||||
before insert
|
||||
on app.version
|
||||
for each row
|
||||
execute procedure app.trigger_set_previous_version();
|
||||
|
||||
--2.
|
||||
--Trigger for adding owner for taskList from goal
|
||||
create or replace function tasks.trigger_set_owner_for_component()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.owner = (select owner from tasks.goals where id = new.goal_id);
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
|
||||
create trigger trigger_set_owner_for_component
|
||||
before insert
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.trigger_set_owner_for_component();
|
||||
|
||||
--3.
|
||||
--Trigger for updating date_complete for task
|
||||
create or replace function tasks.update_date_complete()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
if new.complete != old.complete
|
||||
then
|
||||
if new.complete = true
|
||||
then
|
||||
update tasks.tasks set date_complete = now() where id = old.id;
|
||||
else
|
||||
update tasks.tasks set date_complete = null where id = old.id;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists tr_update_date_complete on tasks.tasks;
|
||||
create trigger tr_update_date_complete
|
||||
after update
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.update_date_complete();
|
||||
|
||||
--4.
|
||||
-- Delete user from collaboration if not assigned to any goal
|
||||
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
count int;
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from collaboration.users_to_goals
|
||||
where user_id = old.user_id
|
||||
limit 1
|
||||
) then
|
||||
delete from collaboration.users where id = old.user_id;
|
||||
end if;
|
||||
|
||||
return old;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
|
||||
create trigger trigger_delete_user_if_not_assigned_to_goal
|
||||
after delete
|
||||
on collaboration.users_to_goals
|
||||
for each row
|
||||
execute function collaboration.delete_user_if_not_assigned_to_goal();
|
||||
|
||||
--5.
|
||||
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
|
||||
create or replace function tasks.check_task_graph_relation_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
from_goal int;
|
||||
to_goal int;
|
||||
begin
|
||||
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
|
||||
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
|
||||
|
||||
if from_goal is null or to_goal is null then
|
||||
raise exception 'Invalid task reference in relation';
|
||||
end if;
|
||||
|
||||
if from_goal <> to_goal then
|
||||
raise exception 'Relation goal_id must match both tasks'' goal_id';
|
||||
end if;
|
||||
|
||||
new.goal_id := from_goal;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
|
||||
create trigger trigger_task_relation_goal
|
||||
before insert or update on tasks.task_relations
|
||||
for each row execute function tasks.check_task_graph_relation_goal();
|
||||
|
||||
--6.
|
||||
--Trigger for logging changes in taskList to history table
|
||||
create or replace function tasks.log_changes_tasks_goal_lists()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
|
||||
create trigger trigger_log_changes_tasks_goal_lists
|
||||
before update or delete
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goal_lists();
|
||||
|
||||
--7.
|
||||
--Trigger for logging changes in goal to history table
|
||||
create or replace function tasks.log_changes_tasks_goals()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
|
||||
create trigger trigger_log_changes_tasks_goals
|
||||
before update or delete
|
||||
on tasks.goals
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goals();
|
||||
|
||||
--8.
|
||||
--Trigger for logging changes in task to history table
|
||||
create or replace function tasks.log_changes_tasks_tasks()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
|
||||
create trigger trigger_log_changes_tasks_tasks
|
||||
before update or delete
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_tasks();
|
||||
|
||||
--9.
|
||||
--Trigger for setting goal_id default for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
goal_id INT;
|
||||
BEGIN
|
||||
|
||||
SELECT gl.goal_id
|
||||
INTO goal_id
|
||||
FROM tasks.goal_lists gl
|
||||
WHERE gl.id = NEW.goal_list_id;
|
||||
|
||||
IF goal_id IS NOT NULL THEN
|
||||
NEW.goal_id := goal_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER before_insert_set_goal_id_for_task
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
|
||||
|
||||
|
||||
--10.
|
||||
--Trigger for adding default roles and permissions for goal
|
||||
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
editor_role_id INTEGER;
|
||||
executor_role_id INTEGER;
|
||||
BEGIN
|
||||
-- 1. Create role "editor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('editor', NEW.id)
|
||||
RETURNING id INTO editor_role_id;
|
||||
|
||||
-- 2. Create role "executor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('executor', NEW.id)
|
||||
RETURNING id INTO executor_role_id;
|
||||
|
||||
-- 3. Add permissions for role "editor"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT editor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'goal_can_edit',
|
||||
'goal_can_add_task_list',
|
||||
'goal_can_manage_users',
|
||||
'component_can_watch_content',
|
||||
'component_can_edit',
|
||||
'component_can_delete',
|
||||
'component_can_add_tasks',
|
||||
'task_can_edit_deadline',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_recovery_history',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_edit_priority',
|
||||
'task_can_delete',
|
||||
'task_can_watch_details',
|
||||
'task_can_assign_users',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'task_can_access_history',
|
||||
'task_can_edit_tags',
|
||||
'task_can_edit_description',
|
||||
'task_can_edit_status',
|
||||
'task_can_edit_note',
|
||||
'kanban_can_manage',
|
||||
'kanban_can_view',
|
||||
'graph_can_manage',
|
||||
'graph_can_view'
|
||||
);
|
||||
|
||||
-- 4. Add permissions for role "viewver"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT executor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'component_can_watch_content',
|
||||
'component_can_add_tasks',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_watch_details',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority'
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists add_roles_after_insert on tasks.goals;
|
||||
|
||||
CREATE TRIGGER add_roles_after_insert
|
||||
AFTER INSERT
|
||||
ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.add_roles_and_permissions();
|
||||
|
||||
|
||||
--11.
|
||||
--Trigger for adjusting start and end dates for task
|
||||
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
start_timestamp TIMESTAMPTZ;
|
||||
end_timestamp TIMESTAMPTZ;
|
||||
BEGIN
|
||||
-- If start_date is NULL, then start_time should be NULL
|
||||
IF NEW.start_date IS NULL THEN
|
||||
NEW.start_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If end_date is NULL, then end_time should be NULL
|
||||
IF NEW.end_date IS NULL THEN
|
||||
NEW.end_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If both dates are set
|
||||
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
|
||||
-- Adjust dates
|
||||
IF NEW.start_date > NEW.end_date THEN
|
||||
-- If start_date is greater than end_date, set end_date to start_date
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- end_time remains unchanged
|
||||
ELSIF NEW.end_date < NEW.start_date THEN
|
||||
-- If end_date is less than start_date, set start_date to end_date
|
||||
NEW.start_date := NEW.end_date;
|
||||
-- start_time remains unchanged
|
||||
END IF;
|
||||
|
||||
-- Prepare timestamps for comparison
|
||||
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
|
||||
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
|
||||
|
||||
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
|
||||
IF start_timestamp > end_timestamp THEN
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- Assign end_time only if start_time is not NULL
|
||||
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
|
||||
NEW.end_time := NEW.start_time;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
|
||||
CREATE TRIGGER adjust_dates_and_times_trigger
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
|
||||
|
||||
--12.
|
||||
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
|
||||
create or replace function tasks.add_self_to_collaboration()
|
||||
returns trigger as $$
|
||||
DECLARE
|
||||
owner_email TEXT;
|
||||
BEGIN
|
||||
|
||||
select email into owner_email
|
||||
from tv_auth.users
|
||||
where id = NEW.owner;
|
||||
|
||||
if owner_email is not null then
|
||||
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
|
||||
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
|
||||
end if;
|
||||
|
||||
return NEW;
|
||||
END;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
|
||||
|
||||
create trigger add_selt_to_collaboration_trg
|
||||
after insert on tasks.goals
|
||||
for each row
|
||||
execute function tasks.add_self_to_collaboration();
|
||||
|
||||
--13.
|
||||
--Trigger for adding default kanban columns for new goal
|
||||
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Add default columns for new goal
|
||||
INSERT INTO tasks.statuses (name, goal_id, view_order)
|
||||
VALUES
|
||||
('TODO', NEW.id, 1),
|
||||
('In Progress', NEW.id, 2),
|
||||
('Done', NEW.id, 3);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
|
||||
|
||||
CREATE TRIGGER kanban_add_default_columns_trg
|
||||
AFTER INSERT ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.kanban_add_default_columns();
|
||||
|
||||
--14.
|
||||
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if there is a record in tasks.statuses with the same goal_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tasks.statuses s
|
||||
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists enforce_task_status_goal on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER enforce_task_status_goal
|
||||
BEFORE INSERT OR UPDATE ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status_id IS NOT NULL)
|
||||
EXECUTE FUNCTION tasks.check_task_status_goal();
|
||||
|
||||
--15.
|
||||
--Trigger for setting default orders value for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_order_value()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.task_order IS NULL THEN
|
||||
NEW.task_order := NEW.id;
|
||||
END IF;
|
||||
IF NEW.kanban_order IS NULL THEN
|
||||
NEW.kanban_order := NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_order_trigger on tasks.tasks;
|
||||
CREATE TRIGGER set_order_trigger
|
||||
BEFORE INSERT ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_order_value();
|
||||
|
||||
--16.
|
||||
--Trigger for setting default view order for new status
|
||||
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_view_order INT;
|
||||
BEGIN
|
||||
-- Determine the next view_order for the given goal_id
|
||||
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
|
||||
FROM tasks.statuses
|
||||
WHERE goal_id = NEW.goal_id;
|
||||
|
||||
-- Assign the calculated value to the view_order field
|
||||
NEW.view_order := new_view_order;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_default_status_view_order on tasks.statuses;
|
||||
|
||||
CREATE TRIGGER set_default_status_view_order
|
||||
BEFORE INSERT ON tasks.statuses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.status_set_default_view_order();
|
||||
|
||||
--17.
|
||||
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks.tasks tt
|
||||
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
|
||||
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
|
||||
) INTO user_exists;
|
||||
|
||||
IF NOT user_exists THEN
|
||||
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
|
||||
|
||||
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
|
||||
BEFORE INSERT ON tasks_auth.task_assignee
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
|
||||
|
||||
--18.
|
||||
--Trigger for adding owner for task, extend owner from goal or taskList
|
||||
|
||||
--delete old function with wrong name
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
drop function if exists tasks.trigger_set_owner_for_task();
|
||||
|
||||
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$body$
|
||||
BEGIN
|
||||
NEW.owner := COALESCE(
|
||||
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
|
||||
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
|
||||
);
|
||||
|
||||
IF NEW.owner IS NULL THEN
|
||||
RAISE EXCEPTION 'Can not insert task without owner';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$body$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
create trigger trigger_set_owner_for_task
|
||||
before insert
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.fn_set_owner_for_task();
|
||||
|
||||
--19.
|
||||
--Trigger for validating that tag and task belong to the same project (goal_id)
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
drop function if exists tasks.check_tag_task_same_goal();
|
||||
|
||||
create or replace function tasks.check_tag_task_same_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
v_tag_goal_id integer;
|
||||
v_task_goal_id integer;
|
||||
begin
|
||||
select goal_id into v_tag_goal_id from tasks.tags where id = new.tag_id;
|
||||
select goal_id into v_task_goal_id from tasks.tasks where id = new.task_id;
|
||||
|
||||
if v_tag_goal_id is null or v_tag_goal_id != v_task_goal_id then
|
||||
raise exception 'Tag (id=%) and task (id=%) belong to different projects', new.tag_id, new.task_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
create trigger trigger_check_tag_task_same_goal
|
||||
before insert on tasks.tasks_to_tags
|
||||
for each row
|
||||
execute function tasks.check_tag_task_same_goal();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.integrations (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
provider VARCHAR(20) NOT NULL CHECK (provider IN ('github', 'gitlab')),
|
||||
access_token_encrypted TEXT,
|
||||
refresh_token_encrypted TEXT,
|
||||
repo_external_id VARCHAR(255),
|
||||
repo_full_name VARCHAR(255),
|
||||
project_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
webhook_id VARCHAR(255),
|
||||
webhook_secret_encrypted TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.integration_task_map (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
integration_id INTEGER NOT NULL REFERENCES tasks.integrations(id) ON DELETE CASCADE,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
|
||||
issue_number INTEGER NOT NULL,
|
||||
issue_state VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
synced_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(integration_id, issue_number)
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
ALTER TABLE tasks.integrations ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMP;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'integrations_can_manage',
|
||||
'User can manage integrations (connect, disconnect, sync)',
|
||||
2,
|
||||
'{
|
||||
"en": "Manage integrations. User can connect, disconnect, configure and sync integrations",
|
||||
"ru": "Управление интеграциями. Пользователь может подключать, отключать, настраивать и синхронизировать интеграции"
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'integrations_can_view',
|
||||
'User can view integrations list',
|
||||
2,
|
||||
'{
|
||||
"en": "View integrations. User can view the list of connected integrations",
|
||||
"ru": "Просмотр интеграций. Пользователь может просматривать список подключённых интеграций"
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.tasks
|
||||
ADD COLUMN IF NOT EXISTS source_url VARCHAR(500);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Notifications
|
||||
CREATE TABLE IF NOT EXISTS tasks.notifications (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body VARCHAR(1000),
|
||||
read BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||
ON tasks.notifications (user_id, created_at DESC) WHERE NOT read;
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE tasks.notifications
|
||||
ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'deadline';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.device_tokens (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(500) NOT NULL,
|
||||
platform VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_device_token UNIQUE (user_id, token)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Convert TIMETZ columns to TIME (without timezone)
|
||||
-- Existing values are converted to UTC automatically by "AT TIME ZONE 'UTC'"
|
||||
ALTER TABLE tasks.tasks
|
||||
ALTER COLUMN start_time TYPE TIME USING start_time AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN end_time TYPE TIME USING end_time AT TIME ZONE 'UTC';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.device_tokens
|
||||
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC';
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
settings JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.webhooks (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
secret_encrypted VARCHAR NOT NULL,
|
||||
events VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
webhook_id INTEGER NOT NULL REFERENCES tasks.webhooks(id) ON DELETE CASCADE,
|
||||
event VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
response_code INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_goal_id ON tasks.webhooks(goal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON tasks.webhook_deliveries(webhook_id);
|
||||
@@ -3,6 +3,9 @@ import CollaborationRoutes from '../tv-modules/collaboration/CollaborationRoutes
|
||||
import CollaborationRolesRoutes from '../tv-modules/collaboration-roles/CollaborationRolesRoutes';
|
||||
import GoalsRoutes from '../tv-modules/goals/GoalsRoutes';
|
||||
import GraphRoutes from '../tv-modules/graph/GraphRoutes';
|
||||
import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes';
|
||||
import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes';
|
||||
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
|
||||
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
|
||||
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
|
||||
import StartRoutes from '../tv-modules/start/StartRoutes';
|
||||
@@ -23,6 +26,9 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/about': StartRoutes,
|
||||
'/module/kanban': KanbanRoutes,
|
||||
'/module/graph': GraphRoutes,
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -16,12 +16,15 @@ import {
|
||||
import { generateString, isEmail, time } from '../../utils/helpers';
|
||||
import EnEmailTemplate from './mail/confirm-email-en';
|
||||
import RuEmailTemplate from './mail/confirm-email-ru';
|
||||
import type { ExternalAuthUser } from './strategies/external-auth.types';
|
||||
|
||||
export default class AuthController {
|
||||
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
|
||||
private readonly jwtExp: string = process.env.ACCESS_LIFE_TIME!;
|
||||
private readonly jwtRefreshExp: string = process.env.REFRESH_LIFE_TIME!;
|
||||
|
||||
private readonly refreshTokenCookieName: string = 'taskview-refresh';
|
||||
|
||||
comparePasswords(pwd: string, hash: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
//Prev version was written in PHP need to replace
|
||||
@@ -91,6 +94,9 @@ export default class AuthController {
|
||||
return this.makeidLogin(16);
|
||||
}
|
||||
|
||||
generateLoginCode() {
|
||||
return `${this.makeidLogin(12)}:${Date.now()}`.toLocaleLowerCase();
|
||||
}
|
||||
/**
|
||||
* Register user by email and send login code to the email
|
||||
* @param req
|
||||
@@ -110,7 +116,7 @@ export default class AuthController {
|
||||
|
||||
const { email } = data.data;
|
||||
|
||||
const code = `${this.makeidLogin(12)}:${Date.now()}`.toLocaleLowerCase();
|
||||
const code = this.generateLoginCode();
|
||||
|
||||
$logger.info(data.data, `[AuthController:sendLoginCode] we got data for send login code`);
|
||||
|
||||
@@ -175,6 +181,86 @@ export default class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
loginByProvider = async (req: Request, res: Response) => {
|
||||
const user = req.user as ExternalAuthUser;
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).send('User not found');
|
||||
}
|
||||
|
||||
let userData = await req.appUser.authManager.repository.getUserByLogin(
|
||||
user.email,
|
||||
isEmail(user.email)
|
||||
);
|
||||
|
||||
if (!userData) {
|
||||
const password = this.makeidLogin(7);
|
||||
const login = this.makeidLogin(7);
|
||||
|
||||
const id = await req.appUser.authManager.repository.registerUserInDb({
|
||||
login,
|
||||
email: user.email,
|
||||
password: hashSync(password, 10),
|
||||
block: 0,
|
||||
confirmEmailCode: '',
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
$logger.error(`Can not register user ${user.email} & login ${login}`);
|
||||
return res.status(500).send(`Can not register user ${user.email} & login ${login}`);
|
||||
}
|
||||
|
||||
userData = await req.appUser.authManager.repository.getUserByLogin(
|
||||
user.email,
|
||||
isEmail(user.email)
|
||||
);
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
$logger.error(`Can not find user ${user.email} after registration`);
|
||||
return res.status(500).send(`Can not find user ${user.email} after registration`);
|
||||
}
|
||||
|
||||
const code = this.generateLoginCode();
|
||||
|
||||
const result = await req.appUser.authManager.repository.updateLoginCode(code, userData.email);
|
||||
|
||||
if (!result) {
|
||||
$logger.error(`Can not update login code for user ${userData.email}`);
|
||||
return res.status(500).send(`Can not update login code for user`);
|
||||
}
|
||||
|
||||
const authData = {
|
||||
code: code.split(':')[0],
|
||||
email: userData.email,
|
||||
};
|
||||
|
||||
const encodedAuthData = encodeURIComponent(JSON.stringify(authData));
|
||||
|
||||
try {
|
||||
const platformData = JSON.parse(req.query.state as string);
|
||||
|
||||
if (platformData.platform === "mobile") {
|
||||
return res.redirect(
|
||||
`taskview://login?tokens=${encodedAuthData}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
$logger.info(`Can not parse platform data from state: ${req.query.state}`);
|
||||
}
|
||||
|
||||
return res.redirect(`${process.env.APP_URL}/login?tokens=${encodedAuthData}`);
|
||||
}
|
||||
|
||||
setRefreshToken = async (res: Response, refreshToken: string) => {
|
||||
res.cookie(this.refreshTokenCookieName, refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "none",
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
loginByCode = async (req: Request, res: Response) => {
|
||||
const schema = z.object({
|
||||
email: z.string().email().toLowerCase(),
|
||||
@@ -202,7 +288,12 @@ export default class AuthController {
|
||||
}
|
||||
|
||||
if (tokenFromDb[0] !== data.data.code) {
|
||||
return res.status(400).end();
|
||||
return res.status(400).send({ message: 'Invalid code' });
|
||||
}
|
||||
|
||||
if (tokenFromDb[1] && Date.now() - +tokenFromDb[1] > 60 * 1000) {
|
||||
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
|
||||
return res.status(400).send({ message: 'Code expired, get new code' });
|
||||
}
|
||||
|
||||
const tokenRowId = await req.appUser.authManager.jwtStorage.initTokenRecord(userData.id);
|
||||
@@ -225,11 +316,9 @@ export default class AuthController {
|
||||
$logger.error(`Can not update tokens in JWT Storage for user ${userData.id} and rowId ${tokenRowId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
|
||||
} catch (_err: unknown) {
|
||||
$logger.error(`Can not update updateLoginCode after login`);
|
||||
}
|
||||
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
|
||||
|
||||
await this.setRefreshToken(res, tokens.refresh);
|
||||
|
||||
return res.json(tokens);
|
||||
};
|
||||
@@ -276,6 +365,8 @@ export default class AuthController {
|
||||
$logger.error(`Can not update tokens in JWT Storage for user ${userData.id} and rowId ${tokenRowId}`);
|
||||
}
|
||||
|
||||
await this.setRefreshToken(res, tokens.refresh);
|
||||
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
@@ -325,7 +416,7 @@ export default class AuthController {
|
||||
emailTemplate = EnEmailTemplate;
|
||||
}
|
||||
|
||||
const confirmUrl = `https://apitaskview.handscream.com/module/auth/confirm/email/${confirmEmailCode}/login/${login}`;
|
||||
const confirmUrl = `https://${process.env.APP_URL}/module/auth/confirm/email/${confirmEmailCode}/login/${login}`;
|
||||
|
||||
if (emailTemplate) {
|
||||
confirmEmailBody = emailTemplate.replace('{link}', confirmUrl);
|
||||
@@ -473,37 +564,46 @@ export default class AuthController {
|
||||
};
|
||||
|
||||
logout = async (req: Request, res: Response) => {
|
||||
if (!req.headers['authorization']) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
this.setRefreshToken(res, '');
|
||||
|
||||
const result = req.headers['authorization'].match(/Bearer\s(\S+)/);
|
||||
const result = req.headers['authorization']?.match(/Bearer\s(\S+)/);
|
||||
|
||||
if (!result) {
|
||||
return res.status(400).end();
|
||||
return res.status(401).send({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const payload = decode(result['1']) as UserJwtPayload;
|
||||
if (!payload) {
|
||||
return res.status(400).end();
|
||||
const tokenId = req.appUser.getTokenId();
|
||||
|
||||
if (!tokenId) {
|
||||
return res.status(401).send({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const deleteResult = await req.appUser.authManager.jwtStorage.deleteTokens(payload.userData.id, result['1']);
|
||||
const deleteResult = await req.appUser.authManager.jwtStorage.deleteTokens(tokenId, result['1']);
|
||||
|
||||
if (!deleteResult) {
|
||||
return res.status(500).end();
|
||||
return res.status(500).send({ message: 'Failed to revoke token' });
|
||||
}
|
||||
|
||||
return res.send();
|
||||
return res.status(204).end();
|
||||
};
|
||||
|
||||
refreshTokens = async (req: Request, res: Response) => {
|
||||
const refreshData = RefreshTokenSchema.safeParse(req.body);
|
||||
if (!refreshData.success) {
|
||||
return res.status(400).end();
|
||||
let refreshToken = req.cookies[this.refreshTokenCookieName];
|
||||
|
||||
if (!refreshToken) {
|
||||
const refreshData = RefreshTokenSchema.safeParse(req.body);
|
||||
|
||||
if (!refreshData.success) {
|
||||
return res.status(400).send({ message: 'Invalid refresh token' });
|
||||
}
|
||||
|
||||
refreshToken = refreshData.data.refreshToken;
|
||||
$logger.info(`Refresh token found in body`);
|
||||
} else {
|
||||
$logger.info(`Refresh token found in cookies`);
|
||||
}
|
||||
|
||||
const payload = await AuthController.validateTokens(refreshData.data.refreshToken);
|
||||
const payload = await AuthController.validateTokens(refreshToken);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(400).end();
|
||||
@@ -522,6 +622,8 @@ export default class AuthController {
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
await this.setRefreshToken(res, newTokens.refresh);
|
||||
|
||||
return res.json(newTokens);
|
||||
};
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export default class AuthModel {
|
||||
return !!(data.rowCount && data.rowCount > 0);
|
||||
} catch (error: unknown) {
|
||||
$logger.error(error, 'Can not update login code');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type NextFunction, type Request, type Response } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import AuthController from './AuthController';
|
||||
import { IsLoggedIn } from './middlewares/is-logged-in';
|
||||
|
||||
import passport from './strategies/passport-login';
|
||||
import { ExternalProviderScope } from './strategies/external-auth.types';
|
||||
export default class AuthRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly authController: AuthController;
|
||||
@@ -29,5 +30,31 @@ export default class AuthRoutes implements Routable {
|
||||
this.router.post('/refresh/token', this.authController.refreshTokens);
|
||||
this.router.post('/delete/account/code', [IsLoggedIn], this.authController.sendDeleteAccountCode);
|
||||
this.router.post('/delete/account', [IsLoggedIn], this.authController.deleteUserAccaunt);
|
||||
|
||||
this.router.get(
|
||||
'/provider/:providerName',
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName],
|
||||
session: false,
|
||||
state: JSON.stringify({
|
||||
platform: req.query.platform || '',
|
||||
})
|
||||
})(req, res, next)
|
||||
);
|
||||
this.router.get(
|
||||
'/provider/:providerName/callback',
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
this.authController.loginByProvider
|
||||
);
|
||||
|
||||
this.router.post(
|
||||
'/provider/:providerName/callback',
|
||||
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
|
||||
scope: ExternalProviderScope[req.params.providerName], session: false
|
||||
})(req, res, next),
|
||||
this.authController.loginByProvider
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ export default class JwtStorage {
|
||||
}
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
$logger.error('Can not complete initTokenRecord', {
|
||||
$logger.error({
|
||||
userId,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
});
|
||||
}, 'Can not complete initTokenRecord');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default class JwtStorage {
|
||||
const res = await this.db.query(query, [accessToken, refreshToken, rowId]);
|
||||
return !!(res.rowCount && res.rowCount > 0);
|
||||
} catch (error: any) {
|
||||
$logger.error('Error updating tokens:', { errorMessage: error.message, errorStack: error.stack });
|
||||
$logger.error({ errorMessage: error.message, errorStack: error.stack }, 'Error updating tokens:');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,11 @@ export default class JwtStorage {
|
||||
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
$logger.error('Error fetching tokens', {
|
||||
$logger.error({
|
||||
rowId,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
});
|
||||
}, 'Error fetching tokens');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,8 @@ export default class JwtStorage {
|
||||
async deleteTokens(userId: number, accessToken: string): Promise<boolean> {
|
||||
try {
|
||||
const query = 'DELETE FROM tv_auth.user_tokens WHERE user_id = $1 AND access_token = $2;';
|
||||
const deleteResul = await this.db.query(query, [userId, accessToken]);
|
||||
return !!(deleteResul.rowCount && deleteResul.rowCount > 0);
|
||||
await this.db.query(query, [userId, accessToken]);
|
||||
return true;
|
||||
} catch (_error: any) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import passport from "passport";
|
||||
import { Strategy as AppleStrategy } from "passport-apple";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { $logger } from "../../../modules/logget";
|
||||
import type { ExternalAuthUser } from "./external-auth.types";
|
||||
|
||||
interface AppleDecodedToken {
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
export function initAppleStrategy() {
|
||||
if (!process.env.APPLE_CLIENT_ID ||
|
||||
!process.env.APPLE_TEAM_ID ||
|
||||
!process.env.APPLE_KEY_ID ||
|
||||
!process.env.APPLE_CALLBACK_URL ||
|
||||
!process.env.APPLE_KEY_LOCATION) {
|
||||
$logger.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
|
||||
console.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
clientID: process.env.APPLE_CLIENT_ID,
|
||||
teamID: process.env.APPLE_TEAM_ID,
|
||||
callbackURL: process.env.APPLE_CALLBACK_URL,
|
||||
keyID: process.env.APPLE_KEY_ID,
|
||||
privateKeyLocation: process.env.APPLE_KEY_LOCATION,
|
||||
scope: ["name", "email"],
|
||||
passReqToCallback: true as const,
|
||||
};
|
||||
|
||||
passport.use(new AppleStrategy(options, async (_req, _accessToken, _refreshToken, idToken, _profile, done) => {
|
||||
try {
|
||||
const decoded = jwt.decode(idToken) as AppleDecodedToken | null;
|
||||
const email = decoded?.email;
|
||||
if (!email) return done(null, undefined);
|
||||
|
||||
const user: ExternalAuthUser = {
|
||||
email,
|
||||
provider: "apple",
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
} catch (e) {
|
||||
done(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ExternalAuthUser = {
|
||||
email: string;
|
||||
provider: 'google' | 'github' | string;
|
||||
}
|
||||
|
||||
export const ExternalProviderScope: Record<ExternalAuthUser['provider'], string[]> = {
|
||||
google: ["email"],
|
||||
github: ["user:email"],
|
||||
apple: ["email"],
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import passport from "passport";
|
||||
import { Strategy as GitHubStrategy } from "passport-github2";
|
||||
import { $logger } from "../../../modules/logget";
|
||||
import type { ExternalAuthUser } from "./external-auth.types";
|
||||
import type { Profile } from "passport-github2";
|
||||
import type { VerifyCallback } from "passport-google-oauth20";
|
||||
|
||||
export function initGithubStrategy() {
|
||||
if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET || !process.env.GITHUB_CALLBACK_URL) {
|
||||
$logger.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
|
||||
console.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: process.env.GITHUB_CALLBACK_URL,
|
||||
scope: ["user:email"],
|
||||
}
|
||||
|
||||
passport.use(new GitHubStrategy(options, async (_accessToken: string, _refreshToken: string, profile: Profile, done: VerifyCallback) => {
|
||||
try {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
if (!email) return done(null, false);
|
||||
|
||||
const user: ExternalAuthUser = {
|
||||
email,
|
||||
provider: "github",
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import passport from "passport";
|
||||
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
||||
import { $logger } from "../../../modules/logget";
|
||||
import type { ExternalAuthUser } from "./external-auth.types";
|
||||
|
||||
export function initGoogleStrategy() {
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_CALLBACK_URL) {
|
||||
$logger.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
|
||||
console.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
}
|
||||
|
||||
passport.use(new GoogleStrategy(options, async (_accessToken, _refreshToken, profile, done) => {
|
||||
try {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
if (!email || !profile._json.email_verified) return done(null, false);
|
||||
|
||||
const user: ExternalAuthUser = {
|
||||
email,
|
||||
provider: "google",
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import passport from "passport";
|
||||
import { initGoogleStrategy } from "./google.strategy";
|
||||
import { initGithubStrategy } from "./github.strategy";
|
||||
import { initAppleStrategy } from "./apple.strategy";
|
||||
|
||||
export function initPassportLogin() {
|
||||
initGoogleStrategy();
|
||||
initGithubStrategy();
|
||||
initAppleStrategy();
|
||||
}
|
||||
|
||||
export default passport;
|
||||
|
||||
@@ -18,6 +18,13 @@ export class GraphRepository {
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async fetchById(id: number): Promise<GraphReturnRelationsType | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(GraphRelationsSchema).where(eq(GraphRelationsSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
public async fetchAllEdges(goalId: number): Promise<GraphReturnRelationsType[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(GraphRelationsSchema).where(eq(GraphRelationsSchema.goalId, goalId))
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { GraphController } from './GraphControler';
|
||||
import { CanManageGraph } from './middlewares/CanManageGraph';
|
||||
import { CanViewGraph } from './middlewares/CanViewGraph';
|
||||
|
||||
export default class GraphRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly graphController: GraphController;
|
||||
@@ -17,8 +20,8 @@ export default class GraphRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('', [IsLoggedIn], this.graphController.addEdge);
|
||||
this.router.get('/:goalId', [IsLoggedIn], this.graphController.fetchAllEdges);
|
||||
this.router.delete('/:id', [IsLoggedIn], this.graphController.deleteEdge);
|
||||
this.router.post('', [IsLoggedIn, CanManageGraph], this.graphController.addEdge);
|
||||
this.router.get('/:goalId', [IsLoggedIn, CanViewGraph], this.graphController.fetchAllEdges);
|
||||
this.router.delete('/:id', [IsLoggedIn, CanManageGraph], this.graphController.deleteEdge);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveGoalId } from './resolveGoalId';
|
||||
|
||||
export const CanManageGraph = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = await resolveGoalId(req);
|
||||
if (!goalId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (checker.hasPermissions(GoalPermissions.GRAPH_CAN_MANAGE)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveGoalId } from './resolveGoalId';
|
||||
|
||||
export const CanViewGraph = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = await resolveGoalId(req);
|
||||
if (!goalId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
|
||||
if (checker.hasPermissions(GoalPermissions.GRAPH_CAN_VIEW)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Request } from 'express';
|
||||
import { GraphRepository } from '../GraphRepository';
|
||||
import { TasksRepository } from '../../tasks/TasksRepository';
|
||||
|
||||
/**
|
||||
* Resolves goalId from graph request.
|
||||
* - GET /:goalId → params.goalId
|
||||
* - POST (addEdge) → resolve via fromTaskId (body.source)
|
||||
* - DELETE /:id → resolve via edge id
|
||||
*/
|
||||
export async function resolveGoalId(req: Request): Promise<number | null> {
|
||||
// Direct goalId in params (fetchAllEdges)
|
||||
if (req.params.goalId) {
|
||||
const id = Number(req.params.goalId);
|
||||
return isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
// addEdge: resolve goalId from task
|
||||
if (req.body?.source) {
|
||||
const taskId = Number(req.body.source);
|
||||
if (isNaN(taskId)) return null;
|
||||
const tasksRepo = new TasksRepository();
|
||||
const task = await tasksRepo.fetchTaskByIdNew(taskId);
|
||||
return task?.goalId ?? null;
|
||||
}
|
||||
|
||||
// deleteEdge: resolve goalId from edge
|
||||
if (req.params.id) {
|
||||
const edgeId = Number(req.params.id);
|
||||
if (isNaN(edgeId)) return null;
|
||||
const graphRepo = new GraphRepository();
|
||||
const edge = await graphRepo.fetchById(edgeId);
|
||||
return edge?.goalId ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError } from '../../utils/api';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { verifyGitHubWebhookSignature, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { verifyGitLabWebhookToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types';
|
||||
|
||||
export default class IntegrationsController {
|
||||
createIntegration = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeAdd(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.create(out).catch(logError);
|
||||
return res.tvJson(result ?? null);
|
||||
};
|
||||
|
||||
deleteIntegration = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeDelete(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.delete(out).catch(logError);
|
||||
return res.tvJson(!!result);
|
||||
};
|
||||
|
||||
toggleIntegration = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeToggle(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.toggle(out).catch(logError);
|
||||
return res.tvJson(result ?? null);
|
||||
};
|
||||
|
||||
fetchIntegrations = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeFetch(req.query);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.fetch(out).catch(logError);
|
||||
return res.tvJson(result ?? []);
|
||||
};
|
||||
|
||||
initiateOAuth = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.query.token as string;
|
||||
if (!token) {
|
||||
return res.status(401).send('token is required');
|
||||
}
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (!userPayload?.userData?.id) {
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const provider = req.params.provider;
|
||||
const projectId = Number(req.query.projectId);
|
||||
if (!projectId || isNaN(projectId)) {
|
||||
return res.status(400).send('projectId is required');
|
||||
}
|
||||
const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id);
|
||||
return res.redirect(url);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.status(500).send('Failed to initiate OAuth');
|
||||
}
|
||||
};
|
||||
|
||||
handleOAuthCallback = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = req.params.provider;
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
|
||||
if (!code || !state) {
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
|
||||
const { projectId, userLogin } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
return res.redirect(`${process.env.APP_URL}/${userLogin}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRepos = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const integrationId = Number(req.query.integrationId);
|
||||
if (!integrationId || isNaN(integrationId)) {
|
||||
return res.status(400).send('integrationId is required');
|
||||
}
|
||||
const repos = await req.appUser.integrationsManager.fetchRepos(integrationId);
|
||||
return res.tvJson(repos);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.tvJson([]);
|
||||
}
|
||||
};
|
||||
|
||||
selectRepo = async (req: Request, res: Response) => {
|
||||
const out = IntegrationsArkTypeSelectRepo(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const result = await req.appUser.integrationsManager.selectRepo(out).catch(logError);
|
||||
return res.tvJson(result ?? null);
|
||||
};
|
||||
|
||||
syncIntegration = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const integrationId = Number(req.body.integrationId);
|
||||
if (!integrationId || isNaN(integrationId)) {
|
||||
return res.status(400).send('integrationId is required');
|
||||
}
|
||||
const synced = await req.appUser.integrationsManager.syncIssues(integrationId);
|
||||
return res.tvJson({ synced });
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.tvJson({ synced: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
handleGitHubWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const signature = req.headers['x-hub-signature-256'] as string;
|
||||
const event = req.headers['x-github-event'] as string;
|
||||
|
||||
if (!signature) {
|
||||
return res.status(401).send('Missing signature');
|
||||
}
|
||||
|
||||
if (event !== 'issues') {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const repoFullName = req.body?.repository?.full_name;
|
||||
if (!repoFullName) {
|
||||
return res.status(400).send('Missing repository');
|
||||
}
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integrations = await repo.fetchAllActiveByRepoFullName(repoFullName);
|
||||
if (integrations.length === 0) {
|
||||
return res.status(404).send('Integration not found');
|
||||
}
|
||||
|
||||
// Verify signature with the first integration that has a webhook secret
|
||||
const withSecret = integrations.find((i) => i.webhookSecretEncrypted);
|
||||
if (!withSecret) {
|
||||
return res.status(401).send('No webhook secret');
|
||||
}
|
||||
const secret = decrypt(withSecret.webhookSecretEncrypted!);
|
||||
const rawBody = (req as any).rawBody as Buffer;
|
||||
if (!rawBody || !verifyGitHubWebhookSignature(rawBody, signature, secret)) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
const action = req.body.action as string;
|
||||
const issue = req.body.issue;
|
||||
if (!issue) {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const issueNumber = issue.number as number;
|
||||
const issueTitle = issue.title as string;
|
||||
const issueBody = (issue.body as string) || null;
|
||||
|
||||
for (const integration of integrations) {
|
||||
const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueNumber);
|
||||
|
||||
if (action === 'opened') {
|
||||
if (!mapping) {
|
||||
const repoFullName = req.body?.repository?.full_name;
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
integration.id,
|
||||
issueNumber,
|
||||
'open',
|
||||
issueBody,
|
||||
false,
|
||||
`${GITHUB_BASE_URL}/${repoFullName}/issues/${issueNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'edited') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueBody);
|
||||
}
|
||||
} else if (action === 'closed') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, true);
|
||||
await repo.updateMappingState(mapping.id, 'closed');
|
||||
}
|
||||
} else if (action === 'reopened') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, false);
|
||||
await repo.updateMappingState(mapping.id, 'open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send('OK');
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.status(500).send('Webhook processing failed');
|
||||
}
|
||||
};
|
||||
|
||||
handleGitLabWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.headers['x-gitlab-token'] as string;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).send('Missing token');
|
||||
}
|
||||
|
||||
if (req.body?.object_kind !== 'issue') {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const projectId = String(req.body?.project?.id);
|
||||
if (!projectId) {
|
||||
return res.status(400).send('Missing project');
|
||||
}
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integrations = await repo.fetchAllActiveByRepoExternalId(projectId);
|
||||
if (integrations.length === 0) {
|
||||
return res.status(404).send('Integration not found');
|
||||
}
|
||||
|
||||
// Verify token with the first integration that has a webhook secret
|
||||
const withSecret = integrations.find((i) => i.webhookSecretEncrypted);
|
||||
if (!withSecret) {
|
||||
return res.status(401).send('No webhook secret');
|
||||
}
|
||||
const secret = decrypt(withSecret.webhookSecretEncrypted!);
|
||||
if (!verifyGitLabWebhookToken(token, secret)) {
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const attrs = req.body.object_attributes;
|
||||
if (!attrs) {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const issueIid = attrs.iid as number;
|
||||
const issueTitle = attrs.title as string;
|
||||
const issueDescription = (attrs.description as string) || null;
|
||||
const action = attrs.action as string;
|
||||
|
||||
for (const integration of integrations) {
|
||||
const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueIid);
|
||||
|
||||
if (action === 'open') {
|
||||
if (!mapping) {
|
||||
const repoPath = req.body?.project?.path_with_namespace;
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
integration.id,
|
||||
issueIid,
|
||||
'open',
|
||||
issueDescription,
|
||||
false,
|
||||
`${GITLAB_BASE_URL}/${repoPath}/-/issues/${issueIid}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueDescription);
|
||||
}
|
||||
} else if (action === 'close') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, true);
|
||||
await repo.updateMappingState(mapping.id, 'closed');
|
||||
}
|
||||
} else if (action === 'reopen') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, false);
|
||||
await repo.updateMappingState(mapping.id, 'open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send('OK');
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.status(500).send('Webhook processing failed');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { encrypt, decrypt } from '../../utils/crypto';
|
||||
|
||||
import { logError } from '../../utils/api';
|
||||
import { $logger } from '../../modules/logget';
|
||||
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
import type { IntegrationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getGitHubOAuthUrl, exchangeGitHubCode, fetchGitHubRepos, fetchGitHubIssues, createGitHubWebhook, updateGitHubIssueState, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
|
||||
export class IntegrationsManager {
|
||||
public readonly repository: IntegrationsRepository;
|
||||
private readonly user: AppUser;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new IntegrationsRepository();
|
||||
}
|
||||
|
||||
async create(data: IntegrationsArgAdd): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
return this.repository.create(data);
|
||||
}
|
||||
|
||||
async delete(data: IntegrationsArgDelete): Promise<boolean> {
|
||||
return this.repository.delete(data);
|
||||
}
|
||||
|
||||
async toggle(data: IntegrationsArgToggle): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await this.repository.toggle(data);
|
||||
if (result && data.isActive) {
|
||||
this.syncIssues(data.id).catch(logError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetch(data: IntegrationsArgFetch): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const projectId = Number(data.projectId);
|
||||
if (isNaN(projectId)) return [];
|
||||
return this.repository.fetchByProjectId(projectId);
|
||||
}
|
||||
|
||||
getOAuthUrl(provider: string, projectId: number, userId: number): string {
|
||||
const state = jwt.sign(
|
||||
{ userId, projectId, provider } as OAuthStatePayload,
|
||||
process.env.JWT_SIGN as string,
|
||||
{ expiresIn: '10m' }
|
||||
);
|
||||
|
||||
if (provider === 'github') {
|
||||
return getGitHubOAuthUrl(state);
|
||||
} else if (provider === 'gitlab') {
|
||||
return getGitLabOAuthUrl(state);
|
||||
}
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> {
|
||||
$logger.debug({ provider }, '[integrations] handleOAuthCallback start');
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload;
|
||||
|
||||
if (payload.provider !== provider) {
|
||||
$logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state');
|
||||
throw new Error('Provider mismatch in state');
|
||||
}
|
||||
|
||||
const userLogin = await this.repository.fetchUserLogin(payload.userId);
|
||||
if (!userLogin) {
|
||||
$logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback');
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
let accessTokenEncrypted: string;
|
||||
let refreshTokenEncrypted: string | null = null;
|
||||
|
||||
if (provider === 'github') {
|
||||
const accessToken = await exchangeGitHubCode(code);
|
||||
accessTokenEncrypted = encrypt(accessToken);
|
||||
} else if (provider === 'gitlab') {
|
||||
const tokens = await exchangeGitLabCode(code);
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = encrypt(tokens.refreshToken);
|
||||
} else {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
await this.repository.createWithToken(
|
||||
provider as 'github' | 'gitlab',
|
||||
payload.projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted,
|
||||
);
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, userLogin };
|
||||
}
|
||||
|
||||
async fetchRepos(integrationId: number): Promise<RepoItemForClient[]> {
|
||||
const integration = await this.repository.fetchById(integrationId);
|
||||
if (!integration || !integration.accessTokenEncrypted) return [];
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) return [];
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
const repos = await fetchGitHubRepos(accessToken);
|
||||
return repos.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
name: r.name,
|
||||
isPrivate: r.private,
|
||||
description: r.description,
|
||||
url: r.html_url,
|
||||
}));
|
||||
} else if (integration.provider === 'gitlab') {
|
||||
const repos = await fetchGitLabRepos(accessToken);
|
||||
return repos.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.path_with_namespace,
|
||||
name: r.name,
|
||||
isPrivate: r.visibility === 'private',
|
||||
description: r.description,
|
||||
url: r.web_url,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async selectRepo(data: IntegrationsArgSelectRepo): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const integration = await this.repository.fetchById(data.integrationId);
|
||||
if (!integration) return false;
|
||||
|
||||
const exists = await this.repository.existsRepoInProject(integration.projectId, data.repoFullName, data.integrationId);
|
||||
if (exists) {
|
||||
$logger.debug({ integrationId: data.integrationId, repoFullName: data.repoFullName, projectId: integration.projectId }, '[integrations] repo already connected to project, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await this.repository.updateRepo(data);
|
||||
if (result) {
|
||||
$logger.debug({ integrationId: data.integrationId, repoFullName: data.repoFullName }, '[integrations] repo selected, starting sync and webhook registration');
|
||||
this.syncIssues(data.integrationId).catch(logError);
|
||||
this.registerWebhook(data.integrationId).catch(logError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async registerWebhook(integrationId: number): Promise<void> {
|
||||
const integration = await this.repository.fetchById(integrationId);
|
||||
if (!integration || !integration.accessTokenEncrypted || !integration.repoFullName) return;
|
||||
|
||||
const apiUrl = process.env.API_URL;
|
||||
if (!apiUrl) return;
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) {
|
||||
$logger.error({ integrationId, provider: integration.provider }, '[integrations] registerWebhook failed: no access token');
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = randomBytes(32).toString('hex');
|
||||
const webhookUrl = `${apiUrl}/module/integrations/webhook/${integration.provider}`;
|
||||
|
||||
let webhookId: string;
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
const result = await createGitHubWebhook(accessToken, integration.repoFullName, webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
$logger.debug({ integrationId, provider: integration.provider, webhookId }, '[integrations] webhook registered');
|
||||
await this.repository.updateWebhook(integrationId, webhookId, encrypt(webhookSecret));
|
||||
}
|
||||
|
||||
async syncIssues(integrationId: number): Promise<number> {
|
||||
const integration = await this.repository.fetchById(integrationId);
|
||||
if (!integration || !integration.accessTokenEncrypted || !integration.repoFullName) return 0;
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) {
|
||||
$logger.error({ integrationId, provider: integration.provider }, '[integrations] syncIssues failed: no access token');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const since = integration.lastSyncedAt?.toISOString();
|
||||
$logger.debug({ integrationId, provider: integration.provider, repo: integration.repoFullName, since: since ?? 'full sync' }, '[integrations] syncIssues start');
|
||||
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
|
||||
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
|
||||
|
||||
// Backfill sourceUrl for existing tasks that don't have it yet
|
||||
if (existingMappings.length > 0) {
|
||||
const baseUrl = integration.provider === 'github' ? GITHUB_BASE_URL : GITLAB_BASE_URL;
|
||||
const issuePath = integration.provider === 'gitlab' ? '/-/issues/' : '/issues/';
|
||||
const prefix = `${baseUrl}/${integration.repoFullName}${issuePath}`;
|
||||
await this.repository.backfillSourceUrls(integrationId, prefix).catch(logError);
|
||||
}
|
||||
|
||||
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null };
|
||||
const newItems: NewIssueItem[] = [];
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
const issues = await fetchGitHubIssues(accessToken, integration.repoFullName, since);
|
||||
for (const issue of issues) {
|
||||
const existing = mappingsByIssueNumber.get(issue.number);
|
||||
if (existing) {
|
||||
const isClosed = issue.state === 'closed';
|
||||
const targetState = isClosed ? 'closed' : 'open';
|
||||
await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError);
|
||||
if (existing.issueState !== targetState) {
|
||||
await this.repository.updateMappingState(existing.id, targetState).catch(logError);
|
||||
}
|
||||
await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.body ?? null).catch(logError);
|
||||
await this.repository.updateTaskSourceUrl(existing.taskId, `${GITHUB_BASE_URL}/${integration.repoFullName}/issues/${issue.number}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.number,
|
||||
issueState: issue.state === 'open' ? 'open' : 'closed',
|
||||
note: issue.body ?? null,
|
||||
complete: issue.state === 'closed',
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${GITHUB_BASE_URL}/${integration.repoFullName}/issues/${issue.number}`,
|
||||
});
|
||||
}
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const issues = await fetchGitLabIssues(accessToken, Number(integration.repoExternalId), since);
|
||||
for (const issue of issues) {
|
||||
const existing = mappingsByIssueNumber.get(issue.iid);
|
||||
if (existing) {
|
||||
const isClosed = issue.state === 'closed';
|
||||
const targetState = isClosed ? 'closed' : 'open';
|
||||
await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError);
|
||||
if (existing.issueState !== targetState) {
|
||||
await this.repository.updateMappingState(existing.id, targetState).catch(logError);
|
||||
}
|
||||
await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.description ?? null).catch(logError);
|
||||
await this.repository.updateTaskSourceUrl(existing.taskId, `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
const isClosed = issue.state === 'closed';
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.iid,
|
||||
issueState: isClosed ? 'closed' : 'open',
|
||||
note: issue.description ?? null,
|
||||
complete: isClosed,
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Issues come newest-first from API.
|
||||
// Reverse so oldest is inserted first (lower ID) and newest last (higher ID).
|
||||
// This way list view (ORDER BY id DESC) shows newest first.
|
||||
// Assign kanbanOrder so newest = smallest (appears first in kanban).
|
||||
// Issues come newest-first from API.
|
||||
// Reverse so oldest is inserted first (lower ID) and newest last (higher ID).
|
||||
// List view (ORDER BY id DESC) shows newest first.
|
||||
// Assign kanbanOrder: each next item goes further into minus from current min.
|
||||
// Newest (last in array) gets the smallest value → appears first in kanban.
|
||||
if (newItems.length > 0) {
|
||||
newItems.reverse();
|
||||
const { KANBAN_ORDER_GAP } = TasksRepository;
|
||||
const min = await this.user.tasksManager.repository.fetchTaskWithMinKanbanOrder(integration.projectId, null);
|
||||
for (let i = 0; i < newItems.length; i++) {
|
||||
newItems[i].kanbanOrder = (min ?? 0) - KANBAN_ORDER_GAP * (i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const created = await this.repository.createTasksAndMappingsBatch(newItems);
|
||||
await this.repository.updateLastSyncedAt(integrationId);
|
||||
|
||||
$logger.debug({ integrationId, created, updatedExisting: existingMappings.length, totalIssuesFetched: newItems.length + existingMappings.length }, '[integrations] syncIssues completed');
|
||||
return created;
|
||||
}
|
||||
|
||||
async onTaskCompleteChanged(taskId: number, complete: boolean): Promise<boolean> {
|
||||
const mapping = await this.repository.fetchMappingByTaskId(taskId);
|
||||
if (!mapping) return true;
|
||||
|
||||
const { integration } = mapping;
|
||||
$logger.debug({ taskId, complete, provider: integration.provider, isActive: integration.isActive, issueNumber: mapping.issueNumber, issueState: mapping.issueState }, '[integrations] onTaskCompleteChanged');
|
||||
if (!integration.isActive || !integration.accessTokenEncrypted || !integration.repoFullName) return true;
|
||||
|
||||
const targetState = complete ? 'closed' : 'open';
|
||||
if (mapping.issueState === targetState) {
|
||||
$logger.debug({ taskId, targetState }, '[integrations] onTaskCompleteChanged: state already matches, skipping');
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessToken = await this.getAccessToken(integration);
|
||||
if (!accessToken) {
|
||||
$logger.error({ taskId, integrationId: integration.id, provider: integration.provider }, '[integrations] onTaskCompleteChanged: no access token');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (integration.provider === 'github') {
|
||||
await updateGitHubIssueState(accessToken, integration.repoFullName, mapping.issueNumber, targetState);
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
await updateGitLabIssueState(
|
||||
accessToken,
|
||||
Number(integration.repoExternalId),
|
||||
mapping.issueNumber,
|
||||
complete ? 'close' : 'reopen',
|
||||
);
|
||||
}
|
||||
|
||||
await this.repository.updateMappingState(mapping.id, targetState);
|
||||
$logger.debug({ taskId, issueNumber: mapping.issueNumber, targetState }, '[integrations] onTaskCompleteChanged: issue state updated');
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise<string | null> {
|
||||
if (!integration.accessTokenEncrypted) return null;
|
||||
|
||||
const accessToken = decrypt(integration.accessTokenEncrypted);
|
||||
|
||||
if (integration.provider !== 'gitlab' || !integration.refreshTokenEncrypted) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Try the current token, refresh on 401
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
|
||||
await axios.get(`${gitlabApiUrl}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
return accessToken;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status !== 401) return accessToken;
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token expired (401), refreshing');
|
||||
}
|
||||
|
||||
// Token expired, refresh it
|
||||
try {
|
||||
const refreshToken = decrypt(integration.refreshTokenEncrypted);
|
||||
const tokens = await refreshGitLabToken(refreshToken);
|
||||
await this.repository.updateTokens(
|
||||
integration.id,
|
||||
encrypt(tokens.accessToken),
|
||||
encrypt(tokens.refreshToken),
|
||||
);
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token refreshed successfully');
|
||||
return tokens.accessToken;
|
||||
} catch (err) {
|
||||
$logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { and, eq, ne, isNull, sql } from 'drizzle-orm';
|
||||
import { IntegrationsSchema, IntegrationTaskMapSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
|
||||
export class IntegrationsRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: IntegrationsArgAdd): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(IntegrationsSchema).values({
|
||||
provider: data.provider,
|
||||
repoFullName: data.repoFullName,
|
||||
projectId: data.projectId,
|
||||
}).returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(data: IntegrationsArgDelete): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(IntegrationsSchema).where(eq(IntegrationsSchema.id, data.id))
|
||||
);
|
||||
if (!result) return false;
|
||||
return !!(result?.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
async toggle(data: IntegrationsArgToggle): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ isActive: data.isActive, updatedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, data.id))
|
||||
.returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchByProjectId(projectId: number): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(eq(IntegrationsSchema.projectId, projectId))
|
||||
);
|
||||
if (!result) return [];
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<IntegrationsSchemaTypeForSelect | undefined> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(eq(IntegrationsSchema.id, id))
|
||||
);
|
||||
if (!result || result.length === 0) return undefined;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async createWithToken(
|
||||
provider: 'github' | 'gitlab',
|
||||
projectId: number,
|
||||
accessTokenEncrypted: string,
|
||||
refreshTokenEncrypted?: string | null,
|
||||
): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(IntegrationsSchema).values({
|
||||
provider,
|
||||
projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted: refreshTokenEncrypted ?? null,
|
||||
}).returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async existsRepoInProject(projectId: number, repoFullName: string, excludeIntegrationId?: number): Promise<boolean> {
|
||||
const conditions = [
|
||||
eq(IntegrationsSchema.projectId, projectId),
|
||||
eq(IntegrationsSchema.repoFullName, repoFullName),
|
||||
];
|
||||
if (excludeIntegrationId) {
|
||||
conditions.push(ne(IntegrationsSchema.id, excludeIntegrationId));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ id: IntegrationsSchema.id }).from(IntegrationsSchema)
|
||||
.where(and(...conditions))
|
||||
);
|
||||
return !!result && result.length > 0;
|
||||
}
|
||||
|
||||
async updateRepo(data: IntegrationsArgSelectRepo): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({
|
||||
repoFullName: data.repoFullName,
|
||||
repoExternalId: data.repoExternalId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(IntegrationsSchema.id, data.integrationId))
|
||||
.returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async createTaskAndMapping(
|
||||
goalId: number,
|
||||
description: string,
|
||||
integrationId: number,
|
||||
issueNumber: number,
|
||||
issueState: string,
|
||||
note?: string | null,
|
||||
complete?: boolean,
|
||||
sourceUrl?: string | null,
|
||||
): Promise<IntegrationTaskMapSchemaTypeForSelect | false> {
|
||||
const tasksRepo = new TasksRepository();
|
||||
const kanbanOrder = await tasksRepo.getNextKanbanOrder(goalId);
|
||||
|
||||
const result = await callWithCatch(async () => {
|
||||
const [task] = await this.db.dbDrizzle.insert(TasksSchema).values({
|
||||
goalId,
|
||||
description,
|
||||
complete: complete ?? false,
|
||||
note: note || null,
|
||||
kanbanOrder,
|
||||
sourceUrl: sourceUrl || null,
|
||||
}).returning();
|
||||
const [mapping] = await this.db.dbDrizzle.insert(IntegrationTaskMapSchema).values({
|
||||
integrationId,
|
||||
taskId: task.id,
|
||||
issueNumber,
|
||||
issueState,
|
||||
}).returning();
|
||||
return mapping;
|
||||
});
|
||||
if (!result) return false;
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchMappingsByIntegrationId(integrationId: number): Promise<IntegrationTaskMapSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationTaskMapSchema)
|
||||
.where(eq(IntegrationTaskMapSchema.integrationId, integrationId))
|
||||
);
|
||||
if (!result) return [];
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateTokens(integrationId: number, accessTokenEncrypted: string, refreshTokenEncrypted: string | null): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ accessTokenEncrypted, refreshTokenEncrypted, updatedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, integrationId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateWebhook(integrationId: number, webhookId: string, webhookSecretEncrypted: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ webhookId, webhookSecretEncrypted, updatedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, integrationId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchAllActiveByRepoFullName(repoFullName: string): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(IntegrationsSchema.repoFullName, repoFullName),
|
||||
eq(IntegrationsSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
async fetchAllActiveByRepoExternalId(repoExternalId: string): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(IntegrationsSchema.repoExternalId, repoExternalId),
|
||||
eq(IntegrationsSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
async fetchMappingByIssueNumber(integrationId: number, issueNumber: number): Promise<IntegrationTaskMapSchemaTypeForSelect | undefined> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(IntegrationTaskMapSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(IntegrationTaskMapSchema.integrationId, integrationId),
|
||||
eq(IntegrationTaskMapSchema.issueNumber, issueNumber),
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!result || result.length === 0) return undefined;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchMappingByTaskId(taskId: number): Promise<(IntegrationTaskMapSchemaTypeForSelect & { integration: IntegrationsSchemaTypeForSelect }) | undefined> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(IntegrationTaskMapSchema)
|
||||
.innerJoin(IntegrationsSchema, eq(IntegrationTaskMapSchema.integrationId, IntegrationsSchema.id))
|
||||
.where(eq(IntegrationTaskMapSchema.taskId, taskId))
|
||||
);
|
||||
if (!result || result.length === 0) return undefined;
|
||||
return {
|
||||
...result[0].integration_task_map,
|
||||
integration: result[0].integrations,
|
||||
};
|
||||
}
|
||||
|
||||
async updateTaskComplete(taskId: number, complete: boolean): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(TasksSchema)
|
||||
.set({ complete })
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateTaskTitleAndNote(taskId: number, description: string, note: string | null): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(TasksSchema)
|
||||
.set({ description, note })
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateTaskSourceUrl(taskId: number, sourceUrl: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(TasksSchema)
|
||||
.set({ sourceUrl })
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async backfillSourceUrls(integrationId: number, urlPrefix: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.execute(sql`
|
||||
UPDATE tasks.tasks t
|
||||
SET source_url = ${urlPrefix} || m.issue_number
|
||||
FROM tasks.integration_task_map m
|
||||
WHERE m.task_id = t.id
|
||||
AND m.integration_id = ${integrationId}
|
||||
AND t.source_url IS NULL
|
||||
`)
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async updateMappingState(mappingId: number, issueState: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationTaskMapSchema)
|
||||
.set({ issueState, syncedAt: new Date() })
|
||||
.where(eq(IntegrationTaskMapSchema.id, mappingId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async createTasksAndMappingsBatch(
|
||||
items: Array<{ goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null }>,
|
||||
): Promise<number> {
|
||||
if (items.length === 0) return 0;
|
||||
let created = 0;
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
||||
const batch = items.slice(i, i + BATCH_SIZE);
|
||||
const result = await callWithCatch(async () => {
|
||||
const tasks = await this.db.dbDrizzle.insert(TasksSchema).values(
|
||||
batch.map((item) => ({
|
||||
goalId: item.goalId,
|
||||
description: item.description,
|
||||
complete: item.complete,
|
||||
note: item.note,
|
||||
kanbanOrder: item.kanbanOrder,
|
||||
sourceUrl: item.sourceUrl,
|
||||
})),
|
||||
).returning({ id: TasksSchema.id });
|
||||
|
||||
await this.db.dbDrizzle.insert(IntegrationTaskMapSchema).values(
|
||||
tasks.map((task, idx) => ({
|
||||
integrationId: batch[idx].integrationId,
|
||||
taskId: task.id,
|
||||
issueNumber: batch[idx].issueNumber,
|
||||
issueState: batch[idx].issueState,
|
||||
})),
|
||||
);
|
||||
|
||||
return tasks.length;
|
||||
});
|
||||
if (result) created += result;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateLastSyncedAt(integrationId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(IntegrationsSchema)
|
||||
.set({ lastSyncedAt: new Date() })
|
||||
.where(eq(IntegrationsSchema.id, integrationId))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchUserLogin(userId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, userId))
|
||||
);
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].login;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import IntegrationsController from './IntegrationsController';
|
||||
import { CanManageIntegrations } from './middlewares/CanManageIntegrations';
|
||||
import { CanViewIntegrations } from './middlewares/CanViewIntegrations';
|
||||
|
||||
export default class IntegrationsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: IntegrationsController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new IntegrationsController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn, CanViewIntegrations], this.controller.fetchIntegrations);
|
||||
this.router.post('', [IsLoggedIn, CanManageIntegrations], this.controller.createIntegration);
|
||||
this.router.delete('', [IsLoggedIn, CanManageIntegrations], this.controller.deleteIntegration);
|
||||
this.router.patch('/toggle', [IsLoggedIn, CanManageIntegrations], this.controller.toggleIntegration);
|
||||
this.router.patch('/select-repo', [IsLoggedIn, CanManageIntegrations], this.controller.selectRepo);
|
||||
this.router.post('/sync', [IsLoggedIn, CanManageIntegrations], this.controller.syncIntegration);
|
||||
this.router.get('/repos', [IsLoggedIn, CanViewIntegrations], this.controller.fetchRepos);
|
||||
this.router.get('/oauth/:provider', this.controller.initiateOAuth);
|
||||
this.router.get('/oauth/:provider/callback', this.controller.handleOAuthCallback);
|
||||
this.router.post('/webhook/github', this.controller.handleGitHubWebhook);
|
||||
this.router.post('/webhook/gitlab', this.controller.handleGitLabWebhook);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveProjectId } from './resolveProjectId';
|
||||
|
||||
export const CanManageIntegrations = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const projectId = await resolveProjectId(req);
|
||||
if (!projectId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(projectId);
|
||||
if (checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_MANAGE)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { resolveProjectId } from './resolveProjectId';
|
||||
|
||||
export const CanViewIntegrations = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const projectId = await resolveProjectId(req);
|
||||
if (!projectId) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(projectId);
|
||||
if (checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_VIEW)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Request } from 'express';
|
||||
import { IntegrationsRepository } from '../IntegrationsRepository';
|
||||
|
||||
/**
|
||||
* Resolves projectId from request.
|
||||
* Checks body (projectId, integrationId, id) and query (projectId, integrationId).
|
||||
*/
|
||||
export async function resolveProjectId(req: Request): Promise<number | null> {
|
||||
// Direct projectId in body or query
|
||||
const directId = req.body?.projectId ?? req.query?.projectId;
|
||||
if (directId) {
|
||||
const id = Number(directId);
|
||||
return isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
// integrationId from body or query, or id from body
|
||||
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
|
||||
if (!integrationId || isNaN(integrationId)) return null;
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integration = await repo.fetchById(integrationId);
|
||||
return integration?.projectId ?? null;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
export const GITHUB_BASE_URL = process.env.GITHUB_BASE_URL || 'https://github.com';
|
||||
const GITHUB_API_URL = process.env.GITHUB_API_URL || 'https://api.github.com';
|
||||
const GITHUB_OAUTH_URL = `${GITHUB_BASE_URL}/login/oauth/authorize`;
|
||||
const GITHUB_TOKEN_URL = `${GITHUB_BASE_URL}/login/oauth/access_token`;
|
||||
|
||||
export type GitHubRepo = {
|
||||
id: number;
|
||||
full_name: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export type GitHubIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
pull_request?: unknown;
|
||||
};
|
||||
|
||||
export function getGitHubOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITHUB_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITHUB_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('GitHub integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'repo',
|
||||
state,
|
||||
});
|
||||
return `${GITHUB_OAUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGitHubCode(code: string): Promise<string> {
|
||||
const res = await axios.post<{ access_token: string; token_type: string }>(
|
||||
GITHUB_TOKEN_URL,
|
||||
{
|
||||
client_id: process.env.GITHUB_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITHUB_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
}
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange GitHub code for token');
|
||||
}
|
||||
return res.data.access_token;
|
||||
}
|
||||
|
||||
export async function fetchGitHubRepos(accessToken: string): Promise<GitHubRepo[]> {
|
||||
const repos: GitHubRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitHubRepo[]>(`${GITHUB_API_URL}/user/repos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
params: {
|
||||
per_page: perPage,
|
||||
page,
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGitHubIssues(accessToken: string, repoFullName: string, since?: string): Promise<GitHubIssue[]> {
|
||||
const issues: GitHubIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitHubIssue[]>(`${GITHUB_API_URL}/repos/${repoFullName}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
per_page: perPage,
|
||||
page,
|
||||
sort: since ? 'updated' : 'created',
|
||||
direction: 'desc',
|
||||
...(since ? { since } : {}),
|
||||
},
|
||||
});
|
||||
// GitHub API returns pull requests as issues too — filter them out
|
||||
const realIssues = res.data.filter((i) => !i.pull_request);
|
||||
issues.push(...realIssues);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGitHubWebhook(
|
||||
accessToken: string,
|
||||
repoFullName: string,
|
||||
webhookUrl: string,
|
||||
secret: string,
|
||||
): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITHUB_API_URL}/repos/${repoFullName}/hooks`,
|
||||
{
|
||||
name: 'web',
|
||||
active: true,
|
||||
events: ['issues'],
|
||||
config: {
|
||||
url: webhookUrl,
|
||||
content_type: 'json',
|
||||
secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGitHubWebhookSignature(rawBody: Buffer, signature: string, secret: string): boolean {
|
||||
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGitHubIssueState(
|
||||
accessToken: string,
|
||||
repoFullName: string,
|
||||
issueNumber: number,
|
||||
state: 'open' | 'closed',
|
||||
): Promise<void> {
|
||||
await axios.patch(
|
||||
`${GITHUB_API_URL}/repos/${repoFullName}/issues/${issueNumber}`,
|
||||
{ state },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const GITLAB_BASE_URL = process.env.GITLAB_BASE_URL || 'https://gitlab.com';
|
||||
const GITLAB_API_URL = process.env.GITLAB_API_URL || `${GITLAB_BASE_URL}/api/v4`;
|
||||
const GITLAB_OAUTH_URL = `${GITLAB_BASE_URL}/oauth/authorize`;
|
||||
const GITLAB_TOKEN_URL = `${GITLAB_BASE_URL}/oauth/token`;
|
||||
|
||||
export type GitLabRepo = {
|
||||
id: number;
|
||||
path_with_namespace: string;
|
||||
name: string;
|
||||
visibility: 'private' | 'internal' | 'public';
|
||||
description: string | null;
|
||||
web_url: string;
|
||||
};
|
||||
|
||||
export type GitLabIssue = {
|
||||
iid: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
state: 'opened' | 'closed';
|
||||
web_url: string;
|
||||
};
|
||||
|
||||
export function getGitLabOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITLAB_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITLAB_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('GitLab integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'api',
|
||||
state,
|
||||
});
|
||||
return `${GITLAB_OAUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGitLabCode(code: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
GITLAB_TOKEN_URL,
|
||||
{
|
||||
client_id: process.env.GITLAB_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITLAB_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: process.env.GITLAB_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange GitLab code for token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshGitLabToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
GITLAB_TOKEN_URL,
|
||||
{
|
||||
client_id: process.env.GITLAB_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITLAB_INTEGRATION_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
redirect_uri: process.env.GITLAB_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to refresh GitLab token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGitLabRepos(accessToken: string): Promise<GitLabRepo[]> {
|
||||
const repos: GitLabRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitLabRepo[]>(`${GITLAB_API_URL}/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
membership: true,
|
||||
per_page: perPage,
|
||||
page,
|
||||
order_by: 'updated_at',
|
||||
sort: 'desc',
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGitLabIssues(accessToken: string, projectId: number, updatedAfter?: string): Promise<GitLabIssue[]> {
|
||||
const issues: GitLabIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitLabIssue[]>(`${GITLAB_API_URL}/projects/${projectId}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
per_page: perPage,
|
||||
page,
|
||||
order_by: updatedAfter ? 'updated_at' : 'created_at',
|
||||
sort: 'desc',
|
||||
...(updatedAfter ? { updated_after: updatedAfter } : {}),
|
||||
},
|
||||
});
|
||||
issues.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGitLabWebhook(
|
||||
accessToken: string,
|
||||
projectId: number,
|
||||
webhookUrl: string,
|
||||
secret: string,
|
||||
): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITLAB_API_URL}/projects/${projectId}/hooks`,
|
||||
{
|
||||
url: webhookUrl,
|
||||
issues_events: true,
|
||||
token: secret,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGitLabWebhookToken(headerToken: string, secret: string): boolean {
|
||||
return headerToken === secret;
|
||||
}
|
||||
|
||||
export async function updateGitLabIssueState(
|
||||
accessToken: string,
|
||||
projectId: number,
|
||||
issueIid: number,
|
||||
stateEvent: 'close' | 'reopen',
|
||||
): Promise<void> {
|
||||
await axios.put(
|
||||
`${GITLAB_API_URL}/projects/${projectId}/issues/${issueIid}`,
|
||||
{ state_event: stateEvent },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const IntegrationsArkTypeAdd = type({
|
||||
provider: "'github' | 'gitlab'",
|
||||
repoFullName: 'string',
|
||||
projectId: 'number',
|
||||
});
|
||||
export type IntegrationsArgAdd = typeof IntegrationsArkTypeAdd.infer;
|
||||
|
||||
export const IntegrationsArkTypeDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
export type IntegrationsArgDelete = typeof IntegrationsArkTypeDelete.infer;
|
||||
|
||||
export const IntegrationsArkTypeToggle = type({
|
||||
id: 'number',
|
||||
isActive: 'boolean',
|
||||
});
|
||||
export type IntegrationsArgToggle = typeof IntegrationsArkTypeToggle.infer;
|
||||
|
||||
export const IntegrationsArkTypeFetch = type({
|
||||
projectId: 'string',
|
||||
});
|
||||
export type IntegrationsArgFetch = typeof IntegrationsArkTypeFetch.infer;
|
||||
|
||||
export const IntegrationsArkTypeSelectRepo = type({
|
||||
integrationId: 'number',
|
||||
repoFullName: 'string',
|
||||
repoExternalId: 'string',
|
||||
});
|
||||
export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer;
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
userId: number;
|
||||
projectId: number;
|
||||
provider: 'github' | 'gitlab';
|
||||
};
|
||||
|
||||
export type RepoItemForClient = {
|
||||
id: number;
|
||||
fullName: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
description: string | null;
|
||||
url: string;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
KanbanArkTypeFetchTasksForColumn,
|
||||
KanbanArkTypeFilters,
|
||||
KanbanArkTypeGetTasksOrderForColumnAndCursor,
|
||||
KanbanArkTypeUpdateTasksOrder,
|
||||
KanbanSchemaAddStatus,
|
||||
@@ -53,7 +54,12 @@ export class KanbanController {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn(data));
|
||||
const filters = KanbanArkTypeFilters(req.query);
|
||||
if (filters instanceof ArkErrors) {
|
||||
return res.status(400).send(filters.summary);
|
||||
}
|
||||
|
||||
return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn({ ...data, filters }));
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DeleteKanbanStatus,
|
||||
type KanbanAddStatus,
|
||||
type KanbanArgFetchTasksForColumn,
|
||||
type KanbanArgFilters,
|
||||
type KanbanArgGetTasksOrderForColumnAndCursor,
|
||||
type KanbanArgUpdateTasksOrder,
|
||||
type KanbanStatusForClient,
|
||||
@@ -44,7 +45,7 @@ export class KanbanManager {
|
||||
return KanbanStatusToClientSchema.parse(status);
|
||||
}
|
||||
|
||||
async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> {
|
||||
async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> {
|
||||
const [tasks, columnVersion] = await Promise.all([
|
||||
this.user.tasksManager.fetchTasksForKanbanColumn(data),
|
||||
this.repository.getColumnVersion(data.goalId, data.columnId)
|
||||
@@ -176,8 +177,7 @@ export class KanbanManager {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// первая задача в колонке
|
||||
newOrder = KANBAN_GAP;
|
||||
newOrder = await this.user.tasksManager.repository.getNextKanbanOrder(data.goalId);
|
||||
}
|
||||
|
||||
if (newOrder !== null) {
|
||||
|
||||
@@ -54,14 +54,28 @@ export const KanbanArkTypeStatusToClient = type({
|
||||
|
||||
export type KanbanStatusClient = typeof KanbanArkTypeStatusToClient.infer;
|
||||
|
||||
const NullableNumberFromString = type('string|number|null').pipe((v) => (v === 'null' || v === null || v === undefined) ? null : Number(v));
|
||||
|
||||
export const KanbanArkTypeFetchTasksForColumn = type({
|
||||
goalId: NumberFromString,
|
||||
columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
cursor: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
columnId: NullableNumberFromString,
|
||||
cursor: NullableNumberFromString,
|
||||
});
|
||||
|
||||
export type KanbanArgFetchTasksForColumn = typeof KanbanArkTypeFetchTasksForColumn.infer;
|
||||
|
||||
const NumberArrayFromCommaSeparatedString = type('string|undefined').pipe((v) => {
|
||||
if (!v) return [];
|
||||
return v.split(',').map(Number).filter((n) => !isNaN(n));
|
||||
});
|
||||
|
||||
export const KanbanArkTypeFilters = type({
|
||||
'listIds?': NumberArrayFromCommaSeparatedString,
|
||||
'assigneeIds?': NumberArrayFromCommaSeparatedString,
|
||||
});
|
||||
|
||||
export type KanbanArgFilters = typeof KanbanArkTypeFilters.infer;
|
||||
|
||||
export const KanbanArkTypeGetTasksOrderForColumnAndCursor = type({
|
||||
goalId: NumberFromString,
|
||||
columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { eq, and, or, isNull, inArray } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { TasksSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { getNotificationService } from './NotificationService';
|
||||
import { NotificationMessages } from './NotificationMessages';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
|
||||
import { DeadlineScheduler } from './schedulers/DeadlineScheduler';
|
||||
import { NotificationType } from './types';
|
||||
import { parseUtcTime } from './utils';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
|
||||
const CLEANUP_JOB = 'notifications-cleanup';
|
||||
const NOTIFICATIONS_RETENTION_DAYS = 1;
|
||||
|
||||
export class NotificationDispatcher implements Dispatcher {
|
||||
private readonly deadlineScheduler = new DeadlineScheduler();
|
||||
private readonly notificationsRepo = new NotificationsRepository();
|
||||
private readonly deviceTokensRepo = new DeviceTokensRepository();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.onTaskCreated(data));
|
||||
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
|
||||
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
await this.cleanupWorker();
|
||||
await this.deadlineScheduler.registerWorker();
|
||||
}
|
||||
|
||||
private async cleanupWorker(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(CLEANUP_JOB);
|
||||
await boss.schedule(CLEANUP_JOB, '0 3 * * *');
|
||||
await boss.work(CLEANUP_JOB, async () => {
|
||||
await this.notificationsRepo.deleteOlderThanDays(NOTIFICATIONS_RETENTION_DAYS);
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
|
||||
if (data.task.endDate) {
|
||||
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
|
||||
}
|
||||
}
|
||||
|
||||
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
|
||||
if (data.changes.complete === true) {
|
||||
this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(data.task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasDeadlineChange =
|
||||
data.changes.endDate !== undefined ||
|
||||
data.changes.endTime !== undefined;
|
||||
|
||||
if (!hasDeadlineChange) return;
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Rescheduling deadline for task=${data.task.id}`);
|
||||
|
||||
this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE);
|
||||
|
||||
if (data.task.endDate) {
|
||||
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
|
||||
} else {
|
||||
await this.deadlineScheduler.cancel(data.task.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
if (data.userIds.length === 0) return;
|
||||
|
||||
const db = Database.getInstance();
|
||||
const task = await db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(eq(TasksSchema.id, data.taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete))));
|
||||
|
||||
if (!task[0]) return;
|
||||
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
const authUserRows = await db.dbDrizzle
|
||||
.select({ userId: authUsers.id })
|
||||
.from(CollaborationUsersSchema)
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(inArray(CollaborationUsersSchema.id, data.userIds));
|
||||
|
||||
const recipientIds = authUserRows
|
||||
.map((r) => r.userId)
|
||||
.filter((id) => id !== data.initiatorId);
|
||||
|
||||
if (recipientIds.length === 0) return;
|
||||
|
||||
await this.handleAssignNotification(data, task[0], recipientIds);
|
||||
await this.handleExpiredDeadlineNotification(data, task[0], recipientIds);
|
||||
}
|
||||
|
||||
private async handleAssignNotification(
|
||||
data: AppEvents['task.assigneesChanged'],
|
||||
task: typeof TasksSchema.$inferSelect,
|
||||
recipientIds: number[],
|
||||
): Promise<void> {
|
||||
const initiatorName = await this.resolveUserName(data.initiatorId);
|
||||
const message = NotificationMessages.assign(task.description, initiatorName);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Assign notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.ASSIGN,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleExpiredDeadlineNotification(
|
||||
data: AppEvents['task.assigneesChanged'],
|
||||
task: typeof TasksSchema.$inferSelect,
|
||||
recipientIds: number[],
|
||||
): Promise<void> {
|
||||
if (!task.endDate) return;
|
||||
|
||||
const isExpired = task.endTime
|
||||
? (parseUtcTime(task.endDate, task.endTime) ?? new Date()) <= new Date()
|
||||
: new Date(`${task.endDate}T00:00:00Z`) <= new Date();
|
||||
|
||||
if (!isExpired) return;
|
||||
|
||||
const tz = task.owner ? await this.deviceTokensRepo.getTimezoneByUserId(task.owner) : 'UTC';
|
||||
const message = NotificationMessages.deadline(task.description, task.endDate, task.endTime, tz);
|
||||
|
||||
$logger.info(`[NotificationDispatcher] Expired deadline notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.DEADLINE,
|
||||
message,
|
||||
{ goalId: task.goalId, goalListId: task.goalListId },
|
||||
data.taskId,
|
||||
);
|
||||
}
|
||||
|
||||
private async onTaskDeleted(data: AppEvents['task.deleted']): Promise<void> {
|
||||
this.notificationsRepo.deleteByTaskAndType(data.taskId, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(data.taskId);
|
||||
}
|
||||
|
||||
private async resolveUserName(userId: number): Promise<string> {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.dbDrizzle
|
||||
.select({ login: UsersSchema.login })
|
||||
.from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, userId))
|
||||
.limit(1);
|
||||
return result[0]?.login || 'Someone';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NotificationMessage } from './types';
|
||||
import { parseUtcTime } from './utils';
|
||||
|
||||
export class NotificationMessages {
|
||||
static deadline(description: string | null, endDate: string, endTime: string | null, timezone: string): NotificationMessage {
|
||||
const title = `Task: ${description || 'Task'}`;
|
||||
|
||||
if (endTime) {
|
||||
const deadline = parseUtcTime(endDate, endTime);
|
||||
if (deadline) {
|
||||
const formatted = deadline.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return { title, body: `Deadline: ${formatted}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { title, body: `Deadline: ${endDate}` };
|
||||
}
|
||||
|
||||
static assign(taskDescription: string | null, assignedByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `Assigned to you by ${assignedByName}`,
|
||||
};
|
||||
}
|
||||
|
||||
static mention(taskDescription: string | null, mentionedByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `${mentionedByName} mentioned you`,
|
||||
};
|
||||
}
|
||||
|
||||
static comment(taskDescription: string | null, commentByName: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `New comment by ${commentByName}`,
|
||||
};
|
||||
}
|
||||
|
||||
static statusChange(taskDescription: string | null, newStatus: string): NotificationMessage {
|
||||
return {
|
||||
title: `Task: ${taskDescription || 'Task'}`,
|
||||
body: `Status changed to ${newStatus}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { NotificationChannel } from './types';
|
||||
|
||||
export interface NotificationMeta {
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationProvider {
|
||||
readonly channel: NotificationChannel;
|
||||
send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { $logger } from '../../modules/logget';
|
||||
import type { NotificationMeta, NotificationProvider } from './NotificationProvider';
|
||||
import type { NotificationMessage } from './types';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
import { UserPreferencesRepository } from './repositories/UserPreferencesRepository';
|
||||
import { NotificationChannel, type NotificationType } from './types';
|
||||
import { CentrifugoProvider } from './providers/CentrifugoProvider';
|
||||
import { FCMProvider } from './providers/FCMProvider';
|
||||
|
||||
export class NotificationService {
|
||||
private readonly providers: Map<NotificationChannel, NotificationProvider>;
|
||||
private readonly repo: NotificationsRepository;
|
||||
private readonly preferences: UserPreferencesRepository;
|
||||
|
||||
constructor() {
|
||||
this.repo = new NotificationsRepository();
|
||||
this.preferences = new UserPreferencesRepository();
|
||||
|
||||
const providerList: NotificationProvider[] = [
|
||||
new CentrifugoProvider(),
|
||||
new FCMProvider(),
|
||||
];
|
||||
|
||||
this.providers = new Map();
|
||||
for (const p of providerList) {
|
||||
this.providers.set(p.channel, p);
|
||||
}
|
||||
}
|
||||
|
||||
async notify(
|
||||
userId: number,
|
||||
type: NotificationType,
|
||||
message: NotificationMessage,
|
||||
meta: NotificationMeta,
|
||||
taskId: number | null = null,
|
||||
): Promise<void> {
|
||||
$logger.info(`[NotificationService] notify user=${userId}, type=${type}, title="${message.title}"`);
|
||||
|
||||
const channels = await this.preferences.getEnabledChannels(userId, type, meta.goalId);
|
||||
if (channels.length === 0) {
|
||||
$logger.info(`[NotificationService] All channels disabled for user=${userId}, type=${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await this.repo.create({ userId, taskId, type, title: message.title, body: message.body });
|
||||
if (!notification) {
|
||||
$logger.warn(`[NotificationService] Failed to create notification record for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
$logger.info(`[NotificationService] Created notification id=${notification.id}, sending to channels=[${channels.join(',')}]`);
|
||||
|
||||
await Promise.allSettled(
|
||||
channels
|
||||
.map((channel) => this.providers.get(channel))
|
||||
.filter(Boolean)
|
||||
.map((provider) =>
|
||||
provider!.send(userId, notification, meta).catch((err) => {
|
||||
$logger.error(err, `[NotificationService] Provider "${provider!.channel}" failed for user ${userId}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async notifyMany(
|
||||
userIds: number[],
|
||||
type: NotificationType,
|
||||
message: NotificationMessage,
|
||||
meta: NotificationMeta,
|
||||
taskId: number | null = null,
|
||||
): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
userIds.map((userId) => this.notify(userId, type, message, meta, taskId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!_instance) {
|
||||
_instance = new NotificationService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CentrifugoClient } from '../../core/CentrifugoClient';
|
||||
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
|
||||
import { UserPreferencesRepository } from './repositories/UserPreferencesRepository';
|
||||
import { UserPreferences } from './UserPreferences';
|
||||
|
||||
const NotificationArkTypeMarkRead = type({
|
||||
notificationId: 'number',
|
||||
});
|
||||
|
||||
const DeviceTokenArkType = type({
|
||||
token: 'string',
|
||||
platform: "'android' | 'ios'",
|
||||
timezone: 'string',
|
||||
});
|
||||
|
||||
export class NotificationsController {
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const cursor = req.query.cursor ? Number(req.query.cursor) : undefined;
|
||||
return res.tvJson(await req.appUser.notificationsManager.fetchByUser(cursor));
|
||||
};
|
||||
|
||||
markRead = async (req: Request, res: Response) => {
|
||||
const out = NotificationArkTypeMarkRead(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
return res.tvJson(await req.appUser.notificationsManager.markRead(out.notificationId));
|
||||
};
|
||||
|
||||
markAllRead = async (_req: Request, res: Response) => {
|
||||
return res.tvJson(await _req.appUser.notificationsManager.markAllRead());
|
||||
};
|
||||
|
||||
registerDevice = async (req: Request, res: Response) => {
|
||||
console.log('[registerDevice] body:', JSON.stringify(req.body));
|
||||
const out = DeviceTokenArkType(req.body);
|
||||
if (out instanceof type.errors) {
|
||||
console.log('[registerDevice] validation error:', out.summary);
|
||||
return res.status(400).send(out.summary);
|
||||
}
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).send('Unauthorized');
|
||||
|
||||
const repo = new DeviceTokensRepository();
|
||||
return res.tvJson(await repo.register(userId, out.token, out.platform, out.timezone));
|
||||
};
|
||||
|
||||
unregisterDevice = async (req: Request, res: Response) => {
|
||||
const { token } = req.body;
|
||||
if (!token || typeof token !== 'string') {
|
||||
return res.status(400).send('token is required');
|
||||
}
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(401).send('Unauthorized');
|
||||
|
||||
const repo = new DeviceTokensRepository();
|
||||
return res.tvJson(await repo.unregister(userId, token));
|
||||
};
|
||||
|
||||
getPreferences = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(400).send('Bad request');
|
||||
|
||||
const repo = new UserPreferencesRepository();
|
||||
const prefs = await repo.load(userId);
|
||||
return res.tvJson({ settings: prefs.toJSON() });
|
||||
};
|
||||
|
||||
savePreferences = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) return res.status(400).send('Bad request');
|
||||
|
||||
const { settings } = req.body;
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return res.status(400).send('settings is required');
|
||||
}
|
||||
|
||||
const repo = new UserPreferencesRepository();
|
||||
const prefs = new UserPreferences(settings);
|
||||
const result = await repo.save(userId, prefs);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
connectionToken = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const publicUrl = process.env.CENTRIFUGO_PUBLIC_URL;
|
||||
if (!publicUrl) {
|
||||
return res.tvJson({ token: null, url: null });
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
|
||||
const token = CentrifugoClient.generateConnectionToken(userId);
|
||||
return res.tvJson({ token, url });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { NotificationsRepository } from './repositories/NotificationsRepository';
|
||||
|
||||
export class NotificationsManager {
|
||||
private readonly user: AppUser;
|
||||
public readonly repository: NotificationsRepository;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new NotificationsRepository();
|
||||
}
|
||||
|
||||
async fetchByUser(cursor?: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return { notifications: [] };
|
||||
const notifications = await this.repository.fetchByUser(userId, cursor);
|
||||
return { notifications };
|
||||
}
|
||||
|
||||
async markRead(notificationId: number) {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return false;
|
||||
return this.repository.markRead(notificationId, userId);
|
||||
}
|
||||
|
||||
async markAllRead() {
|
||||
const userId = this.user.getUserData()?.id;
|
||||
if (!userId) return false;
|
||||
return this.repository.markAllRead(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { NotificationsController } from './NotificationsController';
|
||||
|
||||
export default class NotificationsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: NotificationsController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new NotificationsController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.patch('/read', [IsLoggedIn], this.controller.markRead);
|
||||
this.router.patch('/read-all', [IsLoggedIn], this.controller.markAllRead);
|
||||
this.router.get('/preferences', [IsLoggedIn], this.controller.getPreferences);
|
||||
this.router.put('/preferences', [IsLoggedIn], this.controller.savePreferences);
|
||||
this.router.get('/connection-token', [IsLoggedIn], this.controller.connectionToken);
|
||||
this.router.post('/device/register', [IsLoggedIn], this.controller.registerDevice);
|
||||
this.router.post('/device/unregister', [IsLoggedIn], this.controller.unregisterDevice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
NotificationChannel,
|
||||
NotificationType,
|
||||
type TypeSettings,
|
||||
type TypeSettingsMap,
|
||||
type DeadlineTypeSettings,
|
||||
type DeadlineIntervals,
|
||||
type SettingsJson,
|
||||
} from './types';
|
||||
|
||||
const ALL_CHANNELS: NotificationChannel[] = [
|
||||
NotificationChannel.PUSH,
|
||||
NotificationChannel.WEBSOCKET
|
||||
];
|
||||
|
||||
/**
|
||||
* Manages user notification preferences.
|
||||
* Opt-out model: undefined = enabled.
|
||||
*
|
||||
* Priority:
|
||||
* 1. projects[goalId][type] — highest
|
||||
* 2. global[type] — fallback
|
||||
* 3. undefined — enabled
|
||||
*/
|
||||
export class UserPreferences {
|
||||
private data: SettingsJson;
|
||||
|
||||
constructor(json: unknown) {
|
||||
this.data = (json && typeof json === 'object' ? json : {}) as SettingsJson;
|
||||
}
|
||||
|
||||
getEnabledChannels(type: NotificationType, goalId?: number): NotificationChannel[] {
|
||||
const resolved = this.resolveTypeSettings(type, goalId);
|
||||
if (!resolved?.channels) return ALL_CHANNELS;
|
||||
return ALL_CHANNELS.filter((ch) => resolved.channels![ch] !== false);
|
||||
}
|
||||
|
||||
isChannelEnabled(type: NotificationType, channel: NotificationChannel, goalId?: number): boolean {
|
||||
const resolved = this.resolveTypeSettings(type, goalId);
|
||||
return resolved?.channels?.[channel] !== false;
|
||||
}
|
||||
|
||||
getDeadlineIntervals(goalId?: number): DeadlineIntervals | undefined {
|
||||
const resolved = this.resolveTypeSettings(NotificationType.DEADLINE, goalId) as DeadlineTypeSettings | undefined;
|
||||
return resolved?.intervals;
|
||||
}
|
||||
|
||||
getEnabledDeadlineIntervals(goalId?: number): number[] {
|
||||
const intervals = this.getDeadlineIntervals(goalId);
|
||||
if (!intervals) return [0]; // default: notify at deadline
|
||||
return Object.entries(intervals)
|
||||
.filter(([, enabled]) => enabled !== false)
|
||||
.map(([minutes]) => Number(minutes));
|
||||
}
|
||||
|
||||
getGlobalTypeSettings<T extends NotificationType>(type: T): TypeSettingsMap[T] | undefined {
|
||||
return this.data.global?.[type] as TypeSettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
getProjectTypeSettings<T extends NotificationType>(goalId: number, type: T): TypeSettingsMap[T] | undefined {
|
||||
return this.data.projects?.[String(goalId)]?.[type] as TypeSettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
setGlobalChannel(type: NotificationType, channel: NotificationChannel, enabled: boolean): void {
|
||||
if (!this.data.global) this.data.global = {};
|
||||
if (!this.data.global[type]) this.data.global[type] = {} as TypeSettingsMap[typeof type];
|
||||
if (!this.data.global[type]!.channels) this.data.global[type]!.channels = {};
|
||||
this.data.global[type]!.channels![channel] = enabled;
|
||||
}
|
||||
|
||||
setDeadlineIntervals(intervals: DeadlineIntervals, goalId?: number): void {
|
||||
if (goalId !== undefined) {
|
||||
const key = String(goalId);
|
||||
if (!this.data.projects) this.data.projects = {};
|
||||
if (!this.data.projects[key]) this.data.projects[key] = {};
|
||||
if (!this.data.projects[key][NotificationType.DEADLINE]) this.data.projects[key][NotificationType.DEADLINE] = {};
|
||||
(this.data.projects[key][NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals;
|
||||
} else {
|
||||
if (!this.data.global) this.data.global = {};
|
||||
if (!this.data.global[NotificationType.DEADLINE]) this.data.global[NotificationType.DEADLINE] = {};
|
||||
(this.data.global[NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals;
|
||||
}
|
||||
}
|
||||
|
||||
setProjectChannel(goalId: number, type: NotificationType, channel: NotificationChannel, enabled: boolean): void {
|
||||
const key = String(goalId);
|
||||
if (!this.data.projects) this.data.projects = {};
|
||||
if (!this.data.projects[key]) this.data.projects[key] = {};
|
||||
if (!this.data.projects[key][type]) this.data.projects[key][type] = {} as TypeSettingsMap[typeof type];
|
||||
if (!this.data.projects[key][type]!.channels) this.data.projects[key][type]!.channels = {};
|
||||
this.data.projects[key][type]!.channels![channel] = enabled;
|
||||
}
|
||||
|
||||
removeProjectSettings(goalId: number): void {
|
||||
delete this.data.projects?.[String(goalId)];
|
||||
}
|
||||
|
||||
toJSON(): SettingsJson {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
private resolveTypeSettings(type: NotificationType, goalId?: number): TypeSettings | undefined {
|
||||
const globalSettings = this.data.global?.[type];
|
||||
|
||||
if (goalId === undefined) return globalSettings;
|
||||
|
||||
const projectSettings = this.data.projects?.[String(goalId)]?.[type];
|
||||
if (!projectSettings) return globalSettings;
|
||||
if (!globalSettings) return projectSettings;
|
||||
|
||||
return {
|
||||
channels: { ...globalSettings.channels, ...projectSettings.channels },
|
||||
...('intervals' in globalSettings || 'intervals' in projectSettings
|
||||
? {
|
||||
intervals: {
|
||||
...(globalSettings as DeadlineTypeSettings).intervals,
|
||||
...(projectSettings as DeadlineTypeSettings).intervals,
|
||||
}
|
||||
}
|
||||
: {}
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { getCentrifugoClient } from '../../../core/CentrifugoClient';
|
||||
import type { NotificationMeta, NotificationProvider } from '../NotificationProvider';
|
||||
import { NotificationChannel } from '../types';
|
||||
|
||||
export class CentrifugoProvider implements NotificationProvider {
|
||||
readonly channel = NotificationChannel.WEBSOCKET;
|
||||
|
||||
async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void> {
|
||||
await getCentrifugoClient().publishToUser(userId, 'notification', {
|
||||
notification,
|
||||
goalId: meta.goalId,
|
||||
goalListId: meta.goalListId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import admin from 'firebase-admin';
|
||||
import { getMessaging } from 'firebase-admin/messaging';
|
||||
import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { NotificationMeta, NotificationProvider } from '../NotificationProvider';
|
||||
import { NotificationChannel } from '../types';
|
||||
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
|
||||
export class FCMProvider implements NotificationProvider {
|
||||
readonly channel = NotificationChannel.PUSH;
|
||||
|
||||
private static initialized = false;
|
||||
private static messaging: admin.messaging.Messaging | null = null;
|
||||
|
||||
private readonly repo = new DeviceTokensRepository();
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.enabled = FCMProvider.init();
|
||||
}
|
||||
|
||||
private static init(): boolean {
|
||||
if (FCMProvider.initialized) return true;
|
||||
|
||||
const credentialsPath = process.env.FIREBASE_CREDENTIALS_PATH;
|
||||
if (!credentialsPath) {
|
||||
$logger.warn('[FCM] FIREBASE_CREDENTIALS_PATH not configured — push notifications disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(credentialsPath),
|
||||
});
|
||||
FCMProvider.messaging = getMessaging();
|
||||
FCMProvider.messaging.enableLegacyHttpTransport();
|
||||
FCMProvider.initialized = true;
|
||||
$logger.info('[FCM] Firebase initialized with legacy HTTP/1.1 transport');
|
||||
return true;
|
||||
} catch (err) {
|
||||
$logger.error(err, '[FCM] Failed to initialize Firebase');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise<void> {
|
||||
if (!this.enabled || !FCMProvider.messaging) return;
|
||||
|
||||
const tokens = await this.repo.getByUserId(userId);
|
||||
$logger.info(`[FCM] User ${userId}: found ${tokens.length} device token(s)`);
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
const message: admin.messaging.MulticastMessage = {
|
||||
tokens: tokens.map((t) => t.token),
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body || undefined,
|
||||
},
|
||||
data: {
|
||||
type: notification.type,
|
||||
taskId: notification.taskId ? String(notification.taskId) : '',
|
||||
goalId: String(meta.goalId),
|
||||
goalListId: meta.goalListId ? String(meta.goalListId) : '',
|
||||
notificationId: String(notification.id),
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
sound: 'default',
|
||||
},
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
$logger.info(`[FCM] Sending to ${tokens.length} token(s) for user ${userId}, title="${notification.title}"`);
|
||||
const response = await FCMProvider.messaging.sendEachForMulticast(message);
|
||||
$logger.info(`[FCM] Result: success=${response.successCount}, failure=${response.failureCount}`);
|
||||
|
||||
if (response.failureCount > 0) {
|
||||
const invalidTokens: string[] = [];
|
||||
response.responses.forEach((resp, idx) => {
|
||||
if (!resp.success) {
|
||||
const code = resp.error?.code;
|
||||
if (code === 'messaging/invalid-registration-token' || code === 'messaging/registration-token-not-registered') {
|
||||
invalidTokens.push(tokens[idx].token);
|
||||
} else {
|
||||
$logger.error(resp.error, '[FCM] Failed to send to token');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of invalidTokens) {
|
||||
await this.repo.deleteByToken(token);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
$logger.error(err, `[FCM] Failed to send multicast for user ${userId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { DeviceTokensSchema, type DeviceTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
|
||||
export class DeviceTokensRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async register(userId: number, token: string, platform: string, timezone: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(DeviceTokensSchema).values({
|
||||
userId,
|
||||
token,
|
||||
platform,
|
||||
timezone,
|
||||
}).onConflictDoUpdate({
|
||||
target: [DeviceTokensSchema.userId, DeviceTokensSchema.token],
|
||||
set: { timezone },
|
||||
})
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async unregister(userId: number, token: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(and(
|
||||
eq(DeviceTokensSchema.userId, userId),
|
||||
eq(DeviceTokensSchema.token, token),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async getByUserId(userId: number): Promise<DeviceTokensSchemaTypeForSelect[]> {
|
||||
return await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select()
|
||||
.from(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.userId, userId))
|
||||
) || [];
|
||||
}
|
||||
|
||||
async getTimezoneByUserId(userId: number): Promise<string> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ timezone: DeviceTokensSchema.timezone })
|
||||
.from(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.userId, userId))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0]?.timezone || 'UTC';
|
||||
}
|
||||
|
||||
async deleteByToken(token: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.token, token))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq, desc, lt, sql } from 'drizzle-orm';
|
||||
import { NotificationsSchema, TasksSchema, type NotificationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
|
||||
export class NotificationsRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: { userId: number; taskId: number | null; type: string; title: string; body: string | null }): Promise<NotificationsSchemaTypeForSelect | false> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(NotificationsSchema).values({
|
||||
userId: data.userId,
|
||||
taskId: data.taskId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
}).returning()
|
||||
);
|
||||
if (!result) return false;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async fetchByUser(userId: number, cursor?: number) {
|
||||
const limit = 30;
|
||||
const conditions = [eq(NotificationsSchema.userId, userId)];
|
||||
if (cursor) {
|
||||
conditions.push(lt(NotificationsSchema.id, cursor));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({
|
||||
id: NotificationsSchema.id,
|
||||
userId: NotificationsSchema.userId,
|
||||
taskId: NotificationsSchema.taskId,
|
||||
type: NotificationsSchema.type,
|
||||
title: NotificationsSchema.title,
|
||||
body: NotificationsSchema.body,
|
||||
read: NotificationsSchema.read,
|
||||
createdAt: NotificationsSchema.createdAt,
|
||||
goalId: TasksSchema.goalId,
|
||||
goalListId: TasksSchema.goalListId,
|
||||
})
|
||||
.from(NotificationsSchema)
|
||||
.leftJoin(TasksSchema, eq(NotificationsSchema.taskId, TasksSchema.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(NotificationsSchema.id))
|
||||
.limit(limit)
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
async markRead(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(NotificationsSchema)
|
||||
.set({ read: true })
|
||||
.where(and(
|
||||
eq(NotificationsSchema.id, notificationId),
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async deleteByTaskAndType(taskId: number, type: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(NotificationsSchema)
|
||||
.where(and(
|
||||
eq(NotificationsSchema.taskId, taskId),
|
||||
eq(NotificationsSchema.type, type),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async deleteOlderThanDays(days: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(NotificationsSchema)
|
||||
.where(lt(NotificationsSchema.createdAt, sql`NOW() - INTERVAL '${sql.raw(String(days))} days'`))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async markAllRead(userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(NotificationsSchema)
|
||||
.set({ read: true })
|
||||
.where(and(
|
||||
eq(NotificationsSchema.userId, userId),
|
||||
eq(NotificationsSchema.read, false),
|
||||
))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NotificationPreferencesSchema } from 'taskview-db-schemas';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { callWithCatch } from '../../../utils/helpers';
|
||||
import { UserPreferences } from '../UserPreferences';
|
||||
import type { NotificationChannel, NotificationType } from '../types';
|
||||
|
||||
export class UserPreferencesRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async load(userId: number): Promise<UserPreferences> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ settings: NotificationPreferencesSchema.settings })
|
||||
.from(NotificationPreferencesSchema)
|
||||
.where(eq(NotificationPreferencesSchema.userId, userId))
|
||||
.limit(1)
|
||||
);
|
||||
return new UserPreferences(result?.[0]?.settings);
|
||||
}
|
||||
|
||||
async save(userId: number, preferences: UserPreferences): Promise<boolean> {
|
||||
const settings = preferences.toJSON();
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(NotificationPreferencesSchema)
|
||||
.values({ userId, settings })
|
||||
.onConflictDoUpdate({
|
||||
target: [NotificationPreferencesSchema.userId],
|
||||
set: { settings },
|
||||
})
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async getEnabledChannels(userId: number, type: NotificationType, goalId?: number): Promise<NotificationChannel[]> {
|
||||
const prefs = await this.load(userId);
|
||||
return prefs.getEnabledChannels(type, goalId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { eq, and, or, isNull } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { TasksSchema, TasksAssigneeSchema, GoalsSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import { getJobQueue, cancelJobBySingletonKey } from '../../../core/JobQueue';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { getNotificationService } from '../NotificationService';
|
||||
import { NotificationMessages } from '../NotificationMessages';
|
||||
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
|
||||
import { NotificationType, type DeadlineJobData, type TaskWithDeadline } from '../types';
|
||||
import { parseUtcTime } from '../utils';
|
||||
|
||||
const DEADLINE_JOB = 'deadline-notification';
|
||||
|
||||
export class DeadlineScheduler {
|
||||
private readonly deviceTokensRepo = new DeviceTokensRepository();
|
||||
|
||||
async schedule(task: TaskWithDeadline, initiatorId?: number): Promise<void> {
|
||||
if (!task.endDate) return;
|
||||
|
||||
let startAfter: Date | undefined;
|
||||
|
||||
if (task.endTime) {
|
||||
const deadline = parseUtcTime(task.endDate, task.endTime);
|
||||
if (!deadline) return;
|
||||
startAfter = deadline > new Date() ? deadline : undefined;
|
||||
} else {
|
||||
const deadlineDay = new Date(`${task.endDate}T00:00:00Z`);
|
||||
startAfter = deadlineDay > new Date() ? deadlineDay : undefined;
|
||||
}
|
||||
|
||||
const data: DeadlineJobData = {
|
||||
taskId: task.id,
|
||||
description: task.description ?? '',
|
||||
goalId: task.goalId,
|
||||
goalListId: task.goalListId,
|
||||
endDate: task.endDate,
|
||||
endTime: task.endTime,
|
||||
initiatorId: initiatorId ?? null,
|
||||
immediate: !startAfter,
|
||||
};
|
||||
|
||||
$logger.info(`[DeadlineScheduler] Scheduling task=${task.id} at=${startAfter?.toISOString() ?? 'immediate'}`);
|
||||
|
||||
await getJobQueue().send(DEADLINE_JOB, data, {
|
||||
startAfter,
|
||||
singletonKey: this.singletonKey(task.id),
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(taskId: number): Promise<void> {
|
||||
await cancelJobBySingletonKey(DEADLINE_JOB, this.singletonKey(taskId));
|
||||
}
|
||||
|
||||
async registerWorker(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
const db = Database.getInstance();
|
||||
|
||||
await boss.createQueue(DEADLINE_JOB);
|
||||
|
||||
await boss.work<DeadlineJobData>(DEADLINE_JOB, async ([job]) => {
|
||||
const { taskId, description, goalId, goalListId, endDate, endTime, initiatorId, immediate } = job.data;
|
||||
|
||||
if (!taskId) return;
|
||||
$logger.info(`[DeadlineScheduler] Worker: job=${job.id} task=${taskId}`);
|
||||
|
||||
const task = await db.dbDrizzle
|
||||
.select({ owner: TasksSchema.owner, endDate: TasksSchema.endDate, endTime: TasksSchema.endTime })
|
||||
.from(TasksSchema)
|
||||
.where(and(eq(TasksSchema.id, taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete))));
|
||||
|
||||
if (task.length === 0) {
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: not found or completed`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (task[0].endDate !== endDate || task[0].endTime !== endTime) {
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: stale job, deadline changed`);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientIds = await this.resolveRecipients(db, taskId, goalId, task[0].owner);
|
||||
if (!recipientIds || recipientIds.length === 0) {
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: no recipients`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (immediate && initiatorId) {
|
||||
const idx = recipientIds.indexOf(initiatorId);
|
||||
if (idx !== -1) recipientIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (recipientIds.length === 0) return;
|
||||
|
||||
$logger.info(`[DeadlineScheduler] Task ${taskId}: sending to [${recipientIds.join(',')}]`);
|
||||
|
||||
const tz = task[0].owner ? await this.deviceTokensRepo.getTimezoneByUserId(task[0].owner) : 'UTC';
|
||||
const message = NotificationMessages.deadline(description, endDate, endTime, tz);
|
||||
|
||||
await getNotificationService().notifyMany(
|
||||
recipientIds,
|
||||
NotificationType.DEADLINE,
|
||||
message,
|
||||
{ goalId, goalListId },
|
||||
taskId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private singletonKey(taskId: number): string {
|
||||
return `deadline-${taskId}`;
|
||||
}
|
||||
|
||||
private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise<number[] | null> {
|
||||
const authUsers = alias(UsersSchema, 'auth_users');
|
||||
|
||||
try {
|
||||
const [assignees, goal] = await Promise.all([
|
||||
db.dbDrizzle
|
||||
.select({ userId: authUsers.id })
|
||||
.from(TasksAssigneeSchema)
|
||||
.innerJoin(CollaborationUsersSchema, eq(TasksAssigneeSchema.collabUserId, CollaborationUsersSchema.id))
|
||||
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
|
||||
.where(eq(TasksAssigneeSchema.taskId, taskId)),
|
||||
db.dbDrizzle
|
||||
.select({ owner: GoalsSchema.owner })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, goalId)),
|
||||
]);
|
||||
|
||||
const ids = new Set<number>();
|
||||
if (taskOwner) ids.add(taskOwner);
|
||||
assignees.forEach((r) => ids.add(r.userId));
|
||||
if (goal[0]) ids.add(goal[0].owner);
|
||||
|
||||
return [...ids];
|
||||
} catch (err) {
|
||||
$logger.error(err, '[DeadlineScheduler] Failed to resolve recipients');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
export enum NotificationType {
|
||||
DEADLINE = 'deadline',
|
||||
ASSIGN = 'assign',
|
||||
MENTION = 'mention',
|
||||
COMMENT = 'comment',
|
||||
STATUS_CHANGE = 'status_change',
|
||||
}
|
||||
|
||||
export enum NotificationChannel {
|
||||
PUSH = 'push',
|
||||
WEBSOCKET = 'websocket',
|
||||
EMAIL = 'email',
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base settings — only channels (for instant notification types)
|
||||
*/
|
||||
export interface BaseTypeSettings {
|
||||
channels?: Partial<Record<NotificationChannel, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed intervals in minutes before deadline.
|
||||
* Key = minutes, value = enabled. undefined = enabled (opt-out)
|
||||
*/
|
||||
export interface DeadlineIntervals {
|
||||
0?: boolean; // at deadline
|
||||
15?: boolean; // 15 min before
|
||||
30?: boolean; // 30 min before
|
||||
60?: boolean; // 1 hour before
|
||||
1440?: boolean; // 1 day before
|
||||
}
|
||||
|
||||
/**
|
||||
* Deadline has intervals (minutes before deadline to notify)
|
||||
*/
|
||||
export interface DeadlineTypeSettings extends BaseTypeSettings {
|
||||
intervals?: DeadlineIntervals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant types — only channels, no intervals
|
||||
*/
|
||||
export type InstantTypeSettings = BaseTypeSettings;
|
||||
|
||||
/**
|
||||
* Maps each notification type to its allowed settings shape
|
||||
*/
|
||||
export interface TypeSettingsMap {
|
||||
[NotificationType.DEADLINE]: DeadlineTypeSettings;
|
||||
[NotificationType.ASSIGN]: InstantTypeSettings;
|
||||
[NotificationType.MENTION]: InstantTypeSettings;
|
||||
[NotificationType.COMMENT]: InstantTypeSettings;
|
||||
[NotificationType.STATUS_CHANGE]: InstantTypeSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all possible type settings (for generic use)
|
||||
*/
|
||||
export type TypeSettings = DeadlineTypeSettings | InstantTypeSettings;
|
||||
|
||||
/**
|
||||
* @example
|
||||
* {
|
||||
* "global": {
|
||||
* "deadline": {
|
||||
* "channels": { "push": true, "websocket": true, "email": false },
|
||||
* "intervals": { "0": true, "15": true, "60": true, "1440": false }
|
||||
* },
|
||||
* "assign": {
|
||||
* "channels": { "push": true, "websocket": true }
|
||||
* },
|
||||
* "mention": {
|
||||
* "channels": { "push": false }
|
||||
* }
|
||||
* },
|
||||
* "projects": {
|
||||
* "42": {
|
||||
* "deadline": {
|
||||
* "channels": { "push": false },
|
||||
* "intervals": { "0": true, "30": true }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Result for user with these settings:
|
||||
* - deadline globally: push + websocket, remind at 0/15/60 min before (1440 disabled)
|
||||
* - deadline in project 42: websocket only (push overridden), remind at 0/30 min before
|
||||
* - assign globally: push + websocket
|
||||
* - mention globally: websocket only (push explicitly disabled)
|
||||
* - comment: no settings → all channels enabled (opt-out)
|
||||
*/
|
||||
export interface SettingsJson {
|
||||
global?: { [K in NotificationType]?: TypeSettingsMap[K] };
|
||||
projects?: Record<string, { [K in NotificationType]?: TypeSettingsMap[K] }>;
|
||||
}
|
||||
|
||||
export interface DeadlineJobData {
|
||||
taskId: number;
|
||||
description: string;
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
endDate: string;
|
||||
endTime: string | null;
|
||||
initiatorId: number | null;
|
||||
immediate: boolean;
|
||||
}
|
||||
|
||||
export interface TaskWithDeadline {
|
||||
id: number;
|
||||
description: string | null;
|
||||
goalId: number;
|
||||
goalListId: number | null;
|
||||
owner: number | null;
|
||||
endDate: string | null;
|
||||
endTime: string | null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Parse UTC time string (HH:mm:ss) with date into a Date object
|
||||
*/
|
||||
export function parseUtcTime(dateStr: string, timeStr: string): Date | null {
|
||||
const match = timeStr.match(/^(\d{2}):(\d{2})/);
|
||||
if (!match) return null;
|
||||
return new Date(`${dateStr}T${match[1]}:${match[2]}:00Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a local hour (e.g. 9 for 09:00) in a given IANA timezone
|
||||
* to a UTC Date for the specified date string (YYYY-MM-DD)
|
||||
*/
|
||||
export function localHourToUtc(dateStr: string, hour: number, timezone: string): Date {
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const utcMidnight = new Date(`${dateStr}T00:00:00Z`);
|
||||
const parts = formatter.formatToParts(utcMidnight);
|
||||
const tzHour = Number(parts.find(p => p.type === 'hour')?.value ?? 0);
|
||||
const tzDay = Number(parts.find(p => p.type === 'day')?.value ?? 0);
|
||||
const utcDay = utcMidnight.getUTCDate();
|
||||
|
||||
let offsetHours = tzHour - utcMidnight.getUTCHours();
|
||||
if (tzDay > utcDay) offsetHours += 24;
|
||||
else if (tzDay < utcDay) offsetHours -= 24;
|
||||
|
||||
const result = new Date(`${dateStr}T00:00:00Z`);
|
||||
result.setUTCHours(hour - offsetHours, 0, 0, 0);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = new Date(`${dateStr}T00:00:00Z`);
|
||||
fallback.setUTCHours(hour, 0, 0, 0);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class StartManager {
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new StartRepository();
|
||||
this.repository = new StartRepository(this.user);
|
||||
}
|
||||
|
||||
async fetchAllLists() {
|
||||
|
||||
@@ -7,11 +7,14 @@ import type { TagToTaskInDb } from '../tags/tags.types';
|
||||
import { TaskItemForClient } from '../tasks/TaskItemForClient';
|
||||
import type { AssigneesForTaskFromDb, FetchAllListsResult, UsersByProjectsFromDb } from './start.types';
|
||||
|
||||
//TODO: refactor
|
||||
export class StartRepository {
|
||||
public readonly db: Database;
|
||||
public readonly user: AppUser;
|
||||
|
||||
constructor() {
|
||||
constructor(user: AppUser) {
|
||||
this.db = Database.getInstance();
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
async fetchAllLists(user: AppUser): Promise<FetchAllListsResult[] | false> {
|
||||
@@ -107,7 +110,7 @@ export class StartRepository {
|
||||
|
||||
async fetchAllActiveTasksForGoals(
|
||||
goalsIds: number[],
|
||||
assignees?: AssigneesForTaskFromDb[]
|
||||
assignees?: AssigneesForTaskFromDb[],
|
||||
): Promise<TaskItemForClient[]> {
|
||||
if (goalsIds.length === 0) {
|
||||
return [];
|
||||
@@ -136,9 +139,9 @@ export class StartRepository {
|
||||
const tagsQuery = `select ttt.*
|
||||
from tasks.tags
|
||||
left join tasks.tasks_to_tags ttt on tags.id = ttt.tag_id
|
||||
where owner = 1 and goal_id in (${placeholders.join(',')});`;
|
||||
where goal_id in (${placeholders.join(',')}) and owner = $${goalsIds.length + 1};`;
|
||||
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, goalsIds);
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, [...goalsIds, this.user.getUserData()?.id]);
|
||||
|
||||
if (tagsResult) {
|
||||
tagsResult.rows.forEach((r) => {
|
||||
@@ -209,9 +212,9 @@ export class StartRepository {
|
||||
const tagsQuery = `select ttt.*
|
||||
from tasks.tags
|
||||
left join tasks.tasks_to_tags ttt on tags.id = ttt.tag_id
|
||||
where owner = 1 and goal_id in (${placeholders.join(',')});`;
|
||||
where goal_id in (${placeholders.join(',')}) and owner = $${goalsIds.length + 1};`;
|
||||
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, goalsIds);
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, [...goalsIds, this.user.getUserData()?.id]);
|
||||
|
||||
if (tagsResult) {
|
||||
tagsResult.rows.forEach((r) => {
|
||||
@@ -287,9 +290,9 @@ export class StartRepository {
|
||||
const tagsQuery = `select ttt.*
|
||||
from tasks.tags
|
||||
left join tasks.tasks_to_tags ttt on tags.id = ttt.tag_id
|
||||
where owner = 1 and goal_id in (${placeholders.join(',')});`;
|
||||
where goal_id in (${placeholders.join(',')}) and owner = $${goalsIds.length + 1};`;
|
||||
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, goalsIds);
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, [...goalsIds, this.user.getUserData()?.id]);
|
||||
|
||||
if (tagsResult) {
|
||||
tagsResult.rows.forEach((r) => {
|
||||
@@ -350,9 +353,9 @@ export class StartRepository {
|
||||
const tagsQuery = `select ttt.*
|
||||
from tasks.tags
|
||||
left join tasks.tasks_to_tags ttt on tags.id = ttt.tag_id
|
||||
where owner = 1 and goal_id in (${placeholders.join(',')});`;
|
||||
where goal_id in (${placeholders.join(',')}) and owner = $${goalsIds.length + 1};`;
|
||||
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, goalsIds);
|
||||
const tagsResult = await this.db.query<TagToTaskInDb>(tagsQuery, [...goalsIds, this.user.getUserData()?.id]);
|
||||
|
||||
if (tagsResult) {
|
||||
tagsResult.rows.forEach((r) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { TagsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { GoalsSchema, TagsSchema, TasksToTagsSchema } from 'taskview-db-schemas';
|
||||
import { GoalsSchema, TagsSchema, TasksSchema, TasksToTagsSchema } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { Database } from '../../modules/db';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
@@ -171,6 +171,19 @@ export class TagsRepository {
|
||||
}
|
||||
|
||||
async toggleTagNew(tagId: number, taskId: number): Promise<'delete' | 'add' | null> {
|
||||
const [tag, task] = await Promise.all([
|
||||
callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ goalId: TagsSchema.goalId }).from(TagsSchema).where(eq(TagsSchema.id, tagId))
|
||||
),
|
||||
callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ goalId: TasksSchema.goalId }).from(TasksSchema).where(eq(TasksSchema.id, taskId))
|
||||
),
|
||||
])
|
||||
|
||||
if (!tag?.[0] || !task?.[0] || tag[0].goalId === null || tag[0].goalId !== task[0].goalId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tagExists = await this.tagExists(tagId, taskId);
|
||||
|
||||
if (tagExists) {
|
||||
|
||||
@@ -186,9 +186,9 @@ export class TasksController {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const subtasks = await req.appUser.tasksManager.fetchSubtasks(args.data);
|
||||
// const subtasks = await req.appUser.tasksManager.fetchSubtasks(args.data);
|
||||
|
||||
return res.tvJson(subtasks);
|
||||
return res.tvJson([]);
|
||||
};
|
||||
|
||||
/** @deprecated */
|
||||
@@ -343,13 +343,13 @@ export class TasksController {
|
||||
return res.status(400).send(args.summary);
|
||||
}
|
||||
|
||||
const task = await req.appUser.tasksManager.updateTask(args);
|
||||
const result = await req.appUser.tasksManager.updateTask(args);
|
||||
if (!result) return res.tvJson(null);
|
||||
|
||||
return res.tvJson(task);
|
||||
return res.tvJson({ ...result.task, syncFailed: result.syncFailed });
|
||||
};
|
||||
|
||||
fetchTasksNew = async (req: Request, res: Response) => {
|
||||
// debugger;
|
||||
const out = TaskArkTypeFetchTasksNew(req.query);
|
||||
|
||||
if (out instanceof type.errors) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import type {
|
||||
AddTaskArg,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
type TaskForClientNew,
|
||||
type TasksArgToggleTaskUsers,
|
||||
} from './tasks.server.types';
|
||||
import type { KanbanArgFetchTasksForColumn } from '../kanban/types';
|
||||
import type { KanbanArgFetchTasksForColumn, KanbanArgFilters } from '../kanban/types';
|
||||
|
||||
type TaskFieldPermissionKey = keyof typeof TaskFieldPermissionsForEditOrCreation & keyof TasksSchemaTypeForSelect;
|
||||
|
||||
@@ -80,7 +81,7 @@ export class TasksManager {
|
||||
|
||||
const tagsMap: Record<number, number[]> = {};
|
||||
const ids = tasks.map((t) => t.id);
|
||||
|
||||
|
||||
const tags = await this.repository.fetchTagsForTasks(ids);
|
||||
|
||||
if (tags) {
|
||||
@@ -115,7 +116,7 @@ export class TasksManager {
|
||||
|
||||
const tagsMap: Record<number, number[]> = {};
|
||||
const ids = tasks.map((t) => t.id);
|
||||
|
||||
|
||||
const tags = await this.repository.fetchTagsForTasks(ids);
|
||||
|
||||
if (tags) {
|
||||
@@ -156,6 +157,9 @@ export class TasksManager {
|
||||
|
||||
if (!task) return false;
|
||||
|
||||
// Sync task completion state to linked GitHub/GitLab issue
|
||||
this.user.integrationsManager.onTaskCompleteChanged(arg.taskId, arg.complete).catch(() => { });
|
||||
|
||||
return new TaskItemForClient(task);
|
||||
}
|
||||
|
||||
@@ -259,13 +263,36 @@ export class TasksManager {
|
||||
return await this.repository.updateTransactionType(data);
|
||||
}
|
||||
|
||||
async updateTask(data: TaskArgUpdate): Promise<TaskForClientNew | null> {
|
||||
async updateTask(data: TaskArgUpdate): Promise<{ task: TaskForClientNew; syncFailed?: boolean } | null> {
|
||||
if (data.statusId !== undefined) {
|
||||
const currentTask = await this.repository.fetchTaskByIdNew(data.id);
|
||||
if (currentTask && currentTask.statusId !== data.statusId) {
|
||||
data.kanbanOrder = await this.repository.getNextKanbanOrder(currentTask.goalId);
|
||||
}
|
||||
}
|
||||
|
||||
const task = await this.repository.updateTask(data);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let syncFailed = false;
|
||||
if (data.complete !== undefined) {
|
||||
const synced = await this.user.integrationsManager.onTaskCompleteChanged(data.id, data.complete).catch(() => false);
|
||||
if (!synced) syncFailed = true;
|
||||
}
|
||||
|
||||
const { id, ...changes } = data;
|
||||
eventBus.emit('task.updated', {
|
||||
task,
|
||||
changes,
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
|
||||
const tasks = await this.extendTasksWithTagsAndAssignees([task]);
|
||||
return tasks[0] ?? null;
|
||||
const result = tasks[0] ?? null;
|
||||
if (!result) return null;
|
||||
return { task: result, syncFailed: syncFailed || undefined };
|
||||
}
|
||||
|
||||
async fetchTasksNew(data: TaskArgFetchTasksNew) {
|
||||
@@ -372,25 +399,43 @@ export class TasksManager {
|
||||
let newData = { ...data };
|
||||
|
||||
if (data.kanbanOrder === null || data.kanbanOrder === undefined) {
|
||||
const minKanbanOrder = await this.repository.fetchTaskWithMinKanbanOrder(data.goalId, data.statusId ?? null);
|
||||
const GAP = 16384;
|
||||
newData.kanbanOrder = (minKanbanOrder ?? 0) - GAP;
|
||||
newData.kanbanOrder = await this.repository.getNextKanbanOrder(data.goalId, data.statusId ?? null);
|
||||
}
|
||||
|
||||
const task = await this.repository.addTaskNew(newData);
|
||||
return await this.extendTasksWithTagsAndAssignees(task, true);
|
||||
const result = await this.extendTasksWithTagsAndAssignees(task, true);
|
||||
|
||||
if (task[0]) {
|
||||
eventBus.emit('task.created', {
|
||||
task: task[0],
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteTaskNew(data: TaskArgDelete) {
|
||||
return await this.repository.deleteTaskNew(data);
|
||||
const task = await this.repository.fetchTaskByIdNew(data.taskId);
|
||||
const result = await this.repository.deleteTaskNew(data);
|
||||
if (result) {
|
||||
eventBus.emit('task.deleted', { taskId: data.taskId, goalId: task?.goalId ?? 0, initiatorId: this.user.getUserData()?.id as number });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async toggleTaskUsers(data: TasksArgToggleTaskUsers) {
|
||||
return await this.repository.toggleTaskUsers(data);
|
||||
const result = await this.repository.toggleTaskUsers(data);
|
||||
eventBus.emit('task.assigneesChanged', {
|
||||
taskId: data.taskId,
|
||||
userIds: data.userIds,
|
||||
initiatorId: this.user.getUserData()?.id as number,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> {
|
||||
const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor);
|
||||
async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> {
|
||||
const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor, data.filters);
|
||||
if (!tasks || tasks.length === 0) return { tasks: [], nextCursor: null };
|
||||
return { tasks: await this.extendTasksWithTagsAndAssignees(tasks), nextCursor: tasks[tasks.length - 1].kanbanOrder };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
TaskArgUpdate,
|
||||
TasksArgToggleTaskUsers,
|
||||
} from './tasks.server.types';
|
||||
import type { KanbanArgFilters } from '../kanban/types';
|
||||
|
||||
export class TasksRepository {
|
||||
private readonly db: Database;
|
||||
@@ -274,7 +275,7 @@ export class TasksRepository {
|
||||
|
||||
async fetchSubtasks(taskId: number): Promise<TasksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.parentId, taskId))
|
||||
this.db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.parentId, taskId)).orderBy(asc(TasksSchema.id))
|
||||
);
|
||||
|
||||
return result ?? [];
|
||||
@@ -560,8 +561,10 @@ export class TasksRepository {
|
||||
}
|
||||
|
||||
// complete
|
||||
if (data.showCompleted === 0) conditions.push(eq(TasksSchema.complete, false));
|
||||
else if (data.showCompleted === 1) conditions.push(eq(TasksSchema.complete, true));
|
||||
if (!data.ignoreCompleted) {
|
||||
if (data.showCompleted === 0) conditions.push(eq(TasksSchema.complete, false));
|
||||
else if (data.showCompleted === 1) conditions.push(eq(TasksSchema.complete, true));
|
||||
}
|
||||
|
||||
// assignee filter — через EXISTS
|
||||
if (data.filters?.selectedUser) {
|
||||
@@ -641,30 +644,58 @@ export class TasksRepository {
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null): Promise<TasksSchemaTypeForSelect[]> {
|
||||
async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null, filters?: KanbanArgFilters): Promise<TasksSchemaTypeForSelect[]> {
|
||||
const conditions = [
|
||||
eq(TasksSchema.goalId, goalId),
|
||||
columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId),
|
||||
isNotNull(TasksSchema.kanbanOrder),
|
||||
isNull(TasksSchema.parentId),
|
||||
];
|
||||
if (cursor !== null) {
|
||||
conditions.push(gt(TasksSchema.kanbanOrder, cursor));
|
||||
}
|
||||
if (filters?.listIds && filters.listIds.length > 0) {
|
||||
conditions.push(inArray(TasksSchema.goalListId, filters.listIds));
|
||||
}
|
||||
if (filters?.assigneeIds && filters.assigneeIds.length > 0) {
|
||||
conditions.push(
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select({ one: sql`1` })
|
||||
.from(TasksAssigneeSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(TasksAssigneeSchema.taskId, TasksSchema.id),
|
||||
inArray(TasksAssigneeSchema.collabUserId, filters.assigneeIds)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(TasksSchema).where(and(...conditions)).orderBy(asc(TasksSchema.kanbanOrder)).limit(10)
|
||||
this.db.dbDrizzle.select().from(TasksSchema).where(and(...conditions)).orderBy(asc(TasksSchema.kanbanOrder)).limit(20)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchTaskWithMinKanbanOrder(goalId: number, columnId: number | null): Promise<number | null> {
|
||||
async fetchTaskWithMinKanbanOrder(goalId: number, columnId?: number | null): Promise<number | null> {
|
||||
const conditions = [
|
||||
eq(TasksSchema.goalId, goalId),
|
||||
columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId),
|
||||
];
|
||||
if (columnId !== undefined) {
|
||||
conditions.push(columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId));
|
||||
}
|
||||
const result = await callWithCatch(() => this.db.dbDrizzle.select({
|
||||
minKanbanOrder: sql<number>`MIN(kanban_order)`
|
||||
}).from(TasksSchema).where(and(...conditions)));
|
||||
return result?.[0]?.minKanbanOrder ?? null;
|
||||
}
|
||||
|
||||
static readonly KANBAN_ORDER_GAP = 16384;
|
||||
|
||||
async getNextKanbanOrder(goalId: number, columnId?: number | null): Promise<number> {
|
||||
const min = await this.fetchTaskWithMinKanbanOrder(goalId, columnId);
|
||||
return (min ?? 0) - TasksRepository.KANBAN_ORDER_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const TaskArkTypeUpdate = type({
|
||||
'parentId?': 'number|null',
|
||||
'description?': 'string',
|
||||
'complete?': 'boolean',
|
||||
'goalListId?': 'number',
|
||||
'goalListId?': 'number|null',
|
||||
// 'creatorId?': 'number',
|
||||
'note?': 'string',
|
||||
'priorityId?': '1|2|3|null',
|
||||
@@ -23,7 +23,7 @@ export const TaskArkTypeUpdate = type({
|
||||
'statusId?': 'number|null',
|
||||
'taskOrder?': 'number',
|
||||
'kanbanOrder?': 'number',
|
||||
'amount?': 'number|null',
|
||||
'amount?': 'string|null',
|
||||
'transactionType?': '1|0|null',
|
||||
'nodeGraphPosition?': 'object|null',
|
||||
});
|
||||
@@ -64,6 +64,7 @@ export const TaskArkTypeFetchTasksNew = type({
|
||||
})
|
||||
.pipe(TaskArkTypeFetchTasksNewFilters),
|
||||
'unlimited?': type('string').pipe((v) => v === 'true'),
|
||||
'ignoreCompleted?': type('string').pipe((v) => v === 'true'),
|
||||
});
|
||||
|
||||
export type TaskArgFetchTasksNew = typeof TaskArkTypeFetchTasksNew.infer;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { ArkErrors } from 'arktype';
|
||||
import { WebhooksManager } from './WebhooksManager';
|
||||
import {
|
||||
WebhookArkTypeCreate,
|
||||
WebhookArkTypeUpdate,
|
||||
WebhookArkTypeDelete,
|
||||
WebhookArkTypeFetch,
|
||||
WebhookArkTypeById,
|
||||
WebhookArkTypeFetchDeliveries,
|
||||
} from './types';
|
||||
|
||||
export class WebhooksController {
|
||||
private readonly manager = new WebhooksManager();
|
||||
|
||||
create = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeCreate(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.create(data);
|
||||
if (!result) return res.status(500).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
update = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeUpdate(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.update(data);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
delete = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeDelete(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.delete(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetch = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeFetch(req.query);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.fetchByGoalId(data.goalId);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
rotateSecret = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.rotateSecret(data.id);
|
||||
if (!result) return res.status(404).end();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
testDelivery = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.testDelivery(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
fetchDeliveries = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeFetchDeliveries({ ...req.params, ...req.query });
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.fetchDeliveries(data.id, {
|
||||
cursor: data.cursor,
|
||||
status: data.status,
|
||||
});
|
||||
return res.tvJson(result);
|
||||
};
|
||||
|
||||
retryDelivery = async (req: Request, res: Response) => {
|
||||
const data = WebhookArkTypeById(req.body);
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary);
|
||||
}
|
||||
const result = await this.manager.retryDelivery(data.id);
|
||||
return res.tvJson(result);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { TasksSchema } from 'taskview-db-schemas';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { Database } from '../../modules/db';
|
||||
import { WebhooksRepository } from './WebhooksRepository';
|
||||
import { WebhooksManager } from './WebhooksManager';
|
||||
import type { WebhookDeliverJobData } from './types';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
|
||||
const WEBHOOK_DELIVER_JOB = 'webhook-deliver';
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
|
||||
export class WebhooksDispatcher implements Dispatcher {
|
||||
private readonly repository = new WebhooksRepository();
|
||||
private readonly manager = new WebhooksManager();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('task.created', (data) => this.dispatch('task.created', data.task.goalId, data));
|
||||
eventBus.on('task.updated', (data) => this.dispatch('task.updated', data.task.goalId, data));
|
||||
eventBus.on('task.deleted', (data) => this.dispatch('task.deleted', data.goalId, data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.dispatchAssigneesChanged(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(WEBHOOK_DELIVER_JOB);
|
||||
await boss.work<WebhookDeliverJobData>(WEBHOOK_DELIVER_JOB, async ([job]) => {
|
||||
await this.deliverJob(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatch(event: string, goalId: number, payload: object): Promise<void> {
|
||||
const webhooks = await this.repository.fetchActiveByGoalIdAndEvent(goalId, event);
|
||||
for (const webhook of webhooks) {
|
||||
await this.enqueueDelivery(webhook.id, webhook.url, webhook.secretEncrypted, event, {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
const db = Database.getInstance();
|
||||
const task = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, data.taskId)).limit(1);
|
||||
if (!task[0]) return;
|
||||
await this.dispatch('task.assigneesChanged', task[0].goalId, data);
|
||||
}
|
||||
|
||||
private async enqueueDelivery(webhookId: number, url: string, secretEncrypted: string, event: string, payload: object): Promise<void> {
|
||||
const delivery = await this.repository.createDelivery({ webhookId, event, payload });
|
||||
if (!delivery) return;
|
||||
|
||||
const boss = getJobQueue();
|
||||
await boss.send(WEBHOOK_DELIVER_JOB, {
|
||||
deliveryId: delivery.id,
|
||||
webhookId,
|
||||
url,
|
||||
secretEncrypted,
|
||||
payload,
|
||||
attempt: 1,
|
||||
} satisfies WebhookDeliverJobData);
|
||||
}
|
||||
|
||||
private async deliverJob(data: WebhookDeliverJobData): Promise<void> {
|
||||
const secret = decrypt(data.secretEncrypted);
|
||||
const result = await this.manager.deliver(data.url, secret, data.payload);
|
||||
|
||||
await this.repository.updateDelivery(data.deliveryId, {
|
||||
status: result.success ? 'success' : (data.attempt >= MAX_ATTEMPTS ? 'failed' : 'pending'),
|
||||
responseCode: result.responseCode,
|
||||
attempts: data.attempt,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await this.repository.resetConsecutiveFailures(data.webhookId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.attempt < MAX_ATTEMPTS) {
|
||||
const boss = getJobQueue();
|
||||
const delay = Math.pow(2, data.attempt) * 5;
|
||||
await boss.send(WEBHOOK_DELIVER_JOB, {
|
||||
...data,
|
||||
attempt: data.attempt + 1,
|
||||
}, { startAfter: delay });
|
||||
return;
|
||||
}
|
||||
|
||||
const failures = await this.repository.incrementConsecutiveFailures(data.webhookId);
|
||||
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
await this.repository.deactivate(data.webhookId);
|
||||
$logger.warn(`[Webhooks] Deactivated webhook=${data.webhookId} after ${failures} consecutive failures`);
|
||||
} else {
|
||||
$logger.warn(`[Webhooks] Delivery failed for webhook=${data.webhookId}, consecutive failures: ${failures}/${MAX_CONSECUTIVE_FAILURES}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { randomBytes, createHmac } from 'crypto';
|
||||
import { encrypt, decrypt } from '../../utils/crypto';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { WebhooksRepository } from './WebhooksRepository';
|
||||
import type { WebhookArgCreate, WebhookArgUpdate } from './types';
|
||||
import type { WebhooksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export type WebhookForClient = Omit<WebhooksSchemaTypeForSelect, 'secretEncrypted'>;
|
||||
|
||||
export class WebhooksManager {
|
||||
public readonly repository: WebhooksRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new WebhooksRepository();
|
||||
}
|
||||
|
||||
async create(data: WebhookArgCreate): Promise<{ webhook: WebhookForClient; secret: string } | null> {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
const secretEncrypted = encrypt(secret);
|
||||
|
||||
const webhook = await this.repository.create({
|
||||
goalId: data.goalId,
|
||||
url: data.url,
|
||||
secretEncrypted,
|
||||
events: data.events,
|
||||
});
|
||||
|
||||
if (!webhook) return null;
|
||||
|
||||
return { webhook: this.toClient(webhook), secret };
|
||||
}
|
||||
|
||||
async update(data: WebhookArgUpdate): Promise<WebhookForClient | null> {
|
||||
const updateData: Partial<{ url: string; events: string[]; isActive: boolean }> = {};
|
||||
if (data.url !== undefined) updateData.url = data.url;
|
||||
if (data.events !== undefined) updateData.events = data.events;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
const webhook = await this.repository.update(data.id, updateData);
|
||||
if (!webhook) return null;
|
||||
return this.toClient(webhook);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhookForClient[]> {
|
||||
const webhooks = await this.repository.fetchByGoalId(goalId);
|
||||
return webhooks.map(w => this.toClient(w));
|
||||
}
|
||||
|
||||
async rotateSecret(id: number): Promise<{ secret: string } | null> {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
const secretEncrypted = encrypt(secret);
|
||||
const success = await this.repository.updateSecret(id, secretEncrypted);
|
||||
if (!success) return null;
|
||||
return { secret };
|
||||
}
|
||||
|
||||
async testDelivery(id: number): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const webhook = await this.repository.fetchById(id);
|
||||
if (!webhook) return { success: false };
|
||||
|
||||
const secret = decrypt(webhook.secretEncrypted);
|
||||
const payload = {
|
||||
event: 'webhook.test',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { message: 'This is a test webhook delivery' },
|
||||
};
|
||||
|
||||
return this.deliver(webhook.url, secret, payload);
|
||||
}
|
||||
|
||||
async deliver(url: string, secret: string, payload: object): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', secret).update(body).digest('hex');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': `sha256=${signature}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
return { success: response.ok, responseCode: response.status };
|
||||
} catch (err) {
|
||||
$logger.error(err, `[Webhooks] Delivery failed to ${url}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async retryDelivery(deliveryId: number): Promise<{ success: boolean; responseCode?: number }> {
|
||||
const deliveries = await this.repository.fetchDeliveryById(deliveryId);
|
||||
if (!deliveries) return { success: false };
|
||||
|
||||
const webhook = await this.repository.fetchById(deliveries.webhookId);
|
||||
if (!webhook) return { success: false };
|
||||
|
||||
const secret = decrypt(webhook.secretEncrypted);
|
||||
const result = await this.deliver(webhook.url, secret, deliveries.payload as object);
|
||||
|
||||
await this.repository.updateDelivery(deliveryId, {
|
||||
status: result.success ? 'success' : 'failed',
|
||||
responseCode: result.responseCode,
|
||||
attempts: deliveries.attempts + 1,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string }) {
|
||||
return this.repository.fetchDeliveries(webhookId, options);
|
||||
}
|
||||
|
||||
private toClient(webhook: WebhooksSchemaTypeForSelect): WebhookForClient {
|
||||
const { secretEncrypted, ...rest } = webhook;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { and, desc, eq, lt, sql } from 'drizzle-orm';
|
||||
import { WebhooksSchema, WebhookDeliveriesSchema, type WebhooksSchemaTypeForSelect, type WebhookDeliveriesSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
|
||||
export class WebhooksRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(data: { goalId: number; url: string; secretEncrypted: string; events: string[] }): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(WebhooksSchema).values(data).returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<{ url: string; events: string[]; isActive: boolean }>): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, id))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async updateSecret(id: number, secretEncrypted: string): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ secretEncrypted, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, id))
|
||||
.returning()
|
||||
);
|
||||
return (result?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.goalId, goalId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchActiveByGoalIdAndEvent(goalId: number, event: string): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(
|
||||
and(
|
||||
eq(WebhooksSchema.goalId, goalId),
|
||||
eq(WebhooksSchema.isActive, true),
|
||||
)
|
||||
)
|
||||
);
|
||||
return (result ?? []).filter(w => w.events.includes(event));
|
||||
}
|
||||
|
||||
async createDelivery(data: { webhookId: number; event: string; payload: unknown }): Promise<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(WebhookDeliveriesSchema).values({
|
||||
webhookId: data.webhookId,
|
||||
event: data.event,
|
||||
payload: data.payload,
|
||||
}).returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async updateDelivery(id: number, data: { status: string; responseCode?: number; attempts: number }): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhookDeliveriesSchema)
|
||||
.set({ ...data, lastAttemptAt: new Date() })
|
||||
.where(eq(WebhookDeliveriesSchema.id, id))
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDeliveryById(id: number): Promise<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhookDeliveriesSchema).where(eq(WebhookDeliveriesSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string; limit?: number }): Promise<WebhookDeliveriesSchemaTypeForSelect[]> {
|
||||
const limit = options?.limit ?? 20;
|
||||
const conditions = [eq(WebhookDeliveriesSchema.webhookId, webhookId)];
|
||||
|
||||
if (options?.cursor) {
|
||||
conditions.push(lt(WebhookDeliveriesSchema.id, options.cursor));
|
||||
}
|
||||
if (options?.status) {
|
||||
conditions.push(eq(WebhookDeliveriesSchema.status, options.status));
|
||||
}
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhookDeliveriesSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(WebhookDeliveriesSchema.id))
|
||||
.limit(limit)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async resetConsecutiveFailures(webhookId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ consecutiveFailures: 0 })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
|
||||
async incrementConsecutiveFailures(webhookId: number): Promise<number> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ consecutiveFailures: sql`${WebhooksSchema.consecutiveFailures} + 1` })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
.returning({ consecutiveFailures: WebhooksSchema.consecutiveFailures })
|
||||
);
|
||||
return result?.[0]?.consecutiveFailures ?? 0;
|
||||
}
|
||||
|
||||
async deactivate(webhookId: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import { WebhooksController } from './WebhooksController';
|
||||
|
||||
export default class WebhooksRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: WebhooksController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new WebhooksController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetch);
|
||||
this.router.post('', [IsLoggedIn], this.controller.create);
|
||||
this.router.patch('', [IsLoggedIn], this.controller.update);
|
||||
this.router.delete('', [IsLoggedIn], this.controller.delete);
|
||||
this.router.post('/rotate-secret', [IsLoggedIn], this.controller.rotateSecret);
|
||||
this.router.post('/test', [IsLoggedIn], this.controller.testDelivery);
|
||||
this.router.get('/deliveries/:id', [IsLoggedIn], this.controller.fetchDeliveries);
|
||||
this.router.post('/retry', [IsLoggedIn], this.controller.retryDelivery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
const NumberFromString = type('string|number').pipe((v) => Number(v));
|
||||
|
||||
export const WebhookArkTypeCreate = type({
|
||||
goalId: 'number',
|
||||
url: 'string',
|
||||
events: 'string[]',
|
||||
});
|
||||
|
||||
export type WebhookArgCreate = typeof WebhookArkTypeCreate.infer;
|
||||
|
||||
export const WebhookArkTypeUpdate = type({
|
||||
id: 'number',
|
||||
'url?': 'string',
|
||||
'events?': 'string[]',
|
||||
'isActive?': 'boolean',
|
||||
});
|
||||
|
||||
export type WebhookArgUpdate = typeof WebhookArkTypeUpdate.infer;
|
||||
|
||||
export const WebhookArkTypeDelete = type({
|
||||
id: 'number',
|
||||
});
|
||||
|
||||
export type WebhookArgDelete = typeof WebhookArkTypeDelete.infer;
|
||||
|
||||
export const WebhookArkTypeFetch = type({
|
||||
goalId: NumberFromString,
|
||||
});
|
||||
|
||||
export type WebhookArgFetch = typeof WebhookArkTypeFetch.infer;
|
||||
|
||||
export const WebhookArkTypeById = type({
|
||||
id: NumberFromString,
|
||||
});
|
||||
|
||||
export type WebhookArgById = typeof WebhookArkTypeById.infer;
|
||||
|
||||
const OptionalNumberFromString = type('string|number|undefined').pipe((v) => v === undefined ? undefined : Number(v));
|
||||
|
||||
export const WebhookArkTypeFetchDeliveries = type({
|
||||
id: NumberFromString,
|
||||
'cursor?': OptionalNumberFromString,
|
||||
'status?': 'string',
|
||||
});
|
||||
|
||||
export type WebhookArgFetchDeliveries = typeof WebhookArkTypeFetchDeliveries.infer;
|
||||
|
||||
export const WEBHOOK_EVENTS = [
|
||||
'task.created',
|
||||
'task.updated',
|
||||
'task.deleted',
|
||||
'task.assigneesChanged',
|
||||
] as const;
|
||||
|
||||
export type WebhookEvent = typeof WEBHOOK_EVENTS[number];
|
||||
|
||||
export interface WebhookDeliverJobData {
|
||||
deliveryId: number;
|
||||
webhookId: number;
|
||||
url: string;
|
||||
secretEncrypted: string;
|
||||
payload: object;
|
||||
attempt: number;
|
||||
}
|
||||
@@ -131,6 +131,9 @@ export const GoalPermissions = {
|
||||
TASKS_CAN_RECOVERY_HISTORY: 'task_can_recovery_history',
|
||||
TASKS_CAN_ASSIGN_USERS: 'task_can_assign_users',
|
||||
TASKS_CAN_WATCH_ASSIGNED_USERS: 'task_can_watch_assigned_users',
|
||||
|
||||
INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage',
|
||||
INTEGRATIONS_CAN_VIEW: 'integrations_can_view',
|
||||
} as const;
|
||||
|
||||
export type PermissionsEntityType =
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.ENCRYPTION_KEY;
|
||||
if (!hex || hex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
|
||||
}
|
||||
return Buffer.from(hex, 'hex');
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string): string {
|
||||
const key = getKey();
|
||||
const [ivHex, authTagHex, dataHex] = encrypted.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const data = Buffer.from(dataHex, 'hex');
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default defineConfig({
|
||||
ecma: 2015,
|
||||
compress: true,
|
||||
mangle: {
|
||||
properties: false,//couse error if true after build
|
||||
properties: false,//cause error if true after build
|
||||
},
|
||||
format: {
|
||||
comments: true,
|
||||
@@ -37,9 +37,9 @@ export default defineConfig({
|
||||
external: ['pg-native'],
|
||||
output: {
|
||||
format: 'umd',
|
||||
entryFileNames: 'taskview-server-docker.js',
|
||||
chunkFileNames: 'taskview-server-docker-[hash].js',
|
||||
assetFileNames: 'taskview-server-docker-[name]-[hash].[ext]',
|
||||
entryFileNames: 'taskview-server.js',
|
||||
chunkFileNames: 'taskview-server-[hash].js',
|
||||
assetFileNames: 'taskview-server-[name]-[hash].[ext]',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+30
-4
@@ -1,19 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script builds the docker images for the taskview-ce-monorepo
|
||||
# This script builds the docker images for TaskView Community Edition
|
||||
# - taskview-ce-api-server
|
||||
# - taskview-ce-db-migration
|
||||
# - taskview-ce-webapp
|
||||
|
||||
set -e
|
||||
|
||||
VERSION=$1
|
||||
|
||||
# If version not provided, read from package.json
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
fi
|
||||
|
||||
echo $VERSION
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
nvm use 24
|
||||
node -v
|
||||
|
||||
echo "Building TaskView CE version: $VERSION"
|
||||
|
||||
# Build CE API
|
||||
echo "========================================="
|
||||
echo "Building CE API Server..."
|
||||
echo "========================================="
|
||||
cd api
|
||||
bash build-docker-api.sh $VERSION
|
||||
cd ..
|
||||
|
||||
|
||||
# Build CE Web
|
||||
echo "========================================="
|
||||
echo "Building CE Web App..."
|
||||
echo "========================================="
|
||||
cd web
|
||||
bash build-docker-web.sh $VERSION
|
||||
cd ..
|
||||
cd ..
|
||||
|
||||
echo "========================================="
|
||||
echo "Build complete!"
|
||||
echo "Images built:"
|
||||
echo " - gimanhead/taskview-ce-api-server:$VERSION"
|
||||
echo " - gimanhead/taskview-ce-webapp:$VERSION"
|
||||
echo "========================================="
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: TaskView Documentation
|
||||
description: Official documentation for TaskView - a source-available, self-hosted project and task management platform. Installation guides, feature docs, configuration reference, and more.
|
||||
navigation: false
|
||||
---
|
||||
|
||||
Welcome to the TaskView documentation. TaskView is a self-hosted task management platform for teams and individuals who want full control over their data and workflows.
|
||||
|
||||
## Getting started
|
||||
|
||||
::card-group
|
||||
::card{title="What is TaskView" icon="i-lucide-info" to="/docs/getting-started"}
|
||||
Learn what TaskView is, who it's for, and what features it offers.
|
||||
::
|
||||
::card{title="Installation" icon="i-lucide-download" to="/docs/getting-started/installation"}
|
||||
Deploy TaskView with Docker Compose in 5 minutes.
|
||||
::
|
||||
::card{title="Quick Start" icon="i-lucide-rocket" to="/docs/getting-started/usage"}
|
||||
Create your first project, add lists, and start managing tasks.
|
||||
::
|
||||
::
|
||||
|
||||
## Explore
|
||||
|
||||
::card-group
|
||||
::card{title="Features" icon="i-lucide-layout-grid" to="/docs/features/projects-and-lists"}
|
||||
Projects, tasks, Kanban boards, dependency graphs, and dashboard.
|
||||
::
|
||||
::card{title="Integrations" icon="i-lucide-git-pull-request" to="/docs/integrations/setup"}
|
||||
Connect GitHub and GitLab repositories to sync issues as tasks.
|
||||
::
|
||||
::card{title="Configuration" icon="i-lucide-settings" to="/docs/configuration/environment-variables"}
|
||||
Environment variables, authentication, and server setup.
|
||||
::
|
||||
::card{title="Collaboration" icon="i-lucide-users" to="/docs/collaboration/members"}
|
||||
Team members, roles, and 28 granular permissions.
|
||||
::
|
||||
::card{title="FAQ" icon="i-lucide-circle-help" to="/docs/faq"}
|
||||
Common questions about installation, features, and security.
|
||||
::
|
||||
::card{title="Guides" icon="i-lucide-book-open" to="/docs/guides/deploy-vps-nginx"}
|
||||
Step-by-step guides for production deployment and use cases.
|
||||
::
|
||||
::
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user