Compare commits

...

81 Commits

Author SHA1 Message Date
Gimanh b8b8481fb4 Merge pull request #36 from Gimanh/chore/version-1-24
chore: version up
2026-03-08 22:33:36 +01:00
Nikolai Giman 71f3385842 chore: version up 2026-03-08 22:32:59 +01:00
Gimanh cf35720093 Merge pull request #35 from Gimanh/fix/ui-fixes
fix: ui improvements
2026-03-08 22:15:40 +01:00
Nikolai Giman 3ef150add9 fix: ui improvements 2026-03-08 22:15:11 +01:00
Gimanh 626b532d2e Merge pull request #33 from Gimanh/feat/date-new-placeholders
feat: date new placeholders
2026-03-08 21:12:19 +01:00
Nikolai Giman e5dfd74967 feat: date new placeholders 2026-03-08 21:11:55 +01:00
Gimanh cb8f02af4f Merge pull request #32 from Gimanh/fix/graph-permissions-for-api
fix: handle graph permissions in the API
2026-03-08 20:34:57 +01:00
Nikolai Giman 996dd11af9 fix: handle graph permissions in the API 2026-03-08 20:33:48 +01:00
Gimanh 9a33837ffd Merge pull request #31 from Gimanh/fix/main-screen-checkbox-disabled
fix: read correct goal permissions for task checkbox
2026-03-08 20:31:29 +01:00
Nikolai Giman 7b24da0688 fix: read correct goal permissions for task checkbox 2026-03-08 18:39:50 +01:00
Gimanh 009d252651 Merge pull request #30 from Gimanh/fix/lists-load-for-taskdetail
fix: lists load for task details
2026-03-08 18:11:59 +01:00
Nikolai Giman 3c7733e5b0 fix: lists load for task details 2026-03-08 18:11:06 +01:00
Gimanh 2fa8bd5227 Merge pull request #29 from Gimanh/feat/git-integration
feat: integrations github & gitlab
2026-03-08 17:56:15 +01:00
Nikolai Giman 37039278c4 feat: integrations 2026-03-08 16:30:00 +01:00
Gimanh 3020e41b3a Merge pull request #28 from Gimanh/fix/issues
Fix/issues
2026-03-01 21:23:21 +01:00
Gimanh 8887d5e3e1 Merge branch 'main' into fix/issues 2026-03-01 21:22:58 +01:00
Nikolai Giman 0925547f8d chore: version 2026-03-01 21:22:13 +01:00
Nikolai Giman c6b769a476 fix: subtasks duplication 2026-03-01 21:05:55 +01:00
Nikolai Giman 3c49941693 fix: fetch root tasks for kanban 2026-03-01 18:21:25 +01:00
Gimanh 1d79dad5c8 Merge pull request #27 from Gimanh/chore/api-lib-version
chore: lib version
2026-03-01 17:58:45 +01:00
Nikolai Giman 245adf204f chore: lib version 2026-03-01 17:58:17 +01:00
Gimanh 08da93bd40 Merge pull request #25 from Gimanh/chore/readme
chore: readme
2026-02-23 09:27:24 +01:00
Nikolai Giman 317b32edea chore: readme 2026-02-23 09:26:56 +01:00
Gimanh b24ab9b352 Merge pull request #24 from Gimanh/chore/version-1-20-4
chore: version
2026-02-22 21:18:45 +01:00
Nikolai Giman b0482d438f chore: version 2026-02-22 21:18:20 +01:00
Gimanh 8298125080 Merge pull request #23 from Gimanh/fix/mobile-build
fix: mobile build
2026-02-22 21:08:16 +01:00
Nikolai Giman 47492637b4 fix: mobile build 2026-02-22 21:05:29 +01:00
Gimanh 1c25d1a1bd Merge pull request #22 from Gimanh/feat/version
chore: version
2026-02-22 20:33:38 +01:00
Nikolai Giman f24536313c chore: version 2026-02-22 20:33:16 +01:00
Gimanh 2a56c7c666 Merge pull request #21 from Gimanh/fix/save-locale
Fix/save locale
2026-02-22 20:28:04 +01:00
Nikolai Giman fd39c5303a fix: save the locale in storage 2026-02-22 19:52:39 +01:00
Nikolai Giman 123733e44d fix: types 2026-02-22 19:41:40 +01:00
Gimanh 38d4e39818 Merge pull request #20 from Gimanh/fix/urls
fix: urls
2026-02-22 15:39:41 +01:00
Nikolai Giman 70a321b418 fix: urls 2026-02-22 15:38:58 +01:00
Gimanh ed248c42cd Merge pull request #19 from Gimanh/fix/external-auth
fix: external auth and up version
2026-02-22 14:51:55 +01:00
Nikolai Giman 40f24c7933 fix: external auth and up version 2026-02-22 14:51:31 +01:00
Gimanh ef4601d4a9 Merge pull request #18 from Gimanh/feat/nuxt-ui-client
feat: web and mobile client in with nuxt-ui lib
2026-02-21 22:16:40 +01:00
Nikolai Giman 24341951cd fix: update example env and version 2026-02-21 21:20:26 +01:00
Nikolai Giman b6ba0fbf9b fix: renamed directory web-nuxt-ui => web 2026-02-21 18:48:38 +01:00
Nikolai Giman 1064b88902 fix: add docker scripts 2026-02-21 18:36:22 +01:00
Nikolai Giman b2eab9ec61 fix: renamed old folder 2026-02-21 18:26:35 +01:00
Nikolai Giman 3d0d3eca47 feat: web and mobile client in with nuxt-ui lib 2026-02-21 18:16:12 +01:00
Gimanh 55e5ae4335 Merge pull request #17 from Gimanh/fix/start-screen-loading
fix: start screen loading
2026-02-21 13:06:52 +01:00
Nikolai Giman 3c456edbd8 fix: validate tag and task belong to the same project 2026-02-21 13:00:58 +01:00
Nikolai Giman dc25a02ff2 fix: start screen loading 2026-02-16 00:31:22 +01:00
Gimanh 5c200c2704 Merge pull request #16 from Gimanh/fix/some-fixes
Fix/some fixes
2026-02-14 19:05:43 +01:00
Nikolai Giman 5149e7101e fix: types 2026-02-14 19:03:09 +01:00
Nikolai Giman 471a82b9f5 fix: fix issues 2026-02-08 22:06:49 +01:00
Gimanh 072ad5d629 Merge pull request #15 from Gimanh/chore/ios-version
chore: up ios version
2026-01-25 21:41:59 +01:00
Nikolai Giman dc5fd90bca chore: up ios version 2026-01-25 21:41:30 +01:00
Gimanh 55f6347784 Merge pull request #14 from Gimanh/feat/apple-login
feat: login by Apple
2026-01-25 19:20:44 +01:00
Nikolai Giman 165c1b2444 feat: login by Apple 2026-01-25 19:19:54 +01:00
Gimanh b3cc983965 Merge pull request #13 from Gimanh/chore/version
chore: version
2026-01-23 21:58:10 +01:00
Nikolai Giman a14c332586 chore: version 2026-01-23 21:57:29 +01:00
Gimanh aa91934f19 Merge pull request #12 from Gimanh/fix/use-cookie
fix: use cookie
2026-01-22 20:03:13 +01:00
Gimanh aa8e6e2da6 Merge branch 'main' into fix/use-cookie 2026-01-22 20:02:40 +01:00
Nikolai Giman c4c9a16a09 fix: use cookie 2026-01-22 20:01:26 +01:00
Gimanh 293340c253 Merge pull request #11 from Gimanh/chore/docker-build
add bash script
2026-01-19 00:19:03 +01:00
Nikolai Giman 04bb545c25 add bash script 2026-01-19 00:17:56 +01:00
Gimanh aa235c10ea Merge pull request #10 from Gimanh/chore/extend-app
chore: add extend methods
2026-01-18 23:57:14 +01:00
Nikolai Giman 45e4a11605 chore: add extend methods 2026-01-18 23:56:23 +01:00
Gimanh 55b42dfecb Merge pull request #9 from Gimanh/chore/deps
chore: deps
2026-01-18 22:28:34 +01:00
Nikolai Giman a620c39b50 chore: deps 2026-01-18 22:24:59 +01:00
Nikolai Giman 8edb2d50d3 chore: deps 2026-01-18 22:17:40 +01:00
Nikolai Giman 19093598a0 chore: deps 2026-01-18 22:13:59 +01:00
Gimanh 9091d576e1 Merge pull request #8 from Gimanh/feat/google-auth
feat: add login by Google and GitHub
2026-01-12 21:36:24 +01:00
Nikolai Giman 7d9e4a811a feat: update app version 2026-01-12 00:40:18 +01:00
Nikolai Giman 1b03e61d2b feat: add login by Google and GitHub 2026-01-11 23:22:30 +01:00
Gimanh 0936f51a24 Merge pull request #7 from Gimanh/fix/history-deletion
fix: delete history cascade with entity (goal, list, tasks)
2026-01-08 22:10:00 +01:00
Nikolai Giman c4c6d91456 fix: delete history cascade with entity (goal, list, tasks) 2026-01-08 22:08:23 +01:00
Gimanh 1ba13eef45 Merge pull request #5 from Gimanh/fix/load-more-in-kanban
fix: increase task limit in TasksRepository and handle columnId in Ka…
2026-01-05 20:01:40 +01:00
Nikolai Giman 8a67296673 fix: increase task limit in TasksRepository and handle columnId in KanbanBoard to fix load more issue 2026-01-05 20:00:18 +01:00
Gimanh 8fe3353c5f Merge pull request #4 from Gimanh/fix/subtask-add-ui-bug
fix: add subtasks to selectedTask
2026-01-04 23:58:58 +01:00
Nikolai Giman 61debcda11 fix: add subtasks to selectedTask 2026-01-04 23:57:36 +01:00
Gimanh 615a47eafd Merge pull request #3 from Gimanh/chore/build
chore: update build scripts and package configurations
2026-01-04 22:04:12 +01:00
Nikolai Giman 4705055062 chore: update node to version 24 2026-01-04 22:03:03 +01:00
Nikolai Giman 7426f72257 chore: update build scripts and package configurations 2026-01-04 16:54:08 +01:00
Gimanh 64ddf4acfa Merge pull request #2 from Gimanh/chore/license-file-name
chore: update md
2026-01-03 20:57:30 +01:00
Nikolai Giman d843008091 chore: update md 2026-01-03 20:56:44 +01:00
Gimanh 95cbd330b1 Merge pull request #1 from Gimanh/chore/license-file-name
chore: license & cla
2026-01-03 17:34:07 +01:00
Nikolai Giman cf7055ce60 chore: license & cla 2026-01-03 17:24:57 +01:00
773 changed files with 32601 additions and 8932 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
engine-strict=true
+7 -2
View File
@@ -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.
+14 -4
View File
@@ -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
+22 -1
View File
@@ -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,24 @@ 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
+15 -10
View File
@@ -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"
+1
View File
@@ -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"
+3 -3
View File
@@ -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
View File
@@ -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
+22 -14
View File
@@ -1,15 +1,14 @@
{
"name": "taskview-ce-api-server",
"version": "1.17.0",
"version": "1.24.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,10 +42,14 @@
},
"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",
@@ -56,6 +57,10 @@
"express": "4.21.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",
"pino": "^9.4.0",
"rotating-file-stream": "^3.2.5",
@@ -63,5 +68,8 @@
"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
+2 -2
View File
@@ -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
+34
View File
@@ -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
+3 -4
View File
@@ -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": {}
}
+41 -4
View File
@@ -4,8 +4,20 @@ 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';
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 +27,20 @@ export default class App {
this.app = express();
this.port = port;
this.extendApp();
this.initializeMiddlewares();
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 +50,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 }));
}
@@ -45,7 +83,6 @@ export default class App {
public listen() {
return this.app.listen(this.port, '0.0.0.0', () => {
console.log(`Server is running on port ${this.port}`);
console.log(`Server version is ${VRS}`);
});
}
}
+3
View File
@@ -7,6 +7,7 @@ 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 { TasksManager } from '../tv-modules/tasks/TasksManager';
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
@@ -26,6 +27,7 @@ export class AppUser {
public readonly startManager: StartManager;
public readonly kanbanManager: KanbanManager;
public readonly graphManager: GraphManager;
public readonly integrationsManager: IntegrationsManager;
constructor(userData?: UserJwtPayload) {
this.userData = userData;
@@ -40,6 +42,7 @@ export class AppUser {
this.startManager = new StartManager(this);
this.kanbanManager = new KanbanManager(this);
this.graphManager = new GraphManager(this);
this.integrationsManager = new IntegrationsManager(this);
}
getTokenId(): number | undefined {
+46
View File
@@ -282,5 +282,51 @@
"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"
]
}
}
@@ -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;
+2
View File
@@ -3,6 +3,7 @@ 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 KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
import StartRoutes from '../tv-modules/start/StartRoutes';
@@ -23,6 +24,7 @@ const routes: Record<string, RoutableConstructor> = {
'/module/about': StartRoutes,
'/module/kanban': KanbanRoutes,
'/module/graph': GraphRoutes,
'/module/integrations': IntegrationsRoutes,
};
export default routes;
+125 -23
View File
@@ -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);
};
+1
View File
@@ -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;
+29 -2
View File
@@ -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
);
}
}
+7 -7
View File
@@ -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))
+6 -3
View File
@@ -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,290 @@
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 } from './providers/github.provider';
import { verifyGitLabWebhookToken } 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) {
await repo.createTaskAndMapping(
integration.projectId,
issueTitle,
integration.id,
issueNumber,
'open',
issueBody,
);
}
} 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) {
await repo.createTaskAndMapping(
integration.projectId,
issueTitle,
integration.id,
issueIid,
'open',
issueDescription,
);
}
} 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,356 @@
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 } from './providers/github.provider';
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken } 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]));
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number };
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);
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,
});
}
} 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);
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,
});
}
}
// 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,308 @@
import { and, eq, ne } 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,
): 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,
}).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 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 }>,
): 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,
})),
).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';
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';
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}`,
},
},
);
}
+46
View File
@@ -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 -1
View File
@@ -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() {
+13 -10
View File
@@ -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) => {
+14 -1
View File
@@ -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) {
+3 -2
View File
@@ -343,9 +343,10 @@ 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) => {
+15 -5
View File
@@ -156,6 +156,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 +262,22 @@ 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> {
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 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,9 +384,7 @@ 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);
+14 -4
View File
@@ -274,7 +274,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 +560,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) {
@@ -646,13 +648,14 @@ export class TasksRepository {
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));
}
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 ?? [];
}
@@ -667,4 +670,11 @@ export class TasksRepository {
}).from(TasksSchema).where(and(...conditions)));
return result?.[0]?.minKanbanOrder ?? null;
}
static readonly KANBAN_ORDER_GAP = 16384;
async getNextKanbanOrder(goalId: number, statusId: number | null = null): Promise<number> {
const min = await this.fetchTaskWithMinKanbanOrder(goalId, statusId);
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;
+3
View File
@@ -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 =
+33
View File
@@ -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');
}
+4 -4
View File
@@ -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
View File
@@ -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 "========================================="
+41 -35
View File
@@ -1,36 +1,42 @@
{
"name": "taskview-ce-monorepo",
"version": "1.18.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
"web",
"api",
"taskview-packages/*"
],
"scripts": {
"dev:webapp": "cd web && pnpm dev",
"build:webapp": "cd web && pnpm build",
"build:webapp:dev": "cd web && pnpm build:dev",
"build:packages": "pnpm --filter=taskview-api --filter=taskview-db-schemas run build",
"dev:api": "cd api && pnpm dev",
"build:api": "cd api && pnpm build",
"install:all": "pnpm install",
"update:all": "pnpm update -r",
"clean": "pnpm store prune && rm -rf node_modules",
"lint": "pnpm -r run lint",
"test": "pnpm -r run test"
},
"devDependencies": {
"@types/node": "^18.19.119",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"dependencies": {
"arktype": "2.1.20",
"drizzle-arktype": "0.1.3"
}
}
"name": "taskview-ce-monorepo",
"version": "1.24.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
"web",
"api",
"taskview-packages/*"
],
"scripts": {
"dev:webapp": "cd web && pnpm dev",
"build:webapp": "cd web && pnpm build",
"build:webapp:dev": "cd web && pnpm build:dev",
"build:packages": "pnpm --filter=taskview-api --filter=taskview-db-schemas run build",
"dev:api": "cd api && pnpm dev",
"build:api": "cd api && pnpm build",
"install:all": "pnpm install",
"update:all": "pnpm update -r",
"clean": "pnpm store prune && rm -rf node_modules",
"lint": "pnpm -r run lint",
"test": "pnpm -r run test"
},
"devDependencies": {
"@types/node": "^18.19.119",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"dependencies": {
"@types/passport": "^1.0.17",
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.17",
"arktype": "2.1.20",
"drizzle-arktype": "0.1.3",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0"
}
}
+5754 -6331
View File
File diff suppressed because it is too large Load Diff
+544 -33
View File
@@ -1,59 +1,570 @@
# TaskView API
Библиотека для работы с TaskView API.
TypeScript/JavaScript SDK for the [TaskView](https://taskview.tech) API. Provides a typed interface for managing goals, task lists, tasks, tags, kanban boards, team collaboration, and task dependencies.
## Установка
## Installation
```bash
npm install taskview-api
npm install taskview-api axios
```
## Использование
`axios` is a peer dependency — you provide your own configured instance.
```javascript
import { setupCounter } from 'taskview-api'
## Quick Start
// Использование функции setupCounter
const button = document.querySelector('#counter')
setupCounter(button)
```typescript
import axios from 'axios';
import { TvApi } from 'taskview-api';
const $axios = axios.create({
baseURL: 'https://api.taskview.app',
});
$axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
const api = new TvApi($axios);
```
## Разработка
You can change the base URL at any time:
### Установка зависимостей
```bash
npm install
```typescript
api.setBaseUrl('https://other-api.example.com');
```
### Запуск в режиме разработки
## Authentication
```bash
npm run dev
All API requests (except auth endpoints) require a Bearer token in the `Authorization` header. Below is how to obtain and manage tokens.
### Login
**With email or username and password:**
```typescript
const response = await $axios.post('/module/auth/login', {
login: 'user@example.com', // email or username
password: 'securePassword123',
});
const { access, refresh, userData } = response.data;
```
### Сборка библиотеки
**Passwordless login (email code):**
```bash
npm run build
```typescript
// 1. Request a login code
await $axios.post('/module/auth/send-login-code', {
email: 'user@example.com',
});
// 2. User receives a code via email, then submit it
const response = await $axios.post('/module/auth/login-by-code', {
email: 'user@example.com',
code: '123456',
});
const { access, refresh, userData } = response.data;
```
### Проверка типов
### Using Tokens
```bash
npm run type-check
Once you have the access token, pass it to `TvApi` via the axios instance:
```typescript
import axios from 'axios';
import { TvApi } from 'taskview-api';
const $axios = axios.create({
baseURL: 'https://api.taskview.tech',
});
$axios.defaults.headers.common['Authorization'] = `Bearer ${access}`;
const api = new TvApi($axios);
```
## Структура проекта
Store the refresh token securely (e.g. `httpOnly` cookie or secure storage). The access token has a short lifetime; the refresh token lives longer (up to 30 days).
- `src/index.ts` - основной файл экспорта библиотеки
- `src/counter.ts` - модуль с функцией счетчика
- `dist/` - собранные файлы библиотеки
### Refreshing Tokens
## Форматы сборки
When the access token expires the server returns `401 Unauthorized`. Use the refresh token to obtain a new pair:
Библиотека собирается в следующих форматах:
- ES Module (`.es.js`)
- CommonJS (`.cjs.js`)
- UMD (`.umd.js`)
- TypeScript типы (`.d.ts`)
```typescript
const response = await $axios.post('/module/auth/refresh/token', {
refreshToken: refresh,
});
const { access: newAccess, refresh: newRefresh } = response.data;
// Update the axios header with the new access token
$axios.defaults.headers.common['Authorization'] = `Bearer ${newAccess}`;
```
### Logout
```typescript
await $axios.post('/module/auth/logout');
// Clear stored tokens on the client side
```
### OAuth Providers
TaskView supports OAuth login via **Google**, **GitHub**, and **Apple**. Redirect the user to:
```
GET /module/auth/provider/google
GET /module/auth/provider/github
GET /module/auth/provider/apple
```
After successful authentication the user is redirected back with a login code that can be exchanged for tokens via the `login-by-code` endpoint.
### JWT Payload Structure
The decoded access token contains:
```typescript
{
exp: number; // Expiration timestamp
id: number; // Token ID
type: 'jwt';
userData: {
id: number; // User ID
email: string;
login: string;
permissions: {
[key: string]: {
id: number;
name: string;
description: string;
};
};
};
}
```
## API Modules
The `TvApi` instance exposes the following modules:
| Module | Access | Description |
|---------------------|----------------------|----------------------------------------|
| Goals | `api.goals` | Create, update, delete, fetch goals |
| Goal Lists | `api.goalLists` | Task lists within a goal |
| Tasks | `api.tasks` | Full task CRUD, history, assignments |
| Tags | `api.tags` | Tag management and task tagging |
| Kanban | `api.kanban` | Columns, task ordering, pagination |
| Graph | `api.graph` | Task dependency edges |
| Collaboration | `api.collaboration` | Users, roles, permissions |
---
### Goals
```typescript
// Fetch all goals
const goals = await api.goals.fetchGoals();
// Create a goal
const goal = await api.goals.createGoal({
name: 'Sprint 1',
description: 'First sprint tasks',
color: '#4A90D9',
});
// Update a goal
await api.goals.updateGoal({
id: goal.id,
name: 'Sprint 1 (updated)',
archive: 0,
});
// Delete a goal
await api.goals.deleteGoal(goal.id);
```
### Goal Lists
Goal lists are task lists that belong to a goal.
```typescript
// Fetch lists for a goal
const lists = await api.goalLists.fetchLists({ goalId: goal.id });
// Create a list
const list = await api.goalLists.createList({
goalId: goal.id,
name: 'Backlog',
description: 'Upcoming work',
});
// Update a list
await api.goalLists.updateList({
id: list.id,
name: 'Backlog (v2)',
});
// Delete a list
await api.goalLists.deleteList(list.id);
```
### Tasks
```typescript
// Fetch tasks with pagination and filters
const tasks = await api.tasks.fetch({
goalId: goal.id,
componentId: list.id, // list ID, or -1401 for all tasks
page: 1,
showCompleted: 0, // 0 = hide completed, 1 = show
firstNew: 0, // 0 = oldest first, 1 = newest first
searchText: 'bug', // optional text search
filters: { // optional filters
selectedUser: 5,
priority: 1,
selectedTags: { '12': true, '15': true },
},
});
// Create a task
const task = await api.tasks.createTask({
goalId: goal.id,
description: 'Fix login bug',
priorityId: 1, // 1 = low, 2 = medium, 3 = high
goalListId: list.id,
note: 'Details here',
startDate: '2025-03-01',
endDate: '2025-03-05',
});
// Update a task
await api.tasks.updateTask({
id: task.id,
description: 'Fix login bug (critical)',
complete: true,
priorityId: 3,
});
// Delete a task
await api.tasks.deleteTask(task.id);
// Fetch a single task by ID
const single = await api.tasks.fetchTaskById(task.id);
// Toggle user assignment
await api.tasks.toggleTasksAssignee({
taskId: task.id,
userId: 42,
});
// Task history
const history = await api.tasks.fetchTaskHistory(task.id);
await api.tasks.recoveryTaskHistory(history.history[0].historyId, task.id);
```
### Tags
```typescript
// Fetch all tags
const tags = await api.tags.fetchAllTagsForUser();
// Create a tag
const tag = await api.tags.createTag({
name: 'urgent',
color: '#FF0000',
goalId: goal.id,
});
// Toggle tag on a task (adds if missing, removes if present)
await api.tags.toggleTag({ tagId: tag.id, taskId: task.id });
// Update a tag
await api.tags.updateTag({ id: tag.id, name: 'critical', color: '#CC0000' });
// Delete a tag
await api.tags.deleteTag({ tagId: tag.id });
```
### Kanban
```typescript
// Fetch all columns for a goal
const columns = await api.kanban.fetchAllColumns(goal.id);
// Add a column
const column = await api.kanban.addColumn({
goalId: goal.id,
name: 'In Progress',
});
// Fetch tasks for a column (cursor-based pagination)
const result = await api.kanban.fetchTasksForColumn(goal.id, column.id, null);
// result.tasks, result.nextCursor, result.columnVersion
// Move a task between columns / reorder
await api.kanban.updateTasksOrderAndColumn({
goalId: goal.id,
columnId: column.id,
taskId: task.id,
prevTaskId: null,
nextTaskId: 10,
});
// Update a column
await api.kanban.updateColumn({ id: column.id, goalId: goal.id, name: 'Review' });
// Delete a column
await api.kanban.deleteColumn({ id: column.id, goalId: goal.id });
```
### Graph (Task Dependencies)
```typescript
// Add a dependency edge (source -> target)
const edge = await api.graph.addEdge({ source: 1, target: 2 });
// Fetch all edges for a goal
const edges = await api.graph.fetchAllEdges(goal.id);
// Delete an edge
await api.graph.deleteEdge(edge.id);
```
### Collaboration
```typescript
// Invite a user by email
await api.collaboration.inviteUserToGoal({
goalId: goal.id,
email: 'user@example.com',
});
// Fetch goal members
const members = await api.collaboration.fetchUsersForGoal(goal.id);
// Remove a user
await api.collaboration.deleteUserFromGoal({
goalId: goal.id,
userId: 5,
});
// Roles
const roles = await api.collaboration.fetchRolesForGoal(goal.id);
const newRole = await api.collaboration.createRoleForGoal({
goalId: goal.id,
name: 'Developer',
});
await api.collaboration.toggleUserRoles({
goalId: goal.id,
userId: 5,
roleId: newRole.id,
});
// Permissions
const allPermissions = await api.collaboration.fetchAllPermissions();
await api.collaboration.toggleRolePermission({
goalId: goal.id,
roleId: newRole.id,
permissionId: 3,
});
await api.collaboration.deleteRoleFromGoal({
goalId: goal.id,
roleId: newRole.id,
});
```
## Permissions
The package exports `TvPermissions` — a map of all permission constants:
```typescript
import { TvPermissions } from 'taskview-api';
TvPermissions.GOAL_CAN_DELETE // 'goal_can_delete'
TvPermissions.GOAL_CAN_EDIT // 'goal_can_edit'
TvPermissions.GOAL_CAN_MANAGE_USERS // 'goal_can_manage_users'
TvPermissions.GOAL_CAN_WATCH_CONTENT // 'goal_can_watch_content'
TvPermissions.GOAL_CAN_ADD_TASK_LIST // 'goal_can_add_task_list'
TvPermissions.COMPONENT_CAN_DELETE // 'component_can_delete'
TvPermissions.COMPONENT_CAN_EDIT // 'component_can_edit'
TvPermissions.COMPONENT_CAN_WATCH_CONTENT // 'component_can_watch_content'
TvPermissions.COMPONENT_CAN_ADD_TASKS // 'component_can_add_tasks'
TvPermissions.TASK_CAN_DELETE // 'task_can_delete'
TvPermissions.TASK_CAN_EDIT_DESCRIPTION // 'task_can_edit_description'
TvPermissions.TASK_CAN_EDIT_STATUS // 'task_can_edit_status'
TvPermissions.TASK_CAN_EDIT_NOTE // 'task_can_edit_note'
TvPermissions.TASK_CAN_EDIT_DEADLINE // 'task_can_edit_deadline'
TvPermissions.TASK_CAN_EDIT_TAGS // 'task_can_edit_tags'
TvPermissions.TASK_CAN_EDIT_PRIORITY // 'task_can_edit_priority'
TvPermissions.TASK_CAN_ASSIGN_USERS // 'task_can_assign_users'
TvPermissions.TASK_CAN_ACCESS_HISTORY // 'task_can_access_history'
// ... and more
TvPermissions.KANBAN_CAN_MANAGE // 'kanban_can_manage'
TvPermissions.KANBAN_CAN_VIEW // 'kanban_can_view'
TvPermissions.GRAPH_CAN_MANAGE // 'graph_can_manage'
TvPermissions.GRAPH_CAN_VIEW // 'graph_can_view'
```
## Build Formats
The library ships in three formats:
- **ES Module** — `taskview-api.es.js`
- **CommonJS** — `taskview-api.cjs.js`
- **UMD** — `taskview-api.umd.js`
- **TypeScript declarations** — `index.d.ts`
## Example
```typescript
import axios from 'axios';
import { TvApi } from 'taskview-api';
const BASE_URL = 'http://localhost:1401';
const $axios = axios.create({ baseURL: BASE_URL });
async function login(): Promise<{ access: string; refresh: string }> {
const { data } = await $axios.post('/module/auth/login', {
login: 'test@mail.dest',
password: 'user1!#Q',
});
return data;
}
async function main() {
// 1. Authenticate
console.log('Logging in...');
const { access, refresh } = await login();
console.log('Logged in. Access token received.');
// 2. Set auth header and create API instance
$axios.defaults.headers.common['Authorization'] = `Bearer ${access}`;
const api = new TvApi($axios);
// 3. Goals
console.log('\n--- Goals ---');
const goals = await api.goals.fetchGoals();
console.log(`Found ${goals.length} goal(s)`);
const goal = await api.goals.createGoal({
name: `Example Goal ${Date.now()}`,
description: 'Created by taskview-api example',
color: '#4A90D9',
});
console.log(`Created goal: id=${goal!.id}, name="${goal!.name}"`);
// 4. Goal Lists
console.log('\n--- Goal Lists ---');
const list = await api.goalLists.createList({
goalId: goal!.id,
name: 'Backlog',
description: 'Example task list',
});
console.log(`Created list: id=${list!.id}, name="${list!.name}"`);
const lists = await api.goalLists.fetchLists({ goalId: goal!.id });
console.log(`Goal has ${lists.length} list(s)`);
// 5. Tasks
console.log('\n--- Tasks ---');
const task1 = await api.tasks.createTask({
goalId: goal!.id,
description: 'First task',
priorityId: 1,
goalListId: list!.id,
});
console.log(`Created task: id=${task1!.id}, "${task1!.description}"`);
const task2 = await api.tasks.createTask({
goalId: goal!.id,
description: 'Second task (high priority)',
priorityId: 3,
goalListId: list!.id,
note: 'This is an important task',
});
console.log(`Created task: id=${task2!.id}, "${task2!.description}"`);
const tasks = await api.tasks.fetch({
goalId: goal!.id,
componentId: list!.id,
page: 1,
showCompleted: 0,
firstNew: 0,
});
console.log(`Fetched ${tasks.length} task(s) from list`);
// 6. Update a task
await api.tasks.updateTask({
id: task1!.id,
description: 'First task (updated)',
complete: true,
});
console.log(`Marked task ${task1!.id} as complete`);
// 7. Tags
console.log('\n--- Tags ---');
const tag = await api.tags.createTag({
name: 'example-tag',
color: '#FF5733',
goalId: goal!.id,
});
console.log(`Created tag: id=${tag!.id}, name="${tag!.name}"`);
await api.tags.toggleTag({ tagId: tag!.id, taskId: task2!.id });
console.log(`Added tag "${tag!.name}" to task ${task2!.id}`);
// 8. Kanban
console.log('\n--- Kanban ---');
const column = await api.kanban.addColumn({
goalId: goal!.id,
name: 'In Progress',
});
console.log(`Created kanban column: id=${column.id}, name="${column.name}"`);
const columns = await api.kanban.fetchAllColumns(goal!.id);
console.log(`Goal has ${columns.length} kanban column(s)`);
// 9. Graph (task dependencies)
console.log('\n--- Graph ---');
const edge = await api.graph.addEdge({
source: task1!.id,
target: task2!.id,
});
console.log(`Created dependency: task ${edge.fromTaskId} → task ${edge.toTaskId}`);
const edges = await api.graph.fetchAllEdges(goal!.id);
console.log(`Goal has ${edges.length} dependency edge(s)`);
// 10. Cleanup
console.log('\n--- Cleanup ---');
await api.graph.deleteEdge(edge.id);
console.log('Deleted dependency edge');
await api.kanban.deleteColumn({ id: column.id, goalId: goal!.id });
console.log('Deleted kanban column');
await api.tags.deleteTag({ tagId: tag!.id });
console.log('Deleted tag');
await api.tasks.deleteTask(task2!.id);
await api.tasks.deleteTask(task1!.id);
console.log('Deleted tasks');
await api.goalLists.deleteList(list!.id);
console.log('Deleted list');
await api.goals.deleteGoal(goal!.id);
console.log('Deleted goal');
console.log('\nDone! All examples completed successfully.');
}
main().catch((err) => {
console.error('Error:', err.response?.data || err.message);
process.exit(1);
});
```
@@ -0,0 +1,147 @@
import axios from 'axios';
import { TvApi } from 'taskview-api';
const BASE_URL = 'http://localhost:1401';
const $axios = axios.create({ baseURL: BASE_URL });
async function login(): Promise<{ access: string; refresh: string }> {
const { data } = await $axios.post('/module/auth/login', {
login: 'test@mail.dest',
password: 'user1!#Q',
});
return data;
}
async function main() {
// 1. Authenticate
console.log('Logging in...');
const { access, refresh } = await login();
console.log('Logged in. Access token received.');
// 2. Set auth header and create API instance
$axios.defaults.headers.common['Authorization'] = `Bearer ${access}`;
const api = new TvApi($axios);
// 3. Goals
console.log('\n--- Goals ---');
const goals = await api.goals.fetchGoals();
console.log(`Found ${goals.length} goal(s)`);
const goal = await api.goals.createGoal({
name: `Example Goal ${Date.now()}`,
description: 'Created by taskview-api example',
color: '#4A90D9',
});
console.log(`Created goal: id=${goal!.id}, name="${goal!.name}"`);
// 4. Goal Lists
console.log('\n--- Goal Lists ---');
const list = await api.goalLists.createList({
goalId: goal!.id,
name: 'Backlog',
description: 'Example task list',
});
console.log(`Created list: id=${list!.id}, name="${list!.name}"`);
const lists = await api.goalLists.fetchLists({ goalId: goal!.id });
console.log(`Goal has ${lists.length} list(s)`);
// 5. Tasks
console.log('\n--- Tasks ---');
const task1 = await api.tasks.createTask({
goalId: goal!.id,
description: 'First task',
priorityId: 1,
goalListId: list!.id,
});
console.log(`Created task: id=${task1!.id}, "${task1!.description}"`);
const task2 = await api.tasks.createTask({
goalId: goal!.id,
description: 'Second task (high priority)',
priorityId: 3,
goalListId: list!.id,
note: 'This is an important task',
});
console.log(`Created task: id=${task2!.id}, "${task2!.description}"`);
const tasks = await api.tasks.fetch({
goalId: goal!.id,
componentId: list!.id,
page: 1,
showCompleted: 0,
firstNew: 0,
});
console.log(`Fetched ${tasks.length} task(s) from list`);
// 6. Update a task
await api.tasks.updateTask({
id: task1!.id,
description: 'First task (updated)',
complete: true,
});
console.log(`Marked task ${task1!.id} as complete`);
// 7. Tags
console.log('\n--- Tags ---');
const tag = await api.tags.createTag({
name: 'example-tag',
color: '#FF5733',
goalId: goal!.id,
});
console.log(`Created tag: id=${tag!.id}, name="${tag!.name}"`);
await api.tags.toggleTag({ tagId: tag!.id, taskId: task2!.id });
console.log(`Added tag "${tag!.name}" to task ${task2!.id}`);
// 8. Kanban
console.log('\n--- Kanban ---');
const column = await api.kanban.addColumn({
goalId: goal!.id,
name: 'In Progress',
});
console.log(`Created kanban column: id=${column.id}, name="${column.name}"`);
const columns = await api.kanban.fetchAllColumns(goal!.id);
console.log(`Goal has ${columns.length} kanban column(s)`);
// 9. Graph (task dependencies)
console.log('\n--- Graph ---');
const edge = await api.graph.addEdge({
source: task1!.id,
target: task2!.id,
});
console.log(`Created dependency: task ${edge.fromTaskId} → task ${edge.toTaskId}`);
const edges = await api.graph.fetchAllEdges(goal!.id);
console.log(`Goal has ${edges.length} dependency edge(s)`);
// 10. Cleanup
console.log('\n--- Cleanup ---');
await api.graph.deleteEdge(edge.id);
console.log('Deleted dependency edge');
await api.kanban.deleteColumn({ id: column.id, goalId: goal!.id });
console.log('Deleted kanban column');
await api.tags.deleteTag({ tagId: tag!.id });
console.log('Deleted tag');
await api.tasks.deleteTask(task2!.id);
await api.tasks.deleteTask(task1!.id);
console.log('Deleted tasks');
await api.goalLists.deleteList(list!.id);
console.log('Deleted list');
await api.goals.deleteGoal(goal!.id);
console.log('Deleted goal');
console.log('\nDone! All examples completed successfully.');
}
main().catch((err) => {
console.error('Error:', err.response?.data || err.message);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"name": "taskview-api-example",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "npx tsx index.ts"
},
"dependencies": {
"taskview-api": "latest",
"axios": "^1.2.3",
"tsx": "^4.19.0"
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "taskview-ce-api",
"name": "taskview-api",
"private": false,
"version": "1.17.0",
"version": "1.17.2",
"type": "module",
"main": "./dist/taskview-api.umd.js",
"module": "./dist/taskview-api.es.js",
@@ -0,0 +1,66 @@
import TvApiBase from "./base";
import type { AppResponse } from "./base.types";
import type {
IntegrationArgAdd,
IntegrationArgSelectRepo,
IntegrationArgToggle,
IntegrationResponseAdd,
IntegrationResponseDelete,
IntegrationResponseFetch,
IntegrationResponseRepos,
IntegrationResponseSelectRepo,
IntegrationResponseSync,
IntegrationResponseToggle,
} from "./integrations.types";
export default class TvIntegrationsApi extends TvApiBase {
protected moduleUrl = '/module/integrations';
public async fetchIntegrations(projectId: number) {
return this.request(
this.$axios.get<AppResponse<IntegrationResponseFetch>>(`${this.moduleUrl}`, {
params: { projectId },
})
);
}
public async createIntegration(data: IntegrationArgAdd) {
return this.request(
this.$axios.post<AppResponse<IntegrationResponseAdd>>(`${this.moduleUrl}`, data)
);
}
public async deleteIntegration(id: number) {
return this.request(
this.$axios.delete<AppResponse<IntegrationResponseDelete>>(`${this.moduleUrl}`, {
data: { id },
})
);
}
public async toggleIntegration(data: IntegrationArgToggle) {
return this.request(
this.$axios.patch<AppResponse<IntegrationResponseToggle>>(`${this.moduleUrl}/toggle`, data)
);
}
public async fetchRepos(integrationId: number) {
return this.request(
this.$axios.get<AppResponse<IntegrationResponseRepos>>(`${this.moduleUrl}/repos`, {
params: { integrationId },
})
);
}
public async selectRepo(data: IntegrationArgSelectRepo) {
return this.request(
this.$axios.patch<AppResponse<IntegrationResponseSelectRepo>>(`${this.moduleUrl}/select-repo`, data)
);
}
public async syncIntegration(integrationId: number) {
return this.request(
this.$axios.post<AppResponse<IntegrationResponseSync>>(`${this.moduleUrl}/sync`, { integrationId })
);
}
}
@@ -0,0 +1,50 @@
export type IntegrationProvider = 'github' | 'gitlab';
export type IntegrationItem = {
id: number;
provider: IntegrationProvider;
repoFullName: string | null;
repoExternalId: string | null;
projectId: number;
isActive: boolean;
createdAt: string | null;
updatedAt: string | null;
};
export type IntegrationArgAdd = {
provider: IntegrationProvider;
repoFullName: string;
projectId: number;
};
export type IntegrationArgDelete = {
id: number;
};
export type IntegrationArgToggle = {
id: number;
isActive: boolean;
};
export type IntegrationArgSelectRepo = {
integrationId: number;
repoFullName: string;
repoExternalId: string;
};
export type RepoItem = {
id: number;
fullName: string;
name: string;
isPrivate: boolean;
description: string | null;
url: string;
};
export type IntegrationResponseAdd = IntegrationItem | null;
export type IntegrationResponseFetch = IntegrationItem[];
export type IntegrationResponseToggle = IntegrationItem | null;
export type IntegrationResponseDelete = boolean;
export type IntegrationResponseSelectRepo = IntegrationItem | null;
export type IntegrationResponseRepos = RepoItem[];
export type IntegrationResponseSync = { synced: number };
@@ -122,6 +122,9 @@ export const TvPermissions: Record<Uppercase<keyof GoalPermissions>, keyof GoalP
GRAPH_CAN_MANAGE: 'graph_can_manage',
GRAPH_CAN_VIEW: 'graph_can_view',
INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage',
INTEGRATIONS_CAN_VIEW: 'integrations_can_view',
} as const;
export type GoalPermissions = {
@@ -161,4 +164,7 @@ export type GoalPermissions = {
graph_can_manage?: true;
graph_can_view?: true;
integrations_can_manage?: true;
integrations_can_view?: true;
};
@@ -2,7 +2,6 @@ export const TaskIncomeType = 1;
export const TaskExpenseType = 0;
export type TaskTransactionTypes = typeof TaskIncomeType | typeof TaskExpenseType | null;
//TODO add optional type for fields that can be not allowed by permissions (subtasks, note)
export interface TaskBase {
id: number;
description: string;
@@ -26,8 +25,8 @@ export interface TaskBase {
statusId: number | null;
taskOrder: number | null;
kanbanOrder: number | null;
//we add amount as number but got as string from API
amount: number | string | null; //string bec JS not working with numbers like 3.43
amount: number | string | null;
transactionType: TaskTransactionTypes;
nodeGraphPosition: Record<'x' | 'y', number> | null;
creatorId: number | null;
@@ -36,19 +35,18 @@ export interface TaskBase {
export interface Task extends TaskBase {
tags: number[];
historyId: null | number;
assignedUsers: number[]; //TODO how we update assigned users? maybe we need to do it in different module
assignedUsers: number[];
};
// we can not update defined fields
type NotAllowedToUpdate = 'id' | 'goalId' | 'owner' | 'dateCreation' | 'dateComplete' | 'historyId';
export type TaskArgUpdate = Pick<Task, 'id'> & Partial<Omit<Task, NotAllowedToUpdate>>;
export type TaskResponseUpdate = Task | null;
export type TaskResponseUpdate = (Task & { syncFailed?: boolean }) | null;
export type TaskArgAdd = Pick<Task, 'goalId' | 'description'>
& Partial<Omit<Task, NotAllowedToUpdate | 'subtasks' | 'tags' | 'assignedUsers' | 'creatorId' | 'amount'>>
//we add amount as number but got as string from API
& { amount?: number | null };
& { amount?: number | string | null };
export type TaskResponseAdd = Task | null;
@@ -77,6 +75,7 @@ export type TaskArgFetch = {
searchText?: string;
filters?: TaskFilters;
unlimited?: boolean;
ignoreCompleted?: boolean;
};
export type TaskResponseFetch = Task[];
+2 -1
View File
@@ -8,4 +8,5 @@ export * from '@/api/goals.types';
export * from '@/api/collaboration.types';
export * from '@/api/tags.api.types';
export * from '@/api/goals-list.types';
export * from '@/api/kanban.types';
export * from '@/api/kanban.types';
export * from '@/api/integrations.types';
+5 -1
View File
@@ -5,9 +5,9 @@ import TvGoalApi from "./api/goals";
import { TvCollaborationApi } from "./api/collaboration";
import TvGoalListApi from "./api/goals-list";
import TvTagsApi from "./api/tags";
import TvIntegrationsApi from "./api/integrations";
import TvKanban from "./api/kanban";
// const TASKVIEW_URL = 'https://apitaskview.handscream.com/';
export class TvApi {
protected $axios: AxiosInstance;
@@ -26,6 +26,8 @@ export class TvApi {
public kanban: TvKanban;
public integrations: TvIntegrationsApi;
constructor($axios: AxiosInstance) {
this.$axios = $axios;
@@ -42,6 +44,8 @@ export class TvApi {
this.tags = new TvTagsApi(this.$axios);
this.kanban = new TvKanban(this.$axios);
this.integrations = new TvIntegrationsApi(this.$axios);
}
public setBaseUrl(baseUrl: string) {
@@ -1,5 +1,5 @@
{
"name": "taskview-ce-db-schemas",
"name": "taskview-db-schemas",
"version": "1.17.0",
"type": "module",
"main": "./dist/taskview-db-schemas.umd.js",
@@ -8,3 +8,4 @@ export * from './schemas/tasks-assignee.schema';
export * from './schemas/goals.schema';
export * from './schemas/goals-list.schema';
export * from './schemas/tasks-statuses.schema';
export * from './schemas/integrations.schema';
@@ -0,0 +1,35 @@
import { boolean, integer, pgSchema, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core";
import { GoalsSchema } from "./goals.schema";
import { TasksSchema } from "./tasks.schema";
export const IntegrationsSchema = pgSchema('tasks').table('integrations', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
provider: varchar({ length: 20 }).notNull(),
accessTokenEncrypted: varchar('access_token_encrypted'),
refreshTokenEncrypted: varchar('refresh_token_encrypted'),
repoExternalId: varchar('repo_external_id', { length: 255 }),
repoFullName: varchar('repo_full_name', { length: 255 }),
projectId: integer('project_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }),
webhookId: varchar('webhook_id', { length: 255 }),
webhookSecretEncrypted: varchar('webhook_secret_encrypted'),
isActive: boolean('is_active').notNull().default(true),
lastSyncedAt: timestamp('last_synced_at'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const IntegrationTaskMapSchema = pgSchema('tasks').table('integration_task_map', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
integrationId: integer('integration_id').notNull().references(() => IntegrationsSchema.id, { onDelete: 'cascade' }),
taskId: integer('task_id').notNull().references(() => TasksSchema.id, { onDelete: 'cascade' }),
issueNumber: integer('issue_number').notNull(),
issueState: varchar('issue_state', { length: 20 }).notNull().default('open'),
syncedAt: timestamp('synced_at').defaultNow(),
}, (table) => [
uniqueIndex('integration_issue_unique').on(table.integrationId, table.issueNumber),
]);
export type IntegrationsSchemaTypeForSelect = typeof IntegrationsSchema.$inferSelect;
export type IntegrationsSchemaTypeForInsert = typeof IntegrationsSchema.$inferInsert;
export type IntegrationTaskMapSchemaTypeForSelect = typeof IntegrationTaskMapSchema.$inferSelect;
export type IntegrationTaskMapSchemaTypeForInsert = typeof IntegrationTaskMapSchema.$inferInsert;
@@ -1,4 +1,4 @@
import { integer, pgSchema, varchar, boolean, date, time, jsonb } from "drizzle-orm/pg-core";
import { integer, numeric, pgSchema, varchar, boolean, date, time, jsonb } from "drizzle-orm/pg-core";
import { createInsertSchema } from 'drizzle-arktype';
import { UsersSchema } from "./users.schema";
@@ -20,7 +20,7 @@ export const TasksSchema = pgSchema('tasks').table('tasks', {
statusId: integer('status_id'), //kanban column id
taskOrder: integer('task_order'),
kanbanOrder: integer('kanban_order'),
amount: integer(),
amount: numeric({ precision: 10, scale: 2 }),
transactionType: integer('transaction_type').$type<1 | 0 | null>(),
nodeGraphPosition: jsonb('node_graph_position'),
});
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"context7@claude-plugins-official": true
}
}
+13
View File
@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+37
View File
@@ -0,0 +1,37 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Typecheck
run: pnpm run typecheck
+39 -2
View File
@@ -1,2 +1,39 @@
!build-upload.sh
tsconfig.tsbuildinfo
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Auto-generated type declarations
auto-imports.d.ts
components.d.ts
tsconfig.app.tsbuildinfo
tsconfig.node.tsbuildinfo
# Environment variables
.env
Info.plist
e2e_logs
e2e_pgdata
e2e_updates
playwright-report
test-results
+2 -2
View File
@@ -54,8 +54,8 @@ captures/
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
+5 -8
View File
@@ -1,28 +1,25 @@
apply plugin: 'com.android.application'
android {
namespace "com.handscream.taskview.app"
compileSdk rootProject.ext.compileSdkVersion
namespace = "com.handscream.taskview.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.handscreamgnl.taskview.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1150
versionName "1.15.0"
versionCode 1201
versionName "1.20.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
setProperty("archivesBaseName", "TaskView-$versionName")
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.debug
}
}
}
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-device')
implementation project(':capacitor-preferences')
implementation project(':capacitor-splash-screen')
+11 -4
View File
@@ -1,23 +1,28 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
@@ -25,11 +30,13 @@
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+2 -2
View File
@@ -7,8 +7,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.3'
classpath 'com.google.gms:google-services:4.4.2'
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
+9 -6
View File
@@ -1,18 +1,21 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/.pnpm/@capacitor+android@7.4.4_@capacitor+core@7.4.4/node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.4.4/node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../../node_modules/.pnpm/@capacitor+device@7.0.1_@capacitor+core@7.4.4/node_modules/@capacitor/device/android')
project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@7.0.1_@capacitor+core@7.4.4/node_modules/@capacitor/preferences/android')
project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/.pnpm/@capacitor+splash-screen@7.0.3_@capacitor+core@7.4.4/node_modules/@capacitor/splash-screen/android')
project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../../node_modules/.pnpm/@capgo+capacitor-updater@7.29.0_@capacitor+core@7.4.4/node_modules/@capgo/capacitor-updater/android')
project(':capgo-capacitor-updater').projectDir = new File('../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+14 -11
View File
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,7 +85,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -111,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -144,7 +147,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -152,7 +155,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -201,16 +204,16 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

Some files were not shown because too many files have changed in this diff Show More