Compare commits

..

141 Commits

Author SHA1 Message Date
Nikolai Giman 27e1597c13 Merge pull request #111 from Gimanh/fix/issues
feat: OAuth
2026-09-06 18:07:29 +02:00
Nikolai Giman 3403e1a71d wip: OAuth and mcp 2026-09-06 18:06:35 +02:00
Nikolai Giman e9fb4f6f1e fix: org permissions 2026-08-31 00:54:19 +02:00
Nikolai Giman a173a870d7 feat: OAuth 2026-08-30 23:39:25 +02:00
Nikolai Giman 968c3d2eeb Merge pull request #105 from Gimanh/fix/issues
Fix/issues
2026-08-27 20:44:20 +02:00
Nikolai Giman 171f269ee2 version: 1.53.0 2026-08-27 20:42:36 +02:00
Nikolai Giman f79614ebc5 fix(security): enforce collaboration access permissions 2026-08-27 20:29:54 +02:00
Nikolai Giman a4c465469d wip: updated locales 2026-08-26 23:41:46 +02:00
Nikolai Giman f3e2663ebe Merge branch 'main' into fix/issues 2026-08-26 23:35:04 +02:00
Nikolai Giman 5ce2fe99f0 Merge pull request #108 from murilorosa/feat/pt-BR_locale
feat: add pt-BR locale
2026-08-26 23:27:46 +02:00
Nikolai Giman e11aba4d79 fix: time tracking edit 2026-08-26 23:20:16 +02:00
Murilo Biassio Rosa 0b6f95ec3d feat: add pt-BR locale 2026-08-25 22:21:10 -03:00
Nikolai Giman a596e023db fix: performance issue in graph and update layout building 2026-08-24 22:05:39 +02:00
Nikolai Giman c1685e42b8 feat: task dependencies in detailed panel 2026-08-24 14:21:46 +02:00
Nikolai Giman 9635120e66 fix: add security md 2026-08-23 17:56:17 +02:00
Nikolai Giman 19e0212edf fix: add task on the main screen when no project is created 2026-08-22 22:06:52 +02:00
Nikolai Giman 5dc5b387de fix: disable analytics 2026-08-22 16:43:48 +02:00
Nikolai Giman 8011da1259 fix: #106 2026-08-22 09:01:33 +02:00
Nikolai Giman a6329e998d fix: disabled analytics 2026-08-21 22:51:57 +02:00
Nikolai Giman b372854636 fix: custom server url normalization 2026-08-21 10:16:22 +02:00
Nikolai Giman a243f9ec56 fix: normalize server url 2026-08-21 10:06:54 +02:00
Nikolai Giman ebd25a94ea fix: sso email resolving and switcher for sso 2026-08-20 19:53:29 +02:00
Nikolai Giman 4b142619c0 fix: #6126 2026-08-20 16:13:23 +02:00
Nikolai Giman 6f576e7297 Merge pull request #104 from Gimanh/fix/issues
chore: version
2026-08-20 14:44:26 +02:00
Nikolai Giman 15050541fa chore: version 2026-08-20 14:40:23 +02:00
Nikolai Giman 3afdca99d9 Merge pull request #103 from Gimanh/fix/issues
fix: sso domain verification and #77
2026-08-20 14:37:05 +02:00
Nikolai Giman aaa876412d fix: #101 2026-08-20 13:51:32 +02:00
Nikolai Giman 915e8d9f9f fix: sso domain verification and #77 2026-08-18 18:57:17 +02:00
Nikolai Giman 55ded8bac9 Merge pull request #99 from Gimanh/fix/98
fix: #98
2026-08-04 20:45:47 +02:00
Nikolai Giman 784652ef5b chore: version 2026-08-04 20:44:27 +02:00
Nikolai Giman 1f1a1b770f fix: #98 2026-08-04 20:40:31 +02:00
Nikolai Giman e80ab33dda Merge pull request #97 from Gimanh/feat/http-mcp
feat: http mcp
2026-08-03 00:38:50 +02:00
Nikolai Giman 8c7be7362f feat: http mcp 2026-08-03 00:33:13 +02:00
Nikolai Giman 57ec7c01b6 Merge pull request #96 from Gimanh/feat/email-notification
feat: email notification
2026-08-02 19:10:15 +02:00
Nikolai Giman d0f664f78e feat: email notification 2026-08-02 10:11:22 +02:00
Nikolai Giman 7bbb36d45e Merge pull request #94 from Gimanh/feat/gitea-integration
feat: gitea integration
2026-07-28 19:54:07 +02:00
Nikolai Giman 9934bf06d8 feat: gitea integration 2026-07-28 00:45:50 +02:00
Nikolai Giman fd13b33915 Merge pull request #93 from Gimanh/tests/e2e
tests: add attrs
2026-07-26 19:12:27 +02:00
Nikolai Giman 64089fd6e7 tests: add attrs 2026-07-25 23:09:08 +02:00
Nikolai Giman b7a50049bb Merge pull request #92 from Gimanh/feat/4745-public-api-url
feat: public API URL fix for sso callback
2026-07-18 14:53:34 +02:00
Nikolai Giman 504ae503dd feat: public API URL fix for sso callback 2026-07-18 14:53:00 +02:00
Nikolai Giman 263883f32d Merge pull request #91 from Gimanh/feat/5047-disable-public-registration
feat: ALLOW_PUBLIC_REGISTRATION
2026-07-18 14:18:16 +02:00
Nikolai Giman 90b55fd82a feat: ALLOW_PUBLIC_REGISTRATION 2026-07-18 14:15:28 +02:00
Nikolai Giman 2e9a4945ce Merge pull request #89 from Gimanh/chore/refactor
chore: bootstrap
2026-07-18 10:55:01 +02:00
Nikolai Giman 0d70023fb3 Merge pull request #90 from Gimanh/fix/gh-88-pm2-db-connections
fix: #88
2026-07-16 22:17:46 +02:00
Nikolai Giman 645f2e21e5 fix: #88 2026-07-16 22:16:41 +02:00
Nikolai Giman adce016a05 chore: bootstrap 2026-07-16 19:38:24 +02:00
Nikolai Giman b7f2380d45 Merge pull request #87 from Gimanh/fix/5315-logout-state-reset
fix: clear stores on logout
2026-07-15 21:24:14 +02:00
Nikolai Giman 58eb93e564 fix: clear stores on logout 2026-07-15 21:23:26 +02:00
Nikolai Giman 4e5e5fb579 Merge pull request #86 from Gimanh/feat/recurring-by-completion
wip: recurring
2026-07-14 17:46:05 +02:00
Nikolai Giman 314e5a6377 merge: resolve 2026-07-13 20:27:25 +02:00
Nikolai Giman 955697f40f Merge branch 'main' into feat/recurring-by-completion 2026-07-13 20:23:52 +02:00
Nikolai Giman e5dbba8e2a wip: recurring 2026-07-13 20:15:55 +02:00
Nikolai Giman bb28bac9f7 Merge pull request #85 from Gimanh/chore/ver-1-50-2
chore: 1.50.2
2026-07-12 22:50:30 +02:00
Nikolai Giman 6c990aff4f chore: 1.50.2 2026-07-12 22:49:43 +02:00
Nikolai Giman b296046605 Merge pull request #84 from Gimanh/fix/83-sso-login-by-code-gate
fix: allow login-by-code redemption for SSO and social logins (#83)
2026-07-12 22:46:15 +02:00
Nikolai Giman 40cd8064f5 fix: allow login-by-code redemption for SSO and social logins (#83)
SSO and social OAuth callbacks complete the login by redeeming a
one-time code through POST /module/auth/login-by-code, but the endpoint
was gated behind RequireLoginMethod('magic-link'). With
AUTH_LOGIN_METHODS excluding magic-link (e.g. "sso"), the redemption
returned an empty 403 and the user silently bounced back to the login
page.

- Add RequireAnyLoginMethod and gate /login-by-code on
  magic-link OR sso OR social
- Show a toast on failed code redemption instead of only logging
  to console
2026-07-12 22:44:23 +02:00
Nikolai Giman f4a765f40b Merge pull request #82 from Gimanh/chore/ver-1-50
chore: version
2026-07-11 23:25:56 +02:00
Nikolai Giman b9ce7261f0 chore: version 2026-07-11 23:25:00 +02:00
Nikolai Giman bc08c839bc Merge pull request #81 from Gimanh/fix/72-ui-and-setup
Fix/72 UI and setup
2026-07-11 23:15:50 +02:00
Nikolai Giman 83979ac47c chore: docs 2026-07-11 22:39:47 +02:00
Nikolai Giman a9be0d987a feat: set default project 2026-07-11 22:18:36 +02:00
Nikolai Giman 30274069a0 feat: set base api url from env 2026-07-11 21:49:10 +02:00
Nikolai Giman 0a8497af59 feat: edit default user and change password 2026-07-11 20:29:27 +02:00
Nikolai Giman 284a49ce8c fix: task id and search 2026-07-11 14:43:52 +02:00
Nikolai Giman 64a227303e Merge pull request #80 from Gimanh/feat/mobile-widgets
feat: mobile widgets
2026-07-11 10:59:14 +02:00
Nikolai Giman ae88d0f42a feat: mobile widgets 2026-07-11 10:49:38 +02:00
Nikolai Giman d40cc0aa1b Merge pull request #79 from Gimanh/fix/settings-ui
chore: version
2026-07-07 23:23:08 +02:00
Nikolai Giman e99d9d5515 chore: version 2026-07-07 23:20:02 +02:00
Nikolai Giman 9b38e3cd9d Merge pull request #78 from Gimanh/fix/77
fix: #77
2026-07-07 00:19:14 +02:00
Nikolai Giman 9c6d33cefe fix: #77 2026-07-07 00:16:26 +02:00
Nikolai Giman d008fa4f78 Merge pull request #75 from Gimanh/chore/v-1-49-0
chore: version 1.49.0
2026-07-06 00:11:07 +02:00
Nikolai Giman 8ff7241645 chore: version 1.49.0 2026-07-06 00:09:49 +02:00
Nikolai Giman 9bdfe679bd Merge pull request #74 from Gimanh/fix/70-oidc-login-nonce
fix: oidc login
2026-07-05 23:30:11 +02:00
Nikolai Giman 4e5a330a27 fix: oidc login 2026-07-05 23:28:51 +02:00
Nikolai Giman e5860a3816 Merge pull request #73 from Gimanh/feat/messaging-integrations
feat: slack and tg integration
2026-07-05 21:37:12 +02:00
Nikolai Giman 0f4c484674 feat: slack and tg integration 2026-07-05 17:42:51 +02:00
Nikolai Giman e025c212e5 Merge pull request #69 from Gimanh/feat/inbox
feat: inbox
2026-06-29 00:55:03 +02:00
Nikolai Giman 9b08c82548 feat: inbox 2026-06-29 00:53:53 +02:00
Nikolai Giman 3d9cb5336a Merge pull request #68 from Gimanh/chore/readme.md
chore: readme
2026-06-28 16:09:30 +02:00
Nikolai Giman 8eb94d0d2b chore: readme 2026-06-28 16:09:08 +02:00
Nikolai Giman 78c910f789 Merge pull request #67 from Gimanh/feat/ui-refactor
feat: ui improvements
2026-06-28 15:07:16 +02:00
Nikolai Giman e1a8bb71a0 fix: reload main screen when become active 2026-06-28 15:02:07 +02:00
Nikolai Giman f1ff303e58 fix: ipad safe area issues 2026-06-28 00:04:42 +02:00
Nikolai Giman 22dc24f8e0 fix: ui and recurring issues 2026-06-27 22:51:29 +02:00
Nikolai Giman 58e3cdcbb0 wip: mobile area 2026-06-21 23:47:43 +02:00
Nikolai Giman 28e944f05e wip: search width 2026-06-21 23:22:33 +02:00
Nikolai Giman 8f21a52ca6 wip: style 2026-06-21 23:18:01 +02:00
Nikolai Giman 96ac9f4d7a feat: ui improvements 2026-06-21 23:02:31 +02:00
Nikolai Giman 4c92aaf03d Merge pull request #66 from Gimanh/feat/recur-tasks
feat: recur tasks
2026-06-14 17:29:53 +02:00
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 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
788 changed files with 56028 additions and 2455 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "taskview",
"owner": {
"name": "Nikolai Giman"
},
"plugins": [
{
"name": "taskview",
"source": "./taskview-plugin",
"description": "Manage TaskView projects and tasks from Claude Code. Bundles the TaskView MCP server, skills, and slash commands."
}
]
}
+2
View File
@@ -5,6 +5,8 @@ ReleasesBuilds/*
iconcreator
*.prod.*
.DS_Store
.cursor
.claude
node_modules
__APP_BUILD__
ssl-create
+395 -87
View File
@@ -1,150 +1,458 @@
# TaskView™
![TaskView Logo](./assets/taskview-logo.png)
<p align="center">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView — Kanban board" width="1440" style="max-width: 100%;">
</p>
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.
<p align="center">
<strong>Self-hosted project management for software teams.</strong>
</p>
## 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)
<p align="center">
TaskView combines task management, custom workflows, developer integrations,
analytics, and AI-assisted automation in a platform you can run on your own infrastructure.
</p>
It is designed for teams and individuals who want:
- full control over their data
- transparent architecture
- predictable workflows
- the ability to run the system on their own infrastructure
<details>
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
## Key Features
- Projects, lists, tasks, and subtasks
- Kanban boards
- Clear responsibility assignment
- Permissions and roles
- Web and mobile clients
- Self-hosted by design
<br>
## Licensing
<img src="./assets/taskview/main-light.png" alt="Main dashboard (light theme)" width="100%">
<img src="./assets/taskview/main-dark.png" alt="Main dashboard (dark theme)" width="100%">
<img src="./assets/taskview/main-collapsed-light.png" alt="Main dashboard with collapsed sidebar (light theme)" width="100%">
<img src="./assets/taskview/main-collapsed-dark.png" alt="Main dashboard with collapsed sidebar (dark theme)" width="100%">
<img src="./assets/taskview/kanban-light.png" alt="Kanban board (light theme)" width="100%">
<img src="./assets/taskview/kanban-dark.png" alt="Kanban board (dark theme)" width="100%">
<img src="./assets/taskview/tasks-light.png" alt="Task list view (light theme)" width="100%">
<img src="./assets/taskview/tasks-dark.png" alt="Task list view (dark theme)" width="100%">
<img src="./assets/taskview/recent-tasks.png" alt="Recent tasks overview" width="100%">
<img src="./assets/taskview/sprints.png" alt="Sprint planning" width="100%">
<img src="./assets/taskview/time-tracking.png" alt="Time tracking" width="100%">
<img src="./assets/taskview/analytics.png" alt="Analytics dashboard" width="100%">
<img src="./assets/taskview/members.png" alt="Organization members management" width="100%">
<img src="./assets/taskview/permissions-light.png" alt="Role permissions (light theme)" width="100%">
<img src="./assets/taskview/permissions-dark.png" alt="Role permissions (dark theme)" width="100%">
<img src="./assets/taskview/sso.png" alt="Single Sign-On (SSO) configuration" width="100%">
<img src="./assets/taskview/api-tokens.png" alt="API tokens management" width="100%">
<img src="./assets/taskview/webhooks.png" alt="Webhooks configuration" width="100%">
<img src="./assets/taskview/integrations.png" alt="Integrations" width="100%">
<img src="./assets/taskview/ui-customization.png" alt="UI customization" width="100%">
<img src="./assets/taskview/settings.png" alt="Settings" width="100%">
TaskView uses a source-available license.
</details>
You may:
- self-host TaskView
- modify the source code
- use it internally within your organization
<p align="center">
<a href="https://app.taskview.tech"><strong>TaskView Cloud</strong></a>
·
<a href="https://taskview.tech/docs/"><strong>Documentation</strong></a>
·
<a href="https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867"><strong>iOS</strong></a>
·
<a href="https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app"><strong>Android</strong></a>
·
<a href="https://github.com/Gimanh/taskview-community/releases">
<strong>Releases</strong>
</a>
</p>
You may not:
- offer TaskView as a hosted SaaS
- sell TaskView as a service
- create a competing commercial product
- use the TaskView name or branding for derivative products
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
</a>
<a href="https://github.com/Gimanh/taskview-community/releases">
<img src="https://img.shields.io/github/v/release/Gimanh/taskview-community?label=release&color=brightgreen" alt="Latest release">
</a>
<a href="https://www.npmjs.com/package/taskview-mcp">
<img src="https://img.shields.io/npm/v/taskview-mcp?label=taskview-mcp&logo=npm&color=CB3837" alt="taskview-mcp on npm">
</a>
<a href="https://www.npmjs.com/package/taskview-api">
<img src="https://img.shields.io/npm/v/taskview-api?label=taskview-api&logo=npm&color=CB3837" alt="taskview-api on npm">
</a>
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
</p>
See LICENSE for full terms.
TaskView is a trademark of its authors.
---
## Architecture
## About TaskView
This repository is a monorepo.
TaskView is a source-available project and task management platform for small and growing software teams.
Main product packages:
It is designed for teams that need more structure than a simple task board, but do not want the administration overhead and complexity of large enterprise project management systems.
- `api` backend (Node.js)
- `web` web client and mobile
- `taskview-packages/taskview-api` API client library for building integrations and making HTTP requests
- `taskview-packages/taskview-db-schemas` - Drizzle ORM schemas
With TaskView, you can manage projects, build custom workflows, track dependencies and time, connect development tools, and automate project operations through the API, webhooks, and MCP.
Product packages share a single version.
Your projects. Your infrastructure. Your data.
## Who TaskView is for
## Versioning
TaskView is a good fit for:
TaskView follows semantic versioning:
- `MAJOR` breaking API or data changes
- `MINOR` new features
- `PATCH` bug fixes
The version represents the entire product, not individual packages.
* software development teams;
* startups and small product companies;
* agencies managing internal and client projects;
* organizations that require self-hosting and data ownership;
* teams looking for a lightweight alternative to complex enterprise platforms;
* developers using AI agents to manage and automate project work;
* individuals managing technical or personal projects.
## Why TaskView
## Status
* **Own your data** - run TaskView on your own infrastructure.
* **Flexible workflows** - configure projects, statuses, boards, roles, and permissions.
* **Built for development teams** - connect GitHub, GitLab, API clients, and webhooks.
* **AI-ready** - allow MCP-compatible assistants to work with tasks and projects.
* **Available everywhere** - use TaskView from the web, iOS, and Android.
* **No advertising or behavioral tracking** - your project data remains under your control.
TaskView is under active development.
Breaking changes may occur between versions.
## Features
## Getting started
### Project and task management
This setup is intended for local development.
* Projects, lists, tasks, and nested subtasks
* Custom Kanban boards and task statuses
* Priorities, tags, deadlines, and assignees
* Task dependency graph
* Sprints and recurring tasks
* Task history and activity tracking
* Markdown content
* Search and filtering
* Reusable project workflows
### Development
### Team collaboration
**Prerequisites:**
- Docker
- PostgreSQL
- On macOS you can use https://postgresapp.com/
- Bun (used for API development)
- pnpm
* Multiple organizations and projects
* User invites and granular permissions
* Clear responsibility assignment
* Real-time updates
* Notifications
* Organization-level user management
### Identity and access management
* SAML 2.0
* OpenID Connect
* SCIM provisioning
* Scoped API tokens
* Project-level access restrictions
* Fine-grained token permissions
### Developer integrations
* GitHub integration
* GitLab integration
* Signed webhooks
* TypeScript API client
* Public HTTP API
* MCP server for AI assistants
### Time tracking and analytics
* Built-in time tracking
* Billable and non-billable time
* Project and team workload reports
* Productivity analytics
* Income and expense tracking
* Financial reports
### Applications
* Web application
* iOS application
* Android application
* Self-hosted server deployment
## AI and MCP integration
TaskView includes an MCP server that allows compatible AI tools to interact with your projects.
Depending on the permissions assigned to an API token, an AI assistant can:
* search and inspect projects;
* create and update tasks;
* manage task statuses;
* work with project lists;
* retrieve task context;
* automate repetitive project operations.
MCP access can be restricted by permission and by selected projects.
### Connecting an MCP client
The MCP server is published as [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) and runs over stdio via `npx` — no install required. You only need a TaskView API token (`tvk_...`) — generate one in your account settings (see [API tokens](https://taskview.tech/docs/features/api-tokens)). Scope the token to the minimum permissions and projects the assistant should reach.
**Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global):
```json
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "https://api.taskview.tech",
"TASKVIEW_TOKEN": "tvk_your_token_here"
}
}
}
}
```
**Claude Desktop** — add the same `mcpServers` block to `claude_desktop_config.json`.
**Other MCP clients** (Cursor, Windsurf, etc.) — use the same stdio command `npx -y taskview-mcp` with the `TASKVIEW_URL` and `TASKVIEW_TOKEN` environment variables in that client's MCP configuration.
| Variable | Required | Description |
|---|---|---|
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`, or your self-hosted instance) |
| `TASKVIEW_TOKEN` | yes | API token with the `tvk_` prefix |
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) and the [`taskview-mcp` package README](taskview-packages/taskview-mcp/README.md) for the full tool list and more options.
## Quick start
Clone the repository:
```sh
git clone https://github.com/Gimanh/taskview-community.git
cd taskview-community
```
The recommended way to run TaskView is with Docker Compose.
For deployment instructions, environment variables, HTTPS configuration, updates, and backups, see the official documentation:
[TaskView self-hosting documentation](https://taskview.tech/docs/)
> Production deployments should use persistent volumes, HTTPS, regular database backups, and securely generated secrets.
## Repository structure
TaskView is maintained as a monorepo. Product packages share a single version.
```text
taskview-community/
├── api/
│ └── TaskView backend and HTTP API
├── web/
│ └── Vue web application and Capacitor mobile application
├── taskview-packages/
│ ├── taskview-api/
│ │ └── TypeScript API client
│ └── taskview-db-schemas/
│ │ └── Shared Drizzle ORM schemas
│ └── taskview-mcp/
│ └── MCP server for AI integrations
└── build-dockers.sh
└── Docker image build script
```
### Main packages
| Package | Description |
| --------------------------------------- | ----------------------------------------------- |
| `api` | Node.js backend and API server |
| `web` | Vue web client and Capacitor mobile application |
| `taskview-packages/taskview-api` | TypeScript client for the TaskView API |
| `taskview-packages/taskview-db-schemas` | Shared Drizzle ORM database schemas |
| `taskview-packages/taskview-mcp` | MCP server for AI integrations |
## Technology stack
TaskView is primarily built with:
* Vue
* Nuxt UI
* TypeScript
* Node.js
* Express
* PostgreSQL
* Drizzle ORM
* Capacitor
* Docker
## Local development
These instructions are intended for contributors and local development, not for production deployment.
### Prerequisites
You need the following tools:
* Docker
* PostgreSQL
* Bun
* pnpm
On macOS, PostgreSQL can be installed using [Postgres.app](https://postgresapp.com/).
### Install dependencies
From the repository root:
**Steps:**
1. Install dependencies:
```sh
pnpm install
```
2. Create an `.env` file in the **api** directory based on `.env.example` and configure your local credentials.
3. Run the web application:
### Configure the API
Create an environment file in the `api` directory:
```sh
cp api/.env.example api/.env
```
Update `api/.env` with your local database credentials and application configuration.
### Start the web application
```sh
cd web
pnpm dev
```
1. Run the API locally (API uses Bun in development mode):
### Start the API
In a separate terminal:
```sh
cd api
pnpm start
```
## Building Docker Images
The API uses Bun in the local development environment.
You can build Docker images using the provided bash script. The image version is automatically taken from the root `package.json`.
## Building Docker images
Docker image versions are taken from the root `package.json`.
### Build the images
From the repository root:
**Steps:**
1. Build images:
```sh
./build-dockers.sh
```
2. Verify built images:
### Verify the images
```sh
docker images
```
You should see the following images:
`gimanhead/taskview-ce-api-server`
`gimanhead/taskview-ce-webapp`
`gimanhead/taskview-ce-db-migration`
The following images should be available:
3. Test images locally using Docker Compose:
```sh
cd api/dev-containers-test
docker-compose up
```text
gimanhead/taskview-ce-api-server
gimanhead/taskview-ce-webapp
gimanhead/taskview-ce-db-migration
```
Make sure the image versions match the version defined in the root package.json.
### Test the images locally
```sh
cd api/dev-containers-test
docker compose up
```
Ensure that image versions match the version defined in the root `package.json`.
## Versioning
TaskView follows [Semantic Versioning](https://semver.org/):
* `MAJOR` - breaking API, database, or compatibility changes;
* `MINOR` - backward-compatible features;
* `PATCH` - backward-compatible bug fixes.
The version represents the entire TaskView product, not individual packages.
## Project status
TaskView is under active development.
New features, database migrations, API changes, and breaking changes may be introduced between releases. Review release notes and back up your database before updating a self-hosted installation.
## Roadmap
- Plugin / extension system
- [X] Migrate to NuxtUI or similar ui library
- Enterprise SSO and identity integrations
- [X] Redesign
- Desktop version
Planned areas of development include:
* [ ] Desktop application
* [ ] Plugin and extension system
* [ ] Additional third-party integrations
* [ ] Expanded automation capabilities
* [ ] Continued UI and UX improvements
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
The roadmap may change based on product priorities and community feedback.
## Contributing
Contributions are welcome.
Before contributing:
1. Read [`CONTRIBUTING.md`](./CONTRIBUTING.md).
2. Check existing issues and pull requests.
3. Open an issue before starting a large architectural change.
4. Follow the project's code style and architecture.
5. Sign the Contributor License Agreement when required.
Contributions are accepted under the project's CLA.
## Licensing
TaskView is distributed under the TaskView Source-Available License.
You may:
* self-host TaskView;
* modify the source code;
* use TaskView internally within your organization;
* create integrations and internal extensions.
You may not:
* offer TaskView as a hosted SaaS;
* sell access to TaskView as a service;
* create a competing commercial product based on TaskView;
* use the TaskView name, logo, or branding for derivative products.
See [`LICENSE`](./LICENSE) for the complete terms.
TaskView is **source-available**, not OSI-approved open-source software.
For commercial licensing questions, hosted service permissions, or other use cases not covered by the license, contact the project maintainer.
## Security
Do not publish security vulnerabilities in public GitHub issues.
Report security issues privately — see [SECURITY.md](SECURITY.md) for the reporting channels, response times, scope, and safe-harbor terms.
When running TaskView in production:
* use HTTPS;
* generate unique application secrets;
* restrict database access;
* keep Docker images and dependencies updated;
* configure persistent storage;
* create regular backups;
* restrict API-token permissions to the minimum required scope.
## Links
* [TaskView website](https://taskview.tech/)
* [TaskView Cloud](https://app.taskview.tech/)
* [Documentation](https://taskview.tech/docs/)
* [iOS application](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
* [Android application](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app)
* [GitHub repository](https://github.com/Gimanh/taskview-community)
---
TaskView is developed and maintained by Nikolai Giman.
TaskView is developed and maintained by **Nikolai Giman**.
Copyright © 2026 Nikolai Giman
TaskView™ and the TaskView logo are trademarks of their respective owner.
Copyright © 2026 Nikolai Giman.
+46
View File
@@ -0,0 +1,46 @@
# Security Policy
## Reporting a vulnerability
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Report them privately using one of these channels:
- **GitHub private vulnerability reporting** (preferred): open the **Security** tab of this repository and click **Report a vulnerability**.
- **Email**: [support@taskview.tech](mailto:support@taskview.tech) with `[security]` in the subject.
Please include as much of the following as you can:
- A description of the issue and its impact
- Affected component (API, web app, MCP server, mobile app) and version
- Steps to reproduce, or a proof of concept
- Any suggested mitigation
## What to expect
- We will acknowledge your report within **5 business days**.
- We will keep you informed about progress and aim to release a fix for confirmed issues within **90 days** of the report, sooner for critical issues.
- Once a fix is released, we publish a GitHub Security Advisory for the affected versions and credit the reporter, unless they prefer to stay anonymous.
- We ask that you give us a reasonable time to fix the issue before disclosing it publicly.
## Supported versions
Security fixes are released for the latest minor version line only. Self-hosted installations should upgrade to the latest release to receive them.
## Scope
In scope:
- The TaskView API server, web app, MCP server, and mobile app in this repository
- The hosted service at `app.taskview.tech`
Out of scope:
- Vulnerabilities in third-party dependencies that are not exploitable in TaskView (report them upstream)
- Findings that require a compromised admin account or physical access to the server
- Missing security headers, rate limiting, or best-practice recommendations without a demonstrated impact
- Denial-of-service testing against the hosted service
## Safe harbor
We will not pursue legal action against researchers who act in good faith: test only against their own self-hosted instance or their own accounts on the hosted service, avoid accessing or modifying other users' data, and report findings privately as described above.
+31
View File
@@ -19,6 +19,18 @@ ACCESS_LIFE_TIME=1d
REFRESH_LIFE_TIME=2d
JWT_ALG=HS256
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
#SSO_TRUSTED_DOMAINS=company.com,corp.local
# OAuth 2.1 for third-party MCP clients (ChatGPT, Claude connectors).
# Dynamic Client Registration is on by default; a cloud client cannot connect
# without it, since it has no way to pre-register with your instance. Turn it
# off on a private install that only uses manually seeded clients.
#OAUTH_DYNAMIC_REGISTRATION=false
# Public URL of this API. Used as the OAuth issuer in the discovery documents,
# so it must be the URL clients actually reach — set it behind a proxy.
#API_PUBLIC_URL=https://api.taskview.tech
# SMTP Configuration
SMTP_HOST=smtp.domain.com
SMTP_PORT=465
@@ -27,6 +39,10 @@ SMTP_PASSWORD=your_smtp_password_here
SMTP_ENCRYPTION=ssl
SMTP_FROM_NAME=TaskView
SMTP_FROM_EMAIL=your_email@example.com
# Email a person when they are invited to a project (requires SMTP)
INVITE_EMAIL_ENABLED=false
# Max invite emails one user may trigger per hour (default 30)
# INVITE_EMAIL_HOURLY_LIMIT=30
# Encryption (32-byte hex key for AES-256-GCM)
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
@@ -48,10 +64,25 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
# Gitea Integration OAuth
GITEA_INTEGRATION_CLIENT_ID=
GITEA_INTEGRATION_CLIENT_SECRET=
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
# For self-hosted Gitea, override these:
# GITEA_BASE_URL=https://gitea.yourcompany.com
# GITEA_API_URL=https://gitea.yourcompany.com/api/v1
# 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
+30 -1
View File
@@ -1,9 +1,38 @@
// https://github.com/Gimanh/taskview-community/issues/88
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
const rawInstances = process.env.PM2_INSTANCES;
const instances = rawInstances === 'max'
? 'max'
: Number(rawInstances) > 0
? Number(rawInstances)
: 2;
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
if (instances === 'max') {
console.warn(
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
+ 'the Postgres max_connections limit (default 100).'
);
} else if (instances * poolMax > 80) {
console.warn(
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
);
}
module.exports = {
apps: [
{
name: 'taskview-server',
script: 'taskview-server.js',
instances: 'max',
instances,
watch: true,
ignore_watch: ['logs'],
autorestart: true,
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.42.5",
"version": "1.53.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -24,6 +24,7 @@
"@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",
@@ -61,6 +62,7 @@
"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",
@@ -70,7 +72,9 @@
"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",
+30 -3
View File
@@ -1,11 +1,38 @@
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
const rawInstances = process.env.PM2_INSTANCES;
const instances = rawInstances === 'max'
? 'max'
: Number(rawInstances) > 0
? Number(rawInstances)
: 2;
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
if (instances === 'max') {
console.warn(
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
+ 'the Postgres max_connections limit (default 100).'
);
} else if (instances * poolMax > 80) {
console.warn(
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
);
}
module.exports = {
apps: [
{
name: 'taskview-server',
script: 'taskview-server.js',
instances: 'max',
watch: true,
ignore_watch: ['logs'],
instances,
watch: false,
autorestart: true,
max_memory_restart: '1G',
env_production: {
+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');
}
+31 -26
View File
@@ -1,30 +1,25 @@
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 { LoginMethods } from './tv-modules/auth/LoginMethods';
import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher';
import { PublicApiUrl } from './modules/public-url';
import cookieParser from 'cookie-parser';
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
const allow = new Set([
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
// default allowed origins for official TaskView apps
"https://app.taskview.tech",
"https://taskview.handscream.com",
"capacitor://taskview.handscream.com",
"capacitor://app.taskview.tech",
"https://appleid.apple.com"
]),
...(process.env.CORS_ALLOWED_ORIGINS?.split(',') || []),
]);
export default class App {
public app: express.Application;
public port: number;
constructor(port: number) {
LoginMethods.validateOnStartup();
PublicApiUrl.validateOnStartup();
InviteEmailDispatcher.validateOnStartup();
this.app = express();
this.port = port;
@@ -43,7 +38,17 @@ export default class App {
protected extendApp(): void { }
protected extendMiddlewares(): void { }
private resolveTrustProxy(): boolean | number | string {
const raw = process.env.TRUST_PROXY?.trim();
if (!raw || raw.toLowerCase() === 'false') return false;
if (raw.toLowerCase() === 'true') return true;
if (/^\d+$/.test(raw)) return Number(raw);
return raw;
}
private initializeMiddlewares() {
this.app.set('trust proxy', this.resolveTrustProxy());
//add tvJson method, clien need response format like {response: data}
this.app.use((_req: Request, res: Response, next) => {
res.tvJson = function (data: any) {
@@ -52,19 +57,9 @@ export default class App {
next();
});
this.app.use(cookieParser());
this.app.use(appUserMiddleware);
this.app.use(cors({
credentials: true,
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);
},
}));
this.app.use(helmet());
this.app.use(corsMiddleware);
this.app.use(cookieParser());
this.app.use(express.json({
verify: (req: any, _res, buf) => {
// Store raw body for webhook signature verification github and gitlab integrations
@@ -73,7 +68,17 @@ export default class App {
}
},
}));
this.app.use(express.urlencoded({ extended: true }));
this.app.use(express.urlencoded({
extended: true,
verify: (req: any, _res, buf) => {
// Slack sends slash commands / interactivity as urlencoded; keep the raw body
// for HMAC signature verification (VerifySlackRequest).
if (req.url?.includes('/messaging/slack/')) {
req.rawBody = buf;
}
},
}));
this.app.use(appUserMiddleware);
}
private initializeRoutes() {
+15
View File
@@ -11,7 +11,12 @@ import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsMana
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';
@@ -37,6 +42,11 @@ export class AppUser {
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;
@@ -55,6 +65,11 @@ export class AppUser {
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 {
+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,
+31 -2
View File
@@ -1,5 +1,7 @@
import { EventEmitter } from 'node:events';
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types';
import { $logger } from '../modules/logget';
export interface AppEvents {
@@ -7,9 +9,36 @@ export interface AppEvents {
'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.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale };
'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;
+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}`;
+28
View File
@@ -67,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));
}
}
+10
View File
@@ -3,11 +3,21 @@ 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';
import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher';
import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
new RealtimeDispatcher(),
new WebhooksDispatcher(),
new TimeTrackingDispatcher(),
new SprintsDispatcher(),
new RecurrenceDispatcher(),
new MessagingDispatcher(),
new InviteEmailDispatcher(),
];
export function registerAllEventHandlers() {
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from 'vitest';
import type { Request, Response } from 'express';
import { RequireTokenPermission } from '../require-token-permission';
import { GoalPermissions } from '../../types/auth.types';
const runWith = (tokenPermissions: string[] | undefined) => {
const next = vi.fn();
const end = vi.fn();
const res = { status: vi.fn(() => ({ end })), end } as unknown as Response;
const req = { appUser: { getTokenPermissions: () => tokenPermissions } } as unknown as Request;
RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)(req, res, next);
return { next, res };
};
describe('RequireTokenPermission', () => {
it('lets a browser session through — it carries no token permissions', () => {
const { next, res } = runWith(undefined);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('lets an unrestricted token through, keeping existing integrations working', () => {
const { next } = runWith([]);
expect(next).toHaveBeenCalled();
});
it('lets a token holding the permission through', () => {
const { next } = runWith([GoalPermissions.ORG_CAN_MANAGE]);
expect(next).toHaveBeenCalled();
});
it('blocks a restricted token that was not given the permission', () => {
const { next, res } = runWith([GoalPermissions.TIMETRACKING_CAN_VIEW]);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not accept a neighbouring permission from the same group', () => {
const { next, res } = runWith([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
+7 -1
View File
@@ -4,11 +4,17 @@ 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';
import { OAUTH_ACCESS_TOKEN_PREFIX } from '../tv-modules/oauth/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)) {
if (token && (token.startsWith(TOKEN_PREFIX) || token.startsWith(OAUTH_ACCESS_TOKEN_PREFIX))) {
const record = await getApiTokensManager().validateToken(token);
if (record) {
const authManager = new AppUser().authManager;
+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;
@@ -0,0 +1,34 @@
import type { NextFunction, Request, Response } from 'express';
import type { GoalPermissionType } from '../types/auth.types';
/**
* Narrows what a restricted API / OAuth token may do on surfaces that are guarded
* by an organization role or by project ownership rather than by the project RBAC
* — organizations, SSO configuration, webhooks. Those checks never consult
* GoalPermissionsFetcher, so without this the scope chosen when the token was
* issued would simply not apply to them.
*
* It only ever removes access. Put it AFTER the role or ownership guard, so that
* guard still has the final say on what the human behind the token may do:
*
* [IsLoggedIn, IsOrgAdmin, RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)]
*
* A browser session has no token permissions and passes. A token issued with an
* empty permission list is unrestricted by design — the same meaning it carries
* everywhere else — and also passes, which keeps existing integrations working.
*/
export const RequireTokenPermission = (permission: GoalPermissionType) => {
return (req: Request, res: Response, next: NextFunction) => {
const tokenPermissions = req.appUser.getTokenPermissions();
if (!tokenPermissions || tokenPermissions.length === 0) {
return next();
}
if (tokenPermissions.includes(permission)) {
return next();
}
return res.status(403).end();
};
};
+309 -1
View File
@@ -472,5 +472,313 @@
"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)."
]
},
"50": {
"version": "1.55.0",
"name": "Release 1.55.0",
"releaseDate": "20260628",
"scripts": [
"/1.55.0/0.alter-goals-add-is-inbox.sql",
"/1.55.0/1.backfill-inbox-goals.sql",
"/1.55.0/2.unique-inbox-per-org.sql"
],
"description": [
"Added tasks.goals.is_inbox (BOOLEAN NOT NULL DEFAULT FALSE) — flags a project as the user's personal Inbox. One Inbox per personal organization, auto-created at signup and guarded against deletion and archival in GoalsManager.",
"Backfill: for every personal organization without an Inbox, inserts one tasks.goals row (name='Inbox', is_inbox=true, owner=the org owner, organization_id=the org). The existing AFTER INSERT triggers fully provision it like any project — default kanban statuses, the owner added to collaboration, and the default editor/executor roles with their permissions. Idempotent — skips orgs that already have an Inbox.",
"Partial unique index goals_one_inbox_per_org_uidx ON tasks.goals (organization_id) WHERE is_inbox — enforces at most one Inbox per organization at the DB level, hardening the check-then-insert against concurrent races."
]
},
"51": {
"version": "1.56.0",
"name": "Messaging integrations",
"releaseDate": "20260701",
"scripts": [
"/1.56.0/0.create-messaging-tables.sql",
"/1.56.0/1.alter-messaging-add-events.sql"
],
"description": [
"Messaging integrations module (Slack / Telegram)",
"Outbound delivery connections for personal and project/org owners",
"Pending link tokens for binding + user identity map",
"Per-connection event subscription (which events to deliver)"
]
},
"52": {
"version": "1.57.0",
"name": "Unique project membership",
"releaseDate": "20260702",
"scripts": [
"/1.57.0/0.unique-users-to-goals.sql"
],
"description": [
"Deduplicate collaboration.users_to_goals rows",
"Unique index on (user_id, goal_id) — one membership per user per project"
]
},
"53": {
"version": "1.58.0",
"name": "Messaging channel post-content flag",
"releaseDate": "20260702",
"scripts": [
"/1.58.0/0.alter-messaging-add-post-content.sql"
],
"description": [
"Per-connection opt-out for posting task description to project channels"
]
},
"54": {
"version": "1.59.0",
"name": "Messaging identity workspace scoping",
"releaseDate": "20260705",
"scripts": [
"/1.59.0/0.alter-messaging-identity-add-team.sql"
],
"description": [
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
]
},
"55": {
"version": "1.60.0",
"name": "Recurrence schedule mode",
"releaseDate": "20260712",
"scripts": [
"/1.60.0/0.alter-recurrence-add-schedule-mode.sql"
],
"description": [
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
]
},
"56": {
"version": "1.61.0",
"name": "Gitea integration provider",
"releaseDate": "20260726",
"scripts": [
"/1.61.0/0.alter-integrations-provider-check-gitea.sql"
],
"description": [
"Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'"
]
},
"57": {
"version": "1.62.0",
"name": "Invite email rate limiting",
"releaseDate": "20260730",
"scripts": [
"/1.62.0/0.create-invite-emails.sql"
],
"description": [
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
]
},
"58": {
"version": "1.63.0",
"name": "SSO domain verification",
"releaseDate": "20260813",
"scripts": [
"/1.63.0/0.sso-domain-verification.sql",
"/1.63.0/1.sso-domain-verified-unique.sql"
],
"description": [
"SSO configs require proving ownership of email_domain_restriction before login is allowed: DNS TXT taskview-sso-verify=<token> or https://<domain>/.well-known/taskview-sso-verify.txt. Air-gapped installs can skip this for listed domains via SSO_TRUSTED_DOMAINS.",
"Replaces the plain UNIQUE(email_domain_restriction) with a partial unique index over verified configs only, so an unverified config can no longer squat a domain and block its real owner — multiple orgs may hold a pending config for the same domain, but only one can verify it (first-to-verify wins)."
]
},
"59": {
"version": "1.64.0",
"name": "OAuth 2.1 authorization server",
"releaseDate": "20260830",
"scripts": [
"/1.64.0/0.create-oauth-clients.sql",
"/1.64.0/1.create-oauth-auth-codes.sql",
"/1.64.0/2.create-oauth-grants.sql",
"/1.64.0/3.alter-api-tokens-grant-id.sql"
],
"description": [
"OAuth 2.1 authorization server so third-party MCP clients (ChatGPT, Claude connectors) can act on a user's behalf without the user pasting a permanent tvk_ API token into them.",
"tv_auth.oauth_clients holds the client registry (manually seeded or created via RFC 7591 Dynamic Client Registration); public clients carry no secret and are authenticated by PKCE S256 alone.",
"tv_auth.oauth_auth_codes holds single-use 60-second authorization codes; tv_auth.oauth_grants is one row per connected app and owns the rotating refresh token, with the previous hash kept to detect replay.",
"Access tokens reuse tv_auth.api_tokens (new grant_id column) so validation, permission intersection and RejectApiTokenAuth all keep working unchanged; revoking a grant cascades to its live access tokens."
]
},
"60": {
"version": "1.65.0",
"name": "Organization-level permissions",
"releaseDate": "20260830",
"scripts": [
"/1.65.0/0.organization-permissions.sql"
],
"description": [
"New permission group 'organization' with org_can_view, org_can_manage, org_can_manage_members, sso_can_manage and webhooks_can_manage.",
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
]
}
}
}
@@ -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,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';
@@ -0,0 +1,4 @@
ALTER TABLE tasks.goals
ADD COLUMN IF NOT EXISTS is_inbox BOOLEAN NOT NULL DEFAULT FALSE;
-- Flags a project as the user's personal Inbox. One Inbox per personal organization;
-- drives auto-create at signup, the backfill below, and the delete/archive guard.
@@ -0,0 +1,17 @@
-- For every personal organization that has no Inbox yet, create one.
-- The AFTER INSERT triggers on tasks.goals fully provision the goal, exactly like
-- a normal project: default kanban statuses (kanban_add_default_columns), the owner
-- added to collaboration (add_self_to_collaboration), and the default editor/executor
-- roles with their permissions (add_roles_after_insert). The owner also has full
-- permissions implicitly.
-- Idempotent: orgs that already have an Inbox are skipped.
INSERT INTO tasks.goals (name, owner, organization_id, is_inbox)
SELECT 'Inbox', o.owner_id, o.id, TRUE
FROM tv_auth.organizations o
WHERE o.is_personal = 1
AND NOT EXISTS (
SELECT 1
FROM tasks.goals g
WHERE g.organization_id = o.id
AND g.is_inbox = TRUE
);
@@ -0,0 +1,7 @@
-- Hard guarantee of at most one Inbox per organization. Defends the
-- check-then-insert in GoalsRepository.createInboxGoal against concurrent
-- signups/calls that could both pass the findInboxGoal precheck and insert.
-- Safe to create here: it runs after the idempotent backfill, so no duplicates exist.
CREATE UNIQUE INDEX IF NOT EXISTS goals_one_inbox_per_org_uidx
ON tasks.goals (organization_id)
WHERE is_inbox;
@@ -0,0 +1,47 @@
-- Messaging integrations (Slack / Telegram): outbound delivery targets, pending
-- binding tokens, and the map between a TaskView user and their external account.
-- See promo/integrations.md for the full design.
CREATE TABLE IF NOT EXISTS tasks.messaging_connections (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
provider VARCHAR(20) NOT NULL,
owner_type VARCHAR(20) NOT NULL,
owner_id INTEGER NOT NULL,
target_chat_id VARCHAR(255) NOT NULL,
title VARCHAR(255),
external_team_id VARCHAR(255),
access_token_encrypted VARCHAR,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- One delivery target per (provider, owner, chat) — re-connecting the same chat updates instead of duplicating.
CREATE UNIQUE INDEX IF NOT EXISTS messaging_connection_unique
ON tasks.messaging_connections (provider, owner_type, owner_id, target_chat_id);
-- Pending binding intent, redeemed from the messenger. Owner is polymorphic
-- (user / project / organization); permission to bind is checked when the token is minted.
CREATE TABLE IF NOT EXISTS tasks.messaging_link_tokens (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
token VARCHAR(128) NOT NULL UNIQUE,
provider VARCHAR(20) NOT NULL,
owner_type VARCHAR(20) NOT NULL,
owner_id INTEGER NOT NULL,
created_by INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tasks.messaging_identity_map (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
provider VARCHAR(20) NOT NULL,
external_user_id VARCHAR(255),
linked_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- One identity per provider per user.
CREATE UNIQUE INDEX IF NOT EXISTS messaging_identity_unique
ON tasks.messaging_identity_map (user_id, provider);
@@ -0,0 +1,5 @@
-- Per-connection event subscription: which events this messaging connection delivers.
-- Default covers the common task events; users opt into sprint events in the UI.
ALTER TABLE tasks.messaging_connections
ADD COLUMN IF NOT EXISTS events VARCHAR[] NOT NULL
DEFAULT ARRAY['task.created','task.assigned','task.statusChanged','task.completed']::VARCHAR[];
@@ -0,0 +1,15 @@
-- A user is either a member of a project or not — there is no meaning to two
-- membership rows for the same (user_id, goal_id). Roles live in a separate table
-- (collaboration.users_to_roles), so multi-role membership does not need duplicate rows.
-- Dedupe any existing duplicates (the table has no PK, so key off ctid), then enforce
-- uniqueness. Insert paths use ON CONFLICT DO NOTHING so re-adding a member is idempotent.
DELETE FROM collaboration.users_to_goals
WHERE ctid NOT IN (
SELECT MIN(ctid)
FROM collaboration.users_to_goals
GROUP BY user_id, goal_id
);
CREATE UNIQUE INDEX IF NOT EXISTS users_to_goals_user_goal_uidx
ON collaboration.users_to_goals (user_id, goal_id);
@@ -0,0 +1,5 @@
-- Project channels: opt-out flag for including the RBAC-gated task description in the
-- channel message. Default TRUE (channel is a deliberate broadcast). Personal DMs are
-- unaffected — they always gate the description per recipient (COMPONENT_CAN_WATCH_CONTENT).
ALTER TABLE tasks.messaging_connections
ADD COLUMN IF NOT EXISTS post_content BOOLEAN NOT NULL DEFAULT TRUE;
@@ -0,0 +1 @@
ALTER TABLE tasks.messaging_identity_map ADD COLUMN IF NOT EXISTS external_team_id VARCHAR(255);
@@ -0,0 +1,10 @@
-- 'fixed' — occurrences follow the calendar schedule (rrule anchored at dtstart);
-- 'after-completion' — the next occurrence is one FREQ/INTERVAL step after the
-- day the current instance was completed (Todoist "every!"), no calendar anchor.
ALTER TABLE tasks.recurrence_rules
ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'fixed';
ALTER TABLE tasks.recurrence_rules
DROP CONSTRAINT IF EXISTS recurrence_schedule_mode_valid;
ALTER TABLE tasks.recurrence_rules
ADD CONSTRAINT recurrence_schedule_mode_valid CHECK (schedule_mode IN ('fixed', 'after-completion'));
@@ -0,0 +1,2 @@
ALTER TABLE tasks.integrations DROP CONSTRAINT IF EXISTS integrations_provider_check;
ALTER TABLE tasks.integrations ADD CONSTRAINT integrations_provider_check CHECK (provider IN ('github', 'gitlab', 'gitea'));
@@ -0,0 +1,13 @@
-- Log of sent project-invite emails, used to rate-limit sending:
-- a 24h per-recipient cooldown and an hourly cap per initiator.
-- Rows older than 24 hours are pruned opportunistically before each insert.
CREATE TABLE IF NOT EXISTS collaboration.invite_emails (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
initiator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
sent_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invite_emails_initiator_sent ON collaboration.invite_emails(initiator_id, sent_at);
CREATE INDEX IF NOT EXISTS idx_invite_emails_goal_email_sent ON collaboration.invite_emails(goal_id, email, sent_at);
@@ -0,0 +1,3 @@
ALTER TABLE tv_auth.sso_configs
ADD COLUMN IF NOT EXISTS domain_verify_token VARCHAR,
ADD COLUMN IF NOT EXISTS domain_verified_at TIMESTAMP;
@@ -0,0 +1,6 @@
ALTER TABLE tv_auth.sso_configs
DROP CONSTRAINT IF EXISTS sso_configs_email_domain_restriction_key;
CREATE UNIQUE INDEX IF NOT EXISTS sso_configs_verified_domain_uniq
ON tv_auth.sso_configs (email_domain_restriction)
WHERE domain_verified_at IS NOT NULL;
@@ -0,0 +1,16 @@
-- OAuth 2.1 client registry. Clients are either seeded manually by an operator
-- or created through Dynamic Client Registration (RFC 7591) when it is enabled.
-- Public clients (MCP clients such as ChatGPT or Claude) hold no secret and are
-- authenticated by PKCE alone, so client_secret_hash stays NULL for them.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_clients (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
client_id VARCHAR(64) NOT NULL UNIQUE,
client_secret_hash VARCHAR(64),
name VARCHAR(200) NOT NULL,
redirect_uris VARCHAR[] NOT NULL DEFAULT '{}',
created_via VARCHAR(16) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT oauth_clients_created_via_check CHECK (created_via IN ('manual', 'dcr'))
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON tv_auth.oauth_clients(client_id);
@@ -0,0 +1,24 @@
-- Short-lived, single-use authorization codes issued by the consent screen and
-- redeemed once at the token endpoint. Only the hash is stored, mirroring
-- tv_auth.api_tokens. used_at is set on redemption: a second redemption of the
-- same code is treated as replay and revokes the grant it produced.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_auth_codes (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
code_hash VARCHAR(64) NOT NULL UNIQUE,
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
redirect_uri VARCHAR NOT NULL,
code_challenge VARCHAR(128) NOT NULL,
code_challenge_method VARCHAR(8) NOT NULL DEFAULT 'S256',
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
resource VARCHAR,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
grant_id INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT oauth_auth_codes_challenge_method_check CHECK (code_challenge_method = 'S256')
);
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code_hash ON tv_auth.oauth_auth_codes(code_hash);
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires_at ON tv_auth.oauth_auth_codes(expires_at);
@@ -0,0 +1,33 @@
-- One row per (user, client) authorization — this is what the user sees and
-- revokes as a "connected app". The refresh token hangs off the grant and is
-- rotated on every use; refresh_token_prev_hash keeps the previous value so a
-- replayed refresh token can be detected and the whole grant revoked.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_grants (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
resource VARCHAR,
refresh_token_hash VARCHAR(64) UNIQUE,
refresh_token_prev_hash VARCHAR(64),
refresh_expires_at TIMESTAMP,
last_used_at TIMESTAMP,
revoked_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_user_id ON tv_auth.oauth_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_refresh_token_hash ON tv_auth.oauth_grants(refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_prev_refresh_hash ON tv_auth.oauth_grants(refresh_token_prev_hash);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'oauth_auth_codes_grant_id_fkey'
) THEN
ALTER TABLE tv_auth.oauth_auth_codes
ADD CONSTRAINT oauth_auth_codes_grant_id_fkey
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE SET NULL;
END IF;
END $$;
@@ -0,0 +1,19 @@
-- OAuth access tokens live in tv_auth.api_tokens alongside manually issued
-- tvk_ tokens: same opaque-token storage, same validation path, same permission
-- intersection. grant_id ties an access token to the OAuth grant that minted it,
-- so revoking a connected app deletes its live access tokens immediately.
ALTER TABLE tv_auth.api_tokens
ADD COLUMN IF NOT EXISTS grant_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_grant_id_fkey'
) THEN
ALTER TABLE tv_auth.api_tokens
ADD CONSTRAINT api_tokens_grant_id_fkey
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_api_tokens_grant_id ON tv_auth.api_tokens(grant_id);
@@ -0,0 +1,86 @@
-- Permissions for the surfaces that were previously guarded only by an
-- organization role or by project ownership, and therefore ignored the scope of
-- an API / OAuth token entirely: a token issued with a single permission could
-- still create organizations, add admins, configure SSO and create webhooks.
--
-- These keys let a token be narrowed on those actions too. They never grant
-- anything: the role and ownership checks still run first, and RequireTokenPermission
-- only removes what the token was not given.
INSERT INTO tv_auth.permissions_group (id, name)
VALUES (6, 'organization')
ON CONFLICT (id) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_view',
'View the organization and its members',
6,
'{
"en": "View organization. See the organization and the list of its members.",
"ru": "Просмотр организации. Видеть организацию и список её участников.",
"de": "Organisation ansehen. Die Organisation und ihre Mitglieder sehen.",
"es": "Ver la organización. Ver la organización y la lista de sus miembros.",
"pt-BR": "Ver a organização. Ver a organização e a lista de seus membros."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_manage',
'Create, rename and delete organizations',
6,
'{
"en": "Manage organizations. Create, rename and delete organizations.",
"ru": "Управление организациями. Создавать, переименовывать и удалять организации.",
"de": "Organisationen verwalten. Organisationen erstellen, umbenennen und löschen.",
"es": "Gestionar organizaciones. Crear, renombrar y eliminar organizaciones.",
"pt-BR": "Gerenciar organizações. Criar, renomear e excluir organizações."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_manage_members',
'Add and remove organization members and change their roles',
6,
'{
"en": "Manage members. Add and remove organization members and change their roles.",
"ru": "Управление участниками. Добавлять и удалять участников организации, менять их роли.",
"de": "Mitglieder verwalten. Mitglieder hinzufügen, entfernen und deren Rollen ändern.",
"es": "Gestionar miembros. Añadir y quitar miembros de la organización y cambiar sus roles.",
"pt-BR": "Gerenciar membros. Adicionar e remover membros da organização e alterar seus papéis."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'sso_can_manage',
'Create and change the single sign-on configuration',
6,
'{
"en": "Manage SSO. Create and change the single sign-on configuration of the organization.",
"ru": "Управление SSO. Создавать и изменять настройки единого входа организации.",
"de": "SSO verwalten. Die Single-Sign-on-Konfiguration der Organisation erstellen und ändern.",
"es": "Gestionar SSO. Crear y cambiar la configuración de inicio de sesión único de la organización.",
"pt-BR": "Gerenciar SSO. Criar e alterar a configuração de login único da organização."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'webhooks_can_manage',
'Create, edit and delete project webhooks',
6,
'{
"en": "Manage webhooks. Create, edit and delete webhooks of a project.",
"ru": "Управление вебхуками. Создавать, изменять и удалять вебхуки проекта.",
"de": "Webhooks verwalten. Webhooks eines Projekts erstellen, bearbeiten und löschen.",
"es": "Gestionar webhooks. Crear, editar y eliminar webhooks de un proyecto.",
"pt-BR": "Gerenciar webhooks. Criar, editar e excluir webhooks de um projeto."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
+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) {
+28
View File
@@ -0,0 +1,28 @@
import type { Request } from 'express';
export class PublicApiUrl {
static configured(): string | null {
const raw = process.env.API_PUBLIC_URL;
if (!raw || !raw.trim()) return null;
return raw.trim().replace(/\/+$/, '');
}
static base(req: Request): string {
return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`;
}
static validateOnStartup(): void {
const raw = process.env.API_PUBLIC_URL;
if (!raw || !raw.trim()) return;
let parsed: URL;
try {
parsed = new URL(raw.trim());
} catch {
throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`);
}
}
}
+16
View File
@@ -6,7 +6,10 @@ 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 MessagingRoutes from '../tv-modules/messaging/MessagingRoutes';
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
import OAuthRoutes from '../tv-modules/oauth/OAuthRoutes';
import OAuthWellKnownRoutes from '../tv-modules/oauth/OAuthWellKnownRoutes';
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
@@ -16,6 +19,11 @@ 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;
@@ -34,11 +42,19 @@ const routes: Record<string, RoutableConstructor> = {
'/module/integrations': IntegrationsRoutes,
'/module/notifications': NotificationsRoutes,
'/module/webhooks': WebhooksRoutes,
'/module/messaging': MessagingRoutes,
'/module/api-tokens': ApiTokensRoutes,
'/module/oauth': OAuthRoutes,
'/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,
'/.well-known': OAuthWellKnownRoutes,
};
export default routes;
@@ -0,0 +1,112 @@
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({ period: out.period, timezone: out.timezone, from: out.from, to: 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,
timezone: out.timezone,
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({ period: out.period, timezone: out.timezone, from: out.from, to: 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,175 @@
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 { formatDateInZone } from './helpers'
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, timezone, 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: formatDateInZone(range.from, timezone), to: formatDateInZone(range.to, timezone) },
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)
}
}
+74
View File
@@ -0,0 +1,74 @@
import { type } from 'arktype'
import { sql, type SQL } from 'drizzle-orm'
import { DateTime } from 'luxon'
import type { AnalyticsPeriod } from 'taskview-api'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta, type ResolveRangeArgs } 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 {}
}
}
const DAYS_BY_PERIOD: Record<Exclude<AnalyticsPeriod, 'custom' | 'month'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
}
/**
* Resolve a period into a concrete UTC instant range, with all calendar
* boundaries ("this month", a custom day) computed in the *viewer's* IANA
* timezone — not the server's. The returned Dates are real instants used for
* SQL filtering; the user-facing 'YYYY-MM-DD' labels come from
* {@link formatDateInZone} with the same timezone.
*/
export function resolveRange(args: ResolveRangeArgs): AnalyticsRange | null {
const { period, from, to } = args
const zone = args.timezone && DateTime.now().setZone(args.timezone).isValid ? args.timezone : 'utc'
const now = DateTime.now().setZone(zone)
if (period === 'custom') {
if (!from || !to) return null
const fromDt = DateTime.fromISO(from.slice(0, 10), { zone }).startOf('day')
const toDt = DateTime.fromISO(to.slice(0, 10), { zone }).endOf('day')
if (!fromDt.isValid || !toDt.isValid) return null
if (fromDt > toDt) return null
const maxRangeMs = 366 * 24 * 60 * 60 * 1000
if (toDt.toMillis() - fromDt.toMillis() > maxRangeMs) return null
return { from: fromDt.toJSDate(), to: toDt.toJSDate() }
}
if (period === 'month') {
return { from: now.startOf('month').toJSDate(), to: now.toJSDate() }
}
return { from: now.minus({ days: DAYS_BY_PERIOD[period] }).toJSDate(), to: now.toJSDate() }
}
/**
* Format a range boundary as a 'YYYY-MM-DD' calendar day in the viewer's
* timezone. Slicing a UTC ISO string instead would shift the day across the
* date line for offset timezones (e.g. a "this month" range rendering from the
* 31st of the prior month).
*/
export function formatDateInZone(date: Date, timezone?: string): string {
const dt = DateTime.fromJSDate(date).setZone(timezone && DateTime.fromJSDate(date).setZone(timezone).isValid ? timezone : 'utc')
return dt.toISODate() as string
}
@@ -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 { StaleTasksSection } from './quality/StaleTasksSection'
import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
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 { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
// import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
// import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
// import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
// 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(), // disabled
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
// new OverdueByAgeSection(), // disabled
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// Usage
new StatusDistributionSection(),
// new ActiveProjectsSection(), // disabled
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new IncomePerProjectMonthSection(),
new ExpensePerProjectMonthSection(),
// new IncomePerTagMonthSection(), // disabled
// new ExpensePerTagMonthSection(), // disabled
// new TopProjectsByAmountSection(), // disabled
]
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', de: 'Ohne Tags', es: 'Sin etiquetas' }
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(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CreatedTasksKpi implements SectionBuilder {
readonly id = 'kpi.created_tasks'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: current,
unit: 'count',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,52 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimeKpi implements SectionBuilder {
readonly id = 'kpi.cycle_time'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const median = await ctx.repository.medianCycleTime(ctx.accessibleGoalIds, ctx.range)
const value = median === null ? 0 : Math.round(median * 10) / 10
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value,
unit: 'days',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}

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