Compare commits

..

110 Commits

Author SHA1 Message Date
Nikolai Giman 10a0d01719 chore: version 2026-06-14 17:29:03 +02:00
Nikolai Giman eb0aba02e9 chore: version 2026-06-14 17:26:02 +02:00
Nikolai Giman 9beb2ff9e5 fix: recurring task 2026-06-13 22:55:03 +02:00
Nikolai Giman 76b38b8ac1 feat: recur tasks 2026-06-08 00:11:56 +02:00
Nikolai Giman b7b91e16d3 Merge pull request #65 from Gimanh/feat/sprints
feat: sprints
2026-06-05 09:12:52 +02:00
Nikolai Giman d14398faae fix: middlewares for sprint 2026-06-04 22:12:10 +02:00
Nikolai Giman 02b9f8cd30 feat: calendar settings 2026-06-04 03:43:39 +02:00
Nikolai Giman 6f8e01b446 fix: confirm closing 2026-05-31 23:17:52 +02:00
Nikolai Giman 673cc868f8 feat: sprints 2026-05-31 22:50:03 +02:00
Nikolai Giman eb7565debb Merge pull request #64 from Gimanh/fix/63
fix: #63
2026-05-28 22:02:30 +02:00
Nikolai Giman e09b6e2c0f fix: #63 2026-05-28 22:01:43 +02:00
Nikolai Giman 1dd779555a Merge pull request #61 from Gimanh/chore/version-1-44-1
chore: version
2026-05-27 19:24:28 +02:00
Nikolai Giman 20fc40d597 chore: version 2026-05-27 19:23:14 +02:00
Nikolai Giman 913690d230 Merge pull request #60 from Gimanh/fix/59
fix: https://github.com/Gimanh/taskview-community/issues/59
2026-05-27 19:10:46 +02:00
Nikolai Giman b950b15061 fix: https://github.com/Gimanh/taskview-community/issues/59 2026-05-27 19:10:11 +02:00
Nikolai Giman f5a76b3b2a Merge pull request #58 from Gimanh/chore/vesrion-1-44
chore: version
2026-05-25 21:36:34 +02:00
Nikolai Giman 0ba43489b4 chore: version 2026-05-25 21:34:27 +02:00
Nikolai Giman f143d536b3 Merge pull request #57 from Gimanh/fix/priority-sort
fix: #55
2026-05-24 22:04:34 +02:00
Nikolai Giman 8f091ac411 fix: #55 2026-05-24 22:00:56 +02:00
Nikolai Giman acb86d1286 Merge pull request #56 from Gimanh/feat/ui-improvements
Feat/UI improvements
2026-05-24 17:10:35 +02:00
Nikolai Giman affe83751b feat: dnd for task fields and analytics 2026-05-24 17:09:45 +02:00
Nikolai Giman c200826d31 fix: org email to lower case and ui fixes 2026-05-17 14:35:37 +02:00
Nikolai Giman 065483f701 wip: new charts 2026-05-17 14:08:19 +02:00
Nikolai Giman e323a6e06e Merge pull request #54 from Gimanh/chore/version-1-43
chore: 1.43.0
2026-05-16 21:34:33 +02:00
Nikolai Giman 8972c70700 chore: 1.43.0 2026-05-16 21:28:30 +02:00
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
665 changed files with 58047 additions and 1409 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.
+40 -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,42 @@ 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
# Analytics (optional)
# Comma-separated list of section IDs to enable in the analytics page.
# When unset, all sections are available. When set, only the listed IDs are
# available — in the order they appear here.
# Example:
# ANALYTICS_SECTIONS=kpi.total_income,kpi.total_expense,chart.income_expense_month,chart.income_expense_per_project
# 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
@@ -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
+15 -3
View File
@@ -1,11 +1,12 @@
{
"name": "taskview-ce-api-server",
"version": "1.20.4",
"version": "1.45.0",
"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",
@@ -23,10 +24,12 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/luxon": "^3.7.1",
"@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",
@@ -40,13 +43,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",
@@ -54,18 +59,25 @@
"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",
"luxon": "^3.7.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",
"rrule": "^2.8.1",
"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": {
+4 -2
View File
@@ -7,8 +7,10 @@ config({ override: true });
const valid = AppEnvSchema.safeParse(process.env);
if (!valid.success) {
console.log(valid);
console.log(valid.error);
const details = valid.error.issues
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('\n');
console.error(`Invalid environment variables:\n${details}`);
throw new Error('Invalid environment variables');
}
+14 -25
View File
@@ -1,23 +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",
"https://appleid.apple.com"
]),
...(process.env.CORS_ALLOWED_ORIGINS?.split(',') || []),
]);
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
export default class App {
public app: express.Application;
@@ -30,6 +19,7 @@ export default class App {
this.extendApp();
this.initializeMiddlewares();
registerAllEventHandlers();
this.initializeRoutes();
this.app.use(errorHandler);
this.app.use(passport.initialize());
@@ -50,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() {
@@ -74,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();
});
}
}
+52
View File
@@ -7,7 +7,16 @@ 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 { UiPreferencesManager } from '../tv-modules/ui-preferences/UiPreferencesManager';
import { SprintsManager } from '../tv-modules/sprints/SprintsManager';
import { RecurrenceManager } from '../tv-modules/recurrence/RecurrenceManager';
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
@@ -22,10 +31,22 @@ 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;
public readonly uiPreferencesManager: UiPreferencesManager;
public readonly sprintsManager: SprintsManager;
public readonly recurrenceManager: RecurrenceManager;
constructor(userData?: UserJwtPayload) {
this.userData = userData;
@@ -40,6 +61,15 @@ 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);
this.uiPreferencesManager = new UiPreferencesManager(this);
this.sprintsManager = new SprintsManager(this);
this.recurrenceManager = new RecurrenceManager(this);
}
getTokenId(): number | undefined {
@@ -69,4 +99,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>;
}
+5
View File
@@ -3,6 +3,11 @@ import { $logger } from '../modules/logget';
export class Email {
static async send(data: Message | MessageHeaders): Promise<boolean> {
if (!process.env.SMTP_HOST) {
$logger.warn('SMTP is not configured — skipping email send');
return false;
}
try {
const client = new SMTPClient({
user: process.env.SMTP_USERNAME,
+69
View File
@@ -0,0 +1,69 @@
import { EventEmitter } from 'node:events';
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, 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 };
'sprint.created': { sprint: SprintsSchemaTypeForSelect; initiatorId: number };
'sprint.updated': { sprint: SprintsSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
'sprint.activated': { sprintId: number; goalId: number; initiatorId: number | null };
'sprint.reviewStarted': { sprintId: number; goalId: number; initiatorId: number };
'sprint.completed': { sprintId: number; goalId: number; initiatorId: number };
'sprint.paused': { sprintId: number; goalId: number; initiatorId: number };
'sprint.resumed': { sprintId: number; goalId: number; initiatorId: number };
'sprint.deleted': { sprintId: number; goalId: number; initiatorId: number };
'task.assignedToSprint': {
taskId: number;
sprintId: number | null;
prevSprintId: number | null;
goalId: number;
initiatorId: number;
};
'recurrence.created': { rule: RecurrenceRulesSchemaTypeForSelect; initiatorId: number };
'recurrence.updated': { rule: RecurrenceRulesSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
'recurrence.paused': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.resumed': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.ended': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.deleted': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.instanceSkipped': { ruleId: number; goalId: number; date: string; 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();
+5
View File
@@ -45,6 +45,11 @@ export class FetchTasksQueryBuilder {
args.push(this.data.filters.priority);
}
if (this.data.filters.sprintId !== undefined) {
query += ` AND t.sprint_id = $${args.length + 1}`;
args.push(this.data.filters.sprintId);
}
if (this.limit !== null) {
if (this.data.showCompleted === 0) {
query += ` AND t.complete = $${args.length + 1}`;
+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;
}
}
+80 -6
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, FetchPermissionsForGoalByUserParams, 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';
@@ -23,7 +33,18 @@ export class GoalPermissionsRepository {
}
async fetchPermissionsForGoal(goalId: number, user: AppUser): Promise<GoalPermissionItemsFromDb> {
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [goalId]);
const userData = user.getUserData();
if (!userData) return [];
return this.fetchPermissionsForGoalByUser({ goalId, userId: userData.id, email: userData.email });
}
/**
* Permission set of an arbitrary user for a goal, without an AppUser/request
* context — used by background workers (e.g. deadline notifications) that
* must gate content per recipient.
*/
async fetchPermissionsForGoalByUser(params: FetchPermissionsForGoalByUserParams): Promise<GoalPermissionItemsFromDb> {
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [params.goalId]);
if (goalInfo.rows.length === 0) {
return [];
@@ -32,19 +53,19 @@ export class GoalPermissionsRepository {
let query = '';
let args: any = [];
if (goalInfo.rows[0].owner === user.getUserData()?.id) {
if (goalInfo.rows[0].owner === params.userId) {
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;`;
args = [user.getUserData()?.email, goalId];
args = [params.email, params.goalId];
}
const result = await this.db.query<GoalPermissionItemsFromDb[number]>(query, args);
@@ -59,4 +80,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}"`);
}
}
+28
View File
@@ -0,0 +1,28 @@
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';
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
new RealtimeDispatcher(),
new WebhooksDispatcher(),
new TimeTrackingDispatcher(),
new SprintsDispatcher(),
new RecurrenceDispatcher(),
];
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()
}
+335
View File
@@ -305,5 +305,340 @@
"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."
]
},
"44": {
"version": "1.51.0",
"name": "Release 1.51.0",
"releaseDate": "20260517",
"scripts": [
"/1.51.0/0.tasks_to_tags_indexes.sql"
],
"description": [
"Added btree indexes on tasks.tasks_to_tags(task_id) and (tag_id) — FK constraints do not create indexes in Postgres, so anti-joins (NOT EXISTS for untagged tasks) and inner joins from tasks were seq-scanning. Needed for tag-financial analytics charts."
]
},
"45": {
"version": "1.52.0",
"name": "Release 1.52.0",
"releaseDate": "20260517",
"scripts": [
"/1.52.0/0.ui_preferences.sql"
],
"description": [
"Added tv_auth.ui_preferences table: per-user JSONB storage of UI customization choices (which analytics charts and task detail fields are shown and in what order). One row per user keyed by user_id."
]
},
"46": {
"version": "1.53.0",
"name": "Release 1.53.0",
"releaseDate": "20260528",
"scripts": [
"/1.53.0/0.create-sprints.sql",
"/1.53.0/1.create-sprint-task-outcomes.sql",
"/1.53.0/2.create-sprint-user-capacity.sql",
"/1.53.0/3.create-sprint-retros.sql",
"/1.53.0/4.alter-tasks-add-sprint.sql",
"/1.53.0/5.alter-notifications-add-sprint-id.sql",
"/1.53.0/6.add-sprint-permissions.sql",
"/1.53.0/7.alter-goals-add-estimate-unit.sql",
"/1.53.0/8.alter-sprint-task-outcomes-add-estimate.sql",
"/1.53.0/9.create-sprint-cadence.sql",
"/1.53.0/all-triggers.sql"
],
"description": [
"Added tasks.goals.estimate_unit ('hours' | 'points', default 'points') — per-project unit for sprint estimates & capacity display.",
"Sprints feature. tasks.sprints: sprint container with lifecycle draft/planned/active/review/completed; one active-or-review sprint per project enforced by a partial unique index.",
"tasks.sprint_task_outcomes: per-task result decided at close (accepted/carried-over/dropped/incomplete). tasks.sprint_user_capacity: per-user capacity. tasks.sprint_retros: went well/bad/action items. Sprint capacity column is `capacity` (story points, unitless).",
"Extended tasks.tasks with sprint_id (FK SET NULL) and estimate_value (task estimate in story points, unitless), and tasks.notifications with sprint_id. Sprint availability is governed by the sprint_can_view permission (RBAC), not a per-project flag. Sprint burndown reuses the existing trigger-maintained tasks.date_complete column (no new completion column added).",
"Added permission group 5 'sprints' and permissions sprint_can_view, sprint_can_manage, sprint_can_assign_tasks, sprint_can_view_analytics; backfilled grants for existing projects' editor/executor roles.",
"all-triggers.sql: re-applies all triggers idempotently (drop+create) and updates tasks.add_roles_and_permissions() so NEW projects grant sprint permissions to editor (all four) and executor (view + assign).",
"Added tasks.sprint_task_outcomes.estimate_value — story-points snapshot captured at sprint close so a closed sprint's velocity is frozen (unaffected by later estimate edits or moving the task to another sprint).",
"Added tasks.sprint_cadence — per-project sprint auto-generation config (Linear-style): enabled, length_days, start_date anchor, lookahead, name_template, last_generated_date. A background job keeps current + lookahead future sprints created."
]
},
"47": {
"version": "1.54.0",
"name": "Release 1.54.0",
"releaseDate": "20260605",
"scripts": [
"/1.54.0/0.create-recurrence-rules.sql",
"/1.54.0/1.create-recurrence-skip-dates.sql",
"/1.54.0/2.create-recurrence-template-assignees.sql",
"/1.54.0/3.create-recurrence-template-tags.sql",
"/1.54.0/4.alter-tasks-add-recurrence.sql"
],
"description": [
"Recurring tasks (lazy materialization, Todoist-like UX). tasks.recurrence_rules: one row per series — RFC 5545 RRULE string, floating wall-clock dtstart, IANA timezone, state active/paused/ended, last_instance_date + instances_created drive next-occurrence computation. Template snapshot columns (description/note/priority/status/list/duration) let the series outlive its origin task.",
"tasks.recurrence_skip_dates: per-rule skipped occurrence dates. tasks.recurrence_template_assignees / recurrence_template_tags: snapshot of assignees (collaboration.users) and tags copied onto each materialized instance.",
"Extended tasks.tasks with recurrence_rule_id (FK SET NULL — instances survive rule deletion) and recurrence_instance_date. Partial unique index on (recurrence_rule_id, recurrence_instance_date) makes instance materialization idempotent under concurrent triggers/reconciliation.",
"Exactly one open instance per series exists at any time: completing it materializes the next occurrence (event-driven, O(1)); a nightly pg-boss reconcile job re-creates the open instance only for series stalled by a crash. No new permissions — recurrence editing is gated by existing task_can_edit_deadline."
]
},
"48": {
"version": "1.54.1",
"name": "Release 1.54.1",
"releaseDate": "20260613",
"scripts": [
"/1.54.1/0.add-recurrence-template-task-unique.sql"
],
"description": [
"Partial unique index on tasks.recurrence_rules(template_task_id) WHERE state != 'ended' — one live series per origin task. DB-level backstop for the createRule race where two concurrent POSTs both pass the recurrenceRuleId == null check and create two rules for the same task (the orphaned rule would materialize a duplicate card via the nightly reconcile job)."
]
},
"49": {
"version": "1.54.2",
"name": "Release 1.54.2",
"releaseDate": "20260613",
"scripts": [
"/1.54.2/0.add-recurrence-has-time.sql"
],
"description": [
"Added tasks.recurrence_rules.has_time — explicit flag for whether a series is anchored to a wall-clock time or is date-only. Previously the code inferred 'no time' from a midnight dtstart, which silently collapsed an explicit 00:00 series into date-only. Backfill (has_time = dtstart::time <> '00:00:00') reproduces the old inference so existing series keep their behavior; new series carry the flag through from the origin task's start_time (null = date-only, set = timed, including midnight)."
]
}
}
@@ -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,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';
@@ -0,0 +1,5 @@
CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_task_id
ON tasks.tasks_to_tags(task_id);
CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_tag_id
ON tasks.tasks_to_tags(tag_id);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS tv_auth.ui_preferences (
user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE,
prefs JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS tasks.sprints (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
goal_text VARCHAR(2000),
goal_achieved BOOLEAN,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
start_date DATE NOT NULL,
end_date DATE NOT NULL,
capacity NUMERIC(10, 2),
paused_at TIMESTAMP,
creator_id INTEGER REFERENCES tv_auth.users(id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
review_started_at TIMESTAMP,
completed_at TIMESTAMP,
CONSTRAINT sprint_dates_valid CHECK (end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_sprints_goal_status ON tasks.sprints(goal_id, status);
-- At most one active OR in-review sprint per project, enforced at the DB level
-- (no race). 'review' also holds the slot: the previous sprint must be closed
-- before the next can start.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_sprint_per_goal
ON tasks.sprints(goal_id) WHERE status IN ('active', 'review');
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS tasks.sprint_task_outcomes (
sprint_id INTEGER NOT NULL REFERENCES tasks.sprints(id) ON DELETE CASCADE,
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
outcome VARCHAR(20) NOT NULL,
-- 'accepted' | 'carried-over' | 'dropped' | 'incomplete'
carried_over_to INTEGER REFERENCES tasks.sprints(id) ON DELETE SET NULL,
decided_by INTEGER REFERENCES tv_auth.users(id),
decided_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (sprint_id, task_id)
);
CREATE INDEX IF NOT EXISTS idx_sprint_outcomes_task ON tasks.sprint_task_outcomes(task_id);
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS tasks.sprint_user_capacity (
sprint_id INTEGER NOT NULL REFERENCES tasks.sprints(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
hours NUMERIC(10, 2) NOT NULL,
PRIMARY KEY (sprint_id, user_id)
);
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS tasks.sprint_retros (
sprint_id INTEGER PRIMARY KEY REFERENCES tasks.sprints(id) ON DELETE CASCADE,
went_well TEXT,
went_bad TEXT,
action_items TEXT,
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
edited_by INTEGER REFERENCES tv_auth.users(id)
);
@@ -0,0 +1,6 @@
ALTER TABLE tasks.tasks
ADD COLUMN IF NOT EXISTS sprint_id INTEGER REFERENCES tasks.sprints(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS estimate_value NUMERIC(10, 2);
-- estimate_value: task estimate in story points (unitless).
CREATE INDEX IF NOT EXISTS idx_tasks_sprint_id ON tasks.tasks(sprint_id) WHERE sprint_id IS NOT NULL;
@@ -0,0 +1,4 @@
ALTER TABLE tasks.notifications
ADD COLUMN IF NOT EXISTS sprint_id INTEGER REFERENCES tasks.sprints(id) ON DELETE CASCADE;
-- Lets the client deep-link a notification to "open sprint N" without
-- stuffing metadata into the body.
@@ -0,0 +1,35 @@
-- Permission group 5 = sprints (shown in the role editor; group 1 'app' is hidden there)
INSERT INTO tv_auth.permissions_group (id, name)
VALUES (5, 'sprints')
ON CONFLICT (id) DO NOTHING;
-- New sprint permissions
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES
('sprint_can_view', 'View sprints of the project', 5,
'{"en": "View sprints. See the project sprints, their dates and contents.", "ru": "Просмотр спринтов. Видеть спринты проекта, их даты и состав."}'::jsonb),
('sprint_can_manage', 'Create, edit, activate, close sprints, save retro', 5,
'{"en": "Manage sprints. Create, edit, activate, run review and close sprints; save retros.", "ru": "Управление спринтами. Создавать, редактировать, активировать, проводить ревью и закрывать спринты; сохранять ретро."}'::jsonb),
('sprint_can_assign_tasks', 'Move tasks in and out of sprints', 5,
'{"en": "Assign tasks to sprints. Move tasks into and out of sprints.", "ru": "Назначение задач в спринты. Перемещать задачи в спринты и из них."}'::jsonb),
('sprint_can_view_analytics', 'View sprint burndown and velocity', 5,
'{"en": "View sprint analytics. See burndown and velocity charts.", "ru": "Аналитика спринтов. Видеть burndown и velocity."}'::jsonb)
ON CONFLICT (name) DO NOTHING;
-- Backfill existing projects: grant sprint permissions to their editor/executor roles.
-- (NEW projects get them via tasks.add_roles_and_permissions() — see all-triggers.sql.)
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
SELECT r.id, p.id
FROM collaboration.roles r
CROSS JOIN tv_auth.permissions p
WHERE r.name = 'editor'
AND p.name IN ('sprint_can_view', 'sprint_can_manage', 'sprint_can_assign_tasks', 'sprint_can_view_analytics')
ON CONFLICT DO NOTHING;
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
SELECT r.id, p.id
FROM collaboration.roles r
CROSS JOIN tv_auth.permissions p
WHERE r.name = 'executor'
AND p.name IN ('sprint_can_view', 'sprint_can_assign_tasks')
ON CONFLICT DO NOTHING;
@@ -0,0 +1,3 @@
ALTER TABLE tasks.goals
ADD COLUMN IF NOT EXISTS estimate_unit VARCHAR(10) NOT NULL DEFAULT 'points';
-- 'hours' | 'points' — unit the project measures sprint estimates & capacity in.
@@ -0,0 +1,6 @@
ALTER TABLE tasks.sprint_task_outcomes
ADD COLUMN IF NOT EXISTS estimate_value NUMERIC(10, 2);
-- Snapshot of tasks.estimate_value taken AT sprint close. Sprint history is
-- frozen: later edits to the task's estimate, or moving it to another sprint,
-- must NOT change a closed sprint's velocity. Velocity reads this snapshot,
-- not the live task estimate.
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS tasks.sprint_cadence (
goal_id INTEGER PRIMARY KEY REFERENCES tasks.goals(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
length_days INTEGER NOT NULL DEFAULT 14,
start_date DATE NOT NULL,
lookahead INTEGER NOT NULL DEFAULT 2,
name_template VARCHAR(100) NOT NULL DEFAULT 'Sprint {n}',
last_generated_date DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
edited_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Per-project sprint cadence (Linear-style auto-generation). A background job
-- keeps the current + `lookahead` future sprints created, every `length_days`
-- starting from `start_date`. `last_generated_date` = start_date of the last
-- auto-created sprint, so generation is idempotent and only moves forward.
@@ -0,0 +1,641 @@
--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',
'sprint_can_view',
'sprint_can_manage',
'sprint_can_assign_tasks',
'sprint_can_view_analytics'
);
-- 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',
'sprint_can_view',
'sprint_can_assign_tasks'
);
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,25 @@
CREATE TABLE IF NOT EXISTS tasks.recurrence_rules (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
template_task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
template_description VARCHAR(2000),
template_note VARCHAR(2000),
template_priority_id INTEGER,
template_status_id INTEGER,
template_goal_list_id INTEGER,
template_duration_minutes INTEGER,
rrule TEXT NOT NULL,
dtstart TIMESTAMP NOT NULL,
timezone VARCHAR(50) NOT NULL,
state VARCHAR(20) NOT NULL DEFAULT 'active',
last_instance_date DATE NOT NULL,
instances_created INTEGER NOT NULL DEFAULT 1,
notify_on_occurrence BOOLEAN NOT NULL DEFAULT FALSE,
creator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT recurrence_state_valid CHECK (state IN ('active', 'paused', 'ended'))
);
CREATE INDEX IF NOT EXISTS idx_recurrence_rules_goal ON tasks.recurrence_rules(goal_id);
CREATE INDEX IF NOT EXISTS idx_recurrence_rules_active ON tasks.recurrence_rules(id) WHERE state = 'active';
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS tasks.recurrence_skip_dates (
rule_id INTEGER NOT NULL REFERENCES tasks.recurrence_rules(id) ON DELETE CASCADE,
skip_date DATE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (rule_id, skip_date)
);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS tasks.recurrence_template_assignees (
rule_id INTEGER NOT NULL REFERENCES tasks.recurrence_rules(id) ON DELETE CASCADE,
collab_user_id INTEGER NOT NULL REFERENCES collaboration.users(id) ON DELETE CASCADE,
PRIMARY KEY (rule_id, collab_user_id)
);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS tasks.recurrence_template_tags (
rule_id INTEGER NOT NULL REFERENCES tasks.recurrence_rules(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tasks.tags(id) ON DELETE CASCADE,
PRIMARY KEY (rule_id, tag_id)
);
@@ -0,0 +1,11 @@
ALTER TABLE tasks.tasks
ADD COLUMN IF NOT EXISTS recurrence_rule_id INTEGER REFERENCES tasks.recurrence_rules(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS recurrence_instance_date DATE;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tasks_recurrence_instance
ON tasks.tasks(recurrence_rule_id, recurrence_instance_date)
WHERE recurrence_rule_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_rule_id
ON tasks.tasks(recurrence_rule_id)
WHERE recurrence_rule_id IS NOT NULL;
@@ -0,0 +1,7 @@
-- One live series per origin task: DB-level backstop for the createRule race
-- where two concurrent POSTs both pass the recurrenceRuleId == null check and
-- insert two rules for the same task. Ended series keep their row but release
-- the slot (the origin task itself stays attached to the ended rule anyway).
CREATE UNIQUE INDEX IF NOT EXISTS uniq_recurrence_rules_template_task
ON tasks.recurrence_rules(template_task_id)
WHERE state != 'ended';
@@ -0,0 +1,13 @@
-- A series anchored to a wall-clock time (incl. exactly 00:00) vs a date-only
-- series ("every day", no time) used to be told apart by inspecting dtstart:
-- midnight meant "no time". That collapses an explicit midnight into date-only.
-- Store the distinction explicitly instead.
ALTER TABLE tasks.recurrence_rules
ADD COLUMN IF NOT EXISTS has_time BOOLEAN NOT NULL DEFAULT FALSE;
-- Backfill reproduces the old inference exactly: any series whose dtstart
-- carries a non-midnight wall-clock time was a timed series. Existing
-- midnight/date-only series keep has_time = FALSE — no behavior change.
UPDATE tasks.recurrence_rules
SET has_time = TRUE
WHERE dtstart::time <> '00:00:00';
+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) {
+26
View File
@@ -3,11 +3,24 @@ 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 UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -23,6 +36,19 @@ 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,
'/module/ui-preferences': UiPreferencesRoutes,
'/module/sprints': SprintsRoutes,
'/module/recurrence': RecurrenceRoutes,
'/scim/v2': ScimRoutes,
};
export default routes;
@@ -0,0 +1,111 @@
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 {
fetchCatalog = async (req: Request, res: Response) => {
const catalog = req.appUser.analyticsManager.getCatalog()
return res.tvJson(catalog)
}
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,174 @@
import type {
AnalyticsAvailableGoal,
AnalyticsDrillDownResponse,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionCatalogEntry,
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()
}
getCatalog(): AnalyticsSectionCatalogEntry[] {
return this.registry.catalog()
}
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,923 @@
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,
AmountPerProjectMonthSectionRow,
AmountPerTagMonthSectionRow,
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 { UNTAGGED_TAG_ID } from './types'
import type { AnalyticsRange, DrillDownTaskRow, FetchAmountPerProjectMonthArgs, FetchAmountPerTagMonthArgs } 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 fetchAmountPerTagMonth(args: FetchAmountPerTagMonthArgs): Promise<AmountPerTagMonthSectionRow[]> {
const { goalIds, range, transactionType } = args
const result = await this.db.dbDrizzle.execute<AmountPerTagMonthSectionRow>(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
),
period_tasks as (
select t.id,
coalesce(t.amount, 0)::float as amount,
date_trunc('month', t.date_complete) as month
from tasks.tasks t
where t.goal_id = any(${toIntArraySql(goalIds)})
and t.complete = true
and t.date_complete is not null
and t.amount is not null
and t.transaction_type = ${transactionType}
and t.date_complete >= ${range.from.toISOString()}
and t.date_complete < ${range.to.toISOString()}
),
task_buckets as (
select pt.id, pt.amount, pt.month, tg.id as tag_id, tg.name as tag_name
from period_tasks pt
join tasks.tasks_to_tags tt on tt.task_id = pt.id
join tasks.tags tg on tg.id = tt.tag_id
union all
select pt.id, pt.amount, pt.month, ${UNTAGGED_TAG_ID} as tag_id, '' as tag_name
from period_tasks pt
where not exists (
select 1 from tasks.tasks_to_tags tt where tt.task_id = pt.id
)
),
tag_totals as (
select tag_id, max(tag_name) as tag_name, sum(amount) as total
from task_buckets
group by tag_id
having sum(amount) > 0
order by total desc
),
monthly as (
select tb.month, tb.tag_id, sum(tb.amount)::float as amount
from task_buckets tb
join tag_totals tt on tt.tag_id = tb.tag_id
group by tb.month, tb.tag_id
)
select
to_char(m.month, 'YYYY-MM') as month,
tt.tag_id::int as tag_id,
tt.tag_name as tag_name,
coalesce(mn.amount, 0)::float as amount
from months m
cross join tag_totals tt
left join monthly mn on mn.month = m.month and mn.tag_id = tt.tag_id
order by tt.total desc, m.month asc
`)
return result.rows as AmountPerTagMonthSectionRow[]
}
async fetchAmountPerProjectMonth(args: FetchAmountPerProjectMonthArgs): Promise<AmountPerProjectMonthSectionRow[]> {
const { goalIds, range, transactionType } = args
const result = await this.db.dbDrizzle.execute<AmountPerProjectMonthSectionRow>(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
),
filtered as (
select date_trunc('month', t.date_complete) as month,
g.id as goal_id,
g.name as goal_name,
t.amount::float as amount
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where g.id = any(${toIntArraySql(goalIds)})
and t.complete = true
and t.date_complete is not null
and t.amount is not null
and t.transaction_type = ${transactionType}
and t.date_complete >= ${range.from.toISOString()}
and t.date_complete < ${range.to.toISOString()}
),
project_totals as (
select goal_id, max(goal_name) as goal_name, sum(amount) as total
from filtered
group by goal_id
having sum(amount) > 0
order by total desc
),
monthly as (
select f.month, f.goal_id, sum(f.amount)::float as amount
from filtered f
join project_totals pt on pt.goal_id = f.goal_id
group by f.month, f.goal_id
)
select
to_char(m.month, 'YYYY-MM') as month,
pt.goal_id::int as goal_id,
pt.goal_name as goal_name,
coalesce(mn.amount, 0)::float as amount
from months m
cross join project_totals pt
left join monthly mn on mn.month = m.month and mn.goal_id = pt.goal_id
order by pt.total desc, m.month asc
`)
return result.rows as AmountPerProjectMonthSectionRow[]
}
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,28 @@
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('/sections-catalog', [IsLoggedIn], this.controller.fetchCatalog)
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,150 @@
import type { SectionBuilder } from '../types'
import { CreatedTasksKpi } from './kpi/CreatedTasksKpi'
import { CompletedTasksKpi } from './kpi/CompletedTasksKpi'
import { OverdueKpi } from './kpi/OverdueKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
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'
import type { AnalyticsSectionCatalogEntry } from 'taskview-api'
import { parseEnabledSectionIds } from './sectionEnv'
import { sectionLocales } from './locales'
// ---------------------------------------------------------------------------
// Disabled sections — re-enable once the underlying data model can support them
// honestly. Until then they would report misleading numbers.
//
// 1. AgingOpenTasksSection (chart.aging_open_tasks)
// Measures (now - date_creation) for every open task per assignee. Inflates
// the number with backlog tasks nobody has started working on yet.
// Needs: a boolean is_in_progress column on tasks.tasks, OR a "wip" flag
// on tasks.statuses, so SQL can count only tasks that have been picked up.
//
// 2. CycleTimeKpi (kpi.cycle_time)
// 3. CycleTimeHistogramSection (chart.cycle_time_histogram)
// 4. CycleTimePerProjectSection (chart.cycle_time_per_project)
// All three measure (date_complete - date_creation) for completed tasks.
// That is actually lead time (includes time the task sat in backlog), not
// cycle time. A task that spent 6 months in backlog and was then finished
// in 2 days shows up as a 6-month "cycle".
// Needs: a started_at timestamp on tasks.tasks (set when the task moves
// into an in-progress status) so SQL can compute (date_complete - started_at).
// Trigger or app logic must set started_at on the first transition into a
// "wip" status.
//
// 5. TimeInKanbanStatusSection (chart.time_in_kanban_status)
// Misleadingly named — it averages (now - edit_date) for currently-open
// tasks grouped by their current status. It does NOT measure how long
// each task has actually been in its current status; any unrelated edit
// (description change, assignee change) resets the clock for the metric.
// Needs: a tasks.status_history table tracking (task_id, status_id,
// entered_at, exited_at). Then "time in status X" = avg(exited_at OR now
// - entered_at) for rows in that status. Without a transition log, this
// metric cannot be computed correctly.
// ---------------------------------------------------------------------------
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
const builders: SectionBuilder[] = [
// KPI
new CreatedTasksKpi(),
new CompletedTasksKpi(),
new OverdueKpi(),
// new CycleTimeKpi(), // disabled — see top-of-file comment
new TotalIncomeKpi(),
new TotalExpenseKpi(),
new NetProfitKpi(),
new PlannedIncomeKpi(),
new PlannedExpenseKpi(),
new AmountCoverageKpi(),
// Productivity
new ThroughputSection(),
new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
new BlockedByDependenciesSection(),
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
new OverdueByAgeSection(),
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// Usage
new StatusDistributionSection(),
new ActiveProjectsSection(),
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new IncomePerProjectMonthSection(),
new ExpensePerProjectMonthSection(),
new IncomePerTagMonthSection(),
new ExpensePerTagMonthSection(),
new TopProjectsByAmountSection(),
]
export class SectionRegistry {
private readonly byId: Map<string, SectionBuilder>
private readonly enabledOrder: SectionBuilder[]
constructor() {
this.byId = new Map(builders.map(b => [b.id, b]))
const enabledIds = parseEnabledSectionIds(process.env.ANALYTICS_SECTIONS)
if (enabledIds && enabledIds.length > 0) {
this.enabledOrder = enabledIds
.map((id: string) => this.byId.get(id))
.filter((b: SectionBuilder | undefined): b is SectionBuilder => !!b)
} else {
this.enabledOrder = [...builders]
}
}
all(): SectionBuilder[] {
return [...this.enabledOrder]
}
get(id: string): SectionBuilder | undefined {
const builder = this.byId.get(id)
if (!builder) return undefined
return this.enabledOrder.includes(builder) ? builder : undefined
}
filterByIds(ids?: string[]): SectionBuilder[] {
if (!ids || ids.length === 0) return this.all()
const enabledSet = new Set(this.enabledOrder)
return ids
.map(id => this.byId.get(id))
.filter((b): b is SectionBuilder => !!b && enabledSet.has(b))
}
catalog(): AnalyticsSectionCatalogEntry[] {
const locales = sectionLocales as Record<string, { title?: { ru: string, en: string } }>
return this.enabledOrder.map(b => ({
id: b.id,
group: b.group,
payloadKind: b.defaultChartType === null ? 'kpi' : 'series',
title: locales[b.id]?.title ?? { ru: b.id, en: b.id },
}))
}
}
@@ -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,56 @@
import type { AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
import { buildProjectAmountPayload } from './projectAmountPayload'
export class ExpensePerProjectMonthSection implements SectionBuilder {
readonly id = 'chart.expense_per_project_month'
readonly group = 'financial' as const
readonly allowedChartTypes = ['line', 'bar'] as const
readonly defaultChartType = 'line' 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.fetchAmountPerProjectMonth({
goalIds: ctx.accessibleGoalIds,
range: ctx.range,
transactionType: 0,
})
const loc = this.loc
const payload = buildProjectAmountPayload({
rows,
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: 'date', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,56 @@
import type { AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
import { buildTagAmountPayload } from './tagAmountPayload'
export class ExpensePerTagMonthSection implements SectionBuilder {
readonly id = 'chart.expense_per_tag_month'
readonly group = 'financial' as const
readonly allowedChartTypes = ['line', 'bar'] as const
readonly defaultChartType = 'line' 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.fetchAmountPerTagMonth({
goalIds: ctx.accessibleGoalIds,
range: ctx.range,
transactionType: 0,
})
const loc = this.loc
const payload = buildTagAmountPayload({
rows,
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: 'date', datasets: [], unit: 'currency' },
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,56 @@
import type { AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
import { buildProjectAmountPayload } from './projectAmountPayload'
export class IncomePerProjectMonthSection implements SectionBuilder {
readonly id = 'chart.income_per_project_month'
readonly group = 'financial' as const
readonly allowedChartTypes = ['line', 'bar'] as const
readonly defaultChartType = 'line' 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.fetchAmountPerProjectMonth({
goalIds: ctx.accessibleGoalIds,
range: ctx.range,
transactionType: 1,
})
const loc = this.loc
const payload = buildProjectAmountPayload({
rows,
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: 'date', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,56 @@
import type { AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
import { buildTagAmountPayload } from './tagAmountPayload'
export class IncomePerTagMonthSection implements SectionBuilder {
readonly id = 'chart.income_per_tag_month'
readonly group = 'financial' as const
readonly allowedChartTypes = ['line', 'bar'] as const
readonly defaultChartType = 'line' 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.fetchAmountPerTagMonth({
goalIds: ctx.accessibleGoalIds,
range: ctx.range,
transactionType: 1,
})
const loc = this.loc
const payload = buildTagAmountPayload({
rows,
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: 'date', 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,54 @@
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
import type { AmountPerProjectMonthSectionRow } from '../row.types'
export type BuildProjectAmountPayloadArgs = {
rows: AmountPerProjectMonthSectionRow[]
xAxisLabel?: LocalizedText
yAxisLabel?: LocalizedText
}
export function buildProjectAmountPayload(args: BuildProjectAmountPayloadArgs): AnalyticsSeriesPayload {
const { rows, xAxisLabel, yAxisLabel } = args
const monthSet = new Set<string>()
const projectOrder: number[] = []
const projectNameById = new Map<number, string>()
const valuesByProject = new Map<number, Map<string, number>>()
for (const row of rows) {
monthSet.add(row.month)
if (!projectNameById.has(row.goal_id)) {
projectNameById.set(row.goal_id, row.goal_name)
projectOrder.push(row.goal_id)
}
let perMonth = valuesByProject.get(row.goal_id)
if (!perMonth) {
perMonth = new Map()
valuesByProject.set(row.goal_id, perMonth)
}
perMonth.set(row.month, Number(row.amount))
}
const labels = [...monthSet].sort()
const datasets: AnalyticsDataset[] = projectOrder.map((goalId) => {
const perMonth = valuesByProject.get(goalId) ?? new Map<string, number>()
const name = projectNameById.get(goalId) ?? `#${goalId}`
return {
id: `project_${goalId}`,
label: { ru: name, en: name },
values: labels.map(month => perMonth.get(month) ?? 0),
meta: { goalIds: [goalId] },
}
})
return {
kind: 'series',
labels,
labelKind: 'date',
datasets,
unit: 'currency',
xAxisLabel,
yAxisLabel,
}
}
@@ -0,0 +1,61 @@
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
import type { AmountPerTagMonthSectionRow } from '../row.types'
import { UNTAGGED_TAG_ID } from '../../types'
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
export type BuildTagAmountPayloadArgs = {
rows: AmountPerTagMonthSectionRow[]
xAxisLabel?: LocalizedText
yAxisLabel?: LocalizedText
}
export function buildTagAmountPayload(args: BuildTagAmountPayloadArgs): AnalyticsSeriesPayload {
const { rows, xAxisLabel, yAxisLabel } = args
const monthSet = new Set<string>()
const tagOrder: number[] = []
const tagNameById = new Map<number, LocalizedText>()
const valuesByTag = new Map<number, Map<string, number>>()
for (const row of rows) {
monthSet.add(row.month)
if (!tagNameById.has(row.tag_id)) {
tagNameById.set(
row.tag_id,
row.tag_id === UNTAGGED_TAG_ID ? UNTAGGED_LABEL : { ru: row.tag_name, en: row.tag_name },
)
tagOrder.push(row.tag_id)
}
let perMonth = valuesByTag.get(row.tag_id)
if (!perMonth) {
perMonth = new Map()
valuesByTag.set(row.tag_id, perMonth)
}
perMonth.set(row.month, Number(row.amount))
}
const labels = [...monthSet].sort()
const datasets: AnalyticsDataset[] = tagOrder.map((tagId) => {
const perMonth = valuesByTag.get(tagId) ?? new Map<string, number>()
const isUntagged = tagId === UNTAGGED_TAG_ID
return {
id: isUntagged ? 'untagged' : `tag_${tagId}`,
label: tagNameById.get(tagId) ?? { ru: '', en: '' },
values: labels.map(month => perMonth.get(month) ?? 0),
colorToken: isUntagged ? 'neutral' : undefined,
meta: isUntagged ? { untagged: true } : { tagIds: [tagId] },
}
})
return {
kind: 'series',
labels,
labelKind: 'date',
datasets,
unit: 'currency',
xAxisLabel,
yAxisLabel,
}
}
@@ -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(),
}
}
}

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