Compare commits

...

114 Commits

Author SHA1 Message Date
Nikolai Giman ff539f00f5 Merge pull request #53 from Gimanh/fix/login-by-code
fix: login by code
2026-05-16 14:58:18 +02:00
Nikolai Giman eede789d54 fix: login by code 2026-05-16 14:56:27 +02:00
Nikolai Giman c532f607aa Merge pull request #52 from Gimanh/fix/recovery-password
fix: password recovery
2026-05-16 13:54:18 +02:00
Nikolai Giman 791b9d1c6e fix: password recovery 2026-05-16 13:53:32 +02:00
Nikolai Giman da82f3ae0f Merge pull request #51 from Gimanh/feat/locale-de
chore: lint
2026-05-16 11:02:50 +02:00
Nikolai Giman 96c40d5f49 feat: new locale Deutsch 2026-05-16 11:01:54 +02:00
Nikolai Giman dbd5253423 chore: lint 2026-05-16 09:56:51 +02:00
Nikolai Giman c0c0a49c0d Merge pull request #50 from Gimanh/feat/timetracking
wip: time tracking
2026-05-16 09:13:55 +02:00
Nikolai Giman f0fed9d68b wip: TT for specific goal 2026-05-16 09:12:13 +02:00
Nikolai Giman ee67342313 wip: fixed permissions and UI 2026-05-15 17:52:29 +02:00
Nikolai Giman a4bb9de09f wip: check permissions for TT component 2026-05-15 10:18:11 +02:00
Nikolai Giman 0d01b4d532 wip: tests and permission descriptions 2026-05-14 23:40:22 +02:00
Nikolai Giman a5446ea825 wip: tests for MCP 2026-05-14 23:22:10 +02:00
Nikolai Giman ec50b31642 wip: time tracking page and tests 2026-05-14 22:46:29 +02:00
Nikolai Giman 1706a3124b wip: webhooks and fixes 2026-05-12 22:02:13 +02:00
Nikolai Giman 6cf26fdd61 feat: webhooks for time tracking 2026-05-12 16:54:00 +02:00
Nikolai Giman d081436519 wip: time tracking 2026-05-12 00:03:56 +02:00
Nikolai Giman 40e54966a8 Merge pull request #48 from Gimanh/feat/analytics
feat: analytics
2026-05-09 23:42:28 +02:00
Nikolai Giman 4527ae6c4b fix: locales and permissions for drilldown 2026-05-09 21:34:06 +02:00
Nikolai Giman 9df7b4a7cd fix: indexes 2026-05-09 17:28:21 +02:00
Nikolai Giman 5afcc2d8ec fix: goals archive exclude and permissions check for drilldown 2026-05-08 22:56:01 +02:00
Nikolai Giman d2c9bee0d3 Merge pull request #49 from Gimanh/chore/mobile-version
chore: new mobile version
2026-05-04 00:09:54 +02:00
Nikolai Giman c3ff29501b chore: new mobile version 2026-05-04 00:09:27 +02:00
Nikolai Giman f791822c79 fix: only owner can watch all analytics 2026-05-03 15:35:53 +02:00
Nikolai Giman 344526b1b2 fix: handle drill-down correctly 2026-04-27 01:06:22 +02:00
Nikolai Giman 95f024275c fix: ui issues 2026-04-27 00:55:54 +02:00
Nikolai Giman fd8ea2e328 feat: analytics 2026-04-27 00:39:30 +02:00
Gimanh f5c3fe2bc8 Merge pull request #46 from Gimanh/feat/tv-mcp
feat: mcp and orgs
2026-04-21 10:13:11 +02:00
Nikolai Giman bc8264b979 chore: version 2026-04-21 10:12:42 +02:00
Nikolai Giman 6c8d03c35f chore: add tutorial 2026-04-20 08:51:24 +02:00
Nikolai Giman 7ced0a54e1 fix: migration for basic user 2026-04-19 21:28:55 +02:00
Nikolai Giman 32a2819ebf fix: notifications 2026-04-19 12:56:13 +02:00
Nikolai Giman 9bba904c16 wip: SSO and ORG fix 2026-04-19 12:08:52 +02:00
Nikolai Giman b24f829a6b doc: sso 2026-04-12 23:54:43 +02:00
Nikolai Giman 1be3dac90e fix: oidc 2026-04-12 23:39:03 +02:00
Nikolai Giman af63530390 feat: mcp & sso 2026-04-12 23:14:10 +02:00
Nikolai Giman 8d9276830f fix: init loading 2026-04-06 00:10:25 +02:00
Nikolai Giman 8eab7c2573 fix: slug case and test 2026-04-05 22:45:09 +02:00
Nikolai Giman d06266fb93 fix: ui, locale, tests 2026-04-05 22:39:03 +02:00
Nikolai Giman 4ec6b143ca feat: mcp and orgs 2026-04-05 21:56:15 +02:00
Gimanh e3e896e159 Merge pull request #45 from Gimanh/chore/doc-fix
chore: doc build
2026-03-30 00:35:26 +02:00
Nikolai Giman de26871aff chore: doc build 2026-03-30 00:35:00 +02:00
Gimanh 3aff4c77f2 Merge pull request #44 from Gimanh/chore/version-1-40
chore: new version 1.41
2026-03-30 00:20:56 +02:00
Nikolai Giman 1d6af3ab7d chore: new version 1.41 2026-03-30 00:20:34 +02:00
Gimanh 5600d261bc Merge pull request #43 from Gimanh/feat/api-tokens
feat: api-tokens
2026-03-29 22:42:55 +02:00
Nikolai Giman 35776d9eb5 fix: filter only allowed projects for api-tokens 2026-03-29 15:55:09 +02:00
Nikolai Giman 9f0cfccdc6 feat: api-tokens 2026-03-29 14:29:31 +02:00
Gimanh ba17eff713 Merge pull request #42 from Gimanh/fix/migration
fix: migration
2026-03-23 22:13:07 +01:00
Nikolai Giman 08ee2af865 fix: migration 2026-03-23 22:12:44 +01:00
Gimanh 4412ba1adf Merge pull request #40 from Gimanh/ver/1-30
chore: version 1.32.0
2026-03-22 23:30:20 +01:00
Nikolai Giman 2ad29b77f9 chore: version 1.32.0 2026-03-22 23:29:56 +01:00
Gimanh 9307ab5e45 Merge pull request #39 from Gimanh/feat/notifications
Feat/notifications
2026-03-22 20:10:58 +01:00
Nikolai Giman 2f4e84b54b wip: updater 2026-03-22 20:09:50 +01:00
Nikolai Giman c403673f4d wip: notifications 2026-03-22 20:01:05 +01:00
Nikolai Giman 5c4859743e wip: exclude initiator from notifications 2026-03-15 23:45:13 +01:00
Nikolai Giman 0bf0052909 wip: notifications, detect user assign and send 2026-03-15 23:38:55 +01:00
Nikolai Giman 0e1455b947 wip: notifications 2026-03-15 22:53:51 +01:00
Gimanh 63e2481b9d Merge pull request #38 from Gimanh/fix/integrations
Fix/integrations
2026-03-15 14:43:45 +01:00
Nikolai Giman ade7c5d007 fix: add link to integration task source 2026-03-15 14:29:24 +01:00
Nikolai Giman fc3d03f932 fix: filter issue 2026-03-13 22:35:18 +01:00
Gimanh 9de2b64acf Merge pull request #37 from Gimanh/chore/docs
chore: docs
2026-03-13 21:58:48 +01:00
Nikolai Giman ef206635f6 chore: docs 2026-03-13 21:56:19 +01:00
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
1149 changed files with 71117 additions and 5784 deletions
+23 -5
View File
@@ -5,6 +5,17 @@
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.
[![License](https://img.shields.io/badge/license-Source--Available-blue)](./LICENSE)
[![Active Development](https://img.shields.io/badge/status-active-brightgreen)]()
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**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)
## 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
@@ -128,11 +139,18 @@ Make sure the image versions match the version defined in the root package.json.
## Roadmap
- Plugin / extension system
- Migrate to NuxtUI or similar ui library
- Enterprise SSO and identity integrations
- Redesign
- Desktop version
- [X] Migrate to NuxtUI or similar ui library
- [X] Enterprise SSO and identity integrations
- [X] Redesign
- [X] API tokens
- [X] Webhooks
- [X] MCP server
- [X] Notifications
- [X] Analytics
- [X] API client
- [ ] Desktop version
- [ ] Plugin / extension system
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
+33 -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,35 @@ SMTP_USERNAME=your_email@example.com
SMTP_PASSWORD=your_smtp_password_here
SMTP_ENCRYPTION=ssl
SMTP_FROM_NAME=TaskView
SMTP_FROM_EMAIL=your_email@example.com
SMTP_FROM_EMAIL=your_email@example.com
# Encryption (32-byte hex key for AES-256-GCM)
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=
# GitHub Integration OAuth (separate from login OAuth)
GITHUB_INTEGRATION_CLIENT_ID=
GITHUB_INTEGRATION_CLIENT_SECRET=
GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/github/callback
# For GitHub Enterprise, override these:
# GITHUB_BASE_URL=https://github.yourcompany.com
# GITHUB_API_URL=https://github.yourcompany.com/api/v3
# GitLab Integration OAuth
GITLAB_INTEGRATION_CLIENT_ID=
GITLAB_INTEGRATION_CLIENT_SECRET=
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
# For self-hosted GitLab, override these:
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
# Firebase Cloud Messaging (push notifications for mobile, optional)
# Path to Firebase service account JSON file
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
# Centrifugo (real-time notifications, optional)
# CENTRIFUGO_API_URL=http://localhost:8000
# CENTRIFUGO_API_KEY=your_centrifugo_api_key_here
# CENTRIFUGO_TOKEN_SECRET=your_centrifugo_token_secret_here
# Public port that clients use to connect to Centrifugo (exposed port, not internal docker port)
# CENTRIFUGO_PUBLIC_PORT=8000
+6
View File
@@ -0,0 +1,6 @@
CENTRIFUGO_TOKEN_HMAC_SECRET_KEY=taskview-centrifugo-secret-change-me
CENTRIFUGO_API_KEY=taskview-centrifugo-api-key-change-me
CENTRIFUGO_ALLOWED_ORIGINS=*
CENTRIFUGO_ADMIN=true
CENTRIFUGO_ADMIN_PASSWORD=admin
CENTRIFUGO_ADMIN_SECRET=admin-secret-change-me
+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"
@@ -0,0 +1,15 @@
{
"allow_subscribe_for_client": true,
"user_personal_channel_namespace": "personal",
"namespaces": [
{
"name": "personal",
"presence": false,
"join_leave": false,
"history_size": 10,
"history_ttl": "300s",
"force_recovery": true,
"allow_subscribe_for_client": true
}
]
}
@@ -0,0 +1,15 @@
services:
centrifugo:
image: centrifugo/centrifugo:v5
restart: unless-stopped
command: centrifugo -c config.json
env_file:
- ./.env.centrifugo
ports:
- "8000:8000"
volumes:
- ./centrifugo/config.json:/centrifugo/config.json
ulimits:
nofile:
soft: 65535
hard: 65535
+3 -3
View File
@@ -14,7 +14,7 @@ services:
timeout: 5s
retries: 5
migration:
image: gimanhead/taskview-ce-db-migration:1.18.2
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.2
image: gimanhead/taskview-ce-webapp:latest
restart: "no"
ports:
- "8888:80"
taskview-api-server:
image: gimanhead/taskview-ce-api-server:1.18.2
image: gimanhead/taskview-ce-api-server:latest
restart: "no"
ports:
- "1725:1401"
+15 -4
View File
@@ -1,11 +1,12 @@
{
"name": "taskview-ce-api-server",
"version": "1.17.0",
"version": "1.42.5",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
"build": "vite build",
"build:docker": "vite build --config ./vite.config.docker.mts",
"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",
@@ -24,8 +25,10 @@
"@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",
"@types/ua-parser-js": "^0.7.39",
"aws-sdk": "^2.1691.0",
"mock-aws-s3": "^4.0.2",
"nock": "^13.5.5",
@@ -39,13 +42,15 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@node-saml/node-saml": "^5.1.0",
"@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",
"@xmldom/xmldom": "^0.9.9",
"axios": "1.13.5",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -53,20 +58,26 @@
"drizzle-orm": "^0.44.4",
"emailjs": "^4.0.3",
"express": "4.21.0",
"firebase-admin": "^12.7.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"openid-client": "^6.8.2",
"passport": "^0.7.0",
"passport-apple": "^2.0.2",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"pg": "^8.16.3",
"pg-boss": "^12.14.0",
"pino": "^9.4.0",
"rotating-file-stream": "^3.2.5",
"semver": "^7.6.3",
"taskview-api": "workspace:^",
"taskview-db-schemas": "workspace:^",
"terser": "^5.36.0",
"ua-parser-js": "^2.0.9",
"zod": "^3.23.8"
},
"engines": {
"node": ">=24 <25"
}
}
}
+14 -23
View File
@@ -1,21 +1,12 @@
import cors from 'cors';
import express, { type Request, type Response } from 'express';
import helmet from 'helmet';
import { appUserMiddleware } from './middlewares/app-user-middleware';
import { corsMiddleware } from './middlewares/cors';
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 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",
]),
]);
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
export default class App {
public app: express.Application;
@@ -28,6 +19,7 @@ export default class App {
this.extendApp();
this.initializeMiddlewares();
registerAllEventHandlers();
this.initializeRoutes();
this.app.use(errorHandler);
this.app.use(passport.initialize());
@@ -48,21 +40,19 @@ export default class App {
next();
});
this.app.use(helmet());
this.app.use(corsMiddleware);
this.app.use(cookieParser());
this.app.use(appUserMiddleware);
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(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(helmet());
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
this.app.use(appUserMiddleware);
}
private initializeRoutes() {
@@ -72,8 +62,9 @@ export default class App {
}
public listen() {
return this.app.listen(this.port, '0.0.0.0', () => {
return this.app.listen(this.port, '0.0.0.0', async () => {
console.log(`Server is running on port ${this.port}`);
await startAllWorkers();
});
}
}
+43
View File
@@ -7,7 +7,13 @@ import { KanbanManager } from '../tv-modules/kanban/KanbanManager';
import { GoalListManager } from '../tv-modules/lists/GoalListManager';
import { StartManager } from '../tv-modules/start/StartManager';
import { TagsManager } from '../tv-modules/tags/TagsManager';
import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsManager';
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
import { OrganizationManager } from '../tv-modules/organizations/OrganizationManager';
import { SsoManager } from '../tv-modules/sso/SsoManager';
import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
import { TasksManager } from '../tv-modules/tasks/TasksManager';
import { TimeTrackingManager } from '../tv-modules/time-tracking/TimeTrackingManager';
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
@@ -22,10 +28,19 @@ export class AppUser {
public readonly tagsManager: TagsManager;
public readonly authManager: AuthManager;
private hasActiveToken: boolean = false;
private apiTokenAuth: boolean = false;
private tokenPermissions?: string[];
private allowedGoalIds?: number[];
private userDataFromDb?: UserDbRecord;
public readonly startManager: StartManager;
public readonly kanbanManager: KanbanManager;
public readonly graphManager: GraphManager;
public readonly integrationsManager: IntegrationsManager;
public readonly notificationsManager: NotificationsManager;
public readonly organizationManager: OrganizationManager;
public readonly ssoManager: SsoManager;
public readonly analyticsManager: AnalyticsManager;
public readonly timeTrackingManager: TimeTrackingManager;
constructor(userData?: UserJwtPayload) {
this.userData = userData;
@@ -40,6 +55,12 @@ export class AppUser {
this.startManager = new StartManager(this);
this.kanbanManager = new KanbanManager(this);
this.graphManager = new GraphManager(this);
this.integrationsManager = new IntegrationsManager(this);
this.notificationsManager = new NotificationsManager(this);
this.organizationManager = new OrganizationManager(this);
this.ssoManager = new SsoManager(this);
this.analyticsManager = new AnalyticsManager(this);
this.timeTrackingManager = new TimeTrackingManager(this);
}
getTokenId(): number | undefined {
@@ -69,4 +90,26 @@ export class AppUser {
isBlocked(): boolean {
return this.userDataFromDb?.block !== 0;
}
/**
* Use it for API token authentication.
* @param permissions Permissions that the API token has
*/
setApiTokenAuth(permissions: string[], goalIds: number[]) {
this.apiTokenAuth = true;
this.tokenPermissions = permissions;
this.allowedGoalIds = goalIds;
}
isApiTokenAuth(): boolean {
return this.apiTokenAuth;
}
getAllowedGoalIds(): number[] | undefined {
return this.allowedGoalIds;
}
getTokenPermissions(): string[] | undefined {
return this.tokenPermissions;
}
}
+77
View File
@@ -0,0 +1,77 @@
import jwt from 'jsonwebtoken';
import { $logger } from '../modules/logget';
interface CentrifugoPublishPayload {
channel: string;
data: Record<string, unknown>;
}
export class CentrifugoClient {
private readonly apiUrl: string;
private readonly apiKey: string;
private readonly enabled: boolean;
constructor() {
const url = process.env.CENTRIFUGO_API_URL;
const key = process.env.CENTRIFUGO_API_KEY;
this.enabled = !!(url && key);
this.apiUrl = url || '';
this.apiKey = key || '';
if (!this.enabled) {
$logger.warn('[Centrifugo] Not configured — real-time notifications disabled');
}
}
async publish(channel: string, data: Record<string, unknown>): Promise<boolean> {
if (!this.enabled) return false;
try {
const payload: CentrifugoPublishPayload = { channel, data };
const response = await fetch(`${this.apiUrl}/api/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `apikey ${this.apiKey}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
$logger.error({ status: response.status }, '[Centrifugo] Publish failed');
return false;
}
return true;
} catch (err) {
$logger.error({ err }, '[Centrifugo] Publish error');
return false;
}
}
async publishToUser(userId: number, event: string, data: Record<string, unknown>): Promise<boolean> {
return this.publish(`personal:#${userId}`, { event, ...data });
}
isEnabled(): boolean {
return this.enabled;
}
static generateConnectionToken(userId: number): string {
const secret = process.env.CENTRIFUGO_TOKEN_SECRET || process.env.JWT_SIGN || '';
return jwt.sign(
{ sub: String(userId) },
secret,
{ expiresIn: '24h' }
);
}
}
let _instance: CentrifugoClient | null = null;
export function getCentrifugoClient(): CentrifugoClient {
if (!_instance) {
_instance = new CentrifugoClient();
}
return _instance;
}
+4
View File
@@ -0,0 +1,4 @@
export interface Dispatcher {
register(): void;
registerWorkers(): Promise<void>;
}
+47
View File
@@ -0,0 +1,47 @@
import { EventEmitter } from 'node:events';
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
import { $logger } from '../modules/logget';
export interface AppEvents {
'task.created': { task: TasksSchemaTypeForSelect; initiatorId: number };
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
'time-entry.stopped': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number; durationSeconds: number };
'time-entry.created': { entry: TimeEntryWithUser; initiatorId: number };
'time-entry.updated': { entry: TimeEntryWithUser; changes: Record<string, unknown>; initiatorId: number };
'time-entry.deleted': { entryId: number; taskId: number; goalId: number; userId: number; initiatorId: number };
}
type EventName = keyof AppEvents;
type EventHandler<T extends EventName> = (data: AppEvents[T]) => void | Promise<void>;
class AppEventBus {
private emitter = new EventEmitter();
on<T extends EventName>(event: T, handler: EventHandler<T>) {
this.emitter.on(event, (data: AppEvents[T]) => {
try {
const result = handler(data);
if (result instanceof Promise) {
result.catch((err) => {
$logger.error(err, `EventBus handler error [${event}]`);
});
}
} catch (err) {
$logger.error(err, `EventBus handler error [${event}]`);
}
});
}
emit<T extends EventName>(event: T, data: AppEvents[T]) {
this.emitter.emit(event, data);
}
}
export const eventBus = new AppEventBus();
+42 -3
View File
@@ -39,14 +39,25 @@ export class GoalPermissionsFetcher {
if (!goalId) { return new GoalPermissionsChecker([]); }
//Token authentication check
const allowedGoalIds = this.user.getAllowedGoalIds();
if (allowedGoalIds && allowedGoalIds.length > 0 && !allowedGoalIds.includes(goalId)) {
return new GoalPermissionsChecker([]);
}
if (this.isCacheValid(goalId)) {
return this.checkerCache[goalId].checker;
}
let permissions = await this.goalPermissionsRepository.fetchPermissionsForGoal(goalId, this.user);
const tokenPerms = this.user.getTokenPermissions();
if (tokenPerms && tokenPerms.length > 0) {
permissions = permissions.filter(p => tokenPerms.includes(p.permissionName));
}
this.checkerCache[goalId] = {
checker: new GoalPermissionsChecker(
await this.goalPermissionsRepository.fetchPermissionsForGoal(goalId, this.user)
),
checker: new GoalPermissionsChecker(permissions),
timestamp: performance.now(),
};
@@ -56,4 +67,32 @@ export class GoalPermissionsFetcher {
async getCheckerForGoal(goalId: number): Promise<GoalPermissionsChecker> {
return await this.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL);
}
async getAccessibleGoalIds(organizationId: number, permissions: string[]): Promise<number[]> {
if (permissions.length === 0) return [];
const tokenPerms = this.user.getTokenPermissions();
const effectivePermissions = tokenPerms && tokenPerms.length > 0
? permissions.filter((p) => tokenPerms.includes(p))
: permissions;
if (effectivePermissions.length === 0) return [];
const userData = this.user.getUserData();
if (!userData) return [];
let goalIds = await this.goalPermissionsRepository.fetchGoalIdsWithAnyPermission({
userId: userData.id,
email: userData.email,
organizationId,
permissionNames: effectivePermissions,
});
const allowedGoalIds = this.user.getAllowedGoalIds();
if (allowedGoalIds && allowedGoalIds.length > 0) {
goalIds = goalIds.filter((id) => allowedGoalIds.includes(id));
}
return goalIds;
}
}
+66 -3
View File
@@ -1,5 +1,15 @@
import { and, eq, exists, inArray, or } from 'drizzle-orm';
import {
CollaborationPermissionsToRoleSchema,
CollaborationRolesSchema,
CollaborationUsersSchema,
CollaborationUsersToGoalsSchema,
CollaborationUsersToRolesSchema,
GoalsSchema,
PermissionsSchema,
} from 'taskview-db-schemas';
import { Database } from '../modules/db';
import type { GoalPermissionItemsFromDb } from '../types/auth.types';
import type { FetchGoalIdsWithAnyPermissionParams, GoalPermissionItemsFromDb } from '../types/auth.types';
import type { GoalItemInDb } from '../types/goal.type';
import type { ListItemInDb } from '../types/lists.types';
import type { TaskItemInDb } from '../types/tasks.types';
@@ -35,12 +45,12 @@ export class GoalPermissionsRepository {
if (goalInfo.rows[0].owner === user.getUserData()?.id) {
query = `select name as "permissionName", id as "permissionId" from tv_auth.permissions;`;
} else {
query = `select p.name as "permissionName", p.id as "permissionId"
query = `select p.name as "permissionName", p.id as "permissionId"
from collaboration.users cu
left join collaboration.users_to_goals utg on cu.id = utg.user_id
left join tasks.goals tg on utg.goal_id = tg.id
left join collaboration.users_to_roles utr on utr.user_id = cu.id
left join collaboration.roles rol on utr.role_id = rol.id
left join collaboration.roles rol on utr.role_id = rol.id and rol.goal_id = tg.id
left join collaboration.permissions_to_role ptr on rol.id = ptr.role_id
left join tv_auth.permissions p on ptr.permission_id = p.id
where email = $1 and tg.id = $2 and p.name is not null and p.id is not null;`;
@@ -59,4 +69,57 @@ export class GoalPermissionsRepository {
}
return null;
}
async fetchGoalIdsWithAnyPermission(params: FetchGoalIdsWithAnyPermissionParams): Promise<number[]> {
if (params.permissionNames.length === 0) return [];
const permissionSubquery = this.db.dbDrizzle
.select({ one: CollaborationUsersSchema.id })
.from(CollaborationUsersSchema)
.innerJoin(
CollaborationUsersToGoalsSchema,
and(
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id),
eq(CollaborationUsersToGoalsSchema.goalId, GoalsSchema.id),
),
)
.innerJoin(
CollaborationUsersToRolesSchema,
eq(CollaborationUsersToRolesSchema.userId, CollaborationUsersSchema.id),
)
.innerJoin(
CollaborationRolesSchema,
and(
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
eq(CollaborationRolesSchema.goalId, GoalsSchema.id),
),
)
.innerJoin(
CollaborationPermissionsToRoleSchema,
eq(CollaborationPermissionsToRoleSchema.roleId, CollaborationRolesSchema.id),
)
.innerJoin(
PermissionsSchema,
eq(PermissionsSchema.id, CollaborationPermissionsToRoleSchema.permissionId),
)
.where(
and(
eq(CollaborationUsersSchema.email, params.email),
inArray(PermissionsSchema.name, params.permissionNames),
),
);
const result = await this.db.dbDrizzle
.select({ id: GoalsSchema.id })
.from(GoalsSchema)
.where(
and(
eq(GoalsSchema.organizationId, params.organizationId),
eq(GoalsSchema.archive, 0),
or(eq(GoalsSchema.owner, params.userId), exists(permissionSubquery)),
),
);
return result.map((r) => r.id).filter((id): id is number => Number.isInteger(id));
}
}
+53
View File
@@ -0,0 +1,53 @@
import { PgBoss } from 'pg-boss';
import { $logger } from '../modules/logget';
import { Database } from '../modules/db';
let boss: PgBoss | null = null;
export async function startJobQueue(): Promise<PgBoss> {
boss = new PgBoss({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: +process.env.DB_PORT!,
schema: 'pgboss',
});
boss.on('error', (err) => {
$logger.error(err, '[JobQueue] Error');
});
await boss.start();
$logger.info('[JobQueue] Started');
return boss;
}
export function getJobQueue(): PgBoss {
if (!boss) {
throw new Error('JobQueue not started. Call startJobQueue() first.');
}
return boss;
}
/** Cancel jobs by singletonKey — finds and deletes matching queued jobs */
export async function cancelJobBySingletonKey(queueName: string, singletonKey: string): Promise<void> {
if (!boss) return;
const db = Database.getInstance();
const result = await db.query<{ id: string }>(
`SELECT id FROM pgboss.job WHERE name = $1 AND singleton_key = $2 AND state IN ('created', 'retry')`,
[queueName, singletonKey],
);
const count = result?.rows?.length ?? 0;
if (count === 0) {
$logger.info(`[JobQueue] Cancel: no jobs found for key="${singletonKey}"`);
return;
}
for (const row of result!.rows) {
await boss.deleteJob(queueName, row.id);
$logger.info(`[JobQueue] Deleted job id=${row.id} key="${singletonKey}"`);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { startJobQueue } from './JobQueue';
import type { Dispatcher } from './Dispatcher';
import { NotificationDispatcher } from '../tv-modules/notifications/NotificationDispatcher';
import { RealtimeDispatcher } from '../tv-modules/realtime/RealtimeDispatcher';
import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTrackingDispatcher';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
new RealtimeDispatcher(),
new WebhooksDispatcher(),
new TimeTrackingDispatcher(),
];
export function registerAllEventHandlers() {
dispatchers.forEach((d) => d.register());
}
export async function startAllWorkers() {
await startJobQueue();
for (const d of dispatchers) {
await d.registerWorkers();
}
}
+33 -3
View File
@@ -2,20 +2,50 @@ import type { NextFunction, Request, Response } from 'express';
import { AppUser } from '../core/AppUser';
import { $logger } from '../modules/logget';
import AuthController from '../tv-modules/auth/AuthController';
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
if (req.method === 'OPTIONS') {
req.appUser = new AppUser();
return next();
}
const token = req.headers['authorization']?.split(' ')[1];
if (token && token.startsWith(TOKEN_PREFIX)) {
const record = await getApiTokensManager().validateToken(token);
if (record) {
const authManager = new AppUser().authManager;
const userData = await authManager.repository.fetchUserById(record.userId);
if (userData && userData.block === 0) {
req.appUser = new AppUser({
id: 0,
userData: { id: userData.id, login: userData.login, email: userData.email },
});
req.appUser.setUserDataFromDb(userData);
req.appUser.setHasActiveToken(true);
req.appUser.setApiTokenAuth(record.allowedPermissions, record.allowedGoalIds);
} else {
req.appUser = new AppUser();
}
} else {
req.appUser = new AppUser();
}
return next();
}
if (token) {
const userPayload = await AuthController.validateTokens(token);
if (userPayload) {
req.appUser = new AppUser(userPayload);
try {
const [tokens, userData] = await Promise.allSettled([
req.appUser.authManager.jwtStorage.fetchTokens(userPayload.id),
const [sessionActive, userData] = await Promise.allSettled([
req.appUser.authManager.sessionStorage.isSessionActive(userPayload.id),
req.appUser.authManager.repository.fetchUserById(userPayload.userData.id),
]);
if (tokens.status === 'fulfilled') {
if (sessionActive.status === 'fulfilled' && sessionActive.value) {
req.appUser.setHasActiveToken(true);
}
+22
View File
@@ -0,0 +1,22 @@
import cors from 'cors';
const allow = new Set([
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
'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 const corsMiddleware = cors({
credentials: true,
maxAge: 600,
origin(origin, cb) {
if (!origin || origin === 'null') return cb(null, true);
if (allow.has(origin)) return cb(null, true);
return cb(new Error(`CORS blocked origin: ${origin}`), false);
},
});
+13 -5
View File
@@ -1,18 +1,26 @@
import type { NextFunction, Request, Response } from 'express';
import { corsMiddleware } from './cors';
interface CustomError extends Error {
status?: number;
}
const errorHandler = (err: CustomError, _req: Request, res: Response, _next: NextFunction) => {
const sendError = (err: CustomError, res: Response) => {
const statusCode = err.status || 500;
const isProduction = process.env.NODE_ENV === 'production';
const userMessage = isProduction ? 'Internal Server Error' : err.message || 'Something went wrong';
res.status(statusCode).json({ message: userMessage });
};
res.status(statusCode).json({
message: userMessage,
});
const errorHandler = (err: CustomError, req: Request, res: Response, _next: NextFunction) => {
// Re-run the same cors middleware so error responses get CORS headers even when
// the original pass was bypassed (error thrown before it ran). Reusing the actual
// middleware preserves the allowlist policy — disallowed origins still get no headers.
if (res.getHeader('Access-Control-Allow-Origin')) {
sendError(err, res);
return;
}
corsMiddleware(req, res, () => sendError(err, res));
};
export default errorHandler;
+15
View File
@@ -0,0 +1,15 @@
import type { NextFunction, Request, Response } from 'express'
export const IsOrgMemberIfProvided = async (req: Request, res: Response, next: NextFunction) => {
const organizationId = Number(
req.query.organizationId || req.body.organizationId || req.params.organizationId
)
if (!organizationId) return next()
const member = await req.appUser.organizationManager.getCurrentUserMember(organizationId)
if (!member) return res.status(403).end()
next()
}
+256
View File
@@ -294,5 +294,261 @@
"description": [
"Release 1.18.2"
]
},
"23": {
"version": "1.20.0",
"name": "Release 1.20.0",
"releaseDate": "20260221",
"scripts": [
"/1.20.0/all-triggers.sql"
],
"description": [
"Validate tag and task belong to the same project on insert into tasks_to_tags"
]
},
"24": {
"version": "1.21.0",
"name": "Release 1.21.0",
"releaseDate": "20260302",
"scripts": [
"/1.21.0/0.1.21.0.sql"
],
"description": [
"Added integrations table for GitHub/GitLab integration",
"Added integration_task_map table for issue-task mapping"
]
},
"25": {
"version": "1.22.0",
"name": "Release 1.22.0",
"releaseDate": "20260304",
"scripts": [
"/1.22.0/0.1.22.0.sql"
],
"description": [
"Added last_synced_at column to integrations for incremental sync"
]
},
"26": {
"version": "1.23.0",
"name": "Release 1.23.0",
"releaseDate": "20260314",
"scripts": [
"/1.23.0/0.1.23.0.sql"
],
"description": [
"Added source_url column to tasks for external issue links"
]
},
"27": {
"version": "1.24.0",
"name": "Release 1.24.0",
"releaseDate": "20260315",
"scripts": [
"/1.24.0/0.1.24.0.sql"
],
"description": [
"Added reminders, notifications, and push_subscriptions tables"
]
},
"28": {
"version": "1.25.0",
"name": "Release 1.25.0",
"releaseDate": "20260317",
"scripts": [
"/1.25.0/0.1.25.0.sql"
],
"description": [
"Added type column to notifications table"
]
},
"29": {
"version": "1.26.0",
"name": "Release 1.26.0",
"releaseDate": "20260320",
"scripts": [
"/1.26.0/0.1.26.0.sql"
],
"description": [
"Added notification_preferences table with JSONB settings"
]
},
"30": {
"version": "1.27.0",
"name": "Release 1.27.0",
"releaseDate": "20260322",
"scripts": [
"/1.27.0/0.1.27.0.sql"
],
"description": [
"Added webhooks and webhook_deliveries tables"
]
},
"31": {
"version": "1.28.0",
"name": "Fix missing 1.25.0 migrations",
"releaseDate": "20260323",
"scripts": [
"/1.28.0/0.fix-missing-1.25.0-migrations.sql"
],
"description": [
"Fix: apply missing migrations from 1.25.0 - convert TIMETZ to TIME and add timezone column to device_tokens"
]
},
"32": {
"version": "1.29.0",
"name": "Release 1.29.0",
"releaseDate": "20260328",
"scripts": [
"/1.29.0/0.1.29.0.sql"
],
"description": [
"Added api_tokens table for personal access tokens"
]
},
"33": {
"version": "1.30.0",
"name": "Release 1.30.0",
"releaseDate": "20260328",
"scripts": [
"/1.30.0/0.1.30.0.sql"
],
"description": [
"Added allowed_goal_ids column to api_tokens for project-scoped tokens"
]
},
"34": {
"version": "1.31.0",
"name": "Release 1.31.0",
"releaseDate": "20260328",
"scripts": [
"/1.31.0/0.1.31.0.sql",
"/1.31.0/all-triggers.sql"
],
"description": [
"Remove JWT storage from user_tokens, add session metadata (device_name, user_agent, last_used_at)",
"Add trigger to remove user from task assignees when removed from project collaboration"
]
},
"35": {
"version": "1.32.0",
"name": "Release 1.32.0",
"releaseDate": "20260404",
"scripts": [
"/1.32.0/0.1.32.0.sql",
"/1.32.0/1.migrate-organizations.sql"
],
"description": [
"Added organizations and organization_members tables",
"Migrate existing users to personal workspaces",
"Added organization_id column to goals"
]
},
"36": {
"version": "1.33.0",
"name": "Release 1.33.0",
"releaseDate": "20260411",
"scripts": [
"/1.33.0/0.1.33.0.sql",
"/1.33.0/1.add-saml-signing-fields.sql",
"/1.33.0/2.add-saml-logout-url.sql",
"/1.33.0/3.add-scim-fields.sql"
],
"description": [
"Added SSO support (SAML 2.0 + OIDC)",
"Added sso_configs, sso_identities, saml_request_cache tables",
"Added SAML AuthnRequest signing support",
"Added SAML logout URL for SLO",
"Added SCIM provisioning support"
]
},
"37": {
"version": "1.44.0",
"name": "Release 1.44.0",
"releaseDate": "20260418",
"scripts": [
"/1.44.0/0.create-missing-personal-orgs.sql"
],
"description": [
"Create personal organizations for users without any organization membership"
]
},
"38": {
"version": "1.45.0",
"name": "Release 1.45.0",
"releaseDate": "20260425",
"scripts": [
"/1.45.0/0.analytics_indexes.sql"
],
"description": [
"Add composite indexes on tasks.tasks for analytics queries"
]
},
"39": {
"version": "1.46.0",
"name": "Release 1.46.0",
"releaseDate": "20260426",
"scripts": [
"/1.46.0/0.add-analytics-permission.sql"
],
"description": [
"Add analytics_can_view permission for project-level analytics access"
]
},
"40": {
"version": "1.47.0",
"name": "Release 1.47.0",
"releaseDate": "20260509",
"scripts": [
"/1.47.0/0.analytics_indexes_v2.sql"
],
"description": [
"Add indexes on tasks_auth.task_assignee, collaboration.users_to_goals, tasks.task_relations(to_task_id) for analytics joins",
"Add partial index on tasks.goals(organization_id) WHERE archive = 0 for analytics goal lookup",
"Add partial index on tasks.tasks(goal_id) WHERE complete IS NOT TRUE for open-task analytics queries"
]
},
"41": {
"version": "1.48.0",
"name": "Release 1.48.0",
"releaseDate": "20260511",
"scripts": [
"/1.48.0/0.create-time-entries.sql",
"/1.48.0/1.create-time-entries-history.sql",
"/1.48.0/2.alter-organizations-add-autostop.sql",
"/1.48.0/3.add-timetracking-permissions.sql",
"/1.48.0/all-triggers.sql"
],
"description": [
"Added time-tracking module: tasks.time_entries with one-active-timer-per-user partial unique index",
"Added history.time_entries audit table with FK ON DELETE CASCADE to time_entries (history removed with the entry)",
"Added time_tracking_autostop_hours column to tv_auth.organizations (default 24, NULL = disabled)",
"Added timetracking_can_log and timetracking_can_manage_all goal-level permissions (granted to new projects only, matching the 1.46.0 analytics convention)",
"all-triggers.sql: updated add_roles_and_permissions function with new permissions; added log_changes_time_entries trigger (BEFORE UPDATE writes JSONB snapshot to history.time_entries)"
]
},
"42": {
"version": "1.49.0",
"name": "Release 1.49.0",
"releaseDate": "20260512",
"scripts": [
"/1.49.0/0.time_entries_report_indexes.sql"
],
"description": [
"Added compound indexes on tasks.time_entries (goal_id, started_at, user_id) and (goal_id, started_at, task_id) for time-tracking report aggregations",
"Added partial index on tasks.time_entries(goal_id, started_at) WHERE billable = TRUE for billable-only reports"
]
},
"43": {
"version": "1.50.0",
"name": "Release 1.50.0",
"releaseDate": "20260515",
"scripts": [
"/1.50.0/0.update-timetracking-permissions-descriptions.sql"
],
"description": [
"Updated descriptions of timetracking_can_view and timetracking_can_manage_all to document that these permissions also expose contributor emails through the time-entry log",
"Tightened timetracking_can_log description: it grants only start/stop/createManual, NOT edit/delete (including own entries). Edit/delete now requires timetracking_can_manage_all — see can-access-time-entry middleware change."
]
}
}
@@ -134,3 +134,10 @@ values ('task_can_assign_users',
('task_can_watch_assigned_users',
'User can see assigned users to tasks',
4);
insert into tv_auth.permissions (name, description, permission_group, description_locales)
values ('analytics_can_view',
'User can view analytics for this goal',
2,
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb)
on conflict (name) do nothing;
@@ -0,0 +1,585 @@
--1.
--Trigger set previous version
create or replace function app.trigger_set_previous_version()
returns trigger as
$date_complete$
begin
new.prev_version = old.version;
return new;
end;
$date_complete$
language plpgsql;
drop trigger if exists trigger_set_previous_version on app.version;
create trigger trigger_set_previous_version
before insert
on app.version
for each row
execute procedure app.trigger_set_previous_version();
--2.
--Trigger for adding owner for taskList from goal
create or replace function tasks.trigger_set_owner_for_component()
returns trigger as
$date_complete$
begin
new.owner = (select owner from tasks.goals where id = new.goal_id);
return new;
end;
$date_complete$
language plpgsql;
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
create trigger trigger_set_owner_for_component
before insert
on tasks.goal_lists
for each row
execute procedure tasks.trigger_set_owner_for_component();
--3.
--Trigger for updating date_complete for task
create or replace function tasks.update_date_complete()
returns trigger as
$date_complete$
begin
if new.complete != old.complete
then
if new.complete = true
then
update tasks.tasks set date_complete = now() where id = old.id;
else
update tasks.tasks set date_complete = null where id = old.id;
end if;
end if;
return new;
end;
$date_complete$
language plpgsql;
drop trigger if exists tr_update_date_complete on tasks.tasks;
create trigger tr_update_date_complete
after update
on tasks.tasks
for each row
execute procedure tasks.update_date_complete();
--4.
-- Delete user from collaboration if not assigned to any goal
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
returns trigger as $$
declare
count int;
begin
if not exists (
select 1
from collaboration.users_to_goals
where user_id = old.user_id
limit 1
) then
delete from collaboration.users where id = old.user_id;
end if;
return old;
end;
$$ language plpgsql;
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
create trigger trigger_delete_user_if_not_assigned_to_goal
after delete
on collaboration.users_to_goals
for each row
execute function collaboration.delete_user_if_not_assigned_to_goal();
--5.
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
create or replace function tasks.check_task_graph_relation_goal()
returns trigger as $$
declare
from_goal int;
to_goal int;
begin
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
if from_goal is null or to_goal is null then
raise exception 'Invalid task reference in relation';
end if;
if from_goal <> to_goal then
raise exception 'Relation goal_id must match both tasks'' goal_id';
end if;
new.goal_id := from_goal;
return new;
end;
$$ language plpgsql;
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
create trigger trigger_task_relation_goal
before insert or update on tasks.task_relations
for each row execute function tasks.check_task_graph_relation_goal();
--6.
--Trigger for logging changes in taskList to history table
create or replace function tasks.log_changes_tasks_goal_lists()
returns trigger as
$body$
begin
if tg_op = 'DELETE' then
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
return old;
elseif tg_op = 'UPDATE' then
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
new.edit_date = now();
return new;
end if;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
create trigger trigger_log_changes_tasks_goal_lists
before update or delete
on tasks.goal_lists
for each row
execute procedure tasks.log_changes_tasks_goal_lists();
--7.
--Trigger for logging changes in goal to history table
create or replace function tasks.log_changes_tasks_goals()
returns trigger as
$body$
begin
if tg_op = 'DELETE' then
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
return old;
elseif tg_op = 'UPDATE' then
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
new.edit_date = now();
return new;
end if;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
create trigger trigger_log_changes_tasks_goals
before update or delete
on tasks.goals
for each row
execute procedure tasks.log_changes_tasks_goals();
--8.
--Trigger for logging changes in task to history table
create or replace function tasks.log_changes_tasks_tasks()
returns trigger as
$body$
begin
if tg_op = 'DELETE' then
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
return old;
elseif tg_op = 'UPDATE' then
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
new.edit_date = now();
return new;
end if;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
create trigger trigger_log_changes_tasks_tasks
before update or delete
on tasks.tasks
for each row
execute procedure tasks.log_changes_tasks_tasks();
--9.
--Trigger for setting goal_id default for task
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
RETURNS TRIGGER AS
$$
DECLARE
goal_id INT;
BEGIN
SELECT gl.goal_id
INTO goal_id
FROM tasks.goal_lists gl
WHERE gl.id = NEW.goal_list_id;
IF goal_id IS NOT NULL THEN
NEW.goal_id := goal_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
CREATE TRIGGER before_insert_set_goal_id_for_task
BEFORE INSERT OR UPDATE
ON tasks.tasks
FOR EACH ROW
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
--10.
--Trigger for adding default roles and permissions for goal
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
RETURNS TRIGGER AS
$$
DECLARE
editor_role_id INTEGER;
executor_role_id INTEGER;
BEGIN
-- 1. Create role "editor"
INSERT INTO collaboration.roles (name, goal_id)
VALUES ('editor', NEW.id)
RETURNING id INTO editor_role_id;
-- 2. Create role "executor"
INSERT INTO collaboration.roles (name, goal_id)
VALUES ('executor', NEW.id)
RETURNING id INTO executor_role_id;
-- 3. Add permissions for role "editor"
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
SELECT editor_role_id, id
FROM tv_auth.permissions
WHERE name IN (
'goal_can_watch_content',
'goal_can_edit',
'goal_can_add_task_list',
'goal_can_manage_users',
'component_can_watch_content',
'component_can_edit',
'component_can_delete',
'component_can_add_tasks',
'task_can_edit_deadline',
'task_can_watch_subtasks',
'task_can_watch_note',
'task_can_recovery_history',
'task_can_watch_assigned_users',
'task_can_edit_priority',
'task_can_delete',
'task_can_watch_details',
'task_can_assign_users',
'task_can_add_subtasks',
'task_can_watch_tags',
'task_can_watch_priority',
'task_can_access_history',
'task_can_edit_tags',
'task_can_edit_description',
'task_can_edit_status',
'task_can_edit_note',
'kanban_can_manage',
'kanban_can_view',
'graph_can_manage',
'graph_can_view'
);
-- 4. Add permissions for role "viewver"
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
SELECT executor_role_id, id
FROM tv_auth.permissions
WHERE name IN (
'goal_can_watch_content',
'component_can_watch_content',
'component_can_add_tasks',
'task_can_watch_subtasks',
'task_can_watch_note',
'task_can_watch_assigned_users',
'task_can_watch_details',
'task_can_add_subtasks',
'task_can_watch_tags',
'task_can_watch_priority'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists add_roles_after_insert on tasks.goals;
CREATE TRIGGER add_roles_after_insert
AFTER INSERT
ON tasks.goals
FOR EACH ROW
EXECUTE FUNCTION tasks.add_roles_and_permissions();
--11.
--Trigger for adjusting start and end dates for task
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
RETURNS TRIGGER AS
$$
DECLARE
start_timestamp TIMESTAMPTZ;
end_timestamp TIMESTAMPTZ;
BEGIN
-- If start_date is NULL, then start_time should be NULL
IF NEW.start_date IS NULL THEN
NEW.start_time := NULL;
END IF;
-- If end_date is NULL, then end_time should be NULL
IF NEW.end_date IS NULL THEN
NEW.end_time := NULL;
END IF;
-- If both dates are set
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
-- Adjust dates
IF NEW.start_date > NEW.end_date THEN
-- If start_date is greater than end_date, set end_date to start_date
NEW.end_date := NEW.start_date;
-- end_time remains unchanged
ELSIF NEW.end_date < NEW.start_date THEN
-- If end_date is less than start_date, set start_date to end_date
NEW.start_date := NEW.end_date;
-- start_time remains unchanged
END IF;
-- Prepare timestamps for comparison
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
IF start_timestamp > end_timestamp THEN
NEW.end_date := NEW.start_date;
-- Assign end_time only if start_time is not NULL
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
NEW.end_time := NEW.start_time;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
CREATE TRIGGER adjust_dates_and_times_trigger
BEFORE INSERT OR UPDATE
ON tasks.tasks
FOR EACH ROW
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
--12.
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
create or replace function tasks.add_self_to_collaboration()
returns trigger as $$
DECLARE
owner_email TEXT;
BEGIN
select email into owner_email
from tv_auth.users
where id = NEW.owner;
if owner_email is not null then
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
end if;
return NEW;
END;
$$ language plpgsql;
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
create trigger add_selt_to_collaboration_trg
after insert on tasks.goals
for each row
execute function tasks.add_self_to_collaboration();
--13.
--Trigger for adding default kanban columns for new goal
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
RETURNS TRIGGER AS $$
BEGIN
-- Add default columns for new goal
INSERT INTO tasks.statuses (name, goal_id, view_order)
VALUES
('TODO', NEW.id, 1),
('In Progress', NEW.id, 2),
('Done', NEW.id, 3);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
CREATE TRIGGER kanban_add_default_columns_trg
AFTER INSERT ON tasks.goals
FOR EACH ROW
EXECUTE FUNCTION tasks.kanban_add_default_columns();
--14.
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
RETURNS TRIGGER AS $$
BEGIN
-- Check if there is a record in tasks.statuses with the same goal_id
IF NOT EXISTS (
SELECT 1 FROM tasks.statuses s
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
) THEN
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists enforce_task_status_goal on tasks.tasks;
CREATE TRIGGER enforce_task_status_goal
BEFORE INSERT OR UPDATE ON tasks.tasks
FOR EACH ROW
WHEN (NEW.status_id IS NOT NULL)
EXECUTE FUNCTION tasks.check_task_status_goal();
--15.
--Trigger for setting default orders value for task
CREATE OR REPLACE FUNCTION tasks.set_order_value()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.task_order IS NULL THEN
NEW.task_order := NEW.id;
END IF;
IF NEW.kanban_order IS NULL THEN
NEW.kanban_order := NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists set_order_trigger on tasks.tasks;
CREATE TRIGGER set_order_trigger
BEFORE INSERT ON tasks.tasks
FOR EACH ROW
EXECUTE FUNCTION tasks.set_order_value();
--16.
--Trigger for setting default view order for new status
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
RETURNS TRIGGER AS $$
DECLARE
new_view_order INT;
BEGIN
-- Determine the next view_order for the given goal_id
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
FROM tasks.statuses
WHERE goal_id = NEW.goal_id;
-- Assign the calculated value to the view_order field
NEW.view_order := new_view_order;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists set_default_status_view_order on tasks.statuses;
CREATE TRIGGER set_default_status_view_order
BEFORE INSERT ON tasks.statuses
FOR EACH ROW
EXECUTE FUNCTION tasks.status_set_default_view_order();
--17.
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
RETURNS TRIGGER AS $$
DECLARE
user_exists BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1
FROM tasks.tasks tt
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
) INTO user_exists;
IF NOT user_exists THEN
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
BEFORE INSERT ON tasks_auth.task_assignee
FOR EACH ROW
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
--18.
--Trigger for adding owner for task, extend owner from goal or taskList
--delete old function with wrong name
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
drop function if exists tasks.trigger_set_owner_for_task();
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
RETURNS TRIGGER AS
$body$
BEGIN
NEW.owner := COALESCE(
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
);
IF NEW.owner IS NULL THEN
RAISE EXCEPTION 'Can not insert task without owner';
END IF;
RETURN NEW;
END;
$body$
LANGUAGE plpgsql;
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
create trigger trigger_set_owner_for_task
before insert
on tasks.tasks
for each row
execute procedure tasks.fn_set_owner_for_task();
--19.
--Trigger for validating that tag and task belong to the same project (goal_id)
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
drop function if exists tasks.check_tag_task_same_goal();
create or replace function tasks.check_tag_task_same_goal()
returns trigger as $$
declare
v_tag_goal_id integer;
v_task_goal_id integer;
begin
select goal_id into v_tag_goal_id from tasks.tags where id = new.tag_id;
select goal_id into v_task_goal_id from tasks.tasks where id = new.task_id;
if v_tag_goal_id is null or v_tag_goal_id != v_task_goal_id then
raise exception 'Tag (id=%) and task (id=%) belong to different projects', new.tag_id, new.task_id;
end if;
return new;
end;
$$ language plpgsql;
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
create trigger trigger_check_tag_task_same_goal
before insert on tasks.tasks_to_tags
for each row
execute function tasks.check_tag_task_same_goal();
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS tasks.integrations (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
provider VARCHAR(20) NOT NULL CHECK (provider IN ('github', 'gitlab')),
access_token_encrypted TEXT,
refresh_token_encrypted TEXT,
repo_external_id VARCHAR(255),
repo_full_name VARCHAR(255),
project_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
webhook_id VARCHAR(255),
webhook_secret_encrypted TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tasks.integration_task_map (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
integration_id INTEGER NOT NULL REFERENCES tasks.integrations(id) ON DELETE CASCADE,
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
issue_number INTEGER NOT NULL,
issue_state VARCHAR(20) NOT NULL DEFAULT 'open',
synced_at TIMESTAMP DEFAULT NOW(),
UNIQUE(integration_id, issue_number)
);
@@ -0,0 +1,25 @@
ALTER TABLE tasks.integrations ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMP;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'integrations_can_manage',
'User can manage integrations (connect, disconnect, sync)',
2,
'{
"en": "Manage integrations. User can connect, disconnect, configure and sync integrations",
"ru": "Управление интеграциями. Пользователь может подключать, отключать, настраивать и синхронизировать интеграции"
}'::jsonb
)
ON CONFLICT DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'integrations_can_view',
'User can view integrations list',
2,
'{
"en": "View integrations. User can view the list of connected integrations",
"ru": "Просмотр интеграций. Пользователь может просматривать список подключённых интеграций"
}'::jsonb
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,2 @@
ALTER TABLE tasks.tasks
ADD COLUMN IF NOT EXISTS source_url VARCHAR(500);
@@ -0,0 +1,13 @@
-- Notifications
CREATE TABLE IF NOT EXISTS tasks.notifications (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
body VARCHAR(1000),
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
ON tasks.notifications (user_id, created_at DESC) WHERE NOT read;
@@ -0,0 +1,11 @@
ALTER TABLE tasks.notifications
ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'deadline';
CREATE TABLE IF NOT EXISTS tasks.device_tokens (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
token VARCHAR(500) NOT NULL,
platform VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_device_token UNIQUE (user_id, token)
);
@@ -0,0 +1,5 @@
-- Convert TIMETZ columns to TIME (without timezone)
-- Existing values are converted to UTC automatically by "AT TIME ZONE 'UTC'"
ALTER TABLE tasks.tasks
ALTER COLUMN start_time TYPE TIME USING start_time AT TIME ZONE 'UTC',
ALTER COLUMN end_time TYPE TIME USING end_time AT TIME ZONE 'UTC';
@@ -0,0 +1,2 @@
ALTER TABLE tasks.device_tokens
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC';
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS tasks.notification_preferences (
user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}'
);
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS tasks.webhooks (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
secret_encrypted VARCHAR NOT NULL,
events VARCHAR[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tasks.webhook_deliveries (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
webhook_id INTEGER NOT NULL REFERENCES tasks.webhooks(id) ON DELETE CASCADE,
event VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
response_code INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_webhooks_goal_id ON tasks.webhooks(goal_id);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON tasks.webhook_deliveries(webhook_id);
@@ -0,0 +1,10 @@
-- Fix: these migrations were missing from 1.25.0 migrate.json scripts list
-- Convert TIMETZ columns to TIME (without timezone)
-- Existing values are converted to UTC automatically by "AT TIME ZONE 'UTC'"
ALTER TABLE tasks.tasks
ALTER COLUMN start_time TYPE TIME USING start_time AT TIME ZONE 'UTC',
ALTER COLUMN end_time TYPE TIME USING end_time AT TIME ZONE 'UTC';
ALTER TABLE tasks.device_tokens
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC';
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS tv_auth.api_tokens (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
last_used_at TIMESTAMP,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON tv_auth.api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON tv_auth.api_tokens(user_id);
@@ -0,0 +1 @@
ALTER TABLE tv_auth.api_tokens ADD COLUMN IF NOT EXISTS allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}';
@@ -0,0 +1,6 @@
ALTER TABLE tv_auth.user_tokens
DROP COLUMN IF EXISTS access_token,
DROP COLUMN IF EXISTS refresh_token,
ADD COLUMN IF NOT EXISTS device_name varchar(200),
ADD COLUMN IF NOT EXISTS user_agent text,
ADD COLUMN IF NOT EXISTS last_used_at timestamp;
@@ -0,0 +1,610 @@
--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();
--20.
-- Remove user from task assignees when removed from project collaboration
drop trigger if exists trigger_remove_user_from_task_assignees on collaboration.users_to_goals;
drop function if exists collaboration.remove_user_from_task_assignees();
CREATE OR REPLACE FUNCTION collaboration.remove_user_from_task_assignees()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM tasks_auth.task_assignee
WHERE collab_user_id = OLD.user_id
AND task_id IN (SELECT id FROM tasks.tasks WHERE goal_id = OLD.goal_id);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_remove_user_from_task_assignees ON collaboration.users_to_goals;
CREATE TRIGGER trigger_remove_user_from_task_assignees
BEFORE DELETE
ON collaboration.users_to_goals
FOR EACH ROW
EXECUTE FUNCTION collaboration.remove_user_from_task_assignees();
@@ -0,0 +1,50 @@
-- Organizations
CREATE TABLE IF NOT EXISTS tv_auth.organizations (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name VARCHAR NOT NULL,
slug VARCHAR NOT NULL UNIQUE,
owner_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
logo_url VARCHAR,
is_personal INTEGER NOT NULL DEFAULT 0,
plan VARCHAR NOT NULL DEFAULT 'free',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_organizations_owner_id ON tv_auth.organizations(owner_id);
CREATE INDEX IF NOT EXISTS idx_organizations_slug ON tv_auth.organizations(slug);
-- Organization members (by email, not user_id)
CREATE TABLE IF NOT EXISTS tv_auth.organization_members (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
email VARCHAR NOT NULL,
role VARCHAR NOT NULL DEFAULT 'member',
invited_by INTEGER REFERENCES tv_auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(organization_id, email)
);
CREATE INDEX IF NOT EXISTS idx_org_members_organization_id ON tv_auth.organization_members(organization_id);
CREATE INDEX IF NOT EXISTS idx_org_members_email ON tv_auth.organization_members(email);
-- Add organization_id to goals (nullable for migration, will be set NOT NULL after data migration)
ALTER TABLE tasks.goals ADD COLUMN IF NOT EXISTS organization_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'goals_organization_id_fkey'
AND table_schema = 'tasks'
AND table_name = 'goals'
) THEN
ALTER TABLE tasks.goals
ADD CONSTRAINT goals_organization_id_fkey
FOREIGN KEY (organization_id)
REFERENCES tv_auth.organizations(id)
ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_goals_organization_id ON tasks.goals(organization_id);
@@ -0,0 +1,36 @@
-- Step 1: Create personal organization for each user who owns goals
INSERT INTO tv_auth.organizations (name, slug, owner_id, is_personal)
SELECT
u.login || '''s workspace',
'org-' || substr(md5(random()::text), 1, 8),
u.id,
1
FROM tv_auth.users u
WHERE EXISTS (SELECT 1 FROM tasks.goals g WHERE g.owner = u.id)
ON CONFLICT DO NOTHING;
-- Step 2: Link goals to their owner's organization
UPDATE tasks.goals g
SET organization_id = o.id
FROM tv_auth.organizations o
WHERE g.owner = o.owner_id
AND g.organization_id IS NULL;
-- Step 3: Add owner as org member with role 'owner' (by email)
INSERT INTO tv_auth.organization_members (organization_id, email, role)
SELECT o.id, u.email, 'owner'
FROM tv_auth.organizations o
JOIN tv_auth.users u ON u.id = o.owner_id
ON CONFLICT (organization_id, email) DO NOTHING;
-- Step 4: Add existing goal collaborators as org members (by email)
INSERT INTO tv_auth.organization_members (organization_id, email, role)
SELECT DISTINCT g.organization_id, cu.email, 'member'
FROM collaboration.users_to_goals cutg
JOIN collaboration.users cu ON cu.id = cutg.user_id
JOIN tasks.goals g ON g.id = cutg.goal_id
WHERE g.organization_id IS NOT NULL
ON CONFLICT (organization_id, email) DO NOTHING;
-- Step 5: Make organization_id NOT NULL
ALTER TABLE tasks.goals ALTER COLUMN organization_id SET NOT NULL;
@@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS tv_auth.sso_configs (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
protocol VARCHAR NOT NULL,
display_name VARCHAR NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
saml_entry_point VARCHAR,
saml_issuer VARCHAR,
saml_cert TEXT,
saml_callback_url VARCHAR,
oidc_issuer VARCHAR,
oidc_client_id VARCHAR,
oidc_client_secret VARCHAR,
oidc_callback_url VARCHAR,
oidc_scope VARCHAR,
default_org_role VARCHAR NOT NULL DEFAULT 'member',
email_domain_restriction VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(email_domain_restriction)
);
CREATE TABLE IF NOT EXISTS tv_auth.sso_identities (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
sso_config_id INTEGER NOT NULL REFERENCES tv_auth.sso_configs(id) ON DELETE CASCADE,
external_id VARCHAR NOT NULL,
email VARCHAR NOT NULL,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(sso_config_id, external_id)
);
CREATE INDEX IF NOT EXISTS idx_sso_identities_user_id ON tv_auth.sso_identities(user_id);
CREATE INDEX IF NOT EXISTS idx_sso_identities_email ON tv_auth.sso_identities(email);
CREATE TABLE IF NOT EXISTS tv_auth.saml_request_cache (
key VARCHAR PRIMARY KEY,
value VARCHAR NOT NULL,
created_at BIGINT NOT NULL
);
@@ -0,0 +1,2 @@
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_signing_key TEXT;
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_signing_cert TEXT;
@@ -0,0 +1 @@
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_logout_url VARCHAR;
@@ -0,0 +1,2 @@
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_token VARCHAR;
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_enabled INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,22 @@
-- Create personal organizations for users who don't have any organization membership
-- This covers the default seed user (login: 'user', email: 'test@mail.dest')
-- and any other users who may exist without an organization
INSERT INTO tv_auth.organizations (name, slug, owner_id, is_personal)
SELECT
u.login || '''s workspace',
'org-' || substr(md5(random()::text), 1, 8),
u.id,
1
FROM tv_auth.users u
WHERE NOT EXISTS (
SELECT 1 FROM tv_auth.organization_members om WHERE om.email = u.email
)
ON CONFLICT DO NOTHING;
-- Add these users as owners of their new personal organizations
INSERT INTO tv_auth.organization_members (organization_id, email, role)
SELECT o.id, u.email, 'owner'
FROM tv_auth.organizations o
JOIN tv_auth.users u ON u.id = o.owner_id
WHERE o.is_personal = 1
ON CONFLICT (organization_id, email) DO NOTHING;
@@ -0,0 +1,14 @@
drop index if exists tasks.idx_tasks_goal_id_date_creation;
create index idx_tasks_goal_id_date_creation on tasks.tasks (goal_id, date_creation);
drop index if exists tasks.idx_tasks_goal_id_date_complete;
create index idx_tasks_goal_id_date_complete on tasks.tasks (goal_id, date_complete) where complete = true;
drop index if exists tasks.idx_tasks_goal_id_end_date_open;
create index idx_tasks_goal_id_end_date_open on tasks.tasks (goal_id, end_date) where complete is not true;
drop index if exists tasks.idx_tasks_goal_id_edit_date_open;
create index idx_tasks_goal_id_edit_date_open on tasks.tasks (goal_id, edit_date) where complete is not true;
drop index if exists tasks.idx_tasks_goal_id_transaction_type;
create index idx_tasks_goal_id_transaction_type on tasks.tasks (goal_id, transaction_type) where amount is not null;
@@ -0,0 +1,8 @@
insert into tv_auth.permissions (name, description, permission_group, description_locales)
values (
'analytics_can_view',
'User can view analytics for this goal',
2,
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb
)
on conflict (name) do nothing;
@@ -0,0 +1,20 @@
drop index if exists tasks_auth.idx_task_assignee_task_id;
create index idx_task_assignee_task_id on tasks_auth.task_assignee (task_id);
drop index if exists tasks_auth.idx_task_assignee_user_task;
create index idx_task_assignee_user_task on tasks_auth.task_assignee (collab_user_id, task_id);
drop index if exists collaboration.idx_users_to_goals_user_goal;
create index idx_users_to_goals_user_goal on collaboration.users_to_goals (user_id, goal_id);
drop index if exists collaboration.idx_users_to_goals_goal_id;
create index idx_users_to_goals_goal_id on collaboration.users_to_goals (goal_id);
drop index if exists tasks.idx_task_relations_to_task;
create index idx_task_relations_to_task on tasks.task_relations (to_task_id, from_task_id);
drop index if exists tasks.idx_goals_org_active;
create index idx_goals_org_active on tasks.goals (organization_id) where archive = 0;
drop index if exists tasks.idx_tasks_goal_id_open;
create index idx_tasks_goal_id_open on tasks.tasks (goal_id) where complete is not true;
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS tasks.time_entries (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
ended_at TIMESTAMP NULL,
duration_seconds INTEGER NULL,
description VARCHAR(500) NULL,
source SMALLINT NOT NULL DEFAULT 0,
billable BOOLEAN NOT NULL DEFAULT TRUE,
auto_stopped BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT time_entries_end_after_start CHECK (ended_at IS NULL OR ended_at > started_at),
CONSTRAINT time_entries_duration_non_negative CHECK (duration_seconds IS NULL OR duration_seconds >= 0)
);
CREATE INDEX IF NOT EXISTS idx_time_entries_user_started
ON tasks.time_entries(user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started
ON tasks.time_entries(goal_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_time_entries_task
ON tasks.time_entries(task_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_entries_active_per_user
ON tasks.time_entries(user_id) WHERE ended_at IS NULL;
@@ -0,0 +1,11 @@
CREATE SCHEMA IF NOT EXISTS history;
CREATE TABLE IF NOT EXISTS history.time_entries (
id SERIAL PRIMARY KEY,
entry_id INTEGER NOT NULL REFERENCES tasks.time_entries(id) ON DELETE CASCADE,
edit_date TIMESTAMP NOT NULL DEFAULT NOW(),
entry JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_history_time_entries_entry
ON history.time_entries(entry_id, edit_date DESC);
@@ -0,0 +1,2 @@
ALTER TABLE tv_auth.organizations
ADD COLUMN IF NOT EXISTS time_tracking_autostop_hours INTEGER DEFAULT 24;
@@ -0,0 +1,21 @@
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES
(
'timetracking_can_view',
'View all time entries on this project — both own and other members''',
2,
'{"en": "View time entries. User can see all time entries on this project — both own and other members''. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Логировать и редактировать нельзя."}'::jsonb
),
(
'timetracking_can_log',
'Start/stop timer and create/edit/delete OWN time entries on this project',
2,
'{"en": "Track time. User can start/stop the timer, add manual entries, and edit/delete OWN time entries. Does not grant viewing the project log or managing others'' entries.", "ru": "Учёт времени. Пользователь может запускать/останавливать таймер, добавлять записи вручную и редактировать/удалять СВОИ записи. Не даёт права просматривать журнал проекта и управлять чужими записями."}'::jsonb
),
(
'timetracking_can_manage_all',
'Full time-tracking access: log own time + view and edit/delete entries of any project member',
2,
'{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника. Включает права на просмотр и ведение времени."}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
@@ -0,0 +1,635 @@
--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',
'timetracking_can_view',
'timetracking_can_manage_all'
);
-- 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',
'timetracking_can_view',
'timetracking_can_log'
);
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();
--20.
-- Remove user from task assignees when removed from project collaboration
drop trigger if exists trigger_remove_user_from_task_assignees on collaboration.users_to_goals;
drop function if exists collaboration.remove_user_from_task_assignees();
CREATE OR REPLACE FUNCTION collaboration.remove_user_from_task_assignees()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM tasks_auth.task_assignee
WHERE collab_user_id = OLD.user_id
AND task_id IN (SELECT id FROM tasks.tasks WHERE goal_id = OLD.goal_id);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_remove_user_from_task_assignees ON collaboration.users_to_goals;
CREATE TRIGGER trigger_remove_user_from_task_assignees
BEFORE DELETE
ON collaboration.users_to_goals
FOR EACH ROW
EXECUTE FUNCTION collaboration.remove_user_from_task_assignees();
--21.
--Trigger for logging changes in time_entries to history table
create or replace function tasks.log_changes_time_entries()
returns trigger as
$body$
begin
insert into history.time_entries (entry_id, edit_date, entry)
values (old.id, now(), to_jsonb(old));
new.edited_at = now();
return new;
end
$body$
language plpgsql;
drop trigger if exists trigger_log_changes_time_entries on tasks.time_entries;
create trigger trigger_log_changes_time_entries
before update
on tasks.time_entries
for each row
execute procedure tasks.log_changes_time_entries();
@@ -0,0 +1,9 @@
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_user
ON tasks.time_entries(goal_id, started_at DESC, user_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_task
ON tasks.time_entries(goal_id, started_at DESC, task_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_billable_goal_started
ON tasks.time_entries(goal_id, started_at DESC)
WHERE billable = TRUE;
@@ -0,0 +1,17 @@
UPDATE tv_auth.permissions
SET
description = 'View all time entries; exposes emails of contributors via the entry log',
description_locales = '{"en": "View time entries. User can see all time entries on this project — both own and other members''. Also exposes the emails of all contributors who have logged time on this project. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Также видны email-адреса всех участников, логировавших время. Логировать и редактировать нельзя."}'::jsonb
WHERE name = 'timetracking_can_view';
UPDATE tv_auth.permissions
SET
description = 'Start/stop timer and create manual entries; editing/deleting requires manage_all',
description_locales = '{"en": "Log time. User can start/stop the timer and add manual entries on this project. Cannot edit or delete entries (including own) — editing requires manage_all permission. Does not grant viewing other members'' entries.", "ru": "Учёт времени. Пользователь может запускать/останавливать таймер и добавлять записи вручную в этом проекте. Редактировать и удалять записи (даже свои) нельзя — для этого нужно право управления. Не даёт права просматривать записи других участников."}'::jsonb
WHERE name = 'timetracking_can_log';
UPDATE tv_auth.permissions
SET
description = 'Full time-tracking access: log, view, edit, delete entries; exposes contributor emails',
description_locales = '{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member (including own). Also exposes the emails of all contributors who have logged time on this project. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника (включая свои). Также видны email-адреса всех участников, логировавших время. Включает права на просмотр и ведение времени."}'::jsonb
WHERE name = 'timetracking_can_manage_all';
+12 -1
View File
@@ -8,13 +8,24 @@ export class Database {
public dbDrizzle: ReturnType<typeof drizzle>;
private constructor() {
const poolMaxRaw = Number(process.env.DB_POOL_MAX);
const poolMax = Number.isFinite(poolMaxRaw) && poolMaxRaw > 0 ? poolMaxRaw : 20;
this.pool = new Pool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: +process.env.DB_PORT!,
max: poolMax,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
this.pool.on('connect', (client) => {
client.query("SET TIME ZONE 'UTC'").catch((err) => {
console.error('Failed to set session timezone to UTC:', err);
});
});
this.dbDrizzle = drizzle({ client: this.pool, casing: 'camelCase' });
@@ -48,7 +59,7 @@ export class Database {
try {
const res = await client.query(text, params);
const duration = Date.now() - start;
$logger.info('executed query', { text, duration, rows: res.rowCount });
$logger.info({ text, duration, rows: res.rowCount }, 'executed query');
// console.log('executed query', { text, duration, rows: res.rowCount });
return res;
} catch (err) {
+20
View File
@@ -3,11 +3,21 @@ import CollaborationRoutes from '../tv-modules/collaboration/CollaborationRoutes
import CollaborationRolesRoutes from '../tv-modules/collaboration-roles/CollaborationRolesRoutes';
import GoalsRoutes from '../tv-modules/goals/GoalsRoutes';
import GraphRoutes from '../tv-modules/graph/GraphRoutes';
import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes';
import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes';
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
import StartRoutes from '../tv-modules/start/StartRoutes';
import TagsRouter from '../tv-modules/tags/TagsRouter';
import TasksRoutes from '../tv-modules/tasks/TasksRoutes';
import OrganizationRoutes from '../tv-modules/organizations/OrganizationRoutes';
import SsoRoutes from '../tv-modules/sso/SsoRoutes';
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -23,6 +33,16 @@ const routes: Record<string, RoutableConstructor> = {
'/module/about': StartRoutes,
'/module/kanban': KanbanRoutes,
'/module/graph': GraphRoutes,
'/module/integrations': IntegrationsRoutes,
'/module/notifications': NotificationsRoutes,
'/module/webhooks': WebhooksRoutes,
'/module/api-tokens': ApiTokensRoutes,
'/module/sessions': SessionsRoutes,
'/module/organizations': OrganizationRoutes,
'/module/sso': SsoRoutes,
'/module/analytics': AnalyticsRoutes,
'/module/time-tracking': TimeTrackingRoutes,
'/scim/v2': ScimRoutes,
};
export default routes;
@@ -0,0 +1,106 @@
import { type } from 'arktype'
import type { NextFunction, Request, Response } from 'express'
import type { AnalyticsScope } from 'taskview-api'
import { $logger } from '../../modules/logget'
import { parseDrillDownMeta, resolveRange } from './helpers'
import { AnalyticsDrillDownArkType, AnalyticsFetchSectionsArkType } from './types'
export class AnalyticsController {
fetchSections = async (req: Request, res: Response, next: NextFunction) => {
const out = AnalyticsFetchSectionsArkType(req.query)
if (out instanceof type.errors) {
$logger.warn(`analytics: validation failed: ${out.summary}`)
return res.status(400).send(out.summary)
}
let scope: AnalyticsScope
if (out.scope === 'project') {
if (out.goalId === undefined) {
return res.status(400).send('goalId is required for project scope')
}
scope = { kind: 'project', goalId: out.goalId }
} else {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
if (!range) return res.status(400).send('invalid range')
const sectionIds = out.sections
? out.sections.split(',').map(s => s.trim()).filter(Boolean)
: undefined
try {
const data = await req.appUser.analyticsManager.buildSections({
scope,
organizationId: out.organizationId,
period: out.period,
range,
sectionIds,
})
return res.tvJson(data)
} catch (err) {
$logger.error({
err,
userId: req.appUser.getUserData()?.id,
organizationId: out.organizationId,
scope,
period: out.period,
}, 'Analytics fetchSections failed')
return next(err)
}
}
fetchDrillDown = async (req: Request, res: Response, next: NextFunction) => {
const sectionId = req.params.sectionId
if (!sectionId) return res.status(400).send('missing sectionId')
const out = AnalyticsDrillDownArkType(req.query)
if (out instanceof type.errors) {
$logger.warn(`analytics drill-down: validation failed: ${out.summary}`)
return res.status(400).send(out.summary)
}
let scope: AnalyticsScope
if (out.scope === 'project') {
if (out.goalId === undefined) {
return res.status(400).send('goalId is required for project scope')
}
scope = { kind: 'project', goalId: out.goalId }
} else {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
if (!range) return res.status(400).send('invalid range')
const meta = parseDrillDownMeta(out.meta)
const index = Math.min(out.index ?? 0, 10000)
try {
const data = await req.appUser.analyticsManager.drillDown({
sectionId,
scope,
organizationId: out.organizationId,
period: out.period,
range,
arg: {
bucket: out.bucket ?? '',
index,
datasetId: out.datasetId ?? '',
meta,
},
})
return res.tvJson(data)
} catch (err) {
$logger.error({
err,
sectionId,
userId: req.appUser.getUserData()?.id,
organizationId: out.organizationId,
scope,
}, 'Analytics fetchDrillDown failed')
return next(err)
}
}
}
@@ -0,0 +1,169 @@
import type {
AnalyticsAvailableGoal,
AnalyticsDrillDownResponse,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionsResponse,
} from 'taskview-api'
import type { AppUser } from '../../core/AppUser'
import { $logger } from '../../modules/logget'
import { GoalPermissions } from '../../types/auth.types'
import { AnalyticsRepository } from './AnalyticsRepository'
import { SectionRegistry } from './sections/SectionRegistry'
import type {
AnalyticsArgBuildSections,
AnalyticsArgDrillDown,
BuilderContext,
SectionBuilder,
} from './types'
export class AnalyticsManager {
public readonly repository: AnalyticsRepository
private readonly registry: SectionRegistry
private readonly user: AppUser
constructor(user: AppUser) {
this.user = user
this.repository = new AnalyticsRepository()
this.registry = new SectionRegistry()
}
async getAccessibleGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW])
}
async getDrillDownGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [
GoalPermissions.ANALYTICS_CAN_VIEW,
GoalPermissions.TASKS_CAN_WATCH_DETAILS,
])
}
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
const { scope, organizationId, period, range, sectionIds } = params
const allAccessible = await this.getAccessibleGoalIds(organizationId)
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
const ctx: BuilderContext = {
appUser: this.user,
scope,
period,
range,
accessibleGoalIds,
repository: this.repository,
}
const builders = this.registry.filterByIds(sectionIds)
const eligible = builders.filter(b => this.isBuilderEligible(b, scope))
const settled = await Promise.allSettled(eligible.map(b => b.build(ctx)))
const sections: AnalyticsSection[] = []
const failedSectionIds: string[] = []
settled.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) {
sections.push(r.value)
} else if (r.status === 'rejected') {
const sectionId = eligible[i].id
failedSectionIds.push(sectionId)
$logger.error({
err: r.reason,
sectionId,
userId: this.user.getUserData()?.id,
organizationId,
}, 'Analytics section failed')
}
})
const availableGoals = await this.fetchAvailableGoals(allAccessible)
return {
scope,
period,
range: { from: range.from.toISOString(), to: range.to.toISOString() },
sections,
availableGoals,
failedSectionIds,
}
}
async drillDown(params: AnalyticsArgDrillDown): Promise<AnalyticsDrillDownResponse> {
const { sectionId, scope, organizationId, period, range, arg } = params
const builder = this.registry.get(sectionId)
if (!builder || !builder.drillDown) {
return { sectionId, tasks: [], total: 0 }
}
const aggregateGoalIds = this.narrowToScope(
await this.getAccessibleGoalIds(organizationId),
scope,
)
const accessibleGoalIds = this.narrowToScope(
await this.getDrillDownGoalIds(organizationId),
scope,
)
if (aggregateGoalIds.length > 0 && accessibleGoalIds.length === 0) {
return { sectionId, tasks: [], total: 0, denied: true }
}
const ctx: BuilderContext = {
appUser: this.user,
scope,
period,
range,
accessibleGoalIds,
repository: this.repository,
}
const tasks = await builder.drillDown(ctx, arg).catch((err) => {
$logger.error(`Analytics drill-down ${sectionId} failed: ${err}`)
return []
})
return { sectionId, tasks, total: tasks.length }
}
private async fetchGoalIds(organizationId: number, permissions: string[]): Promise<number[]> {
const userData = this.user.getUserData()
if (!userData?.id || !userData?.email) return []
const ids = await this.user.organizationManager.isCurrentUserOrgOwner(organizationId)
? await this.repository.fetchAllGoalIdsInOrg(organizationId)
: await this.repository.fetchGoalIdsWithPermissions(
userData.id,
userData.email,
organizationId,
permissions,
)
return this.applyTokenFilter(ids)
}
private applyTokenFilter(ids: number[]): number[] {
const tokenAllowed = this.user.getAllowedGoalIds()
if (tokenAllowed && tokenAllowed.length > 0) {
return ids.filter(id => tokenAllowed.includes(id))
}
return ids
}
private narrowToScope(allAccessible: number[], scope: AnalyticsScope): number[] {
if (scope.kind === 'project') {
return allAccessible.includes(scope.goalId) ? [scope.goalId] : []
}
return allAccessible
}
private isBuilderEligible(builder: SectionBuilder, scope: AnalyticsScope): boolean {
if (builder.requiresGoalScope && scope.kind !== 'project') return false
return true
}
private async fetchAvailableGoals(goalIds: number[]): Promise<AnalyticsAvailableGoal[]> {
if (goalIds.length === 0) return []
return await this.repository.getGoalsForIds(goalIds)
}
}
@@ -0,0 +1,808 @@
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
import { GoalsSchema } from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import { toIntArraySql } from './helpers'
import type {
ActiveProjectsSectionRow,
AgingOpenTasksSectionRow,
AmountCoverageKpiRow,
BlockedByDependenciesSectionRow,
CompletedTasksKpiRow,
CreatedTasksKpiRow,
CycleTimeHistogramSectionRow,
CycleTimeKpiRow,
CycleTimePerProjectSectionRow,
IncomeExpenseMonthSectionRow,
IncomeExpensePerProjectSectionRow,
NetProfitKpiRow,
OverdueByAgeSectionRow,
OverdueKpiRow,
PlannedExpenseKpiRow,
PlannedIncomeKpiRow,
PriorityMixOverTimeSectionRow,
StaleTasksSectionRow,
StatusDistributionSectionRow,
ThroughputSectionRow,
TimeInKanbanStatusSectionRow,
TopProjectsByAmountSectionRow,
TotalExpenseKpiRow,
TotalIncomeKpiRow,
WorkloadByAssigneeSectionRow,
} from './sections/row.types'
import type { AnalyticsRange, DrillDownTaskRow } from './types'
type Bucket = 'day' | 'week' | 'month'
const BUCKET_SQL: Record<Bucket, { trunc: SQL, interval: SQL }> = {
day: { trunc: sql.raw("'day'"), interval: sql.raw("'1 day'::interval") },
week: { trunc: sql.raw("'week'"), interval: sql.raw("'1 week'::interval") },
month: { trunc: sql.raw("'month'"), interval: sql.raw("'1 month'::interval") },
}
function bucketLiterals(bucket: Bucket): { trunc: SQL, interval: SQL } {
const lit = BUCKET_SQL[bucket]
if (!lit) throw new Error(`Invalid bucket: ${String(bucket)}`)
return lit
}
const DRILL_DOWN_LIMIT = 200
const DRILL_DOWN_TASK_FIELDS = sql`
t.id::int as "id",
t.description as "description",
t.goal_id::int as "goalId",
g.name as "goalName",
coalesce(t.complete, false) as "complete",
t.priority_id::int as "priorityId",
t.end_date::text as "endDate",
t.date_creation::text as "date_creation",
t.date_complete::text as "date_complete"
`
export class AnalyticsRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
// ================== Goal lookups ==================
async fetchAllGoalIdsInOrg(organizationId: number): Promise<number[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: GoalsSchema.id })
.from(GoalsSchema)
.where(and(
eq(GoalsSchema.organizationId, organizationId),
eq(GoalsSchema.archive, 0),
)),
)
return (result ?? []).map(r => r.id).filter((id): id is number => id !== null)
}
async fetchGoalIdsWithPermissions(
userId: number,
email: string,
organizationId: number,
permissionNames: string[],
): Promise<number[]> {
if (permissionNames.length === 0) return []
const result = await this.db.dbDrizzle.execute<{ id: number }>(sql`
select g.id from tasks.goals g
where g.organization_id = ${organizationId}
and g.archive = 0
and (
g.owner = ${userId}
or (
select count(distinct p.name)
from collaboration.users cu
join collaboration.users_to_goals utg on utg.user_id = cu.id and utg.goal_id = g.id
join collaboration.users_to_roles utr on utr.user_id = cu.id
join collaboration.roles r on r.id = utr.role_id and r.goal_id = g.id
join collaboration.permissions_to_role ptr on ptr.role_id = r.id
join tv_auth.permissions p on p.id = ptr.permission_id
where cu.email = ${email}
and p.name = any(${sql`ARRAY[${sql.join(permissionNames.map(n => sql`${n}`), sql`, `)}]::text[]`})
) = ${permissionNames.length}
)
`)
return result.rows.map(r => Number(r.id)).filter(id => Number.isInteger(id))
}
async getGoalsForIds(ids: number[]) {
if (ids.length === 0) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
id: GoalsSchema.id,
name: GoalsSchema.name,
})
.from(GoalsSchema)
.where(inArray(GoalsSchema.id, ids)),
)
return (result ?? []).map(r => ({ id: r.id ?? 0, name: r.name ?? '' }))
}
// ================== KPI ==================
async countCreated(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<CreatedTasksKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.count ?? 0)
}
async countCompleted(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<CompletedTasksKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.count ?? 0)
}
async countOverdue(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<OverdueKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and end_date is not null
and end_date < current_date
`)
return Number(result.rows[0]?.count ?? 0)
}
async medianCycleTime(goalIds: number[], range: AnalyticsRange): Promise<number | null> {
const result = await this.db.dbDrizzle.execute<CycleTimeKpiRow>(sql`
select percentile_cont(0.5) within group (
order by extract(epoch from (date_complete - date_creation)) / 86400
)::float as median
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and date_creation is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
const m = result.rows[0]?.median
return m === null || m === undefined ? null : Number(m)
}
async sumIncome(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<TotalIncomeKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type = 1
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumExpense(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<TotalExpenseKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type = 0
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumIncomeAndExpense(goalIds: number[], range: AnalyticsRange): Promise<{ income: number, expense: number }> {
const result = await this.db.dbDrizzle.execute<NetProfitKpiRow>(sql`
select
coalesce(sum(case when transaction_type = 1 then amount else 0 end), 0)::float as income,
coalesce(sum(case when transaction_type = 0 then amount else 0 end), 0)::float as expense
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type in (0, 1)
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
const r = result.rows[0] ?? { income: 0, expense: 0 }
return { income: Number(r.income), expense: Number(r.expense) }
}
async sumPlannedIncome(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<PlannedIncomeKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and transaction_type = 1
and amount is not null
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumPlannedExpense(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<PlannedExpenseKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and transaction_type = 0
and amount is not null
`)
return Number(result.rows[0]?.total ?? 0)
}
async amountCoverage(goalIds: number[]): Promise<{ total: number, withAmount: number }> {
const result = await this.db.dbDrizzle.execute<AmountCoverageKpiRow>(sql`
select
count(*)::int as total,
count(*) filter (where amount is not null)::int as with_amount
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
`)
const r = result.rows[0] ?? { total: 0, with_amount: 0 }
return { total: Number(r.total), withAmount: Number(r.with_amount) }
}
// ================== Productivity ==================
async fetchThroughput(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<ThroughputSectionRow[]> {
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
const goalIdsSql = toIntArraySql(goalIds)
const result = await this.db.dbDrizzle.execute<ThroughputSectionRow>(sql`
with buckets as (
select generate_series(
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
${intervalSql}
) as bucket
),
created as (
select date_trunc(${bucketSql}, date_creation) as bucket, count(*)::int as count
from tasks.tasks
where goal_id = any(${goalIdsSql})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
group by date_trunc(${bucketSql}, date_creation)
),
completed as (
select date_trunc(${bucketSql}, date_complete) as bucket, count(*)::int as count
from tasks.tasks
where goal_id = any(${goalIdsSql})
and complete = true
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
group by date_trunc(${bucketSql}, date_complete)
)
select
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
coalesce(c.count, 0)::int as created,
coalesce(d.count, 0)::int as completed
from buckets b
left join created c on c.bucket = b.bucket
left join completed d on d.bucket = b.bucket
order by b.bucket asc
`)
return result.rows as ThroughputSectionRow[]
}
async fetchPriorityMix(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<PriorityMixOverTimeSectionRow[]> {
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
const goalIdsSql = toIntArraySql(goalIds)
const result = await this.db.dbDrizzle.execute<PriorityMixOverTimeSectionRow>(sql`
with buckets as (
select generate_series(
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
${intervalSql}
) as bucket
),
created as (
select date_trunc(${bucketSql}, date_creation) as bucket, priority_id
from tasks.tasks
where goal_id = any(${goalIdsSql})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
)
select
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
coalesce(sum(case when c.priority_id = 3 then 1 else 0 end), 0)::int as high,
coalesce(sum(case when c.priority_id = 2 then 1 else 0 end), 0)::int as medium,
coalesce(sum(case when c.priority_id = 1 then 1 else 0 end), 0)::int as low,
coalesce(sum(case when c.priority_id is null then 1 else 0 end), 0)::int as none
from buckets b
left join created c on c.bucket = b.bucket
group by b.bucket
order by b.bucket asc
`)
return result.rows as PriorityMixOverTimeSectionRow[]
}
// ================== Workload ==================
async fetchWorkloadByAssignee(goalIds: number[]): Promise<WorkloadByAssigneeSectionRow[]> {
const result = await this.db.dbDrizzle.execute<WorkloadByAssigneeSectionRow>(sql`
select * from (
select
cu.id as user_id,
coalesce(cu.email, 'Unknown') as user_name,
sum(case when t.priority_id = 3 then 1 else 0 end)::int as high,
sum(case when t.priority_id = 2 then 1 else 0 end)::int as medium,
sum(case when t.priority_id = 1 then 1 else 0 end)::int as low,
sum(case when t.priority_id is null then 1 else 0 end)::int as no_priority
from tasks.tasks t
join tasks_auth.task_assignee ta on ta.task_id = t.id
join collaboration.users cu on cu.id = ta.collab_user_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
group by cu.id, cu.email
) s
order by (s.high * 3 + s.medium * 2 + s.low + s.no_priority) desc
limit 30
`)
return result.rows as WorkloadByAssigneeSectionRow[]
}
async fetchBlockedByDeps(goalIds: number[]): Promise<BlockedByDependenciesSectionRow[]> {
const result = await this.db.dbDrizzle.execute<BlockedByDependenciesSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
count(distinct t.id)::int as blocked
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
join tasks.task_relations r on r.to_task_id = t.id
join tasks.tasks src on src.id = r.from_task_id
where g.id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and (src.complete is null or src.complete = false)
group by g.id, g.name
order by blocked desc
`)
return result.rows as BlockedByDependenciesSectionRow[]
}
async fetchTimeInKanbanStatus(goalId: number, accessibleGoalIds: number[]): Promise<TimeInKanbanStatusSectionRow[]> {
const result = await this.db.dbDrizzle.execute<TimeInKanbanStatusSectionRow>(sql`
select
s.id as status_id,
coalesce(s.name, 'Без статуса') as status_name,
avg(extract(epoch from (now() - coalesce(t.edit_date, t.date_creation))) / 86400.0)::float as avg_days,
count(t.id)::int as task_count
from tasks.tasks t
left join tasks.statuses s on s.id = t.status_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
group by s.id, s.name, s.view_order
order by s.view_order nulls last, s.name
`)
return result.rows as TimeInKanbanStatusSectionRow[]
}
async fetchAgingOpenTasks(goalIds: number[]): Promise<AgingOpenTasksSectionRow[]> {
const result = await this.db.dbDrizzle.execute<AgingOpenTasksSectionRow>(sql`
select
cu.id as user_id,
coalesce(cu.email, 'Unknown') as user_name,
avg(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as avg_age,
max(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as max_age,
count(distinct t.id)::int as task_count
from tasks.tasks t
join tasks_auth.task_assignee ta on ta.task_id = t.id
join collaboration.users cu on cu.id = ta.collab_user_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
group by cu.id, cu.email
having count(distinct t.id) > 0
order by avg_age desc nulls last
limit 20
`)
return result.rows as AgingOpenTasksSectionRow[]
}
// ================== Quality ==================
async fetchOverdueByAge(goalIds: number[]): Promise<OverdueByAgeSectionRow> {
const result = await this.db.dbDrizzle.execute<OverdueByAgeSectionRow>(sql`
select
sum(case when (current_date - end_date) between 1 and 3 then 1 else 0 end)::int as bucket_1_3,
sum(case when (current_date - end_date) between 4 and 7 then 1 else 0 end)::int as bucket_4_7,
sum(case when (current_date - end_date) between 8 and 14 then 1 else 0 end)::int as bucket_8_14,
sum(case when (current_date - end_date) > 14 then 1 else 0 end)::int as bucket_15_plus
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and end_date is not null
and end_date < current_date
`)
return (result.rows[0] ?? {
bucket_1_3: 0, bucket_4_7: 0, bucket_8_14: 0, bucket_15_plus: 0,
}) as OverdueByAgeSectionRow
}
async fetchCycleTimeHistogram(goalIds: number[], range: AnalyticsRange): Promise<CycleTimeHistogramSectionRow> {
const result = await this.db.dbDrizzle.execute<CycleTimeHistogramSectionRow>(sql`
with durations as (
select extract(epoch from (date_complete - date_creation)) / 86400.0 as days
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and date_creation is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
)
select
sum(case when days < 1 then 1 else 0 end)::int as bucket_0_1,
sum(case when days >= 1 and days < 3 then 1 else 0 end)::int as bucket_1_3,
sum(case when days >= 3 and days < 7 then 1 else 0 end)::int as bucket_3_7,
sum(case when days >= 7 and days < 14 then 1 else 0 end)::int as bucket_7_14,
sum(case when days >= 14 and days < 30 then 1 else 0 end)::int as bucket_14_30,
sum(case when days >= 30 then 1 else 0 end)::int as bucket_30_plus
from durations
`)
return (result.rows[0] ?? {
bucket_0_1: 0, bucket_1_3: 0, bucket_3_7: 0,
bucket_7_14: 0, bucket_14_30: 0, bucket_30_plus: 0,
}) as CycleTimeHistogramSectionRow
}
async fetchStaleTasks(goalIds: number[]): Promise<StaleTasksSectionRow[]> {
const result = await this.db.dbDrizzle.execute<StaleTasksSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
count(t.id)::int as stale
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
group by g.id, g.name
order by stale desc
`)
return result.rows as StaleTasksSectionRow[]
}
async fetchCycleTimePerProject(goalIds: number[], range: AnalyticsRange): Promise<CycleTimePerProjectSectionRow[]> {
const result = await this.db.dbDrizzle.execute<CycleTimePerProjectSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
percentile_cont(0.5) within group (
order by extract(epoch from (t.date_complete - t.date_creation)) / 86400.0
)::float as median_days,
count(t.id)::int as completed
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.complete = true
and t.date_complete is not null
and t.date_creation is not null
and t.date_complete >= ${range.from.toISOString()}
and t.date_complete < ${range.to.toISOString()}
group by g.id, g.name
having count(t.id) > 0
order by median_days desc nulls last
`)
return result.rows as CycleTimePerProjectSectionRow[]
}
// ================== Usage ==================
async fetchStatusDistribution(goalId: number, accessibleGoalIds: number[]): Promise<StatusDistributionSectionRow[]> {
const result = await this.db.dbDrizzle.execute<StatusDistributionSectionRow>(sql`
select
s.id as status_id,
coalesce(s.name, 'No status') as status_name,
count(t.id)::int as count
from tasks.tasks t
left join tasks.statuses s on s.id = t.status_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
group by s.id, s.name
having count(t.id) > 0
order by count desc
`)
return result.rows as StatusDistributionSectionRow[]
}
async fetchActiveProjects(goalIds: number[]): Promise<ActiveProjectsSectionRow[]> {
const result = await this.db.dbDrizzle.execute<ActiveProjectsSectionRow>(sql`
with last_activity as (
select g.id as goal_id,
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
from tasks.goals g
left join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
group by g.id
)
select status_key, count(*)::int as count
from (
select
case
when last_at is null then 'empty'
when last_at >= now() - interval '14 days' then 'active'
when last_at >= now() - interval '30 days' then 'fading'
else 'dead'
end as status_key
from last_activity
) s
group by status_key
order by case status_key
when 'active' then 1
when 'fading' then 2
when 'dead' then 3
when 'empty' then 4
end
`)
return result.rows as ActiveProjectsSectionRow[]
}
// ================== Financial ==================
async fetchIncomeExpenseMonth(goalIds: number[], range: AnalyticsRange): Promise<IncomeExpenseMonthSectionRow[]> {
const result = await this.db.dbDrizzle.execute<IncomeExpenseMonthSectionRow>(sql`
with months as (
select generate_series(
date_trunc('month', ${range.from.toISOString()}::timestamp),
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
'1 month'::interval
) as month
),
totals as (
select
date_trunc('month', date_complete) as month,
sum(case when transaction_type = 1 then coalesce(amount, 0) else 0 end)::float as income,
sum(case when transaction_type = 0 then coalesce(amount, 0) else 0 end)::float as expense
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and amount is not null
and transaction_type in (0, 1)
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
group by date_trunc('month', date_complete)
)
select
to_char(m.month, 'YYYY-MM') as month,
coalesce(t.income, 0)::float as income,
coalesce(t.expense, 0)::float as expense
from months m
left join totals t on t.month = m.month
order by m.month asc
`)
return result.rows as IncomeExpenseMonthSectionRow[]
}
async fetchIncomeExpensePerProject(goalIds: number[]): Promise<IncomeExpensePerProjectSectionRow[]> {
const result = await this.db.dbDrizzle.execute<IncomeExpensePerProjectSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense,
(sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)
- sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end))::float as net
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.amount is not null
and t.transaction_type in (0, 1)
group by g.id, g.name
having sum(coalesce(t.amount, 0)) > 0
order by net desc
limit 20
`)
return result.rows as IncomeExpensePerProjectSectionRow[]
}
async fetchTopProjectsByAmount(goalIds: number[]): Promise<TopProjectsByAmountSectionRow[]> {
const result = await this.db.dbDrizzle.execute<TopProjectsByAmountSectionRow>(sql`
select * from (
select
g.id as goal_id,
g.name as goal_name,
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.amount is not null
and t.transaction_type in (0, 1)
group by g.id, g.name
having sum(coalesce(t.amount, 0)) > 0
) s
order by (s.income + s.expense) desc
limit 15
`)
return result.rows as TopProjectsByAmountSectionRow[]
}
// ================== Drill-down ==================
async fetchOverdueTasks(goalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.end_date is not null
and t.end_date < current_date
order by t.end_date asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOverdueTasksInRange(
goalIds: number[],
minDays: number,
maxDays: number | null,
): Promise<DrillDownTaskRow[]> {
const maxClause = maxDays !== null
? sql`and (current_date - t.end_date) <= ${maxDays}`
: sql``
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.end_date is not null
and (current_date - t.end_date) >= ${minDays}
${maxClause}
order by t.end_date asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchStaleTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
order by coalesce(t.edit_date, t.date_creation) asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchBlockedTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks.task_relations r on r.to_task_id = t.id
join tasks.tasks src on src.id = r.from_task_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
and (src.complete is null or src.complete = false)
order by t.id
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksAssignedTo(goalIds: number[], userId: number): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks_auth.task_assignee ta on ta.task_id = t.id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and ta.collab_user_id = ${userId}
order by t.id, t.date_creation asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksAssignedWithPriority(
goalIds: number[],
userId: number,
priorityFilter: number | 'null' | undefined,
): Promise<DrillDownTaskRow[]> {
const priorityClause: SQL = priorityFilter === undefined
? sql``
: priorityFilter === 'null'
? sql`and t.priority_id is null`
: sql`and t.priority_id = ${priorityFilter}`
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks_auth.task_assignee ta on ta.task_id = t.id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and ta.collab_user_id = ${userId}
${priorityClause}
order by t.id, t.date_creation desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksInActiveProjects(
goalIds: number[],
statusKey: 'active' | 'fading' | 'dead',
): Promise<DrillDownTaskRow[]> {
let activityClause: SQL
if (statusKey === 'active') {
activityClause = sql`last_at >= now() - interval '14 days'`
} else if (statusKey === 'fading') {
activityClause = sql`last_at >= now() - interval '30 days' and last_at < now() - interval '14 days'`
} else {
activityClause = sql`last_at < now() - interval '30 days'`
}
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
with last_activity as (
select g.id as goal_id,
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
from tasks.goals g
left join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
group by g.id
),
matching_goals as (
select goal_id from last_activity where ${activityClause}
)
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join matching_goals m on m.goal_id = t.goal_id
where (t.complete is null or t.complete = false)
order by t.id, coalesce(t.edit_date, t.date_creation) desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchPlannedTasksByType(goalIds: number[], transactionType: 0 | 1): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.transaction_type = ${transactionType}
and t.amount is not null
order by t.amount desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
}
@@ -0,0 +1,27 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { AnalyticsController } from './AnalyticsController'
import { CanAccessAnalytics } from './middlewares/CanAccessAnalytics'
export default class AnalyticsRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: AnalyticsController
constructor() {
this.router = Router()
this.controller = new AnalyticsController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
const guards = [IsLoggedIn, RejectApiTokenAuth, CanAccessAnalytics]
this.router.get('/sections', guards, this.controller.fetchSections)
this.router.get('/drilldown/:sectionId', guards, this.controller.fetchDrillDown)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { type } from 'arktype'
import { sql, type SQL } from 'drizzle-orm'
import type { AnalyticsPeriod } from 'taskview-api'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta } from './types'
const MAX_INT32 = 2147483647
export function toIntArraySql(ids: ReadonlyArray<number>): SQL {
const safe = ids.filter(
(id): id is number =>
typeof id === 'number' && Number.isInteger(id) && id > 0 && id < MAX_INT32,
)
return sql.raw(`ARRAY[${safe.join(',')}]::int[]`)
}
export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
if (!raw) return {}
try {
const result = DrillDownMetaArkType(JSON.parse(raw))
return result instanceof type.errors ? {} : result
} catch {
return {}
}
}
export function resolveRange(
period: AnalyticsPeriod,
from?: string,
to?: string,
): AnalyticsRange | null {
const now = new Date()
if (period === 'custom') {
if (!from || !to) return null
const fromDate = new Date(from)
const toDate = new Date(to)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
if (fromDate > toDate) return null
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
return { from: fromDate, to: toDate }
}
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
}
const days = daysByPeriod[period]
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return { from: fromDate, to: now }
}
@@ -0,0 +1,41 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
import { parsePositiveInt } from '../../../utils/helpers'
export const CanAccessAnalytics = async (req: Request, res: Response, next: NextFunction) => {
const orgId = parsePositiveInt(req.query?.organizationId)
if (orgId === null) return res.status(400).end()
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
if (!member) return res.status(403).end()
if (await req.appUser.organizationManager.isCurrentUserOrgOwner(orgId)) return next()
const scope = req.query?.scope
if (scope === 'project') {
const goalId = parsePositiveInt(req.query?.goalId)
if (goalId === null) return res.status(400).end()
const checker = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError)
if (!checker) {
$logger.error('Can not get permissions for CanAccessAnalytics middleware')
return res.status(500).end()
}
if (!checker.hasPermissions(GoalPermissions.ANALYTICS_CAN_VIEW)) return res.status(403).end()
return next()
}
const accessibleGoalIds = await req.appUser.analyticsManager
.getAccessibleGoalIds(orgId)
.catch(logError)
if (!accessibleGoalIds || accessibleGoalIds.length === 0) return res.status(403).end()
return next()
}
@@ -0,0 +1,81 @@
import type { SectionBuilder } from '../types'
import { CreatedTasksKpi } from './kpi/CreatedTasksKpi'
import { CompletedTasksKpi } from './kpi/CompletedTasksKpi'
import { OverdueKpi } from './kpi/OverdueKpi'
import { CycleTimeKpi } from './kpi/CycleTimeKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
// import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
// import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
import { NetProfitKpi } from './financial/NetProfitKpi'
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
const builders: SectionBuilder[] = [
// KPI
new CreatedTasksKpi(),
new CompletedTasksKpi(),
new OverdueKpi(),
new CycleTimeKpi(),
new TotalIncomeKpi(),
new TotalExpenseKpi(),
new NetProfitKpi(),
new PlannedIncomeKpi(),
new PlannedExpenseKpi(),
new AmountCoverageKpi(),
// Productivity
new ThroughputSection(),
// new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
// new BlockedByDependenciesSection(),
// new TimeInKanbanStatusSection(),
// new AgingOpenTasksSection(),
// Quality
new OverdueByAgeSection(),
// new CycleTimeHistogramSection(),
new StaleTasksSection(),
// new CycleTimePerProjectSection(),
// Usage
// new StatusDistributionSection(),
new ActiveProjectsSection(),
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new TopProjectsByAmountSection(),
]
export class SectionRegistry {
private readonly byId: Map<string, SectionBuilder>
constructor() {
this.byId = new Map(builders.map(b => [b.id, b]))
}
all(): SectionBuilder[] {
return [...this.byId.values()]
}
get(id: string): SectionBuilder | undefined {
return this.byId.get(id)
}
filterByIds(ids?: string[]): SectionBuilder[] {
if (!ids || ids.length === 0) return this.all()
return ids.map(id => this.byId.get(id)).filter((b): b is SectionBuilder => !!b)
}
}
@@ -0,0 +1,52 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class AmountCoverageKpi implements SectionBuilder {
readonly id = 'kpi.amount_coverage'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { total, withAmount } = await ctx.repository.amountCoverage(ctx.accessibleGoalIds)
const percent = total === 0 ? 0 : Math.round((withAmount / total) * 100)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: percent,
unit: 'percent',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'percent' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,68 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class IncomeExpenseMonthSection implements SectionBuilder {
readonly id = 'chart.income_expense_month'
readonly group = 'financial' as const
readonly allowedChartTypes = ['bar', 'line', 'area', 'stackedArea'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchIncomeExpenseMonth(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.month),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
},
],
unit: 'currency',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class IncomeExpensePerProjectSection implements SectionBuilder {
readonly id = 'chart.income_expense_per_project'
readonly group = 'financial' as const
readonly allowedChartTypes = ['bar', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchIncomeExpensePerProject(ctx.accessibleGoalIds)
const loc = this.loc
const goalIds = rows.map(r => r.goal_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
meta: { goalIds },
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
meta: { goalIds },
},
],
unit: 'currency',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,78 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class NetProfitKpi implements SectionBuilder {
readonly id = 'kpi.net_profit'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const cur = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const current = cur.income - cur.expense
const previous = prev.income - prev.expense
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, previous),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return {
value: 100,
direction: current > 0 ? ('up' as const) : ('down' as const),
isGood: current >= 0,
}
}
const pct = Math.round(((current - prev) / Math.abs(prev)) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,57 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
import { sectionLocales } from '../locales'
export class PlannedExpenseKpi implements SectionBuilder {
readonly id = 'kpi.planned_expense'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.sumPlannedExpense(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(value),
unit: 'currency',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 0)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,57 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
import { sectionLocales } from '../locales'
export class PlannedIncomeKpi implements SectionBuilder {
readonly id = 'kpi.planned_income'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.sumPlannedIncome(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(value),
unit: 'currency',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 1)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,73 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TopProjectsByAmountSection implements SectionBuilder {
readonly id = 'chart.top_projects_by_amount'
readonly group = 'financial' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchTopProjectsByAmount(ctx.accessibleGoalIds)
const loc = this.loc
const goalIds = rows.map(r => r.goal_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
stack: 'amount',
meta: { goalIds },
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
stack: 'amount',
meta: { goalIds },
},
],
unit: 'currency',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TotalExpenseKpi implements SectionBuilder {
readonly id = 'kpi.total_expense'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: false }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct <= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TotalIncomeKpi implements SectionBuilder {
readonly id = 'kpi.total_income'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CompletedTasksKpi implements SectionBuilder {
readonly id = 'kpi.completed_tasks'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: current,
unit: 'count',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CreatedTasksKpi implements SectionBuilder {
readonly id = 'kpi.created_tasks'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: current,
unit: 'count',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,52 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimeKpi implements SectionBuilder {
readonly id = 'kpi.cycle_time'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const median = await ctx.repository.medianCycleTime(ctx.accessibleGoalIds, ctx.range)
const value = median === null ? 0 : Math.round(median * 10) / 10
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value,
unit: 'days',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,56 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class OverdueKpi implements SectionBuilder {
readonly id = 'kpi.overdue'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.countOverdue(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value,
unit: 'count',
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchOverdueTasks(ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,682 @@
import type { LocalizedText } from 'taskview-api'
export type SectionLocale = {
title: LocalizedText
description?: LocalizedText
help?: {
summary: LocalizedText
details: LocalizedText
}
datasets?: Record<string, LocalizedText>
labels?: Record<string, LocalizedText>
xAxisLabel?: LocalizedText
yAxisLabel?: LocalizedText
}
const join = (parts: string[]) => parts.join('\n')
export const sectionLocales = {
// ===== KPI =====
'kpi.created_tasks': {
title: { ru: 'Создано задач', en: 'Created tasks' },
help: {
summary: {
ru: 'Количество созданных задач за выбранный период',
en: 'Number of tasks created in the selected period',
},
details: {
ru: 'Показывает входящий поток работы. Сравните со счётчиком «Закрыто задач» — если создаётся больше, чем закрывается, команда не успевает справляться, и бэклог растёт. Delta показывает изменение относительно предыдущего периода той же длины.',
en: 'Shows incoming work volume. Compare against "Completed tasks" — if creation outpaces completion, the backlog is growing and the team is falling behind. The delta compares against the equivalent previous period.',
},
},
},
'kpi.completed_tasks': {
title: { ru: 'Закрыто задач', en: 'Completed tasks' },
help: {
summary: {
ru: 'Количество задач, закрытых за период',
en: 'Tasks completed in the selected period',
},
details: {
ru: 'Реальный выход команды — сколько работы было доведено до конца. Сопоставляйте с «Создано задач»: если создано ≫ закрыто, нарастает бэклог. Стабильный тренд роста = хороший признак ускорения процессов.',
en: 'Actual team output — how much work got finished. Compare with "Created tasks": if creation far exceeds completion, the backlog is growing. A steady upward trend indicates process acceleration.',
},
},
},
'kpi.overdue': {
title: { ru: 'Просрочено', en: 'Overdue' },
help: {
summary: {
ru: 'Открытые задачи с прошедшим дедлайном',
en: 'Open tasks past their due date',
},
details: {
ru: 'Работа, требующая срочного внимания. Если число стабильно растёт — команда перегружена или дедлайны нереалистичны. Кликните по KPI, чтобы увидеть конкретные просроченные задачи.',
en: 'Work that needs immediate attention. A steadily growing number signals team overload or unrealistic deadlines. Click the KPI to drill into the specific overdue tasks.',
},
},
},
'kpi.cycle_time': {
title: { ru: 'Cycle time (медиана)', en: 'Cycle time (median)' },
description: { ru: 'Медианное время от создания до завершения', en: 'Median time from creation to completion' },
help: {
summary: {
ru: 'Медианное время от создания задачи до её закрытия',
en: 'Median time from task creation to completion',
},
details: {
ru: 'Сколько в среднем живёт задача с момента создания до закрытия. Рост показателя = замедление процессов. Используется медиана, а не среднее — выбросы (застрявшие задачи) не искажают картину.',
en: "How long a task typically lives from creation to completion. A rising number means slowing processes. We use the median rather than the mean so that outliers (stuck tasks) don't distort the picture.",
},
},
},
'kpi.total_income': {
title: { ru: 'Доходы', en: 'Income' },
description: { ru: 'Сумма за период', en: 'Sum for period' },
help: {
summary: {
ru: 'Сумма всех доходов за выбранный период',
en: 'Total income for the selected period',
},
details: {
ru: 'Складываются суммы по закрытым задачам, у которых отмечен тип «доход». Сравнение — с предыдущим периодом такой же длины. Зелёная стрелка вверх — доход вырос, это хорошо.',
en: 'Sum of amounts on closed tasks marked as "income". Compared against the previous equivalent period. A green up arrow means income grew, which is good.',
},
},
},
'kpi.total_expense': {
title: { ru: 'Расходы', en: 'Expense' },
description: { ru: 'Сумма за период', en: 'Sum for period' },
help: {
summary: {
ru: 'Сумма всех расходов за выбранный период',
en: 'Total expenses for the selected period',
},
details: {
ru: 'Складываются суммы по закрытым задачам, у которых отмечен тип «расход». Сравнение — с предыдущим периодом такой же длины. Зелёная стрелка вниз — расходы снизились, это хорошо.',
en: 'Sum of amounts on closed tasks marked as "expense". Compared against the previous equivalent period. A green down arrow means expenses dropped, which is good.',
},
},
},
'kpi.planned_income': {
title: { ru: 'Планируемые доходы', en: 'Planned income' },
description: { ru: 'По открытым задачам', en: 'From open tasks' },
help: {
summary: {
ru: 'Сумма доходов по открытым задачам с указанной суммой',
en: 'Sum of income from open tasks with amount set',
},
details: {
ru: 'Складываются суммы по всем открытым (не завершённым) задачам с типом «доход» и заполненной суммой. Это снимок ожидаемых поступлений — пока задача не закрыта, доход считается планируемым. Не зависит от выбранного периода.',
en: 'Sum of amounts from all open (incomplete) tasks marked as "income" with a filled amount. This is a snapshot of expected income — until a task is closed, the income is planned. Not affected by the selected period.',
},
},
},
'kpi.planned_expense': {
title: { ru: 'Планируемые расходы', en: 'Planned expense' },
description: { ru: 'По открытым задачам', en: 'From open tasks' },
help: {
summary: {
ru: 'Сумма расходов по открытым задачам с указанной суммой',
en: 'Sum of expenses from open tasks with amount set',
},
details: {
ru: 'Складываются суммы по всем открытым (не завершённым) задачам с типом «расход» и заполненной суммой. Это снимок ожидаемых трат — пока задача не закрыта, расход считается планируемым. Не зависит от выбранного периода.',
en: 'Sum of amounts from all open (incomplete) tasks marked as "expense" with a filled amount. This is a snapshot of expected spending — until a task is closed, the expense is planned. Not affected by the selected period.',
},
},
},
'kpi.net_profit': {
title: { ru: 'Чистая прибыль', en: 'Net profit' },
description: { ru: 'Доходы минус расходы', en: 'Income minus expense' },
help: {
summary: {
ru: 'Чистая прибыль за период: доходы минус расходы',
en: 'Net profit for the period: income minus expense',
},
details: {
ru: 'Считается как разница между всеми доходами и расходами по закрытым задачам в периоде. Положительное число — заработали больше, чем потратили. Дельта показывает, насколько изменилась прибыль по сравнению с предыдущим периодом такой же длины.',
en: 'Calculated as the difference between all income and expense from closed tasks in the period. A positive number means you earned more than you spent. The delta shows how profit changed vs the previous equivalent period.',
},
},
},
'kpi.amount_coverage': {
title: { ru: 'Заполнено amount', en: 'Amount coverage' },
description: { ru: '% задач с заполненной суммой', en: '% of tasks with amount filled' },
help: {
summary: {
ru: 'Доля задач с заполненным полем суммы',
en: 'Share of tasks with the amount field filled',
},
details: {
ru: 'Показатель качества данных для финансовой аналитики. Если % низкий — графики «Доходы/расходы» и «Топ проектов» отражают только малую часть реальности. Стоит либо не доверять им, либо наладить практику заполнения amount/transactionType.',
en: 'Data quality indicator for financial analytics. If this percentage is low, the Income/Expense and Top Projects charts only reflect a small slice of reality — either treat them with caution or establish a practice of filling the amount/transactionType fields.',
},
},
},
// ===== Productivity =====
'chart.throughput': {
title: { ru: 'Создано vs закрыто', en: 'Created vs completed' },
description: {
ru: 'Баланс входящей и закрываемой работы',
en: 'Balance of incoming and completed work',
},
help: {
summary: {
ru: 'Созданные и закрытые задачи по периодам',
en: 'Created and completed tasks over time',
},
details: {
ru: 'Главный индикатор здоровья проекта. Зазор между линиями — предупреждение: вы создаёте больше, чем закрываете, и бэклог растёт. Постоянный зазор → накопление долга, команда не справляется. Линии близко друг к другу → работа идёт в темпе поступления.',
en: 'The primary project health indicator. A gap between the lines is a warning — creation outpaces completion, so the backlog is growing. A persistent gap means the team is falling behind; tight lines mean work is getting done at the rate it arrives.',
},
},
datasets: {
created: { ru: 'Создано', en: 'Created' },
completed: { ru: 'Закрыто', en: 'Completed' },
},
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.priority_mix': {
title: { ru: 'Приоритеты создаваемых задач', en: 'Priority mix over time' },
description: { ru: 'Распределение новых задач по приоритету', en: 'Distribution of new tasks by priority' },
help: {
summary: {
ru: 'Как меняется распределение приоритетов у создаваемых задач',
en: 'How priority mix of newly created tasks shifts over time',
},
details: {
ru: 'Если доля High растёт — вероятно, проблемы с планированием или команда в режиме пожаротушения. Здоровая продуктовая работа имеет больше Medium и Low, чем High. Также обратите внимание на долю задач «Без приоритета» — это сигнал плохой практики триажа.',
en: 'If the High share grows, the team may be firefighting or planning poorly. Healthy product work has more Medium/Low than High. Also watch the "No priority" share — a large portion points to poor triage practice.',
},
},
datasets: {
high: { ru: 'Высокий', en: 'High' },
medium: { ru: 'Средний', en: 'Medium' },
low: { ru: 'Низкий', en: 'Low' },
none: { ru: 'Без приоритета', en: 'No priority' },
},
yAxisLabel: { ru: 'Создано задач', en: 'Tasks created' },
},
// ===== Workload =====
'chart.workload_by_assignee': {
title: { ru: 'Нагрузка по исполнителям', en: 'Workload by assignee' },
description: { ru: 'Открытые задачи, сгруппированные по приоритету', en: 'Open tasks grouped by priority' },
help: {
summary: {
ru: 'Количество открытых задач на каждого исполнителя с разбивкой по приоритету',
en: 'Open tasks per assignee, broken down by priority',
},
details: {
ru: 'Быстрый взгляд на перегруз команды. Если у одного человека 15+ задач или много High-приоритета — нужно перераспределить нагрузку. Это не рейтинг эффективности: размер задач разный, и некоторые люди формально числятся в assignee, но не работают.',
en: 'A quick check for team overload. If one person has 15+ tasks or many High-priority items, workload needs rebalancing. This is not a performance ranking: task sizes vary and some people appear as formal assignees without actively working.',
},
},
datasets: {
high: { ru: 'Высокий', en: 'High' },
medium: { ru: 'Средний', en: 'Medium' },
low: { ru: 'Низкий', en: 'Low' },
no_priority: { ru: 'Без приоритета', en: 'No priority' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.blocked_by_deps': {
title: { ru: 'Заблокировано зависимостями', en: 'Blocked by dependencies' },
description: { ru: 'Открытые задачи, ждущие завершения зависимостей', en: 'Open tasks waiting on incomplete prerequisites' },
help: {
summary: {
ru: 'Открытые задачи, которые не могут стартовать, пока не закрыты их предшественники',
en: 'Open tasks that cannot start until their predecessors are completed',
},
details: {
ru: 'Явные блокеры процесса — здесь нужно вмешательство PM в первую очередь. Высокое число = поток остановлен, нужно разблокировать ключевые задачи. Зависимости берутся из графа задач (стрелка от A к B = B зависит от A).',
en: 'Explicit process blockers — the PM should address these first. A high count means the pipeline is stalled and key prerequisites need attention. Dependencies are derived from the task graph (an arrow from A to B means B depends on A).',
},
},
datasets: {
blocked: { ru: 'Заблокировано', en: 'Blocked' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.time_in_kanban_status': {
title: { ru: 'Время в колонках канбана', en: 'Time in kanban columns' },
description: { ru: 'Среднее время жизни открытой задачи в каждом статусе', en: 'Average open-task age per kanban column' },
help: {
summary: {
ru: 'Среднее время, которое открытые задачи проводят в каждом статусе',
en: 'Average age of open tasks broken down by kanban column',
},
details: {
ru: 'Выявляет узкие места процесса. Если задачи застревают в «Review» на 5+ дней — не хватает ревьюеров. Если в «In Progress» — WIP-лимит превышен. Требует выбора проекта, потому что колонки канбана уникальны для каждого.',
en: 'Reveals process bottlenecks. If tasks sit in "Review" for 5+ days, you lack reviewers; long times in "In Progress" mean the WIP limit is exceeded. Requires selecting a project because kanban columns are unique per project.',
},
},
datasets: {
avg_days: { ru: 'Среднее время в колонке', en: 'Avg time in column' },
},
yAxisLabel: { ru: 'Дней', en: 'Days' },
},
'chart.aging_open_tasks': {
title: { ru: 'Возраст открытых задач', en: 'Aging of open tasks' },
description: { ru: 'Средний и максимальный возраст открытых задач по исполнителям', en: 'Average and maximum open-task age per assignee' },
help: {
summary: {
ru: 'Сколько дней прошло с момента создания открытых задач, по исполнителям',
en: "Days since creation for each assignee's open tasks",
},
details: {
ru: join([
'Что считается «возрастом»:',
'Сколько дней прошло с момента создания задачи до сегодня. Например: задача создана 1 апреля, сегодня 21 апреля → возраст = 20 дней.',
'',
'Что показывает график:',
'Для каждого исполнителя берутся все его открытые (незавершённые) задачи и считается:',
'• Средний возраст — насколько старые в среднем его задачи',
'• Максимум — возраст самой старой его открытой задачи',
'',
'Как читать:',
'• Высокий средний → накопилось много старых задач: возможно, перегруз или человек не двигает бэклог',
'• Высокий максимум при низком среднем → в целом всё быстро, но есть одна-две «висящих» задачи — добить или закрыть как устаревшие',
'• Оба значения низкие → задачи либо свежие, либо быстро закрываются — здоровая ситуация',
'• Большой разрыв между ними → есть «тяжёлые хвосты»: отдельные давние задачи выбиваются из общего ритма',
'',
'Нюансы:',
'• Учитываются исполнители (assignee), а не создатели задач',
'• Если задача назначена двоим — попадёт к обоим (это корректно: оба за неё отвечают)',
'• Сверху списка — исполнители с самой старой в среднем работой',
'',
'Что НЕ измеряется:',
'• Время в работе — это отдельная метрика Cycle Time',
'• Время до дедлайна — смотрите Overdue-метрики',
'• Эффективность — старая задача может быть просто большой или заблокированной',
]),
en: join([
'What "age" means:',
'How many days have passed since the task was created until today. Example: task created Apr 1, today is Apr 21 → age = 20 days.',
'',
'What the chart shows:',
'For each assignee, we take all their open (incomplete) tasks and compute:',
'• Average age — how old their tasks are on average',
'• Max — age of their oldest open task',
'',
'How to read it:',
'• High average → lots of old tasks piled up: possibly overloaded or not moving the backlog',
'• High max with low average → generally fast, but one or two "stuck" tasks — finish them or close as obsolete',
'• Both low → tasks are either fresh or closed quickly — a healthy state',
'• Large gap between them → "heavy tails": isolated old tasks breaking away from the norm',
'',
'Notes:',
'• Counted by assignee, not by creator',
'• A task assigned to two people appears for both (correct: both are responsible)',
'• Top of the list = assignees with the oldest typical work',
'',
'What is NOT measured:',
"• Time in active work — that's the separate Cycle Time metric",
'• Time until deadline — see Overdue metrics',
'• Efficiency — an old task may simply be large or blocked',
]),
},
},
datasets: {
avg_age: { ru: 'Средний возраст', en: 'Average age' },
max_age: { ru: 'Максимум', en: 'Max' },
},
xAxisLabel: { ru: 'Дней', en: 'Days' },
},
// ===== Quality =====
'chart.overdue_by_age': {
title: { ru: 'Просроченные по срокам давности', en: 'Overdue by age' },
help: {
summary: {
ru: 'Просроченные задачи, сгруппированные по времени с даты дедлайна',
en: 'Overdue tasks grouped by how long they have been overdue',
},
details: {
ru: 'Приоритизация «тушения пожаров». Задачи, просроченные 1-3 дня — скорее всего в работе и скоро закроются. 15+ дней — либо срочно решать, либо закрывать как устаревшие: такие дедлайны уже потеряли смысл.',
en: 'Firefighting priority. Tasks overdue 13 days are likely close to finishing. Tasks overdue 15+ days need urgent resolution or should be closed as obsolete — those deadlines have already lost meaning.',
},
},
datasets: {
overdue: { ru: 'Просрочено', en: 'Overdue' },
},
labels: {
bucket_1_3: { ru: '13 дн', en: '13 d' },
bucket_4_7: { ru: '47 дн', en: '47 d' },
bucket_8_14: { ru: '814 дн', en: '814 d' },
bucket_15_plus: { ru: '15+ дн', en: '15+ d' },
},
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.cycle_time_histogram': {
title: { ru: 'Распределение cycle time', en: 'Cycle time distribution' },
description: {
ru: 'Сколько задач закрылось в каждом диапазоне по длительности',
en: 'How many tasks closed in each duration range',
},
help: {
summary: {
ru: 'Сколько закрытых задач попало в каждый диапазон по длительности',
en: 'How many completed tasks fall into each duration range',
},
details: {
ru: join([
'Что показывает:',
'Все закрытые за период задачи разбиты на 6 диапазонов по длительности (от создания до закрытия). Столбец показывает, сколько задач попало в каждый диапазон.',
'',
'Как читать:',
'• Большинство в «<1д» и «1-3д» → команда закрывает задачи быстро',
'• Перевес в «7-14д» и больше → задачи крупные или долго лежат в бэклоге',
'• Высокий столбец в «30+д» → есть проблема с давними задачами, которые наконец-то были закрыты',
'',
'Нюанс:',
'Учитывается полная жизнь задачи — от создания до закрытия, включая время в бэклоге. Задача, созданная 2 месяца назад и закрытая за день, попадёт в «30+д», а не в «<1д».',
]),
en: join([
'What it shows:',
'All tasks closed during the period are split into 6 duration buckets (from creation to completion). Each bar shows how many tasks fell into that bucket.',
'',
'How to read it:',
'• Most in "<1d" and "1-3d" → team closes tasks quickly',
'• Skewed toward "7-14d" and higher → tasks are large or sit in the backlog for a long time',
'• Tall bar in "30+d" → old tasks finally closed, signalling backlog debt',
'',
'Note:',
'Measures the full task life — from creation to close, including time spent in the backlog. A task created 2 months ago and finished in a day lands in "30+d", not "<1d".',
]),
},
},
datasets: {
tasks: { ru: 'Задач', en: 'Tasks' },
},
xAxisLabel: { ru: 'Длительность', en: 'Duration' },
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.stale_tasks': {
title: { ru: 'Забытые задачи', en: 'Stale tasks' },
description: { ru: 'Открытые задачи без изменений более 30 дней', en: 'Open tasks without changes for over 30 days' },
help: {
summary: {
ru: 'Открытые задачи, по которым не было никаких изменений более 30 дней',
en: 'Open tasks with no changes for more than 30 days',
},
details: {
ru: 'Карта «где гниёт работа» по проектам. Кандидаты либо на чистку (удалить устаревшее), либо на ре-активацию (если всё ещё актуально). Большое число на проекте = бэклог перегружен неактуальной работой, пора провести ревью.',
en: '"Where work rots" — per project. Candidates for cleanup (delete obsolete) or reactivation (if still relevant). A high number on a project means the backlog is bloated with obsolete work and needs a review.',
},
},
datasets: {
stale: { ru: 'Без движения >30д', en: 'No activity >30d' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.cycle_time_per_project': {
title: { ru: 'Cycle time по проектам', en: 'Cycle time per project' },
description: { ru: 'Медианное время выполнения закрытых задач', en: 'Median completion time for finished tasks' },
help: {
summary: {
ru: 'Сколько в среднем задача живёт от создания до закрытия в каждом проекте',
en: 'How long a task typically lives from creation to completion in each project',
},
details: {
ru: join([
'Что показывает:',
'Для каждого проекта — типичное время, за которое задача проходит путь от создания до закрытия. Используется медиана: «половина задач в этом проекте закрывается быстрее, чем за X дней».',
'',
'Какие задачи учитываются:',
'Только закрытые задачи, у которых дата закрытия попадает в выбранный период. Проекты без закрытых задач за период не показаны.',
'',
'Как читать:',
'Видно, какие проекты движутся быстрее, какие медленнее. Если один проект в 2-3 раза медленнее остальных — повод поговорить с его PM: возможно, мешают блокеры, задачи слишком крупные или процесс провисает.',
'',
'⚠ Не пугайтесь больших чисел:',
'',
'1. Считается полная жизнь задачи, а не время в работе.',
'Если задача 2 месяца лежала в бэклоге, а потом её сделали за 3 дня — здесь будет 63 дня. Реальное «время в работе» — только 3 из них. К сожалению, отделить «время лежания» от «времени работы» пока нельзя.',
'',
'2. Период фильтрует по дате закрытия, не создания.',
'Задача создана в январе, закрыта в апреле — попадёт в апрельский период. Её время = вся её жизнь (95 дней), а не «время за апрель».',
'',
'3. Используется медиана, а не среднее.',
'Один задавненный тикет, который наконец-то закрыли, не сломает показатель. Медиана говорит честно: «половина задач закрывается быстрее».',
'',
'4. Не сравнивайте напрямую разные по сути проекты.',
'Маркетинг с короткими постами и разработка с крупными фичами имеют разные «нормальные» значения. Сравнивайте проект сам с собой во времени, а не с соседями по списку.',
]),
en: join([
'What it shows:',
"For each project — the typical time a task spends from creation to completion. We use the median: \"half of this project's tasks close faster than X days\".",
'',
'Which tasks are counted:',
'Only closed tasks whose completion date falls within the selected period. Projects with no completions in the period are hidden.',
'',
'How to read it:',
"You can see which projects move faster and which slower. If one project is 23× slower than the rest, it's worth talking to its PM — there may be blockers, oversized tasks, or a sagging process.",
'',
"⚠ Don't panic over big numbers:",
'',
'1. We count the full task life, not just time in active work.',
"If a task sat in the backlog for 2 months and was then done in 3 days, it counts as 63 days. The actual \"in-progress\" time was only 3 days. We can't separate \"waiting\" from \"working\" yet.",
'',
'2. The period filters by completion date, not creation.',
'A task created in January and closed in April lands in the April period. Its time = its whole life (95 days), not "time during April".',
'',
'3. We use the median, not the mean.',
"A long-forgotten ticket that finally closed won't break the metric. The median says honestly: \"half of the tasks close faster\".",
'',
"4. Don't directly compare projects of different nature.",
'A marketing project with short posts and a dev project with large features have different normal values. Compare a project against itself over time, not against its neighbors in the list.',
]),
},
},
datasets: {
median: { ru: 'Медиана cycle time', en: 'Median cycle time' },
},
xAxisLabel: { ru: 'Дней', en: 'Days' },
},
// ===== Usage =====
'chart.status_distribution': {
title: { ru: 'Распределение по статусам', en: 'Status distribution' },
description: {
ru: 'Открытые задачи в колонках канбана',
en: 'Open tasks across kanban columns',
},
help: {
summary: {
ru: 'Доли открытых задач в каждой колонке канбана выбранного проекта',
en: 'Share of open tasks in each kanban column of the selected project',
},
details: {
ru: 'Моментальный снимок «где сейчас концентрация работы». Перекос в одну колонку (например, «In Review») = процесс застрял там, нужно разблокировать. Требует выбора проекта, потому что колонки канбана у каждого проекта свои.',
en: 'A snapshot of "where the work currently sits". A heavy skew into one column (e.g. "In Review") means the process is stuck there and needs unblocking. Requires selecting a project because each project has its own kanban columns.',
},
},
datasets: {
count: { ru: 'Задач', en: 'Tasks' },
},
},
'chart.active_projects': {
title: { ru: 'Активные vs мёртвые проекты', en: 'Active vs dead projects' },
description: { ru: 'По активности за 14 / 30 дней', en: 'By activity in last 14 / 30 days' },
help: {
summary: {
ru: 'Сколько проектов активны, затухают или мертвы по последней активности',
en: 'How many projects are active, fading, or dead by recent activity',
},
details: {
ru: join([
'Что показывает:',
'Все ваши проекты разбиты на 4 группы по тому, когда в них последний раз что-то делали:',
'• Активен — были изменения за последние 14 дней',
'• Затухает — последние правки 14-30 дней назад',
'• Мёртв — никакой активности более 30 дней',
'• Без задач — проект создан, но задач в нём нет',
'',
'Зачем смотреть:',
'Мёртвые и пустые проекты захламляют боковое меню — их можно архивировать, чтобы было видно только живое. Затухающие — повод проверить, всё ли в порядке (закрыли тему или забыли).',
'',
'Drill-down:',
'Кликните по столбцу или сектору, чтобы посмотреть открытые задачи в проектах этой категории. Особенно полезно для «мёртвых» — увидите, что лежит в заброшенных проектах.',
]),
en: join([
'What it shows:',
'All your projects split into 4 groups based on when something was last done in them:',
'• Active — there were edits in the last 14 days',
'• Fading — last edits 14-30 days ago',
'• Dead — no activity for over 30 days',
'• Empty — project exists but has no tasks',
'',
'Why look at it:',
'Dead and empty projects clutter the sidebar — archive them to keep only the live ones in view. Fading projects are worth a check — finished or forgotten.',
'',
'Drill-down:',
"Click a bar or sector to see open tasks in projects of that category. Especially useful for \"dead\" — see what's lying in abandoned projects.",
]),
},
},
datasets: {
count: { ru: 'Проектов', en: 'Projects' },
},
labels: {
active: { ru: 'Активен', en: 'Active' },
fading: { ru: 'Затухает', en: 'Fading' },
dead: { ru: 'Мёртв', en: 'Dead' },
empty: { ru: 'Без задач', en: 'Empty' },
},
yAxisLabel: { ru: 'Проектов', en: 'Projects' },
},
// ===== Financial =====
'chart.income_expense_month': {
title: { ru: 'Доходы и расходы по месяцам', en: 'Income and expense per month' },
description: { ru: 'Суммы завершённых финансовых задач', en: 'Amounts of completed financial tasks' },
help: {
summary: {
ru: 'Суммы завершённых финансовых задач, сгруппированные по месяцам',
en: 'Completed financial task amounts grouped by month',
},
details: {
ru: 'Требует заполнения полей Amount и Transaction Type на задачах. Используется командами, ведущими лёгкий финансовый трекинг в TaskView (частый кейс у small business). Для достоверности проверьте KPI «Заполнено amount» — при низком покрытии картина неполная.',
en: 'Requires the Amount and Transaction Type fields to be filled on tasks. Used by teams running lightweight financial tracking inside TaskView (common small-business case). Cross-check with the "Amount coverage" KPI — a low coverage means the picture is incomplete.',
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
yAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
'chart.income_expense_per_project': {
title: { ru: 'Доходы и расходы по проектам', en: 'Income and expense per project' },
description: {
ru: 'Сколько каждый проект принёс и сколько потратил',
en: 'How much each project earned and spent',
},
help: {
summary: {
ru: 'Доходы и расходы каждого проекта рядом, чтобы сравнить напрямую',
en: 'Income and expense for each project side-by-side for direct comparison',
},
details: {
ru: join([
'Что показывает:',
'Для каждого проекта — два столбца рядом: зелёный (доходы) и красный (расходы). Берутся суммы из задач, где у вас отмечена сумма и тип транзакции.',
'',
'Чем отличается от «Топ проектов по сумме»:',
'Там показан общий оборот — высота столбца = доходы + расходы вместе. Здесь — сравнение двух величин напрямую: видно, где проект зарабатывает больше, чем тратит, а где наоборот.',
'',
'Как читать:',
'• Зелёный выше красного → проект прибыльный',
'• Красный выше зелёного → проект пока в минус',
'• Оба маленькие → слабая активность или вы редко заполняете финансовые поля',
'',
'Сортировка — по чистой прибыли по убыванию: прибыльные проекты сверху.',
'',
'Что нужно для попадания в график:',
'У задачи должна быть указана сумма и тип (доход/расход). Если вы не пользуетесь финансовыми полями TaskView — этот график будет пустым. Чтобы понять качество данных, смотрите карточку «Заполнено amount».',
'',
'Что НЕ включено:',
'• Задачи без указанной суммы',
'• Задачи без типа транзакции',
'• Реальная прибыль после налогов и комиссий — TaskView показывает только то, что вы ввели сами',
]),
en: join([
'What it shows:',
"For each project — two side-by-side bars: green (income) and red (expense). The numbers come from tasks where you've filled in an amount and transaction type.",
'',
'Difference from "Top projects by amount":',
'That chart shows total turnover — bar height = income + expense combined. This one compares the two values directly: you can see which project earns more than it spends and vice versa.',
'',
'How to read it:',
'• Green taller than red → project is profitable',
'• Red taller than green → project is in the red so far',
'• Both small → weak activity or you rarely fill in financial fields',
'',
'Sorted by net profit descending: profitable projects on top.',
'',
"What's needed to appear on the chart:",
"A task needs both an amount and a type (income/expense). If you don't use TaskView's financial fields, this chart will be empty. To gauge data quality, check the \"Amount coverage\" card.",
'',
'What is NOT included:',
'• Tasks without an amount',
'• Tasks without a transaction type',
'• Real profit after taxes and fees — TaskView only shows what you enter yourself',
]),
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
yAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
'chart.top_projects_by_amount': {
title: { ru: 'Топ проектов по сумме', en: 'Top projects by amount' },
description: { ru: 'Суммарный доход и расход в каждом проекте', en: 'Total income and expense per project' },
help: {
summary: {
ru: 'Проекты, отсортированные по суммарному финансовому обороту',
en: 'Projects ranked by total financial turnover',
},
details: {
ru: 'Где крутятся деньги. Income + Expense как стек показывает полный оборот, а не только прибыль — так видно и затратные проекты, а не только прибыльные. Для сравнения чистой прибыли смотрите разницу сегментов.',
en: 'Shows where the money flows. Stacking income and expense reveals total turnover, not just profit — so expensive projects are visible, not only profitable ones. To compare net profit, eyeball the segment gap.',
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
xAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
} satisfies Record<string, SectionLocale>
export type SectionLocaleId = keyof typeof sectionLocales
@@ -0,0 +1,68 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class PriorityMixOverTimeSection implements SectionBuilder {
readonly id = 'chart.priority_mix'
readonly group = 'productivity' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const bucket = this.pickBucket(ctx.range.from, ctx.range.to)
const rows = await ctx.repository.fetchPriorityMix(ctx.accessibleGoalIds, ctx.range, bucket)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.bucket),
labelKind: 'date',
datasets: [
{ id: 'high', label: loc.datasets!.high, values: rows.map(r => Number(r.high)), colorToken: 'danger', stack: 'priority' },
{ id: 'medium', label: loc.datasets!.medium, values: rows.map(r => Number(r.medium)), colorToken: 'warning', stack: 'priority' },
{ id: 'low', label: loc.datasets!.low, values: rows.map(r => Number(r.low)), colorToken: 'info', stack: 'priority' },
{ id: 'none', label: loc.datasets!.none, values: rows.map(r => Number(r.none)), colorToken: 'neutral', stack: 'priority' },
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private pickBucket(from: Date, to: Date): 'day' | 'week' | 'month' {
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)
if (days <= 14) return 'day'
if (days <= 120) return 'week'
return 'month'
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,82 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class ThroughputSection implements SectionBuilder {
readonly id = 'chart.throughput'
readonly group = 'productivity' as const
readonly allowedChartTypes = ['area', 'line', 'bar'] as const
readonly defaultChartType = 'area' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const bucket = this.pickBucket(ctx.range.from, ctx.range.to)
const rows = await ctx.repository.fetchThroughput(ctx.accessibleGoalIds, ctx.range, bucket)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.bucket),
labelKind: 'date',
datasets: [
{
id: 'created',
label: loc.datasets!.created,
values: rows.map(r => Number(r.created)),
colorToken: 'info',
},
{
id: 'completed',
label: loc.datasets!.completed,
values: rows.map(r => Number(r.completed)),
colorToken: 'success',
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private pickBucket(from: Date, to: Date): 'day' | 'week' | 'month' {
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)
if (days <= 14) return 'day'
if (days <= 120) return 'week'
return 'month'
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'date',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,73 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimeHistogramSection implements SectionBuilder {
readonly id = 'chart.cycle_time_histogram'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const row = await ctx.repository.fetchCycleTimeHistogram(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const labels = ['<1д', '13д', '37д', '714д', '1430д', '30+д']
const values = [
Number(row.bucket_0_1),
Number(row.bucket_1_3),
Number(row.bucket_3_7),
Number(row.bucket_7_14),
Number(row.bucket_14_30),
Number(row.bucket_30_plus),
]
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels,
labelKind: 'category',
datasets: [
{
id: 'tasks',
label: loc.datasets!.tasks,
values,
colorToken: 'primary',
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,63 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimePerProjectSection implements SectionBuilder {
readonly id = 'chart.cycle_time_per_project'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchCycleTimePerProject(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'median',
label: loc.datasets!.median,
values: rows.map(r => r.median_days === null || r.median_days === undefined ? 0 : Math.round(Number(r.median_days) * 10) / 10),
colorToken: 'info',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'days',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,93 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class OverdueByAgeSection implements SectionBuilder {
readonly id = 'chart.overdue_by_age'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const row = await ctx.repository.fetchOverdueByAge(ctx.accessibleGoalIds)
const loc = this.loc
const bucketKeys = ['bucket_1_3', 'bucket_4_7', 'bucket_8_14', 'bucket_15_plus'] as const
const labelTexts = bucketKeys.map(k => loc.labels![k])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: labelTexts.map(l => l.ru),
labelTexts,
labelKind: 'category',
datasets: [
{
id: 'overdue',
label: loc.datasets!.overdue,
values: [
Number(row.bucket_1_3),
Number(row.bucket_4_7),
Number(row.bucket_8_14),
Number(row.bucket_15_plus),
],
colorToken: 'danger',
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
const ranges: Array<[number, number | null]> = [
[1, 3],
[4, 7],
[8, 14],
[15, null],
]
const range = ranges[arg.index]
if (!range) return []
const [minDays, maxDays] = range
return ctx.repository.fetchOverdueTasksInRange(ctx.accessibleGoalIds, minDays, maxDays)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'category',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class StaleTasksSection implements SectionBuilder {
readonly id = 'chart.stale_tasks'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchStaleTasks(ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'stale',
label: loc.datasets!.stale,
values: rows.map(r => Number(r.stale)),
colorToken: 'warning',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const goalIds = arg.meta?.goalIds ?? []
const goalId = goalIds[arg.index]
if (!goalId || !ctx.accessibleGoalIds.includes(goalId)) return []
return ctx.repository.fetchStaleTasksInGoal(goalId, ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,130 @@
// SQL row types returned by each section's query.
// Field names match the SELECT aliases verbatim (snake_case where applicable).
// ===== KPI =====
export type CreatedTasksKpiRow = { count: number }
export type CompletedTasksKpiRow = { count: number }
export type OverdueKpiRow = { count: number }
export type CycleTimeKpiRow = { median: number | null }
export type TotalIncomeKpiRow = { total: number }
export type TotalExpenseKpiRow = { total: number }
export type PlannedIncomeKpiRow = { total: number }
export type PlannedExpenseKpiRow = { total: number }
export type NetProfitKpiRow = { income: number; expense: number }
export type AmountCoverageKpiRow = { total: number; with_amount: number }
// ===== Productivity =====
export type ThroughputSectionRow = {
bucket: string
created: number
completed: number
}
export type PriorityMixOverTimeSectionRow = {
bucket: string
high: number
medium: number
low: number
none: number
}
// ===== Workload =====
export type WorkloadByAssigneeSectionRow = {
user_id: number | null
user_name: string | null
high: number
medium: number
low: number
no_priority: number
}
export type BlockedByDependenciesSectionRow = {
goal_id: number
goal_name: string
blocked: number
}
export type TimeInKanbanStatusSectionRow = {
status_id: number | null
status_name: string | null
avg_days: number | null
task_count: number
}
export type AgingOpenTasksSectionRow = {
user_id: number | null
user_name: string | null
avg_age: number | null
max_age: number | null
task_count: number
}
// ===== Quality =====
export type OverdueByAgeSectionRow = {
bucket_1_3: number
bucket_4_7: number
bucket_8_14: number
bucket_15_plus: number
}
export type CycleTimeHistogramSectionRow = {
bucket_0_1: number
bucket_1_3: number
bucket_3_7: number
bucket_7_14: number
bucket_14_30: number
bucket_30_plus: number
}
export type StaleTasksSectionRow = {
goal_id: number
goal_name: string
stale: number
}
export type CycleTimePerProjectSectionRow = {
goal_id: number
goal_name: string
median_days: number | null
completed: number
}
// ===== Usage =====
export type StatusDistributionSectionRow = {
status_id: number | null
status_name: string | null
count: number
}
export type ActiveProjectsSectionRow = {
status_key: 'active' | 'fading' | 'dead' | 'empty'
count: number
}
// ===== Financial =====
export type IncomeExpenseMonthSectionRow = {
month: string
income: number
expense: number
}
export type IncomeExpensePerProjectSectionRow = {
goal_id: number
goal_name: string
income: number
expense: number
net: number
}
export type TopProjectsByAmountSectionRow = {
goal_id: number
goal_name: string
income: number
expense: number
}
@@ -0,0 +1,89 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
type StatusKey = 'active' | 'fading' | 'dead' | 'empty'
const COLOR_BY_STATUS: Record<StatusKey, 'success' | 'warning' | 'danger' | 'neutral'> = {
active: 'success',
fading: 'warning',
dead: 'danger',
empty: 'neutral',
}
export class ActiveProjectsSection implements SectionBuilder {
readonly id = 'chart.active_projects'
readonly group = 'usage' as const
readonly allowedChartTypes = ['bar', 'donut'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchActiveProjects(ctx.accessibleGoalIds)
const loc = this.loc
const labelTexts = rows.map(r => loc.labels![r.status_key])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: labelTexts.map(l => l.ru),
labelTexts,
labelKind: 'category',
datasets: [
{
id: 'count',
label: loc.datasets!.count,
values: rows.map(r => Number(r.count)),
meta: { statusKeys: rows.map(r => r.status_key) },
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
const firstRow = rows[0]
if (firstRow) {
payload.datasets[0].colorToken = COLOR_BY_STATUS[firstRow.status_key] ?? 'primary'
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
const statusKeys = arg.meta?.statusKeys ?? []
const statusKey = statusKeys[arg.index]
if (!statusKey || statusKey === 'empty') return []
return ctx.repository.fetchOpenTasksInActiveProjects(ctx.accessibleGoalIds, statusKey)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,64 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class StatusDistributionSection implements SectionBuilder {
readonly id = 'chart.status_distribution'
readonly group = 'usage' as const
readonly allowedChartTypes = ['donut', 'bar'] as const
readonly defaultChartType = 'donut' as const
readonly requiresGoalScope = true
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.scope.kind !== 'project') return null
const goalId = ctx.scope.goalId
if (!ctx.accessibleGoalIds.includes(goalId)) return null
const rows = await ctx.repository.fetchStatusDistribution(goalId, ctx.accessibleGoalIds)
const loc = this.loc
const topN = 6
let labels: string[]
let values: number[]
if (rows.length > topN) {
const top = rows.slice(0, topN - 1)
const rest = rows.slice(topN - 1)
labels = [...top.map(r => r.status_name ?? 'No status'), 'Другое']
values = [...top.map(r => Number(r.count)), rest.reduce((sum, r) => sum + Number(r.count), 0)]
} else {
labels = rows.map(r => r.status_name ?? 'No status')
values = rows.map(r => Number(r.count))
}
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels,
labelKind: 'category',
datasets: [
{
id: 'count',
label: loc.datasets!.count,
values,
},
],
unit: 'count',
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,80 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class AgingOpenTasksSection implements SectionBuilder {
readonly id = 'chart.aging_open_tasks'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar', 'line', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchAgingOpenTasks(ctx.accessibleGoalIds)
const loc = this.loc
const userIds = rows.map(r => r.user_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.user_name ?? 'Unknown'),
labelKind: 'category',
datasets: [
{
id: 'avg_age',
label: loc.datasets!.avg_age,
values: rows.map(r => r.avg_age === null || r.avg_age === undefined ? 0 : Math.round(Number(r.avg_age) * 10) / 10),
colorToken: 'warning',
meta: { userIds },
},
{
id: 'max_age',
label: loc.datasets!.max_age,
values: rows.map(r => r.max_age === null || r.max_age === undefined ? 0 : Math.round(Number(r.max_age) * 10) / 10),
colorToken: 'danger',
meta: { userIds },
},
],
unit: 'days',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const userIds = arg.meta?.userIds ?? []
const userId = userIds[arg.index]
if (!userId || ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchOpenTasksAssignedTo(ctx.accessibleGoalIds, userId)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class BlockedByDependenciesSection implements SectionBuilder {
readonly id = 'chart.blocked_by_deps'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchBlockedByDeps(ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'blocked',
label: loc.datasets!.blocked,
values: rows.map(r => Number(r.blocked)),
colorToken: 'danger',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const goalIds = arg.meta?.goalIds ?? []
const goalId = goalIds[arg.index]
if (!goalId || !ctx.accessibleGoalIds.includes(goalId)) return []
return ctx.repository.fetchBlockedTasksInGoal(goalId, ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,53 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TimeInKanbanStatusSection implements SectionBuilder {
readonly id = 'chart.time_in_kanban_status'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly requiresGoalScope = true
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.scope.kind !== 'project') return null
const goalId = ctx.scope.goalId
if (!ctx.accessibleGoalIds.includes(goalId)) return null
const rows = await ctx.repository.fetchTimeInKanbanStatus(goalId, ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.status_name ?? 'Без статуса'),
labelKind: 'category',
datasets: [
{
id: 'avg_days',
label: loc.datasets!.avg_days,
values: rows.map(r => r.avg_days === null || r.avg_days === undefined ? 0 : Math.round(Number(r.avg_days) * 10) / 10),
colorToken: 'info',
},
],
unit: 'days',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,117 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class WorkloadByAssigneeSection implements SectionBuilder {
readonly id = 'chart.workload_by_assignee'
readonly group = 'workload' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea', 'bar', 'line', 'area'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchWorkloadByAssignee(ctx.accessibleGoalIds)
const loc = this.loc
const userIds = rows.map(r => r.user_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.user_name ?? 'Unknown'),
labelKind: 'category',
datasets: [
{
id: 'high',
label: loc.datasets!.high,
values: rows.map(r => Number(r.high)),
colorToken: 'danger',
stack: 'priority',
meta: { userIds },
},
{
id: 'medium',
label: loc.datasets!.medium,
values: rows.map(r => Number(r.medium)),
colorToken: 'warning',
stack: 'priority',
meta: { userIds },
},
{
id: 'low',
label: loc.datasets!.low,
values: rows.map(r => Number(r.low)),
colorToken: 'info',
stack: 'priority',
meta: { userIds },
},
{
id: 'no_priority',
label: loc.datasets!.no_priority,
values: rows.map(r => Number(r.no_priority)),
colorToken: 'neutral',
stack: 'priority',
meta: { userIds },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const userIds = arg.meta?.userIds ?? []
const userId = userIds[arg.index]
if (!userId || ctx.accessibleGoalIds.length === 0) return []
const priorityByDataset: Record<string, number | 'null'> = {
high: 3,
medium: 2,
low: 1,
no_priority: 'null',
}
const priorityFilter = priorityByDataset[arg.datasetId]
if (priorityFilter === undefined) return []
return ctx.repository.fetchOpenTasksAssignedWithPriority(
ctx.accessibleGoalIds,
userId,
priorityFilter,
)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'category',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import { type } from 'arktype'
import type {
AnalyticsChartType,
AnalyticsPeriod,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionGroup,
} from 'taskview-api'
import type { AppUser } from '../../core/AppUser'
import type { AnalyticsRepository } from './AnalyticsRepository'
const positiveIntFromQuery = type('string | number')
.pipe((v) => Number(v))
.narrow((n, ctx) => Number.isInteger(n) && n > 0 ? true : ctx.mustBe('a positive integer'))
const nonNegativeIntFromQuery = type('string | number')
.pipe((v) => Number(v))
.narrow((n, ctx) => Number.isInteger(n) && n >= 0 ? true : ctx.mustBe('a non-negative integer'))
export const AnalyticsFetchSectionsArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'from?': 'string',
'to?': 'string',
'sections?': 'string',
})
export const AnalyticsDrillDownArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'from?': 'string',
'to?': 'string',
'bucket?': 'string',
'datasetId?': 'string',
'meta?': 'string',
'index?': nonNegativeIntFromQuery,
})
export const DrillDownMetaArkType = type({
'goalIds?': 'number[]',
'userIds?': 'number[]',
'statusKeys?': "('active' | 'fading' | 'dead' | 'empty')[]",
})
export type DrillDownMeta = typeof DrillDownMetaArkType.infer
export type AnalyticsRange = {
from: Date
to: Date
}
export type BuilderContext = {
appUser: AppUser
scope: AnalyticsScope
period: AnalyticsPeriod
range: AnalyticsRange
accessibleGoalIds: number[]
repository: AnalyticsRepository
}
export type SectionDrillDownArg = {
bucket: string
index: number
datasetId: string
meta?: DrillDownMeta
}
export type DrillDownTaskRow = {
id: number
description: string
goalId: number
goalName: string
complete: boolean
priorityId: number | null
endDate: string | null
date_creation: string
date_complete: string | null
}
export interface SectionBuilder {
readonly id: string
readonly group: AnalyticsSectionGroup
readonly allowedChartTypes: readonly AnalyticsChartType[]
readonly defaultChartType: AnalyticsChartType | null
readonly requiresGoalScope?: boolean
readonly cacheTtlSec?: number
build(ctx: BuilderContext): Promise<AnalyticsSection | null>
drillDown?(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]>
}
export type AnalyticsArgBuildSections = {
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
sectionIds?: string[]
}
export type AnalyticsArgDrillDown = {
sectionId: string
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
arg: SectionDrillDownArg
}
@@ -0,0 +1,53 @@
import type { Request, Response } from 'express';
import { ArkErrors } from 'arktype';
import { getApiTokensManager } from './ApiTokensManager';
import { ApiTokenArkTypeCreate, ApiTokenArkTypeDelete } from './types';
import { Database } from '../../modules/db';
export class ApiTokensController {
private get manager() { return getApiTokensManager(); }
create = async (req: Request, res: Response) => {
const data = ApiTokenArkTypeCreate(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.create(userId, data);
if (!result) return res.status(500).end();
return res.tvJson(result);
};
delete = async (req: Request, res: Response) => {
const data = ApiTokenArkTypeDelete(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.delete(data.id, userId);
return res.tvJson(result);
};
fetch = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.fetchAll(userId);
return res.tvJson(result);
};
fetchPermissions = async (_req: Request, res: Response) => {
const db = Database.getInstance();
const result = await db.query<{ id: number; name: string; description: string; permissionGroup: number }>(
`SELECT id, name, description, permission_group as "permissionGroup" FROM tv_auth.permissions WHERE permission_group <> 1 ORDER BY permission_group, id`
);
return res.tvJson(result?.rows ?? []);
};
}
@@ -0,0 +1,68 @@
import { randomBytes, createHash } from 'crypto';
import { ApiTokensRepository } from './ApiTokensRepository';
import { TOKEN_PREFIX, type ApiTokenArgCreate } from './types';
import type { ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
export type ApiTokenForClient = Omit<ApiTokensSchemaTypeForSelect, 'tokenHash'>;
export class ApiTokensManager {
public readonly repository: ApiTokensRepository;
constructor() {
this.repository = new ApiTokensRepository();
}
async create(userId: number, data: ApiTokenArgCreate): Promise<{ token: string; item: ApiTokenForClient } | null> {
const raw = randomBytes(32).toString('hex');
const fullToken = TOKEN_PREFIX + raw;
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : null;
const record = await this.repository.create({
userId,
name: data.name,
tokenHash,
allowedPermissions: data.allowedPermissions ?? [],
allowedGoalIds: data.allowedGoalIds ?? [],
expiresAt,
});
if (!record) return null;
return { token: fullToken, item: this.toClient(record) };
}
async delete(id: number, userId: number): Promise<boolean> {
return this.repository.delete(id, userId);
}
async fetchAll(userId: number): Promise<ApiTokenForClient[]> {
const tokens = await this.repository.fetchByUserId(userId);
return tokens.map((t) => this.toClient(t));
}
async validateToken(fullToken: string): Promise<ApiTokensSchemaTypeForSelect | null> {
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
const record = await this.repository.findByTokenHash(tokenHash);
if (!record) return null;
if (record.expiresAt && record.expiresAt < new Date()) return null;
this.repository.updateLastUsedAt(record.id).catch(() => {});
return record;
}
private toClient(token: ApiTokensSchemaTypeForSelect): ApiTokenForClient {
const { tokenHash, ...rest } = token;
return rest;
}
}
let _instance: ApiTokensManager | null = null;
export function getApiTokensManager(): ApiTokensManager {
if (!_instance) _instance = new ApiTokensManager();
return _instance;
}
@@ -0,0 +1,50 @@
import { and, eq } from 'drizzle-orm';
import { ApiTokensSchema, type ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
export class ApiTokensRepository {
private readonly db: Database;
constructor() {
this.db = Database.getInstance();
}
async create(data: { userId: number; name: string; tokenHash: string; allowedPermissions: string[]; allowedGoalIds: number[]; expiresAt: Date | null }): Promise<ApiTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(ApiTokensSchema).values(data).returning()
);
return result?.[0] ?? null;
}
async delete(id: number, userId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(ApiTokensSchema).where(
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId))
)
);
return !!result?.rowCount;
}
async fetchByUserId(userId: number): Promise<ApiTokensSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.userId, userId))
);
return result ?? [];
}
async findByTokenHash(tokenHash: string): Promise<ApiTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.tokenHash, tokenHash))
);
return result?.[0] ?? null;
}
async updateLastUsedAt(id: number): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(ApiTokensSchema)
.set({ lastUsedAt: new Date() })
.where(eq(ApiTokensSchema.id, id))
);
}
}
@@ -0,0 +1,27 @@
import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { ApiTokensController } from './ApiTokensController';
import { RejectApiTokenAuth } from './middlewares/RejectApiTokenAuth';
export default class ApiTokensRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly controller: ApiTokensController;
constructor() {
this.router = Router();
this.controller = new ApiTokensController();
this.initRoutes();
}
getRouter() {
return this.router;
}
initRoutes() {
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch);
this.router.post('', [IsLoggedIn, RejectApiTokenAuth], this.controller.create);
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete);
this.router.get('/permissions', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetchPermissions);
}
}
@@ -0,0 +1,8 @@
import type { NextFunction, Request, Response } from 'express';
export const RejectApiTokenAuth = (req: Request, res: Response, next: NextFunction) => {
if (req.appUser.isApiTokenAuth()) {
return res.status(403).end();
}
return next();
};
+18
View File
@@ -0,0 +1,18 @@
import { type } from 'arktype';
export const ApiTokenArkTypeCreate = type({
name: 'string',
'allowedPermissions?': 'string[]',
'allowedGoalIds?': 'number[]',
'expiresAt?': 'string|null',
});
export type ApiTokenArgCreate = typeof ApiTokenArkTypeCreate.infer;
export const ApiTokenArkTypeDelete = type({
id: 'number',
});
export type ApiTokenArgDelete = typeof ApiTokenArkTypeDelete.infer;
export const TOKEN_PREFIX = 'tvk_';
+136 -110
View File
@@ -1,4 +1,5 @@
import { compare, hashSync } from 'bcryptjs';
import { randomInt } from 'crypto';
import type { Request, Response } from 'express';
import jwt, { type Algorithm, decode } from 'jsonwebtoken';
import { z } from 'zod';
@@ -16,7 +17,11 @@ import {
import { generateString, isEmail, time } from '../../utils/helpers';
import EnEmailTemplate from './mail/confirm-email-en';
import RuEmailTemplate from './mail/confirm-email-ru';
import LoginCodeEmailTemplate from './mail/login-code-en';
import type { ExternalAuthUser } from './strategies/external-auth.types';
import { OrganizationRepository } from '../organizations/OrganizationRepository';
const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
export default class AuthController {
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
@@ -24,6 +29,15 @@ export default class AuthController {
private readonly jwtRefreshExp: string = process.env.REFRESH_LIFE_TIME!;
private readonly refreshTokenCookieName: string = 'taskview-refresh';
private readonly orgRepository: OrganizationRepository = new OrganizationRepository();
private async createPersonalWorkspace(userId: number, email: string, login: string) {
const slug = `org-${crypto.randomUUID().slice(0, 8)}`
const org = await this.orgRepository.create({ name: `${login}'s workspace`, slug }, userId, true)
if (org) {
await this.orgRepository.addMember(org.id, email, 'owner')
}
}
comparePasswords(pwd: string, hash: string): Promise<boolean> {
return new Promise((resolve) => {
@@ -79,15 +93,13 @@ export default class AuthController {
}
makeidLogin(length: number) {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
let result = ''
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const charactersLength = characters.length
for (let i = 0; i < length; i++) {
result += characters.charAt(randomInt(charactersLength))
}
return result;
return result
}
generateEmailConfirmCode() {
@@ -95,7 +107,8 @@ export default class AuthController {
}
generateLoginCode() {
return `${this.makeidLogin(12)}:${Date.now()}`.toLocaleLowerCase();
const code = String(randomInt(100000, 1000000));
return `${code}:${Date.now()}`;
}
/**
* Register user by email and send login code to the email
@@ -118,7 +131,6 @@ export default class AuthController {
const code = this.generateLoginCode();
$logger.info(data.data, `[AuthController:sendLoginCode] we got data for send login code`);
let userData = await req.appUser.authManager.repository.getUserByLogin(email, isEmail(email));
@@ -129,8 +141,6 @@ export default class AuthController {
}
if (!userData) {
$logger.info(`[AuthController:sendLoginCode] trying to register user ${email}`);
const password = this.makeidLogin(7),
login = this.makeidLogin(7);
@@ -142,42 +152,53 @@ export default class AuthController {
confirmEmailCode: '',
});
if (!id) {
$logger.error(`Can not register user ${email}`);
$logger.error(`Can not register user`);
return res.status(500).end();
}
$logger.info(`[AuthController:sendLoginCode] user registered ${email}`);
await this.createPersonalWorkspace(id, email, login)
$logger.info(`[AuthController:sendLoginCode] user registered`);
}
userData = await req.appUser.authManager.repository.getUserByLogin(email, isEmail(email));
if (!userData) {
$logger.error(`Can not fetch user after registration by code ${email}`);
$logger.error(`Can not fetch user after registration by code`);
return res.status(500).end();
}
const lastUpdate = userData.remember_token?.split(':')[1];
const now = Date.now();
const RESEND_COOLDOWN_MS = 60 * 1000;
if (!lastUpdate || (lastUpdate && now - +lastUpdate > 60 * 1000)) {
$logger.info(`[AuthController:sendLoginCode] updating login code for user ${email}`);
await req.appUser.authManager.repository.updateLoginCode(code, email);
$logger.info(`[AuthController:sendLoginCode] sending code by email to ${email}`);
await this.sendCodeByEmail(code.split(':')[0], email);
if (lastUpdate && now - +lastUpdate < RESEND_COOLDOWN_MS) {
return res.status(429).send({
message: 'Please wait before requesting another code.',
retryAfter: Math.ceil((RESEND_COOLDOWN_MS - (now - +lastUpdate)) / 1000),
});
}
$logger.info(`[AuthController:sendLoginCode] updating login code for user`);
await req.appUser.authManager.repository.updateLoginCode(code, email);
$logger.info(`[AuthController:sendLoginCode] sending code by email to`);
this.sendCodeByEmail(code.split(':')[0], email)
.then((ok) => {
if (!ok) $logger.error({ email }, 'Failed to send login code email');
})
.catch((err) => $logger.error({ err, email }, 'Failed to send login code email'));
return res.status(200).end();
};
async sendCodeByEmail(code: string, email: string) {
const text = `Your TaskView verification code is ${code}\n\nUse this code to sign in. The code expires in 5 minutes.\n\nIf you didn't request this code, ignore this email.`;
const html = LoginCodeEmailTemplate.replace('{code}', code);
return await Email.send({
text: null,
text,
to: email,
subject: 'Code',
subject: `Your TaskView code: ${code}`,
from: process.env.SMTP_FROM_EMAIL as string,
attachment: [{ data: `<span>Code <strong>${code}</strong></span>`, alternative: true }],
attachment: [{ data: html, alternative: true }],
});
}
@@ -206,10 +227,12 @@ export default class AuthController {
});
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}`);
$logger.error(`Can not register user`);
return res.status(500).send(`Can not register user`);
}
await this.createPersonalWorkspace(id, user.email, login)
userData = await req.appUser.authManager.repository.getUserByLogin(
user.email,
isEmail(user.email)
@@ -217,7 +240,7 @@ export default class AuthController {
}
if (!userData) {
$logger.error(`Can not find user ${user.email} after registration`);
$logger.error(`Can not find user after registration`);
return res.status(500).send(`Can not find user ${user.email} after registration`);
}
@@ -226,7 +249,7 @@ export default class AuthController {
const result = await req.appUser.authManager.repository.updateLoginCode(code, userData.email);
if (!result) {
$logger.error(`Can not update login code for user ${userData.email}`);
$logger.error(`Can not update login code for user`);
return res.status(500).send(`Can not update login code for user`);
}
@@ -252,19 +275,42 @@ export default class AuthController {
return res.redirect(`${process.env.APP_URL}/login?tokens=${encodedAuthData}`);
}
private parseLifetimeToMs(lifetime: string): number {
const match = lifetime.match(/^(\d+)([smhdw])$/)
if (!match) return 1000 * 60 * 60 * 24 * 30
const value = parseInt(match[1])
const unit = match[2]
const multipliers: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
}
return value * (multipliers[unit] || 86_400_000)
}
setRefreshToken = async (res: Response, refreshToken: string) => {
res.cookie(this.refreshTokenCookieName, refreshToken, {
httpOnly: true,
secure: true,
sameSite: "none",
maxAge: 1000 * 60 * 60 * 24 * 30,
maxAge: this.parseLifetimeToMs(this.jwtRefreshExp),
});
}
clearRefreshToken = (res: Response) => {
res.clearCookie(this.refreshTokenCookieName, {
httpOnly: true,
secure: true,
sameSite: "none",
});
}
loginByCode = async (req: Request, res: Response) => {
const schema = z.object({
email: z.string().email().toLowerCase(),
code: z.string().min(12).toLowerCase(),
email: z.string().trim().email().toLowerCase(),
code: z.string().trim().regex(/^\d{6}$/, '6-digit code'),
});
const data = schema.safeParse(req.body);
@@ -291,33 +337,28 @@ export default class AuthController {
return res.status(400).send({ message: 'Invalid code' });
}
if (tokenFromDb[1] && Date.now() - +tokenFromDb[1] > 60 * 1000) {
if (tokenFromDb[1] && Date.now() - +tokenFromDb[1] > LOGIN_CODE_TTL_MS) {
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);
if (!tokenRowId) {
// Invalidate code immediately to prevent replay attacks
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
req.headers['user-agent']
);
if (!sessionId) {
return res.status(500).end();
}
const tokens = this.getTokens({
id: tokenRowId,
id: sessionId,
userData,
} as const);
const updateResult = await req.appUser.authManager.jwtStorage.updateTokens(
tokens.access,
tokens.refresh,
tokenRowId
);
if (!updateResult) {
$logger.error(`Can not update tokens in JWT Storage for user ${userData.id} and rowId ${tokenRowId}`);
}
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
await this.setRefreshToken(res, tokens.refresh);
return res.json(tokens);
@@ -335,7 +376,6 @@ export default class AuthController {
const userData = await req.appUser.authManager.repository.getUserByLogin(login, isEmail(login));
if (!userData) {
$logger.info(`Can not find user with login ${login}`);
return res.status(400).end();
}
@@ -345,26 +385,20 @@ export default class AuthController {
const valid = await this.comparePasswords(password, userData.password);
if (valid) {
const tokenRowId = await req.appUser.authManager.jwtStorage.initTokenRecord(userData.id);
if (!tokenRowId) {
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
req.headers['user-agent']
);
if (!sessionId) {
return res.status(500).end();
}
const tokens = this.getTokens({
id: tokenRowId,
id: sessionId,
userData,
} as const);
const updateResult = await req.appUser.authManager.jwtStorage.updateTokens(
tokens.access,
tokens.refresh,
tokenRowId
);
if (!updateResult) {
$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);
@@ -381,7 +415,7 @@ export default class AuthController {
email = (email as string).toLowerCase();
if (!isEmail(email)) {
return res.status(40).end();
return res.status(400).end();
}
password = hashSync(password, 10);
@@ -404,6 +438,8 @@ export default class AuthController {
return res.status(500).end();
}
await this.createPersonalWorkspace(id, email, login)
let emailTemplate: string = '';
let confirmEmailBody: string = '';
const acceptLanguage = req.headers['accept-language'];
@@ -416,7 +452,7 @@ export default class AuthController {
emailTemplate = EnEmailTemplate;
}
const confirmUrl = `https://apitaskview.handscream.com/module/auth/confirm/email/${confirmEmailCode}/login/${login}`;
const confirmUrl = `${process.env.APP_URL}/module/auth/confirm/email/${confirmEmailCode}/login/${login}`;
if (emailTemplate) {
confirmEmailBody = emailTemplate.replace('{link}', confirmUrl);
@@ -485,48 +521,44 @@ export default class AuthController {
return res.status(400).send();
}
// Always respond 200 to avoid user-enumeration. SMTP send runs in background,
// so the response never races the browser preflight/timeout.
const respond = () => res.status(200).send({ sent: true });
const userData = await req.appUser.authManager.repository.getUserByLogin(data.data.email, true);
if (!userData) {
return res.status(400).send();
}
if (!this.canSendRemindEmail(userData)) {
return res.status(400).send();
if (!userData || !this.canSendRemindEmail(userData)) {
return respond();
}
const code = generateString(18);
const seconds = time();
const result = await req.appUser.authManager.repository.setReminderCodeAndTime(userData.email, code, seconds);
if (!result) {
$logger.error(`Can not set remind_code and time for user ${userData.email}`);
return res.status(500).send();
$logger.error('Can not set remind_code and time for user');
return respond();
}
const remindPasswordUrl = `${process.env.APP_URL}/login/?resetCode=${code}&login=${userData.login}`;
const remindPasswordBody = `<html>
<body>
<p><a href="${remindPasswordUrl}"> Reset password link! </a></p>
</body>
</html>`;
const sendResult = await Email.send({
Email.send({
text: null,
to: userData.email, //'gimanhead@gmail.com',
to: userData.email,
subject: 'Remind password!',
from: process.env.SMTP_FROM_EMAIL as string,
attachment: [{ data: remindPasswordBody, alternative: true }],
});
// const sendResult = await Email.send('', 'Remind password', 'gimanhead@gmail.com', remindPasswordBody);
})
.then((ok) => {
if (!ok) $logger.error({ to: userData.email }, 'Failed to send remind password email');
})
.catch((err) => $logger.error({ err, to: userData.email }, 'Failed to send remind password email'));
if (sendResult) {
return res.status(200).send({ sent: true });
}
return res.status(500).send();
return respond();
};
changeRemindedPassword = async (req: Request, res: Response) => {
@@ -551,8 +583,6 @@ export default class AuthController {
const passwordHash = hashSync(parsedData.data.password, 10);
$logger.debug(`Update ${passwordHash} for ${userData.id}`);
const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userData.id);
$logger.debug(`Update result ${result}`);
@@ -560,28 +590,25 @@ export default class AuthController {
return res.status(500).send();
}
await req.appUser.authManager.repository.setReminderCodeAndTime(userData.email, null, null);
return res.status(200).send({ reset: true });
};
logout = async (req: Request, res: Response) => {
this.setRefreshToken(res, '');
this.clearRefreshToken(res);
const result = req.headers['authorization']?.match(/Bearer\s(\S+)/);
const sessionId = req.appUser.getTokenId();
const userId = req.appUser.getUserData()?.id;
if (!result) {
if (!sessionId || !userId) {
return res.status(401).send({ message: 'Unauthorized' });
}
const tokenId = req.appUser.getTokenId();
if (!tokenId) {
return res.status(401).send({ message: 'Unauthorized' });
}
const deleteResult = await req.appUser.authManager.jwtStorage.deleteTokens(tokenId, result['1']);
const deleteResult = await req.appUser.authManager.sessionStorage.deleteSession(sessionId, userId);
if (!deleteResult) {
return res.status(500).send({ message: 'Failed to revoke token' });
return res.status(500).send({ message: 'Failed to delete session' });
}
return res.status(204).end();
@@ -606,21 +633,20 @@ export default class AuthController {
const payload = await AuthController.validateTokens(refreshToken);
if (!payload) {
$logger.info('Refresh token validation failed');
this.clearRefreshToken(res);
return res.status(400).end();
}
const isActive = await req.appUser.authManager.sessionStorage.isSessionActive(payload.id);
if (!isActive) {
this.clearRefreshToken(res);
return res.status(401).end();
}
const newTokens = this.getTokens(payload);
const update = await req.appUser.authManager.jwtStorage.updateTokens(
newTokens.access,
newTokens.refresh,
payload.id
);
if (!update) {
$logger.error(`Can not refresh tokens for ${payload}`);
return res.status(500).end();
}
await req.appUser.authManager.sessionStorage.updateLastUsed(payload.id);
await this.setRefreshToken(res, newTokens.refresh);
@@ -644,12 +670,12 @@ export default class AuthController {
});
if (!sendResult) {
$logger.error(`Can not send account deletion code for user ${userId}`);
$logger.error(`Can not send account deletion code for user`);
}
const insertCode = await req.appUser.authManager.repository.addDeleteAccountCode(code, userId);
if (!insertCode) {
$logger.error(`Can not insert account deletion code for user ${userId}`);
$logger.error(`Can not insert account deletion code for user`);
return res.status(500).end();
}
return res.status(200).end();
+3 -3
View File
@@ -1,15 +1,15 @@
import type { AppUser } from '../../core/AppUser';
import AuthModel from './AuthModel';
import JwtStorage from './JwtStorage';
import SessionStorage from './SessionStorage';
export class AuthManager {
protected readonly user: AppUser;
public readonly repository: AuthModel;
public readonly jwtStorage: JwtStorage;
public readonly sessionStorage: SessionStorage;
constructor(user: AppUser) {
this.user = user;
this.repository = new AuthModel();
this.jwtStorage = new JwtStorage();
this.sessionStorage = new SessionStorage();
}
}

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