Compare commits

...

60 Commits

Author SHA1 Message Date
Nikolai Giman 6c990aff4f chore: 1.50.2 2026-07-12 22:49:43 +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
489 changed files with 27252 additions and 1502 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
+393 -97
View File
@@ -1,162 +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>
[![License](https://img.shields.io/badge/license-Source--Available-blue)](./LICENSE)
[![Active Development](https://img.shields.io/badge/status-active-brightgreen)]()
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**iOS**](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867) · [**Android**](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
<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>
## 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)
<details>
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
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
<br>
## Key Features
- Projects, lists, tasks, and subtasks
- Kanban boards
- Clear responsibility assignment
- Permissions and roles
- Web and mobile clients
- Self-hosted by design
<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%">
## Licensing
</details>
TaskView uses a source-available license.
<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:
- self-host TaskView
- modify the source code
- use it internally within your organization
<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>
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
---
See LICENSE for full terms.
TaskView is a trademark of its authors.
## About TaskView
## Architecture
TaskView is a source-available project and task management platform for small and growing software teams.
This repository is a monorepo.
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.
Main product packages:
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.
- `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
Your projects. Your infrastructure. Your data.
Product packages share a single version.
## Who TaskView is for
TaskView is a good fit for:
## Versioning
* 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.
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.
## Why TaskView
* **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.
## Status
## Features
TaskView is under active development.
Breaking changes may occur between versions.
### Project and task management
## Getting started
* 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
This setup is intended for local development.
### Team collaboration
### Development
* Multiple organizations and projects
* User invites and granular permissions
* Clear responsibility assignment
* Real-time updates
* Notifications
* Organization-level user management
**Prerequisites:**
- Docker
- PostgreSQL
- On macOS you can use https://postgresapp.com/
- Bun (used for API development)
- pnpm
### 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
Planned areas of development include:
- [X] Migrate to NuxtUI or similar ui library
- [X] Enterprise SSO and identity integrations
- [X] Redesign
- [X] API tokens
- [X] Webhooks
- [X] MCP server
- [X] Notifications
- [X] Analytics
- [X] API client
- [ ] Desktop version
- [ ] Plugin / extension system
* [ ] Desktop application
* [ ] Plugin and extension system
* [ ] Additional third-party integrations
* [ ] Expanded automation capabilities
* [ ] Continued UI and UX improvements
The roadmap may change based on product priorities and community feedback.
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
## 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 using the contact information provided in the repository or on the TaskView website.
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.
+7
View File
@@ -52,6 +52,13 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
# 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
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.42.5",
"version": "1.50.2",
"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,6 +72,7 @@
"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:^",
+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');
}
+23 -1
View File
@@ -5,6 +5,7 @@ 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 cookieParser from 'cookie-parser';
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
@@ -13,6 +14,8 @@ export default class App {
public port: number;
constructor(port: number) {
LoginMethods.validateOnStartup();
this.app = express();
this.port = port;
@@ -31,7 +34,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) {
@@ -51,7 +64,16 @@ 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);
}
+9
View File
@@ -14,6 +14,9 @@ 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';
@@ -41,6 +44,9 @@ export class AppUser {
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;
@@ -61,6 +67,9 @@ export class AppUser {
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,
+23 -1
View File
@@ -1,5 +1,5 @@
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 { $logger } from '../modules/logget';
@@ -16,6 +16,28 @@ export interface AppEvents {
'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}`;
+15 -4
View File
@@ -9,7 +9,7 @@ import {
PermissionsSchema,
} from 'taskview-db-schemas';
import { Database } from '../modules/db';
import type { FetchGoalIdsWithAnyPermissionParams, 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';
@@ -33,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 [];
@@ -42,7 +53,7 @@ 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"
@@ -54,7 +65,7 @@ export class GoalPermissionsRepository {
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);
+6
View File
@@ -4,12 +4,18 @@ import { NotificationDispatcher } from '../tv-modules/notifications/Notification
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';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
new RealtimeDispatcher(),
new WebhooksDispatcher(),
new TimeTrackingDispatcher(),
new SprintsDispatcher(),
new RecurrenceDispatcher(),
new MessagingDispatcher(),
];
export function registerAllEventHandlers() {
+154
View File
@@ -550,5 +550,159 @@
"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"
]
}
}
@@ -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);
+8
View File
@@ -6,6 +6,7 @@ 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 SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
@@ -18,6 +19,9 @@ 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;
@@ -36,12 +40,16 @@ const routes: Record<string, RoutableConstructor> = {
'/module/integrations': IntegrationsRoutes,
'/module/notifications': NotificationsRoutes,
'/module/webhooks': WebhooksRoutes,
'/module/messaging': MessagingRoutes,
'/module/api-tokens': ApiTokensRoutes,
'/module/sessions': SessionsRoutes,
'/module/organizations': OrganizationRoutes,
'/module/sso': SsoRoutes,
'/module/analytics': AnalyticsRoutes,
'/module/time-tracking': TimeTrackingRoutes,
'/module/ui-preferences': UiPreferencesRoutes,
'/module/sprints': SprintsRoutes,
'/module/recurrence': RecurrenceRoutes,
'/scim/v2': ScimRoutes,
};
@@ -6,6 +6,11 @@ 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) {
@@ -23,7 +28,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
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
@@ -36,6 +41,7 @@ export class AnalyticsController {
organizationId: out.organizationId,
period: out.period,
range,
timezone: out.timezone,
sectionIds,
})
return res.tvJson(data)
@@ -71,7 +77,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
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)
@@ -3,12 +3,14 @@ import type {
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,
@@ -28,6 +30,10 @@ export class AnalyticsManager {
this.registry = new SectionRegistry()
}
getCatalog(): AnalyticsSectionCatalogEntry[] {
return this.registry.catalog()
}
async getAccessibleGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW])
}
@@ -40,7 +46,7 @@ export class AnalyticsManager {
}
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
const { scope, organizationId, period, range, sectionIds } = params
const { scope, organizationId, period, range, timezone, sectionIds } = params
const allAccessible = await this.getAccessibleGoalIds(organizationId)
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
@@ -81,7 +87,7 @@ export class AnalyticsManager {
return {
scope,
period,
range: { from: range.from.toISOString(), to: range.to.toISOString() },
range: { from: formatDateInZone(range.from, timezone), to: formatDateInZone(range.to, timezone) },
sections,
availableGoals,
failedSectionIds,
@@ -7,6 +7,8 @@ import type {
ActiveProjectsSectionRow,
AgingOpenTasksSectionRow,
AmountCoverageKpiRow,
AmountPerProjectMonthSectionRow,
AmountPerTagMonthSectionRow,
BlockedByDependenciesSectionRow,
CompletedTasksKpiRow,
CreatedTasksKpiRow,
@@ -30,7 +32,8 @@ import type {
TotalIncomeKpiRow,
WorkloadByAssigneeSectionRow,
} from './sections/row.types'
import type { AnalyticsRange, DrillDownTaskRow } from './types'
import { UNTAGGED_TAG_ID } from './types'
import type { AnalyticsRange, DrillDownTaskRow, FetchAmountPerProjectMonthArgs, FetchAmountPerTagMonthArgs } from './types'
type Bucket = 'day' | 'week' | 'month'
@@ -619,6 +622,118 @@ export class AnalyticsRepository {
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 (
@@ -22,6 +22,7 @@ export default class AnalyticsRoutes implements Routable {
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)
}
}
+43 -23
View File
@@ -1,7 +1,8 @@
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 } from './types'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta, type ResolveRangeArgs } from './types'
const MAX_INT32 = 2147483647
@@ -23,32 +24,51 @@ export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
}
}
export function resolveRange(
period: AnalyticsPeriod,
from?: string,
to?: string,
): AnalyticsRange | null {
const now = new Date()
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 fromDate = new Date(from)
const toDate = new Date(to)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
if (fromDate > toDate) return null
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
return { from: fromDate, to: toDate }
const 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() }
}
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
if (period === 'month') {
return { from: now.startOf('month').toJSDate(), to: now.toJSDate() }
}
const days = daysByPeriod[period]
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return { from: fromDate, to: now }
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
}
@@ -2,21 +2,20 @@ import type { SectionBuilder } from '../types'
import { CreatedTasksKpi } from './kpi/CreatedTasksKpi'
import { CompletedTasksKpi } from './kpi/CompletedTasksKpi'
import { OverdueKpi } from './kpi/OverdueKpi'
import { CycleTimeKpi } from './kpi/CycleTimeKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
// import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
// import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
@@ -24,13 +23,54 @@ import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
import { NetProfitKpi } from './financial/NetProfitKpi'
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
import type { AnalyticsSectionCatalogEntry } from 'taskview-api'
import { parseEnabledSectionIds } from './sectionEnv'
import { sectionLocales } from './locales'
// ---------------------------------------------------------------------------
// Disabled sections — re-enable once the underlying data model can support them
// honestly. Until then they would report misleading numbers.
//
// 1. AgingOpenTasksSection (chart.aging_open_tasks)
// Measures (now - date_creation) for every open task per assignee. Inflates
// the number with backlog tasks nobody has started working on yet.
// Needs: a boolean is_in_progress column on tasks.tasks, OR a "wip" flag
// on tasks.statuses, so SQL can count only tasks that have been picked up.
//
// 2. CycleTimeKpi (kpi.cycle_time)
// 3. CycleTimeHistogramSection (chart.cycle_time_histogram)
// 4. CycleTimePerProjectSection (chart.cycle_time_per_project)
// All three measure (date_complete - date_creation) for completed tasks.
// That is actually lead time (includes time the task sat in backlog), not
// cycle time. A task that spent 6 months in backlog and was then finished
// in 2 days shows up as a 6-month "cycle".
// Needs: a started_at timestamp on tasks.tasks (set when the task moves
// into an in-progress status) so SQL can compute (date_complete - started_at).
// Trigger or app logic must set started_at on the first transition into a
// "wip" status.
//
// 5. TimeInKanbanStatusSection (chart.time_in_kanban_status)
// Misleadingly named — it averages (now - edit_date) for currently-open
// tasks grouped by their current status. It does NOT measure how long
// each task has actually been in its current status; any unrelated edit
// (description change, assignee change) resets the clock for the metric.
// Needs: a tasks.status_history table tracking (task_id, status_id,
// entered_at, exited_at). Then "time in status X" = avg(exited_at OR now
// - entered_at) for rows in that status. Without a transition log, this
// metric cannot be computed correctly.
// ---------------------------------------------------------------------------
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
const builders: SectionBuilder[] = [
// KPI
new CreatedTasksKpi(),
new CompletedTasksKpi(),
new OverdueKpi(),
new CycleTimeKpi(),
// new CycleTimeKpi(), // disabled — see top-of-file comment
new TotalIncomeKpi(),
new TotalExpenseKpi(),
new NetProfitKpi(),
@@ -39,43 +79,72 @@ const builders: SectionBuilder[] = [
new AmountCoverageKpi(),
// Productivity
new ThroughputSection(),
// new PriorityMixOverTimeSection(),
new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
// new BlockedByDependenciesSection(),
// new TimeInKanbanStatusSection(),
// new AgingOpenTasksSection(),
new BlockedByDependenciesSection(),
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
new OverdueByAgeSection(),
// new CycleTimeHistogramSection(),
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// Usage
// new StatusDistributionSection(),
new StatusDistributionSection(),
new ActiveProjectsSection(),
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new IncomePerProjectMonthSection(),
new ExpensePerProjectMonthSection(),
new IncomePerTagMonthSection(),
new ExpensePerTagMonthSection(),
new TopProjectsByAmountSection(),
]
export class SectionRegistry {
private readonly byId: Map<string, SectionBuilder>
private readonly enabledOrder: SectionBuilder[]
constructor() {
this.byId = new Map(builders.map(b => [b.id, b]))
const enabledIds = parseEnabledSectionIds(process.env.ANALYTICS_SECTIONS)
if (enabledIds && enabledIds.length > 0) {
this.enabledOrder = enabledIds
.map((id: string) => this.byId.get(id))
.filter((b: SectionBuilder | undefined): b is SectionBuilder => !!b)
} else {
this.enabledOrder = [...builders]
}
}
all(): SectionBuilder[] {
return [...this.byId.values()]
return [...this.enabledOrder]
}
get(id: string): SectionBuilder | undefined {
return this.byId.get(id)
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()
return ids.map(id => this.byId.get(id)).filter((b): b is SectionBuilder => !!b)
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,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,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,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,
}
}
File diff suppressed because it is too large Load Diff
@@ -128,3 +128,17 @@ export type TopProjectsByAmountSectionRow = {
income: number
expense: number
}
export type AmountPerTagMonthSectionRow = {
month: string
tag_id: number
tag_name: string
amount: number
}
export type AmountPerProjectMonthSectionRow = {
month: string
goal_id: number
goal_name: string
amount: number
}
@@ -0,0 +1,8 @@
export function parseEnabledSectionIds(raw: string | undefined): string[] | null {
if (!raw) return null
const ids = raw
.split(',')
.map(s => s.trim())
.filter(Boolean)
return ids.length > 0 ? ids : null
}
@@ -4,12 +4,7 @@ import { sectionLocales } from '../locales'
type StatusKey = 'active' | 'fading' | 'dead' | 'empty'
const COLOR_BY_STATUS: Record<StatusKey, 'success' | 'warning' | 'danger' | 'neutral'> = {
active: 'success',
fading: 'warning',
dead: 'danger',
empty: 'neutral',
}
const STATUS_ORDER: readonly StatusKey[] = ['active', 'fading', 'dead', 'empty']
export class ActiveProjectsSection implements SectionBuilder {
readonly id = 'chart.active_projects'
@@ -27,7 +22,8 @@ export class ActiveProjectsSection implements SectionBuilder {
const rows = await ctx.repository.fetchActiveProjects(ctx.accessibleGoalIds)
const loc = this.loc
const labelTexts = rows.map(r => loc.labels![r.status_key])
const countByStatus = new Map(rows.map(r => [r.status_key, Number(r.count)]))
const labelTexts = STATUS_ORDER.map(key => loc.labels![key])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
@@ -38,19 +34,14 @@ export class ActiveProjectsSection implements SectionBuilder {
{
id: 'count',
label: loc.datasets!.count,
values: rows.map(r => Number(r.count)),
meta: { statusKeys: rows.map(r => r.status_key) },
values: STATUS_ORDER.map(key => countByStatus.get(key) ?? 0),
meta: { statusKeys: STATUS_ORDER },
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
const firstRow = rows[0]
if (firstRow) {
payload.datasets[0].colorToken = COLOR_BY_STATUS[firstRow.status_key] ?? 'primary'
}
return {
id: this.id,
title: loc.title,
+31 -2
View File
@@ -20,8 +20,9 @@ const nonNegativeIntFromQuery = type('string | number')
export const AnalyticsFetchSectionsArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'sections?': 'string',
@@ -30,8 +31,9 @@ export const AnalyticsFetchSectionsArkType = type({
export const AnalyticsDrillDownArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'bucket?': 'string',
@@ -53,6 +55,14 @@ export type AnalyticsRange = {
to: Date
}
export type ResolveRangeArgs = {
period: AnalyticsPeriod
/** IANA timezone the period boundaries are resolved in; defaults to UTC. */
timezone?: string
from?: string
to?: string
}
export type BuilderContext = {
appUser: AppUser
scope: AnalyticsScope
@@ -97,6 +107,8 @@ export type AnalyticsArgBuildSections = {
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
/** IANA timezone of the viewer; used to format the response range. */
timezone?: string
sectionIds?: string[]
}
@@ -108,3 +120,20 @@ export type AnalyticsArgDrillDown = {
range: AnalyticsRange
arg: SectionDrillDownArg
}
export type FetchAmountPerTagMonthArgs = {
goalIds: number[]
range: AnalyticsRange
transactionType: 0 | 1
}
export type FetchAmountPerProjectMonthArgs = {
goalIds: number[]
range: AnalyticsRange
transactionType: 0 | 1
}
// Sentinel id used in tag-grouped analytics queries to represent tasks that
// have no tags assigned. tasks.tags.id is GENERATED ALWAYS AS IDENTITY (positive
// integers only), so -1 cannot collide with a real tag.
export const UNTAGGED_TAG_ID = -1
+194 -1
View File
@@ -6,7 +6,11 @@ import { z } from 'zod';
import { Email } from '../../core/Email';
import { $logger } from '../../modules/logget';
import {
ChangeDefaultUserCredentialsSchema,
ChangeOwnPasswordByPasswordSchema,
ChangeOwnPasswordSchema,
ChangePasswordDataScheme,
type PasswordChangeConfirmationMode,
ConfirmEmailReqDataSchema,
RefreshTokenSchema,
RemindPasswordSchema,
@@ -15,13 +19,19 @@ import {
UserJwtPayloadSchema,
} from '../../types/auth.types';
import { generateString, isEmail, time } from '../../utils/helpers';
import { LoginMethods } from './LoginMethods';
import EnEmailTemplate from './mail/confirm-email-en';
import RuEmailTemplate from './mail/confirm-email-ru';
import LoginCodeEmailTemplate from './mail/login-code-en';
import type { ExternalAuthUser } from './strategies/external-auth.types';
import { OrganizationRepository } from '../organizations/OrganizationRepository';
import { GoalsRepository } from '../goals/GoalsRepository';
const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
const PASSWORD_CHANGE_CODE_TTL_S = 15 * 60;
const PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S = 60;
// Seeded by migration 0.0.0 (app_permissions.sql) on self-hosted installs.
const DEFAULT_USER_EMAIL = 'test@mail.dest';
export default class AuthController {
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
@@ -30,12 +40,14 @@ export default class AuthController {
private readonly refreshTokenCookieName: string = 'taskview-refresh';
private readonly orgRepository: OrganizationRepository = new OrganizationRepository();
private readonly goalsRepository: GoalsRepository = new GoalsRepository();
private async createPersonalWorkspace(userId: number, email: string, login: string) {
const slug = `org-${crypto.randomUUID().slice(0, 8)}`
const org = await this.orgRepository.create({ name: `${login}'s workspace`, slug }, userId, true)
if (org) {
await this.orgRepository.addMember(org.id, email, 'owner')
await this.goalsRepository.createInboxGoal({ ownerId: userId, organizationId: org.id })
}
}
@@ -310,7 +322,7 @@ export default class AuthController {
loginByCode = async (req: Request, res: Response) => {
const schema = z.object({
email: z.string().trim().email().toLowerCase(),
code: z.string().trim().regex(/^\d{6}$/, '6-digit code'),
code: z.string().trim().min(6).max(64),
});
const data = schema.safeParse(req.body);
@@ -653,6 +665,187 @@ export default class AuthController {
return res.json(newTokens);
};
getLoginOptions = async (_req: Request, res: Response) => {
return res.status(200).send({
magicLink: LoginMethods.isEnabled('magic-link'),
password: LoginMethods.isEnabled('password'),
sso: LoginMethods.isEnabled('sso'),
socialProviders: LoginMethods.availableSocialProviders(),
});
};
private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode {
return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email';
}
getPasswordChangeMode = async (_req: Request, res: Response) => {
return res.status(200).send({ mode: this.passwordChangeConfirmationMode() });
};
sendPasswordChangeCode = async (req: Request, res: Response) => {
if (this.passwordChangeConfirmationMode() !== 'email') {
return res.status(403).send();
}
const userEmail = req.appUser.getUserData()?.email;
if (!userEmail) {
return res.status(400).end();
}
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
if (!userData) {
return res.status(400).end();
}
const now = Math.floor(Date.now() / 1000);
const sinceLastCode = userData.remind_password_time ? now - userData.remind_password_time : null;
if (sinceLastCode !== null && sinceLastCode < PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S) {
return res.status(429).send({
message: 'Please wait before requesting another code.',
retryAfter: PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S - sinceLastCode,
});
}
// High-entropy code: the shared remind_password_code column is also redeemable
// via the unauthenticated /password/reset endpoint, so a short numeric code
// would be brute-forceable there.
const code = generateString(12);
const saved = await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, code, now);
if (!saved) {
$logger.error(`Can not save password change code for user ${userData.id}`);
return res.status(500).end();
}
const text = `Your TaskView password change code is ${code}\n\nUse this code to confirm your new password. The code expires in 15 minutes.\n\nIf you didn't request this change, ignore this email.`;
Email.send({
text,
to: userEmail,
subject: `Your TaskView password change code: ${code}`,
from: process.env.SMTP_FROM_EMAIL as string,
})
.then((ok) => {
if (!ok) $logger.error({ to: userEmail }, 'Failed to send password change code email');
})
.catch((err) => $logger.error({ err, to: userEmail }, 'Failed to send password change code email'));
return res.status(200).end();
};
changeOwnPassword = async (req: Request, res: Response) => {
const userEmail = req.appUser.getUserData()?.email;
if (!userEmail) {
return res.status(400).end();
}
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
if (!userData) {
return res.status(400).send();
}
if (this.passwordChangeConfirmationMode() === 'password') {
const parsedData = ChangeOwnPasswordByPasswordSchema.safeParse(req.body);
if (!parsedData.success) {
return res.status(400).send();
}
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
if (!validPassword) {
return res.status(403).send({ field: 'currentPassword' });
}
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
}
const parsedData = ChangeOwnPasswordSchema.safeParse(req.body);
if (!parsedData.success) {
return res.status(400).send();
}
if (!userData.remind_password_code || !userData.remind_password_time) {
return res.status(400).send();
}
const now = Math.floor(Date.now() / 1000);
if (now > userData.remind_password_time + PASSWORD_CHANGE_CODE_TTL_S) {
return res.status(400).send();
}
if (userData.remind_password_code !== parsedData.data.code) {
return res.status(400).send();
}
await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, null, null);
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
};
private async applyNewPassword(res: Response, req: Request, userId: number, newPassword: string) {
const passwordHash = hashSync(newPassword, 10);
const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userId);
if (!result) {
$logger.error(`Can not update password for user ${userId}`);
return res.status(500).send();
}
const currentSessionId = req.appUser.getTokenId();
await req.appUser.authManager.sessionStorage.deleteAllSessions(userId, currentSessionId);
return res.status(200).send({ changed: true });
}
changeDefaultUserCredentials = async (req: Request, res: Response) => {
const parsedData = ChangeDefaultUserCredentialsSchema.safeParse(req.body);
if (!parsedData.success) {
return res.status(400).send();
}
const userEmail = req.appUser.getUserData()?.email;
if (!userEmail) {
return res.status(400).end();
}
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
if (!userData || userData.email.toLowerCase() !== DEFAULT_USER_EMAIL) {
return res.status(403).send();
}
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
if (!validPassword) {
return res.status(403).send({ field: 'currentPassword' });
}
const { login, email } = parsedData.data;
if (login !== userData.login && (await req.appUser.authManager.repository.getUserByLogin(login))) {
return res.status(409).send({ field: 'login' });
}
if (email !== userData.email && (await req.appUser.authManager.repository.getUserByLogin(email, true))) {
return res.status(409).send({ field: 'email' });
}
const updated = await req.appUser.authManager.repository.updateUserCredentials({
userId: userData.id,
oldEmail: userData.email,
login,
email,
passwordHash: hashSync(parsedData.data.password, 10),
});
if (updated === 'conflict') {
return res.status(409).send({ field: 'email' });
}
if (updated !== 'ok') {
return res.status(500).send();
}
// JWTs carry login/email and refresh does not re-read them from the DB,
// so drop every session and make the user sign in with the new credentials.
await req.appUser.authManager.sessionStorage.deleteAllSessions(userData.id);
this.clearRefreshToken(res);
return res.status(200).send({ changed: true });
};
sendDeleteAccountCode = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id;
const userEmail = req.appUser.getUserData()?.email;
+35 -1
View File
@@ -1,6 +1,8 @@
import { eq } from 'drizzle-orm';
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import type { RegisterUserInDb, UserDbRecord } from '../../types/auth.types';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
export default class AuthModel {
private readonly db: Database;
@@ -133,6 +135,38 @@ export default class AuthModel {
}
}
async updateUserCredentials(args: UpdateUserCredentialsArgs): Promise<UpdateUserCredentialsResult> {
try {
await this.db.dbDrizzle.transaction(async (tx) => {
await tx
.update(UsersSchema)
.set({ login: args.login, email: args.email, password: args.passwordHash })
.where(eq(UsersSchema.id, args.userId));
await tx
.update(OrganizationMembersSchema)
.set({ email: args.email })
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
await tx
.update(CollaborationUsersSchema)
.set({ email: args.email })
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
await tx
.update(SsoIdentitiesSchema)
.set({ email: args.email })
.where(eq(SsoIdentitiesSchema.userId, args.userId));
});
return 'ok';
} catch (error) {
// unique(organization_id, email): the new email is already an invited member of one of the user's orgs
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
if (pgCode === '23505') {
return 'conflict';
}
$logger.error(error, `Can not update credentials for user ${args.userId}`);
return 'error';
}
}
async updateUserPassword(password: string, userId: number): Promise<boolean> {
try {
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
+17 -5
View File
@@ -2,6 +2,8 @@ import { Router, type NextFunction, type Request, type Response } from 'express'
import type { Routable } from '../../types/routable.type';
import AuthController from './AuthController';
import { IsLoggedIn } from './middlewares/is-logged-in';
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth';
import { RequireAnyLoginMethod, RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method';
import passport from './strategies/passport-login';
import { ExternalProviderScope } from './strategies/external-auth.types';
export default class AuthRoutes implements Routable {
@@ -19,13 +21,20 @@ export default class AuthRoutes implements Routable {
}
initRoutes() {
this.router.post('/send-login-code', this.authController.sendLoginCode);
this.router.post('/login-by-code', this.authController.loginByCode);
this.router.post('/login', this.authController.login);
this.router.get('/login-options', this.authController.getLoginOptions);
this.router.post('/send-login-code', [RequireLoginMethod('magic-link')], this.authController.sendLoginCode);
// Shared one-time-code redemption: magic-link emails, SSO callbacks and social
// OAuth callbacks all complete the login through this endpoint
this.router.post('/login-by-code', [RequireAnyLoginMethod(['magic-link', 'sso', 'social'])], this.authController.loginByCode);
this.router.post('/login', [RequireLoginMethod('password')], this.authController.login);
this.router.post('/registration', this.authController.registration);
this.router.get('/confirm/email/:code/login/:login', this.authController.confirmEmail);
this.router.post('/email/recovery', this.authController.remindPassword);
this.router.post('/password/reset', this.authController.changeRemindedPassword);
this.router.post('/email/recovery', [RequireLoginMethod('password')], this.authController.remindPassword);
this.router.post('/password/reset', [RequireLoginMethod('password')], this.authController.changeRemindedPassword);
this.router.get('/password/change/mode', [IsLoggedIn], this.authController.getPasswordChangeMode);
this.router.post('/password/change/code', [IsLoggedIn, RejectApiTokenAuth], this.authController.sendPasswordChangeCode);
this.router.post('/password/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeOwnPassword);
this.router.post('/credentials/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeDefaultUserCredentials);
this.router.post('/logout', [IsLoggedIn], this.authController.logout);
this.router.post('/refresh/token', this.authController.refreshTokens);
this.router.post('/delete/account/code', [IsLoggedIn], this.authController.sendDeleteAccountCode);
@@ -33,6 +42,7 @@ export default class AuthRoutes implements Routable {
this.router.get(
'/provider/:providerName',
RequireSocialProvider,
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
scope: ExternalProviderScope[req.params.providerName],
session: false,
@@ -43,6 +53,7 @@ export default class AuthRoutes implements Routable {
);
this.router.get(
'/provider/:providerName/callback',
RequireSocialProvider,
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
scope: ExternalProviderScope[req.params.providerName], session: false
})(req, res, next),
@@ -51,6 +62,7 @@ export default class AuthRoutes implements Routable {
this.router.post(
'/provider/:providerName/callback',
RequireSocialProvider,
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
scope: ExternalProviderScope[req.params.providerName], session: false
})(req, res, next),
+66
View File
@@ -0,0 +1,66 @@
import type { LoginMethod } from '../../types/auth.types';
export class LoginMethods {
static readonly ALL: LoginMethod[] = ['magic-link', 'password', 'sso', 'social'];
static enabled(): Set<LoginMethod> {
const raw = process.env.AUTH_LOGIN_METHODS;
if (!raw || !raw.trim()) {
return new Set(LoginMethods.ALL);
}
return new Set(LoginMethods.parse(raw).valid);
}
static isEnabled(method: LoginMethod): boolean {
return LoginMethods.enabled().has(method);
}
static validateOnStartup(): void {
const raw = process.env.AUTH_LOGIN_METHODS;
if (!raw || !raw.trim()) return;
const { valid, invalid } = LoginMethods.parse(raw);
if (invalid.length > 0) {
throw new Error(
`AUTH_LOGIN_METHODS contains unknown values: ${invalid.join(', ')}. Allowed: ${LoginMethods.ALL.join(', ')}`
);
}
if (valid.length === 0) {
throw new Error('AUTH_LOGIN_METHODS disables every login method — nobody would be able to sign in');
}
}
static configuredSocialProviders(): string[] {
const providers: string[] = [];
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_CALLBACK_URL) {
providers.push('google');
}
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_CALLBACK_URL) {
providers.push('github');
}
if (
process.env.APPLE_CLIENT_ID &&
process.env.APPLE_TEAM_ID &&
process.env.APPLE_KEY_ID &&
process.env.APPLE_CALLBACK_URL &&
process.env.APPLE_KEY_LOCATION
) {
providers.push('apple');
}
return providers;
}
static availableSocialProviders(): string[] {
return LoginMethods.isEnabled('social') ? LoginMethods.configuredSocialProviders() : [];
}
private static parse(raw: string): { valid: LoginMethod[]; invalid: string[] } {
const values = raw
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
const valid = values.filter((value): value is LoginMethod => (LoginMethods.ALL as string[]).includes(value));
const invalid = values.filter((value) => !(LoginMethods.ALL as string[]).includes(value));
return { valid, invalid };
}
}
@@ -0,0 +1,29 @@
import type { NextFunction, Request, Response } from 'express';
import type { LoginMethod } from '../../../types/auth.types';
import { LoginMethods } from '../LoginMethods';
export const RequireLoginMethod = (method: LoginMethod) => {
return (_req: Request, res: Response, next: NextFunction) => {
if (!LoginMethods.isEnabled(method)) {
return res.status(403).send();
}
return next();
};
};
export const RequireAnyLoginMethod = (methods: LoginMethod[]) => {
return (_req: Request, res: Response, next: NextFunction) => {
if (!methods.some((method) => LoginMethods.isEnabled(method))) {
return res.status(403).send();
}
return next();
};
};
export const RequireSocialProvider = (req: Request, res: Response, next: NextFunction) => {
const providerName = String(req.params.providerName || '').toLowerCase();
if (!LoginMethods.availableSocialProviders().includes(providerName)) {
return res.status(403).send();
}
return next();
};
@@ -16,8 +16,7 @@ export function initAppleStrategy() {
!process.env.APPLE_KEY_ID ||
!process.env.APPLE_CALLBACK_URL ||
!process.env.APPLE_KEY_LOCATION) {
$logger.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
console.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
$logger.debug("Apple login is not configured (APPLE_CLIENT_ID / APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_CALLBACK_URL / APPLE_KEY_LOCATION) — skipping");
return;
}
@@ -7,8 +7,7 @@ import type { VerifyCallback } from "passport-google-oauth20";
export function initGithubStrategy() {
if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET || !process.env.GITHUB_CALLBACK_URL) {
$logger.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
console.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
$logger.debug("GitHub login is not configured (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / GITHUB_CALLBACK_URL) — skipping");
return;
}
@@ -5,8 +5,7 @@ import type { ExternalAuthUser } from "./external-auth.types";
export function initGoogleStrategy() {
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_CALLBACK_URL) {
$logger.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
console.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
$logger.debug("Google login is not configured (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_CALLBACK_URL) — skipping");
return;
}
const options = {
@@ -74,7 +74,7 @@ export class CollaborationRepository {
}
await this.db
.query(`insert into collaboration.users_to_goals (goal_id, user_id) values ($1, $2)`, [goalId, userId])
.query(`insert into collaboration.users_to_goals (goal_id, user_id) values ($1, $2) on conflict (user_id, goal_id) do nothing`, [goalId, userId])
.catch(logError);
return userId ?? false;
@@ -220,7 +220,7 @@ export class CollaborationRepository {
await tx.insert(CollaborationUsersToGoalsSchema).values({
userId: userId,
goalId: args.goalId,
});
}).onConflictDoNothing();
return user;
})
+20
View File
@@ -63,8 +63,16 @@ export default class GoalsManager {
return new GoalItemForClient(goal, (await this.getPermissionsForGoal(goal.id)).getAllPermissions());
}
private async isInboxGoal(goalId: number): Promise<boolean> {
const goal = await this.goalsRepository.findGoalById(goalId);
return !!goal && goal.isInbox;
}
/** @deprecated use deleteGoalNew instead */
async deleteGoal(goalId: number): Promise<boolean> {
if (await this.isInboxGoal(goalId)) {
return false;
}
return await this.goalsRepository.deleteGoal(goalId);
}
@@ -88,6 +96,9 @@ export default class GoalsManager {
}
async updateArchive(goalId: number, archive: GoalItemInDb['archive']) {
if (archive === 1 && (await this.isInboxGoal(goalId))) {
return false;
}
return await this.goalsRepository.updateArchive(goalId, archive);
}
@@ -134,6 +145,12 @@ export default class GoalsManager {
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsItemForClientWithPermissions | false> {
// The Inbox must never be archived. Archiving flows through this endpoint
// (PATCH /module/goals), not the unrouted updateArchive, so the guard lives here.
if (goalData.archive === 1 && (await this.isInboxGoal(goalData.id))) {
return false;
}
const goal = await this.goalsRepository.updateGoalNew(goalData);
if (!goal) {
@@ -153,6 +170,9 @@ export default class GoalsManager {
}
async deleteGoalNew(goalData: GoalsArgDelete) {
if (await this.isInboxGoal(goalData.goalId)) {
return false;
}
return await this.goalsRepository.deleteGoalNew(goalData);
}
+52 -3
View File
@@ -12,7 +12,7 @@ import type { AddGoalToDbArg, GoalItemInDb, GoalItemsInDb, UpdateGoalDbArg } fro
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import { callWithCatch } from '../../utils/helpers';
import type { GoalsArgAdd, GoalsArgDelete, GoalsArgUpdate } from './types';
import type { GoalsArgAdd, GoalsArgCreateInbox, GoalsArgDelete, GoalsArgUpdate } from './types';
export class GoalsRepository {
private readonly db: Database;
@@ -156,6 +156,8 @@ export class GoalsRepository {
archive: GoalsSchema.archive,
backlogVersion: GoalsSchema.backlogVersion,
organizationId: GoalsSchema.organizationId,
estimateUnit: GoalsSchema.estimateUnit,
isInbox: GoalsSchema.isInbox,
})
.from(GoalsSchema)
.leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId))
@@ -231,9 +233,56 @@ export class GoalsRepository {
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
async findInboxGoal(organizationId: number): Promise<GoalsSchemaTypeForSelect | false> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(goalData).where(eq(GoalsSchema.id, goalData.id)).returning()
this.db.dbDrizzle
.select()
.from(GoalsSchema)
.where(and(eq(GoalsSchema.organizationId, organizationId), eq(GoalsSchema.isInbox, true)))
);
if (!result || result.length === 0) return false;
return result[0];
}
async createInboxGoal(args: GoalsArgCreateInbox): Promise<GoalsSchemaTypeForSelect | false> {
const existing = await this.findInboxGoal(args.organizationId);
if (existing) {
return existing;
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(GoalsSchema)
.values({
name: 'Inbox',
owner: args.ownerId,
organizationId: args.organizationId,
isInbox: true,
})
.returning()
);
if (!result) {
return false;
}
return result[0];
}
async updateGoalNew(goalData: GoalsArgUpdate): Promise<GoalsSchemaTypeForSelect | false> {
const updates: Partial<typeof GoalsSchema.$inferInsert> = {};
if (goalData.name !== undefined) updates.name = goalData.name;
if (goalData.description !== undefined) updates.description = goalData.description;
if (goalData.color !== undefined) updates.color = goalData.color;
if (goalData.estimateUnit !== undefined) updates.estimateUnit = goalData.estimateUnit;
if (goalData.archive !== undefined) updates.archive = goalData.archive;
if (Object.keys(updates).length === 0) {
return this.findGoalById(goalData.id);
}
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(GoalsSchema).set(updates).where(eq(GoalsSchema.id, goalData.id)).returning()
);
if (!result) {
return false;
+7
View File
@@ -16,6 +16,8 @@ export const GoalsArkTypeUpdate = type({
'name?': 'string | null',
'description?': 'string | null',
'color?': 'string | null',
"estimateUnit?": "'hours' | 'points'",
'archive?': '0 | 1',
});
export type GoalsArgUpdate = typeof GoalsArkTypeUpdate.infer;
@@ -33,3 +35,8 @@ export const GoalsArkTypeFetch = type({
export type GoalsArgFetch = typeof GoalsArkTypeFetch.infer;
export type GoalsItemForClientWithPermissions = GoalsSchemaTypeForSelect & { permissions: GoalPermissionsForClient };
export type GoalsArgCreateInbox = {
ownerId: number;
organizationId: number;
};
+11 -1
View File
@@ -3,7 +3,7 @@ import { Database } from '../../modules/db';
import type { GoalItemInDb } from '../../types/goal.type';
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import type { KanbanStatusItemInDb } from './types';
import type { KanbanStatusItemInDb, StatusBelongsToGoalArgs } from './types';
import { callWithCatch } from '../../utils/helpers';
import { and, asc, eq, gt, gte, isNull, lt, lte, sql } from 'drizzle-orm';
import type { TaskItemInDb } from '../../types/tasks.types';
@@ -218,4 +218,14 @@ export class KanbanRepository {
return result?.[0]?.columnVersion ?? null;
}
/** Validates payload references to a kanban column from other modules (recurrence templates, etc.). */
async statusBelongsToGoal(args: StatusBelongsToGoalArgs): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle
.select({ id: TasksStatusesSchema.id })
.from(TasksStatusesSchema)
.where(and(eq(TasksStatusesSchema.id, args.statusId), eq(TasksStatusesSchema.goalId, args.goalId)))
.limit(1));
return !!result?.[0];
}
}
+8
View File
@@ -33,6 +33,8 @@ export type KanbanArgUpdateStatus = typeof KanbanArkTypeStatusUpdate.infer;
export type KanbanArgFetchAllStatuses = typeof KanbanArkTypeFetchAllStatuses.infer;
export type StatusBelongsToGoalArgs = { statusId: number; goalId: number };
export type KanbanStatusInDb = {
id: number;
goal_id: number;
@@ -69,9 +71,15 @@ const NumberArrayFromCommaSeparatedString = type('string|undefined').pipe((v) =>
return v.split(',').map(Number).filter((n) => !isNaN(n));
});
const SprintFilterFromString = type('string|number').pipe((v) => {
const n = Number(v);
return isNaN(n) ? undefined : n;
});
export const KanbanArkTypeFilters = type({
'listIds?': NumberArrayFromCommaSeparatedString,
'assigneeIds?': NumberArrayFromCommaSeparatedString,
'sprintId?': SprintFilterFromString,
});
export type KanbanArgFilters = typeof KanbanArkTypeFilters.infer;
@@ -1,4 +1,4 @@
import { desc, eq } from 'drizzle-orm';
import { and, desc, eq } from 'drizzle-orm';
import type { GoalsListSchemaTypeForSelect } from 'taskview-db-schemas';
import { GoalsListSchema } from 'taskview-db-schemas';
import type { AppUser } from '../../core/AppUser';
@@ -7,7 +7,7 @@ import type { GoalListInDb } from '../../types/goal-list.types';
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import { callWithCatch } from '../../utils/helpers';
import type { GoalListArgAdd, GoalListArgDelete, GoalListArgFetch, GoalListArgUpdate } from './list.types';
import type { GoalListArgAdd, GoalListArgDelete, GoalListArgFetch, GoalListArgUpdate, ListBelongsToGoalArgs } from './list.types';
export class GoalListsRepository {
private readonly db: Database;
@@ -145,4 +145,16 @@ export class GoalListsRepository {
return !!(result.rowCount && result.rowCount > 0);
}
/** Validates payload references to a list from other modules (recurrence templates, etc.). */
async listBelongsToGoal(args: ListBelongsToGoalArgs): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: GoalsListSchema.id })
.from(GoalsListSchema)
.where(and(eq(GoalsListSchema.id, args.listId), eq(GoalsListSchema.goalId, args.goalId)))
.limit(1)
);
return !!result?.[0];
}
}
+2
View File
@@ -27,3 +27,5 @@ export const GoalListArkTypeFetch = type({
});
export type GoalListArgFetch = typeof GoalListArkTypeFetch.infer;
export type ListBelongsToGoalArgs = { listId: number; goalId: number };
@@ -0,0 +1,241 @@
import type { Request, Response } from 'express';
import { ArkErrors } from 'arktype';
import AuthController from '../auth/AuthController';
import { AppUser } from '../../core/AppUser';
import { GoalPermissions } from '../../types/auth.types';
import { $logger } from '../../modules/logget';
import { MessagingManager } from './MessagingManager';
import { SlackInboundManager } from './SlackInboundManager';
import { SLACK_OAUTH_NONCE_COOKIE } from './config';
import {
MessagingArkTypeConnectLink,
MessagingArkTypeById,
MessagingArkTypeToggle,
MessagingArkTypeUpdateEvents,
MessagingArkTypeProviderParam,
MessagingArkTypeProjectToggle,
MessagingArkTypeProjectDelete,
MessagingArkTypeProjectPostContent,
} from './types';
import { getAuthorizedGoalId } from './middlewares/ProjectMessagingPermission';
export class MessagingController {
private readonly manager = new MessagingManager();
private readonly slackInbound = new SlackInboundManager();
// Slash command (/task). Signature already verified by VerifySlackRequest.
slackCommands = async (req: Request, res: Response) => {
try {
const reply = await this.slackInbound.handleSlashCommand(req.body ?? {});
return res.json(reply);
} catch (err) {
$logger.error(err, '[Messaging/Slack] slash command failed');
return res.json({ response_type: 'ephemeral', text: 'Something went wrong handling that command.' });
}
};
// Button clicks and modal submissions. Payload is a JSON string in the `payload` field.
slackInteractivity = async (req: Request, res: Response) => {
try {
const raw = (req.body as { payload?: unknown })?.payload;
const interaction = typeof raw === 'string' ? JSON.parse(raw) : raw;
const result = await this.slackInbound.handleInteraction(interaction ?? {});
return res.json(result ?? {});
} catch (err) {
$logger.error(err, '[Messaging/Slack] interactivity failed');
return res.status(200).end();
}
};
fetch = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.fetchPersonalConnections(userId);
return res.tvJson(result);
};
connectLink = async (req: Request, res: Response) => {
const data = MessagingArkTypeConnectLink({ ...req.params, ...req.query });
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.createPersonalConnectLink(userId, data.provider);
if (!result) return res.status(503).end();
return res.tvJson(result);
};
toggle = async (req: Request, res: Response) => {
const data = MessagingArkTypeToggle(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.togglePersonal(data.id, userId, data.isActive);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
delete = async (req: Request, res: Response) => {
const data = MessagingArkTypeById(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.deletePersonal(data.id, userId);
return res.tvJson(result);
};
updateEvents = async (req: Request, res: Response) => {
const data = MessagingArkTypeUpdateEvents(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.updatePersonalEvents(data.id, userId, data.events);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
fetchProject = async (_req: Request, res: Response) => {
const result = await this.manager.fetchProjectConnections(getAuthorizedGoalId(res));
return res.tvJson(result);
};
projectConnectLink = async (req: Request, res: Response) => {
const data = MessagingArkTypeProviderParam(req.params);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const userId = req.appUser.getUserData()?.id;
if (!userId) return res.status(401).end();
const result = await this.manager.createProjectConnectLink(getAuthorizedGoalId(res), userId, data.provider);
if (!result) return res.status(503).end();
return res.tvJson(result);
};
toggleProject = async (req: Request, res: Response) => {
const data = MessagingArkTypeProjectToggle(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.toggleProject(data.id, getAuthorizedGoalId(res), data.isActive);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
deleteProject = async (req: Request, res: Response) => {
const data = MessagingArkTypeProjectDelete(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.deleteProject(data.id, getAuthorizedGoalId(res));
return res.tvJson(result);
};
updateProjectEvents = async (req: Request, res: Response) => {
const data = MessagingArkTypeUpdateEvents(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.updateProjectEvents(data.id, getAuthorizedGoalId(res), data.events);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
updateProjectPostContent = async (req: Request, res: Response) => {
const data = MessagingArkTypeProjectPostContent(req.body);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const result = await this.manager.updateProjectPostContent(data.id, getAuthorizedGoalId(res), data.postContent);
if (!result) return res.status(404).end();
return res.tvJson(result);
};
// Slack uses OAuth (browser redirect), so the user's JWT is passed as ?token and
// validated manually — a full-page redirect can't carry the API Authorization header.
slackOAuthStart = async (req: Request, res: Response) => {
try {
const token = req.query.token as string;
if (!token) return res.status(401).send('token is required');
const userPayload = await AuthController.validateTokens(token);
const userId = userPayload?.userData?.id;
if (!userId) return res.status(401).send('Invalid token');
if (!this.manager.getProvider('slack')?.isConfigured()) {
return res.status(503).send('Slack is not configured on the server (set SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_CALLBACK_URL)');
}
const scope: 'user' | 'project' = req.query.scope === 'project' ? 'project' : 'user';
let ownerId = userId;
if (scope === 'project') {
const goalId = Number(req.query.goalId);
if (!goalId || Number.isNaN(goalId)) return res.status(400).send('goalId is required');
const checker = await new AppUser(userPayload).permissionsFetcher.getCheckerForGoal(goalId);
if (!checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_MANAGE)) return res.status(403).end();
ownerId = goalId;
}
const returnPath = this.safeReturnPath(req.query.returnPath);
const start = await this.manager.startOAuthConnect('slack', { ownerType: scope, ownerId, userId, returnPath });
if (!start) return res.status(503).send('Slack is not configured on the server');
// The provider hands back the anti-CSRF nonce cookie to set on this browser.
if (start.kind === 'oauth') {
res.cookie(start.setCookie.name, start.setCookie.value, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: start.setCookie.maxAgeMs,
});
}
return res.redirect(start.url);
} catch (err) {
$logger.error(err, '[Messaging/Slack] Failed to start OAuth');
return res.status(500).send('Failed to start Slack OAuth');
}
};
slackOAuthCallback = async (req: Request, res: Response) => {
const code = req.query.code as string;
const state = req.query.state as string;
const nonce = req.cookies?.[SLACK_OAUTH_NONCE_COOKIE] as string | undefined;
res.clearCookie(SLACK_OAUTH_NONCE_COOKIE);
if (!code || !state) return res.redirect(`${process.env.APP_URL}?messaging=error`);
try {
const result = await this.manager.handleInbound('slack', { source: 'oauth-callback', payload: { code, state }, cookie: nonce });
return res.redirect(`${process.env.APP_URL}${this.safeReturnPath(result?.redirect)}?messaging=connected`);
} catch (err) {
$logger.error({ error: err instanceof Error ? err.message : String(err), hasNonce: !!nonce }, '[Messaging/Slack] OAuth callback failed');
return res.redirect(`${process.env.APP_URL}?messaging=error`);
}
};
private safeReturnPath(value: unknown): string {
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')
? value
: '';
}
telegramWebhook = async (req: Request, res: Response) => {
res.status(200).end();
await this.manager.handleInbound('telegram', { source: 'webhook', payload: req.body });
};
}
@@ -0,0 +1,371 @@
import { eq } from 'drizzle-orm';
import { SprintsSchema, TasksSchema } from 'taskview-db-schemas';
import { eventBus, type AppEvents } from '../../core/EventBus';
import { getJobQueue } from '../../core/JobQueue';
import { Database } from '../../modules/db';
import { decrypt } from '../../utils/crypto';
import { $logger } from '../../modules/logget';
import { GoalPermissionsRepository } from '../../core/GoalPermissionsRepository';
import { GoalPermissionsChecker } from '../../core/GoalPermissionsChecker';
import { GoalPermissions } from '../../types/auth.types';
import { MessagingRepository } from './MessagingRepository';
import { MessagingManager } from './MessagingManager';
import { buildMessagingMessage } from './messages';
import { buildTaskDeepLink } from './utils';
import type { Dispatcher } from '../../core/Dispatcher';
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
import type { MessagingDeliverJobData, MessagingEvent, MessagingProviderId } from './types';
import type { MessagingDispatchArgs, MessagingRecipient, MessagingTaskContext } from './types.internal';
const MESSAGING_DELIVER_JOB = 'messaging-deliver';
const MAX_ATTEMPTS = 3;
export class MessagingDispatcher implements Dispatcher {
private readonly repository = new MessagingRepository();
private readonly manager = new MessagingManager();
private readonly permissionsRepo = new GoalPermissionsRepository();
register(): void {
eventBus.on('task.created', (data) => this.onTaskCreated(data));
eventBus.on('task.assigneesChanged', (data) => this.onTaskAssigned(data));
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
eventBus.on('task.assignedToSprint', (data) => this.onTaskAddedToSprint(data));
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
eventBus.on('sprint.created', (data) => this.onSprintObj('sprint.created', data));
eventBus.on('sprint.updated', (data) => this.onSprintObj('sprint.updated', data));
eventBus.on('sprint.activated', (data) => this.onSprint('sprint.started', data));
eventBus.on('sprint.reviewStarted', (data) => this.onSprint('sprint.reviewStarted', data));
eventBus.on('sprint.completed', (data) => this.onSprint('sprint.completed', data));
eventBus.on('sprint.paused', (data) => this.onSprint('sprint.paused', data));
eventBus.on('sprint.resumed', (data) => this.onSprint('sprint.resumed', data));
eventBus.on('sprint.deleted', (data) => this.onSprint('sprint.deleted', data));
eventBus.on('collaboration.userAdded', (data) => this.onMember('member.added', data.goalId, data.email, data.initiatorId));
eventBus.on('collaboration.userRemoved', (data) => this.onMemberByCollab('member.removed', data.goalId, data.collaborationUserId, data.initiatorId));
eventBus.on('collaboration.rolesChanged', (data) => this.onMemberByCollab('member.rolesChanged', data.goalId, data.collaborationUserId, data.initiatorId));
eventBus.on('time-entry.started', (data) => this.onTimeEntry('time.started', data.goalId, data.taskId, data.userId));
eventBus.on('time-entry.stopped', (data) => this.onTimeEntry('time.stopped', data.goalId, data.taskId, data.userId));
eventBus.on('time-entry.created', (data) => this.onTimeEntry('time.logged', data.entry.goalId, data.entry.taskId, data.initiatorId));
eventBus.on('time-entry.updated', (data) => this.onTimeEntry('time.updated', data.entry.goalId, data.entry.taskId, data.initiatorId));
eventBus.on('time-entry.deleted', (data) => this.onTimeEntry('time.deleted', data.goalId, data.taskId, data.initiatorId));
eventBus.on('recurrence.created', (data) => this.onRecurrence('recurrence.created', data.rule.goalId, data.initiatorId));
eventBus.on('recurrence.updated', (data) => this.onRecurrence('recurrence.updated', data.rule.goalId, data.initiatorId));
eventBus.on('recurrence.paused', (data) => this.onRecurrence('recurrence.paused', data.goalId, data.initiatorId));
eventBus.on('recurrence.resumed', (data) => this.onRecurrence('recurrence.resumed', data.goalId, data.initiatorId));
eventBus.on('recurrence.ended', (data) => this.onRecurrence('recurrence.ended', data.goalId, data.initiatorId));
eventBus.on('recurrence.deleted', (data) => this.onRecurrence('recurrence.deleted', data.goalId, data.initiatorId));
eventBus.on('recurrence.instanceSkipped', (data) => this.onRecurrence('recurrence.skipped', data.goalId, data.initiatorId));
}
async registerWorkers(): Promise<void> {
const boss = getJobQueue();
await boss.createQueue(MESSAGING_DELIVER_JOB);
await boss.work<MessagingDeliverJobData>(MESSAGING_DELIVER_JOB, async ([job]) => {
await this.deliverJob(job.data);
});
}
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
// A brand-new task usually has no assignees yet, so target project members.
const recipients = await this.repository.fetchProjectMemberRecipients(data.task.goalId);
await this.dispatch({
event: 'task.created',
goalId: data.task.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(data.task)
});
}
private async onTaskAssigned(data: AppEvents['task.assigneesChanged']): Promise<void> {
if (data.userIds.length === 0) return;
const task = await this.fetchTask(data.taskId);
if (!task) return;
// Keep only collab ids still assigned AND still project members, then resolve to auth recipients.
const currentAssignees = new Set(await this.repository.fetchCurrentAssigneeCollabIds(task.id));
const stillAssigned = data.userIds.filter(id => currentAssignees.has(id));
const memberCollabIds = await this.repository.filterCollabIdsInGoal(stillAssigned, task.goalId);
if (memberCollabIds.length === 0) return;
const recipients = await this.repository.resolveCollabIdsToRecipients(memberCollabIds);
await this.dispatch({
event: 'task.assigned',
goalId: task.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(task)
});
}
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
const changes = data.changes ?? {};
let event: MessagingEvent;
let titleOverride: string | undefined;
if ('complete' in changes && data.task.complete === true) event = 'task.completed';
else if ('complete' in changes && data.task.complete === false) {
// Reopen: gated by the same task.completed subscription, but shown as "reopened".
event = 'task.completed';
titleOverride = '[Task reopened]';
} else if ('statusId' in changes) event = 'task.statusChanged';
else event = 'task.edited';
const recipients = await this.taskAssigneeRecipients(data.task.id, data.task.goalId);
await this.dispatch({
event,
goalId: data.task.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(data.task),
titleOverride
});
}
private async onTaskAddedToSprint(data: AppEvents['task.assignedToSprint']): Promise<void> {
if (!data.sprintId) return;
const task = await this.fetchTask(data.taskId);
if (!task) return;
const recipients = await this.taskAssigneeRecipients(data.taskId, data.goalId);
await this.dispatch({
event: 'task.addedToSprint',
goalId: data.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
task: this.taskCtx(task)
});
}
private async onTaskDeleted(data: AppEvents['task.deleted']): Promise<void> {
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
await this.dispatch({
event: 'task.deleted',
goalId: data.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
body: `#${data.taskId}`
});
}
private async onSprintObj(event: MessagingEvent, data: { sprint: { goalId: number; name: string }; initiatorId: number }): Promise<void> {
const recipients = await this.repository.fetchProjectMemberRecipients(data.sprint.goalId);
await this.dispatch({
event,
goalId: data.sprint.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
body: data.sprint.name
});
}
private async onSprint(event: MessagingEvent, data: { sprintId: number; goalId: number; initiatorId: number | null }): Promise<void> {
const name = await this.fetchSprintName(data.sprintId);
const recipients = await this.repository.fetchProjectMemberRecipients(data.goalId);
await this.dispatch({
event,
goalId: data.goalId,
personalRecipients: recipients,
initiatorId: data.initiatorId,
body: name
});
}
private async onMember(event: MessagingEvent, goalId: number, body: string, initiatorId: number): Promise<void> {
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
await this.dispatch({
event,
goalId,
personalRecipients: recipients,
initiatorId,
body
});
}
private async onMemberByCollab(event: MessagingEvent, goalId: number, collabId: number, initiatorId: number): Promise<void> {
const email = await this.repository.fetchCollabEmail(collabId);
await this.onMember(event, goalId, email ?? '', initiatorId);
}
private async onTimeEntry(event: MessagingEvent, goalId: number, taskId: number, initiatorId: number | null): Promise<void> {
const task = await this.fetchTask(taskId);
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
// Time entries reference a task → gate the task description like task events.
await this.dispatch({
event,
goalId,
personalRecipients: recipients,
initiatorId,
task: task ? this.taskCtx(task) : undefined,
body: task ? undefined : `#${taskId}`
});
}
private async onRecurrence(event: MessagingEvent, goalId: number, initiatorId: number): Promise<void> {
// Recurring-rule template content is not shown (avoids leaking gated task content); title only.
const recipients = await this.repository.fetchProjectMemberRecipients(goalId);
await this.dispatch({ event, goalId, personalRecipients: recipients, initiatorId, body: '' });
}
private async dispatch(args: MessagingDispatchArgs): Promise<void> {
// No initiator exclusion: messaging is an explicit opt-in feed — if you
// subscribed to an event you receive it, even for your own actions.
const url = args.task ? await this.buildTaskUrl(args.goalId, args.task.id, args.task.goalListId) : undefined;
const projectName = await this.repository.fetchGoalName(args.goalId);
// Assignee emails are RBAC-gated content. Compute once, then include per recipient
// only when they may see assignees (TASKS_CAN_WATCH_ASSIGNED_USERS) — same treatment
// as the description, which is gated by COMPONENT_CAN_WATCH_CONTENT.
const footerText = args.task ? await this.assigneesFooter(args.task.id) : undefined;
let personalCount = 0;
for (const r of args.personalRecipients) {
const checker = args.task ? await this.checkerForRecipient(args.goalId, r) : null;
const body = this.bodyForRecipient(args, checker);
const footer = footerText && checker?.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS) ? footerText : undefined;
const message = buildMessagingMessage({
event: args.event,
audience: 'personal',
body,
url,
taskId: args.task?.id,
projectName,
footer,
completed: args.task?.complete,
titleOverride: args.titleOverride
});
const connections = await this.subscribed('user', r.userId, args.event);
personalCount += connections.length;
await this.enqueueAll(connections, message);
}
// Project channel: a shared channel's membership doesn't map to TaskView content
// permission. By default the description AND assignees ARE posted (a channel is a
// deliberate broadcast); a connection can opt out via post_content — then task events
// get title + link only (no description, no assignee emails). Non-task bodies
// (sprint name, …) are not RBAC-gated content.
const projectConnections = await this.subscribed('project', args.goalId, args.event);
for (const connection of projectConnections) {
const projectBody = args.task
? (connection.postContent ? (args.task.description ?? `#${args.task.id}`) : '')
: (args.body ?? '');
const projectFooter = connection.postContent ? footerText : undefined;
// A content-hidden channel gets title + link only — no description, no assignee footer,
// and no action buttons (their modals would expose members / task state).
const projectMessage = buildMessagingMessage({
event: args.event,
audience: 'project',
body: projectBody,
url,
taskId: args.task?.id,
projectName,
footer: projectFooter,
completed: args.task?.complete,
titleOverride: args.titleOverride,
actions: connection.postContent
});
await this.enqueueAll([connection], projectMessage);
}
$logger.info(
{ event: args.event, goalId: args.goalId, members: args.personalRecipients.length, personalConns: personalCount, projectConns: projectConnections.length },
'[Messaging] dispatch',
);
}
private bodyForRecipient(args: MessagingDispatchArgs, checker: GoalPermissionsChecker | null): string {
if (!args.task) return args.body ?? '';
const canWatch = checker?.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) ?? false;
return canWatch ? (args.task.description ?? `#${args.task.id}`) : `#${args.task.id}`;
}
/** Per-recipient permission checker for the goal; fail-closed (empty) on lookup error. */
private async checkerForRecipient(goalId: number, recipient: MessagingRecipient): Promise<GoalPermissionsChecker> {
const permissions = await this.permissionsRepo
.fetchPermissionsForGoalByUser({ goalId, userId: recipient.userId, email: recipient.email })
.catch((err) => {
$logger.error(err, `[Messaging] permission check failed for user=${recipient.userId}`);
return [];
});
return new GoalPermissionsChecker(permissions);
}
private async buildTaskUrl(goalId: number, taskId: number, goalListId: number | null): Promise<string | undefined> {
const orgSlug = await this.repository.fetchGoalOrgSlug(goalId);
return buildTaskDeepLink(orgSlug, goalId, taskId, goalListId);
}
/** "👤 email1, email2" of the task's current assignees, or undefined if none. */
private async assigneesFooter(taskId: number): Promise<string | undefined> {
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
if (collabIds.length === 0) return undefined;
const assignees = await this.repository.resolveCollabIdsToRecipients(collabIds);
if (assignees.length === 0) return undefined;
return `👤 ${assignees.map((a) => a.email).join(', ')}`;
}
private async subscribed(ownerType: string, ownerId: number, event: MessagingEvent): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
const connections = await this.repository.fetchActiveByOwner(ownerType, ownerId);
return connections.filter(c => c.events.includes(event));
}
private async taskAssigneeRecipients(taskId: number, goalId: number): Promise<MessagingRecipient[]> {
const collabIds = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
const memberCollabIds = await this.repository.filterCollabIdsInGoal(collabIds, goalId);
return this.repository.resolveCollabIdsToRecipients(memberCollabIds);
}
private taskCtx(task: { id: number; goalListId: number | null; description: string | null; complete: boolean | null }): MessagingTaskContext {
return { id: task.id, goalListId: task.goalListId, description: task.description, complete: task.complete === true };
}
private async fetchTask(taskId: number) {
const db = Database.getInstance();
const rows = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, taskId)).limit(1);
return rows[0] ?? null;
}
private async fetchSprintName(sprintId: number): Promise<string> {
const db = Database.getInstance();
const rows = await db.dbDrizzle.select({ name: SprintsSchema.name }).from(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).limit(1);
return rows[0]?.name ?? `#${sprintId}`;
}
private async enqueueAll(connections: MessagingConnectionsSchemaTypeForSelect[], message: ReturnType<typeof buildMessagingMessage>): Promise<void> {
const boss = getJobQueue();
for (const connection of connections) {
await boss.send(MESSAGING_DELIVER_JOB, {
connectionId: connection.id,
provider: connection.provider as MessagingProviderId,
chatId: connection.targetChatId,
accessTokenEncrypted: connection.accessTokenEncrypted,
message,
attempt: 1,
} satisfies MessagingDeliverJobData);
}
}
private async deliverJob(data: MessagingDeliverJobData): Promise<void> {
const provider = this.manager.getProvider(data.provider);
if (!provider || !provider.isConfigured()) return;
const result = await provider.deliver({
chatId: data.chatId,
accessToken: data.accessTokenEncrypted ? decrypt(data.accessTokenEncrypted) : null,
message: data.message,
});
$logger.info({ connectionId: data.connectionId, provider: data.provider, success: result.success, errorCode: result.errorCode }, '[Messaging] deliver result');
if (result.success) return;
if (data.attempt < MAX_ATTEMPTS) {
const boss = getJobQueue();
const delay = Math.pow(2, data.attempt) * 5;
await boss.send(MESSAGING_DELIVER_JOB, { ...data, attempt: data.attempt + 1 }, { startAfter: delay });
return;
}
$logger.warn(`[Messaging] Delivery failed for connection=${data.connectionId} after ${MAX_ATTEMPTS} attempts`);
}
}
@@ -0,0 +1,199 @@
import { AppUser } from '../../core/AppUser';
import { GoalPermissions } from '../../types/auth.types';
import { $logger } from '../../modules/logget';
import { MessagingRepository } from './MessagingRepository';
import { TelegramProvider } from './providers/telegram.provider';
import { SlackProvider } from './providers/slack.provider';
import { buildTaskDeepLink } from './utils';
import type { MessagingProvider } from './providers/MessagingProvider';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingConnectionForClient, MessagingConnectLinkResult } from './types.internal';
import { sanitizeMessagingEvents, type MessagingMessage, type MessagingProviderId } from './types';
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
export class MessagingManager {
public readonly repository = new MessagingRepository();
private readonly telegram = new TelegramProvider();
private readonly slack = new SlackProvider();
private readonly providers: Map<MessagingProviderId, MessagingProvider>;
constructor() {
this.providers = new Map<MessagingProviderId, MessagingProvider>([
['telegram', this.telegram],
['slack', this.slack],
]);
}
getProvider(id: MessagingProviderId): MessagingProvider | undefined {
return this.providers.get(id);
}
private async beginConnect(id: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
const provider = this.getProvider(id);
if (!provider?.isConfigured()) return null;
const start = await provider.startConnect(ctx);
if (start.kind === 'deep-link') {
const row = await this.repository.createLinkToken({
token: start.persistToken.token,
provider: id,
ownerType: ctx.ownerType,
ownerId: ctx.ownerId,
createdBy: ctx.userId,
expiresAt: start.persistToken.expiresAt,
});
if (!row) return null;
}
return start;
}
async createPersonalConnectLink(userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'user', ownerId: userId, userId }));
}
async createProjectConnectLink(goalId: number, userId: number, provider: MessagingProviderId): Promise<MessagingConnectLinkResult | null> {
return this.toLinkResult(provider, await this.beginConnect(provider, { ownerType: 'project', ownerId: goalId, userId }));
}
private toLinkResult(provider: MessagingProviderId, start: ConnectStart | null): MessagingConnectLinkResult | null {
if (start?.kind !== 'deep-link') return null;
return { provider, url: start.url, token: start.persistToken.token, expiresAt: start.persistToken.expiresAt.toISOString() };
}
async startOAuthConnect(provider: MessagingProviderId, ctx: ConnectContext): Promise<ConnectStart | null> {
return this.beginConnect(provider, ctx);
}
async handleInbound(id: MessagingProviderId, raw: InboundRaw): Promise<{ redirect?: string } | null> {
const provider = this.getProvider(id);
if (!provider) return null;
const intent = await provider.parseInbound(raw);
if (!intent) return null;
if (intent.kind === 'createConnection') {
await this.repository.createConnection(intent.connection);
if (intent.identity) await this.repository.upsertIdentity({ userId: intent.identity.userId, provider: id, externalUserId: intent.identity.externalUserId, externalTeamId: intent.identity.externalTeamId });
return { redirect: intent.redirect };
}
if (intent.kind === 'command') {
const reply = await this.runCreateTask(id, intent);
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: reply });
return null;
}
await this.bindByToken(id, provider, intent);
return null;
}
private async runCreateTask(id: MessagingProviderId, intent: Extract<InboundIntent, { kind: 'command' }>): Promise<MessagingMessage> {
const note = (title: string): MessagingMessage => ({ event: 'task.created', title });
const userId = await this.repository.findUserIdByExternalId({ provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
if (!userId) return note('Link your account in TaskView first (Personal → Connect), then try /task again.');
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: id, channelId: intent.chatId, externalTeamId: null });
if (goalIds.length === 0) return note('This chat is not linked to a TaskView project.');
if (goalIds.length > 1) return note('This chat is linked to several projects — create the task in TaskView.');
const appUser = await this.buildAppUser(userId);
if (!appUser) return note('Could not resolve your TaskView account.');
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
return note('You do not have permission to create tasks in this project.');
}
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description: intent.text });
const task = created?.[0];
if (!task) return note('Failed to create the task.');
const orgSlug = await this.repository.fetchGoalOrgSlug(goalIds[0]);
const url = buildTaskDeepLink(orgSlug, goalIds[0], task.id, task.goalListId ?? null);
return { event: 'task.created', title: '✅ Task created', body: intent.text, url };
}
private async buildAppUser(userId: number): Promise<AppUser | null> {
const record = await new AppUser().authManager.repository.fetchUserById(userId);
if (!record || record.block !== 0) return null;
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
}
private async bindByToken(id: MessagingProviderId, provider: MessagingProvider, intent: Extract<InboundIntent, { kind: 'bindByToken' }>): Promise<void> {
const link = await this.repository.findValidLinkToken(id, intent.token);
// Silently drop unknown/expired tokens or a token used in the wrong chat kind — no
// reply, so the endpoint can't be used as an outbound-message amplifier.
if (!link || link.ownerType !== intent.scope) return;
const connection = await this.repository.createConnection({
provider: id,
ownerType: link.ownerType,
ownerId: link.ownerId,
targetChatId: intent.chatId,
title: intent.title,
externalTeamId: null,
accessTokenEncrypted: null,
});
if (!connection) {
$logger.error(`[Messaging] Failed to create ${id} connection for ${link.ownerType}=${link.ownerId}`);
return;
}
// Telegram user IDs are globally unique, so there is no workspace to scope by.
if (intent.scope === 'user') await this.repository.upsertIdentity({ userId: link.ownerId, provider: id, externalUserId: intent.externalUserId, externalTeamId: null });
await this.repository.consumeLinkToken(link.id);
const text = intent.scope === 'user'
? 'Done! Your TaskView account is linked — notifications will be delivered here.'
: 'Done! This chat is connected to your TaskView project — project events will be posted here.';
await provider.deliver({ chatId: intent.chatId, accessToken: null, message: { event: 'task.created', title: text } });
}
async fetchPersonalConnections(userId: number): Promise<MessagingConnectionForClient[]> {
const connections = await this.repository.fetchByOwner('user', userId);
return connections.map(c => this.toClient(c));
}
async togglePersonal(id: number, userId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.setActiveOwned({ id, ownerType: 'user', ownerId: userId, isActive });
return updated ? this.toClient(updated) : null;
}
async deletePersonal(id: number, userId: number): Promise<boolean> {
return this.repository.deleteOwned({ id, ownerType: 'user', ownerId: userId });
}
async updatePersonalEvents(id: number, userId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'user', ownerId: userId, events: sanitizeMessagingEvents(events) });
return updated ? this.toClient(updated) : null;
}
async fetchProjectConnections(goalId: number): Promise<MessagingConnectionForClient[]> {
const connections = await this.repository.fetchByOwner('project', goalId);
return connections.map(c => this.toClient(c));
}
async toggleProject(id: number, goalId: number, isActive: boolean): Promise<MessagingConnectionForClient | null> {
// goalId was verified against the caller by IsGoalOwnerByGoalId; the WHERE
// clause also binds the connection to that goal, so no cross-project mutation.
const updated = await this.repository.setActiveOwned({ id, ownerType: 'project', ownerId: goalId, isActive });
return updated ? this.toClient(updated) : null;
}
async deleteProject(id: number, goalId: number): Promise<boolean> {
return this.repository.deleteOwned({ id, ownerType: 'project', ownerId: goalId });
}
async updateProjectEvents(id: number, goalId: number, events: string[]): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.updateEventsOwned({ id, ownerType: 'project', ownerId: goalId, events: sanitizeMessagingEvents(events) });
return updated ? this.toClient(updated) : null;
}
async updateProjectPostContent(id: number, goalId: number, postContent: boolean): Promise<MessagingConnectionForClient | null> {
const updated = await this.repository.setPostContentOwned({ id, ownerType: 'project', ownerId: goalId, postContent });
return updated ? this.toClient(updated) : null;
}
private toClient(connection: MessagingConnectionsSchemaTypeForSelect): MessagingConnectionForClient {
const { accessTokenEncrypted, ...rest } = connection;
return rest;
}
}
@@ -0,0 +1,311 @@
import { createHash } from 'crypto';
import { and, eq, gt, inArray, isNull } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import {
CollaborationUsersSchema,
CollaborationUsersToGoalsSchema,
GoalsSchema,
MessagingConnectionsSchema,
MessagingIdentityMapSchema,
MessagingLinkTokensSchema,
OrganizationsSchema,
TasksAssigneeSchema,
UsersSchema,
type MessagingConnectionsSchemaTypeForSelect,
type MessagingLinkTokensSchemaTypeForSelect,
} from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
import { SLACK_WEBHOOK_PREFIX } from './config';
import type {
MessagingChannelLookup,
MessagingConnectionCreate,
MessagingIdentityLookup,
MessagingIdentityUpsert,
MessagingLinkTokenCreate,
MessagingOwnedRef,
MessagingOwnedToggle,
MessagingRecipient,
} from './types.internal';
export class MessagingRepository {
private readonly db: Database;
constructor() {
this.db = Database.getInstance();
}
async createConnection(data: MessagingConnectionCreate): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(MessagingConnectionsSchema)
.values(data)
.onConflictDoUpdate({
target: [
MessagingConnectionsSchema.provider,
MessagingConnectionsSchema.ownerType,
MessagingConnectionsSchema.ownerId,
MessagingConnectionsSchema.targetChatId,
],
set: {
title: data.title,
externalTeamId: data.externalTeamId,
accessTokenEncrypted: data.accessTokenEncrypted,
isActive: true,
updatedAt: new Date(),
},
})
.returning()
);
return result?.[0] ?? null;
}
async fetchById(id: number): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(eq(MessagingConnectionsSchema.id, id))
);
return result?.[0] ?? null;
}
async fetchByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
and(
eq(MessagingConnectionsSchema.ownerType, ownerType),
eq(MessagingConnectionsSchema.ownerId, ownerId),
)
)
);
return result ?? [];
}
async fetchActiveByOwner(ownerType: string, ownerId: number): Promise<MessagingConnectionsSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingConnectionsSchema).where(
and(
eq(MessagingConnectionsSchema.ownerType, ownerType),
eq(MessagingConnectionsSchema.ownerId, ownerId),
eq(MessagingConnectionsSchema.isActive, true),
)
)
);
return result ?? [];
}
async setActiveOwned(args: MessagingOwnedToggle): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(MessagingConnectionsSchema)
.set({ isActive: args.isActive, updatedAt: new Date() })
.where(this.ownedWhere(args))
.returning()
);
return result?.[0] ?? null;
}
async deleteOwned(args: MessagingOwnedRef): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(MessagingConnectionsSchema).where(this.ownedWhere(args))
);
return !!result?.rowCount;
}
private ownedWhere(args: MessagingOwnedRef) {
return and(
eq(MessagingConnectionsSchema.id, args.id),
eq(MessagingConnectionsSchema.ownerType, args.ownerType),
eq(MessagingConnectionsSchema.ownerId, args.ownerId),
);
}
async resolveCollabIdsToRecipients(collabIds: number[]): Promise<MessagingRecipient[]> {
if (collabIds.length === 0) return [];
const authUsers = alias(UsersSchema, 'auth_users');
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
.from(CollaborationUsersSchema)
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
.where(inArray(CollaborationUsersSchema.id, collabIds))
);
return rows ?? [];
}
async updateEventsOwned(args: MessagingOwnedRef & { events: string[] }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(MessagingConnectionsSchema)
.set({ events: args.events, updatedAt: new Date() })
.where(this.ownedWhere(args))
.returning()
);
return result?.[0] ?? null;
}
async setPostContentOwned(args: MessagingOwnedRef & { postContent: boolean }): Promise<MessagingConnectionsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(MessagingConnectionsSchema)
.set({ postContent: args.postContent, updatedAt: new Date() })
.where(this.ownedWhere(args))
.returning()
);
return result?.[0] ?? null;
}
async fetchCollabEmail(collabId: number): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ email: CollaborationUsersSchema.email })
.from(CollaborationUsersSchema)
.where(eq(CollaborationUsersSchema.id, collabId))
.limit(1)
);
return rows?.[0]?.email ?? null;
}
async fetchProjectMemberRecipients(goalId: number): Promise<MessagingRecipient[]> {
const authUsers = alias(UsersSchema, 'auth_users');
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: authUsers.id, email: authUsers.email })
.from(CollaborationUsersToGoalsSchema)
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
.innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email))
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
);
return rows ?? [];
}
async fetchGoalName(goalId: number): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ name: GoalsSchema.name }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1)
);
return rows?.[0]?.name ?? null;
}
async fetchGoalOrgSlug(goalId: number): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
.from(GoalsSchema)
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
.where(eq(GoalsSchema.id, goalId))
.limit(1)
);
return rows?.[0]?.slug ?? null;
}
async filterCollabIdsInGoal(collabIds: number[], goalId: number): Promise<number[]> {
if (collabIds.length === 0) return [];
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: CollaborationUsersToGoalsSchema.userId })
.from(CollaborationUsersToGoalsSchema)
.where(and(
eq(CollaborationUsersToGoalsSchema.goalId, goalId),
inArray(CollaborationUsersToGoalsSchema.userId, collabIds),
))
);
return (rows ?? []).map(r => r.userId);
}
async fetchCurrentAssigneeCollabIds(taskId: number): Promise<number[]> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ collabUserId: TasksAssigneeSchema.collabUserId })
.from(TasksAssigneeSchema)
.where(eq(TasksAssigneeSchema.taskId, taskId))
);
return (rows ?? []).map(r => r.collabUserId);
}
async createLinkToken(data: MessagingLinkTokenCreate): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(MessagingLinkTokensSchema)
.values({ ...data, token: this.hashToken(data.token) })
.returning()
);
return result?.[0] ?? null;
}
async findValidLinkToken(provider: string, token: string): Promise<MessagingLinkTokensSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(MessagingLinkTokensSchema).where(
and(
eq(MessagingLinkTokensSchema.provider, provider),
eq(MessagingLinkTokensSchema.token, this.hashToken(token)),
gt(MessagingLinkTokensSchema.expiresAt, new Date()),
)
)
);
return result?.[0] ?? null;
}
async consumeLinkToken(id: number): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.delete(MessagingLinkTokensSchema).where(eq(MessagingLinkTokensSchema.id, id))
);
}
async findUserIdByExternalId(args: MessagingIdentityLookup): Promise<number | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ userId: MessagingIdentityMapSchema.userId })
.from(MessagingIdentityMapSchema)
.where(and(
eq(MessagingIdentityMapSchema.provider, args.provider),
eq(MessagingIdentityMapSchema.externalUserId, args.externalUserId),
args.externalTeamId === null
? isNull(MessagingIdentityMapSchema.externalTeamId)
: eq(MessagingIdentityMapSchema.externalTeamId, args.externalTeamId),
))
.limit(1)
);
return rows?.[0]?.userId ?? null;
}
async fetchProjectGoalIdsByChannel(args: MessagingChannelLookup): Promise<number[]> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ goalId: MessagingConnectionsSchema.ownerId })
.from(MessagingConnectionsSchema)
.where(and(
eq(MessagingConnectionsSchema.provider, args.provider),
eq(MessagingConnectionsSchema.ownerType, 'project'),
eq(MessagingConnectionsSchema.targetChatId, args.channelId),
eq(MessagingConnectionsSchema.isActive, true),
args.externalTeamId === null
? isNull(MessagingConnectionsSchema.externalTeamId)
: eq(MessagingConnectionsSchema.externalTeamId, args.externalTeamId),
))
);
return rows?.map((r) => r.goalId) ?? [];
}
async fetchSlackBotTokenEncrypted(teamId: string): Promise<string | null> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.select({ token: MessagingConnectionsSchema.accessTokenEncrypted })
.from(MessagingConnectionsSchema)
.where(and(
eq(MessagingConnectionsSchema.provider, 'slack'),
eq(MessagingConnectionsSchema.externalTeamId, teamId),
eq(MessagingConnectionsSchema.isActive, true),
))
);
return rows?.map((r) => r.token).find((t): t is string => !!t && !t.startsWith(SLACK_WEBHOOK_PREFIX)) ?? null;
}
async fetchGoalCollabMembers(goalId: number): Promise<{ collabId: number; email: string }[]> {
const rows = await callWithCatch(() =>
this.db.dbDrizzle.selectDistinct({ collabId: CollaborationUsersSchema.id, email: CollaborationUsersSchema.email })
.from(CollaborationUsersToGoalsSchema)
.innerJoin(CollaborationUsersSchema, eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id))
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
);
return rows ?? [];
}
async upsertIdentity(args: MessagingIdentityUpsert): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.insert(MessagingIdentityMapSchema)
.values({ userId: args.userId, provider: args.provider, externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() })
.onConflictDoUpdate({
target: [MessagingIdentityMapSchema.userId, MessagingIdentityMapSchema.provider],
set: { externalUserId: args.externalUserId, externalTeamId: args.externalTeamId, linkedAt: new Date() },
})
);
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}
@@ -0,0 +1,51 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
import { MessagingController } from './MessagingController'
import { VerifyTelegramWebhook } from './middlewares/VerifyTelegramWebhook'
import { VerifySlackRequest } from './middlewares/VerifySlackRequest'
import { CanManageProjectMessaging, CanViewProjectMessaging } from './middlewares/ProjectMessagingPermission'
export default class MessagingRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: MessagingController
constructor() {
this.router = Router()
this.controller = new MessagingController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
// Personal (per-user) connections — scoped to the authenticated user.
this.router.get('', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetch)
this.router.patch('/toggle', [IsLoggedIn, RejectApiTokenAuth], this.controller.toggle)
this.router.patch('/events', [IsLoggedIn, RejectApiTokenAuth], this.controller.updateEvents)
this.router.delete('', [IsLoggedIn, RejectApiTokenAuth], this.controller.delete)
this.router.get('/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth], this.controller.connectLink)
// Project connections — guarded by project ownership (goalId in body/query).
this.router.get('/project', [IsLoggedIn, RejectApiTokenAuth, CanViewProjectMessaging], this.controller.fetchProject)
this.router.patch('/project/toggle', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.toggleProject)
this.router.patch('/project/events', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectEvents)
this.router.patch('/project/post-content', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.updateProjectPostContent)
this.router.delete('/project', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.deleteProject)
this.router.get('/project/:provider/connect-link', [IsLoggedIn, RejectApiTokenAuth, CanManageProjectMessaging], this.controller.projectConnectLink)
// Slack OAuth — public (auth carried by the JWT ?token on start and the signed state on callback).
this.router.get('/slack/oauth/start', this.controller.slackOAuthStart)
this.router.get('/slack/oauth/callback', this.controller.slackOAuthCallback)
// Inbound webhook — public, protected by the Telegram secret-token header.
this.router.post('/telegram/webhook', [VerifyTelegramWebhook], this.controller.telegramWebhook)
// Slack inbound (slash commands + interactivity) — public, protected by the Slack signature.
this.router.post('/slack/commands', [VerifySlackRequest], this.controller.slackCommands)
this.router.post('/slack/interactivity', [VerifySlackRequest], this.controller.slackInteractivity)
}
}
@@ -0,0 +1,245 @@
import { AppUser } from '../../core/AppUser';
import { GoalPermissions } from '../../types/auth.types';
import { decrypt } from '../../utils/crypto';
import { MessagingRepository } from './MessagingRepository';
import { SlackProvider } from './providers/slack.provider';
import {
SLACK_ACTION_ASSIGN,
SLACK_ACTION_DONE,
SLACK_ACTION_REOPEN,
SLACK_ASSIGN_BLOCK,
SLACK_ASSIGN_SELECT_ACTION,
SLACK_VIEW_ASSIGN_CALLBACK,
} from './providers/slack.constants';
import { buildTaskDeepLink, escapeSlackText } from './utils';
import type { SlackEphemeralReply, SlackInteractionPayload, SlackSlashCommandPayload } from './types.internal';
// Orchestrates inbound Slack actions: /task (create), and Done / Assign buttons.
// Every path resolves the Slack user to a TaskView user (identity map) and enforces
// that user's RBAC before touching anything.
export class SlackInboundManager {
private readonly repository = new MessagingRepository();
private readonly slack = new SlackProvider();
async handleSlashCommand(payload: SlackSlashCommandPayload): Promise<SlackEphemeralReply> {
const userId = await this.resolveUser(payload.user_id, payload.team_id);
if (!userId) return this.ephemeral(this.linkPrompt());
const description = (payload.text ?? '').trim();
if (!description) return this.ephemeral('Usage: /task <description>');
const goalIds = await this.repository.fetchProjectGoalIdsByChannel({ provider: 'slack', channelId: payload.channel_id ?? '', externalTeamId: payload.team_id ?? null });
if (goalIds.length === 0) return this.ephemeral('This channel is not linked to a TaskView project.');
if (goalIds.length > 1) return this.ephemeral('This channel is linked to several projects — create the task in TaskView.');
const appUser = await this.buildAppUser(userId);
if (!appUser) return this.ephemeral('Could not resolve your TaskView account.');
const checker = await appUser.permissionsFetcher.getCheckerForGoal(goalIds[0]);
if (!checker.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS)) {
return this.ephemeral('You do not have permission to create tasks in this project.');
}
const created = await appUser.tasksManager.addTaskNew({ goalId: goalIds[0], description });
if (!created?.[0]) return this.ephemeral('Failed to create the task.');
return this.ephemeral(`✅ Task created: ${description}`);
}
async handleInteraction(interaction: SlackInteractionPayload): Promise<object | void> {
if (interaction.type === 'view_submission') return this.handleAssignSubmit(interaction);
if (interaction.type === 'block_actions') return this.handleBlockAction(interaction);
}
private async handleBlockAction(i: SlackInteractionPayload): Promise<void> {
const action = i.actions?.[0];
const taskId = Number(action?.value);
if (!action?.action_id || !taskId) return;
const userId = await this.resolveUser(i.user?.id, i.team?.id);
if (!userId) return this.ackEphemeral(i.response_url, this.linkPrompt());
if (action.action_id === SLACK_ACTION_DONE) return this.completeTask(i, userId, taskId);
if (action.action_id === SLACK_ACTION_REOPEN) return this.reopenTask(i, userId, taskId);
if (action.action_id === SLACK_ACTION_ASSIGN) return this.openAssignModal(i, userId, taskId);
}
private async completeTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
const appUser = await this.buildAppUser(userId);
if (!appUser) return;
const task = await appUser.tasksManager.fetchTaskById({ taskId });
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
}
await appUser.tasksManager.updateTask({ id: taskId, complete: true });
// Show which task (description gated by content permission, like notifications) + a link.
const label = checker.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT) && task.description
? task.description.slice(0, 200)
: `#${taskId}`;
const orgSlug = await this.repository.fetchGoalOrgSlug(task.goalId);
const url = buildTaskDeepLink(orgSlug, task.goalId, taskId, task.goalListId ?? null);
const text = url ? `✅ Task completed: <${url}|${escapeSlackText(label)}>` : `✅ Task completed: ${escapeSlackText(label)}`;
await this.ackEphemeral(i.response_url, text);
}
private async reopenTask(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
const appUser = await this.buildAppUser(userId);
if (!appUser) return;
const task = await appUser.tasksManager.fetchTaskById({ taskId });
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
}
await appUser.tasksManager.updateTask({ id: taskId, complete: false });
await this.ackEphemeral(i.response_url, '↩️ Task reopened');
}
private async openAssignModal(i: SlackInteractionPayload, userId: number, taskId: number): Promise<void> {
const appUser = await this.buildAppUser(userId);
if (!appUser) return;
const task = await appUser.tasksManager.fetchTaskById({ taskId });
// Same reply as permission-denied below, so this can't be used to probe which task IDs exist.
if (!task) return this.ackEphemeral(i.response_url, "You don't have access to this task.");
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
return this.ackEphemeral(i.response_url, "You don't have access to this task.");
}
const members = await this.repository.fetchGoalCollabMembers(task.goalId);
if (members.length === 0) return this.ackEphemeral(i.response_url, 'This project has no members to assign.');
const botToken = await this.botTokenForTeam(i.team?.id);
if (!botToken || !i.trigger_id) return this.ackEphemeral(i.response_url, 'Slack workspace is not fully connected.');
// Pre-select the current assignees so the modal shows who's assigned and lets the
// user add or remove — submit sets the whole list.
const currentAssignees = await this.repository.fetchCurrentAssigneeCollabIds(taskId);
const metadata = JSON.stringify({ taskId });
await this.slack.openModal({ botToken, triggerId: i.trigger_id, view: this.assignView(members, currentAssignees, metadata) });
}
private async handleAssignSubmit(i: SlackInteractionPayload): Promise<object> {
if (i.view?.callback_id !== SLACK_VIEW_ASSIGN_CALLBACK) return {};
const meta = this.parseMeta(i.view.private_metadata);
const taskId = Number(meta.taskId);
if (!taskId) return {};
const selected = i.view.state?.values?.[SLACK_ASSIGN_BLOCK]?.[SLACK_ASSIGN_SELECT_ACTION]?.selected_options ?? [];
const selectedCollabIds = selected.map((o) => Number(o.value)).filter((n) => Number.isInteger(n) && n > 0);
const userId = await this.resolveUser(i.user?.id, i.team?.id);
if (!userId) return this.viewError(this.linkPrompt());
const appUser = await this.buildAppUser(userId);
if (!appUser) return {};
const task = await appUser.tasksManager.fetchTaskById({ taskId });
if (!task) return {};
const checker = await appUser.permissionsFetcher.getCheckerForGoal(task.goalId);
if (!checker.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
return this.viewError('You do not have permission to assign this task.');
}
// toggleTaskUsers is called directly (not via the HTTP CanUpdateTaskAssignee guard),
// so re-validate every selected id is a member of this goal — Slack doesn't guarantee
// the submitted values are among the options we offered.
const valid = await this.repository.filterCollabIdsInGoal(selectedCollabIds, task.goalId);
if (valid.length !== selectedCollabIds.length) return this.viewError('One of the selected users is not a member of this project.');
// SET the assignee list to exactly the selection — this both adds and removes.
// An empty selection clears everyone (the input block is optional).
await appUser.tasksManager.toggleTaskUsers({ taskId, userIds: selectedCollabIds });
return {}; // closes the modal
}
private async resolveUser(slackUserId?: string, teamId?: string): Promise<number | null> {
if (!slackUserId) return null;
// Scope by workspace: a Slack user id is unique only within its team.
return this.repository.findUserIdByExternalId({ provider: 'slack', externalUserId: slackUserId, externalTeamId: teamId ?? null });
}
private async buildAppUser(userId: number): Promise<AppUser | null> {
const record = await new AppUser().authManager.repository.fetchUserById(userId);
if (!record || record.block !== 0) return null;
return new AppUser({ id: 0, userData: { id: record.id, login: record.login, email: record.email } });
}
private async botTokenForTeam(teamId?: string): Promise<string | null> {
if (!teamId) return null;
const encrypted = await this.repository.fetchSlackBotTokenEncrypted(teamId);
if (!encrypted) return null;
try {
return decrypt(encrypted);
} catch {
return null;
}
}
private assignView(members: { collabId: number; email: string }[], currentAssignees: number[], privateMetadata: string): object {
const current = new Set(currentAssignees);
const options = members.slice(0, 100).map((m) => ({
text: { type: 'plain_text', text: m.email.slice(0, 75) },
value: String(m.collabId),
}));
const initialOptions = options.filter((o) => current.has(Number(o.value)));
const element: Record<string, unknown> = {
type: 'multi_static_select',
action_id: SLACK_ASSIGN_SELECT_ACTION,
placeholder: { type: 'plain_text', text: 'Select assignees' },
options,
};
// Slack rejects an empty initial_options array — only set it when there are current ones.
if (initialOptions.length > 0) element.initial_options = initialOptions;
return {
type: 'modal',
callback_id: SLACK_VIEW_ASSIGN_CALLBACK,
private_metadata: privateMetadata,
title: { type: 'plain_text', text: 'Assignees' },
submit: { type: 'plain_text', text: 'Save' },
close: { type: 'plain_text', text: 'Cancel' },
blocks: [
{
type: 'input',
block_id: SLACK_ASSIGN_BLOCK,
optional: true, // allow clearing everyone
label: { type: 'plain_text', text: 'Assignees' },
element,
},
],
};
}
private parseMeta(raw?: string): { taskId?: number } {
try {
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
private viewError(message: string): object {
return { response_action: 'errors', errors: { [SLACK_ASSIGN_BLOCK]: message } };
}
private async ackEphemeral(responseUrl: string | undefined, text: string): Promise<void> {
if (responseUrl) await this.slack.respondEphemeral(responseUrl, text);
}
private ephemeral(text: string): SlackEphemeralReply {
return { response_type: 'ephemeral', text };
}
private linkPrompt(): string {
return `Link your Slack account in TaskView first: ${process.env.APP_URL ?? ''}`;
}
}
+21
View File
@@ -0,0 +1,21 @@
// Configuration constants for the messaging module. Endpoints are env-overridable
// (a mock server in tests, or an enterprise proxy), defaulting to the public provider
// URLs. The Slack Web API methods all derive from one base.
const SLACK_API_BASE = process.env.SLACK_API_BASE_URL || 'https://slack.com/api';
export const SLACK_AUTHORIZE_URL = process.env.SLACK_AUTHORIZE_URL || 'https://slack.com/oauth/v2/authorize';
export const SLACK_WEBHOOK_PREFIX = process.env.SLACK_WEBHOOK_PREFIX || 'https://hooks.slack.com/';
export const SLACK_TOKEN_URL = `${SLACK_API_BASE}/oauth.v2.access`;
export const SLACK_POST_MESSAGE_URL = `${SLACK_API_BASE}/chat.postMessage`;
export const SLACK_UPDATE_MESSAGE_URL = `${SLACK_API_BASE}/chat.update`;
export const SLACK_VIEWS_OPEN_URL = `${SLACK_API_BASE}/views.open`;
export const SLACK_OAUTH_NONCE_COOKIE = 'msg_slack_oauth_nonce';
export const OAUTH_STATE_TTL = '10m';
export const OAUTH_NONCE_MAX_AGE_MS = 10 * 60 * 1000;
export const TELEGRAM_API = 'https://api.telegram.org';
// Lifetime of a deep-link binding token (connect-link flow).
export const LINK_TTL_MS = 15 * 60 * 1000;
+46
View File
@@ -0,0 +1,46 @@
import type { BuildMessagingMessageArgs, MessagingEvent, MessagingMessage } from './types';
type Audience = 'personal' | 'project';
// Delivered message titles. English on the server, matching NotificationMessages —
// the backend has no i18n/per-recipient locale, so notification text is English by
// convention. (The event-selection UI is localized separately via frontend i18n.)
// Personal wording is second-person where it differs from the project-channel wording.
const TITLES: Record<MessagingEvent, { personal: string; project: string }> = {
'task.created': { personal: '[New task]', project: '[New task]' },
'task.assigned': { personal: '[Task assigned to you]', project: '[Task assigned]' },
'task.statusChanged': { personal: '[Task status changed]', project: '[Task status changed]' },
'task.completed': { personal: '[Task completed]', project: '[Task completed]' },
'task.edited': { personal: '[Task edited]', project: '[Task edited]' },
'task.addedToSprint': { personal: '[Task added to sprint]', project: '[Task added to sprint]' },
'task.deleted': { personal: '[Task deleted]', project: '[Task deleted]' },
'sprint.created': { personal: '[Sprint created]', project: '[Sprint created]' },
'sprint.updated': { personal: '[Sprint edited]', project: '[Sprint edited]' },
'sprint.started': { personal: '[Sprint started]', project: '[Sprint started]' },
'sprint.reviewStarted': { personal: '[Sprint review started]', project: '[Sprint review started]' },
'sprint.completed': { personal: '[Sprint completed]', project: '[Sprint completed]' },
'sprint.paused': { personal: '[Sprint paused]', project: '[Sprint paused]' },
'sprint.resumed': { personal: '[Sprint resumed]', project: '[Sprint resumed]' },
'sprint.deleted': { personal: '[Sprint deleted]', project: '[Sprint deleted]' },
'member.added': { personal: '[Member added]', project: '[Member added]' },
'member.removed': { personal: '[Member removed]', project: '[Member removed]' },
'member.rolesChanged': { personal: '[Member roles changed]', project: '[Member roles changed]' },
'time.started': { personal: '[Timer started]', project: '[Timer started]' },
'time.stopped': { personal: '[Timer stopped]', project: '[Timer stopped]' },
'time.logged': { personal: '[Time logged]', project: '[Time logged]' },
'time.updated': { personal: '[Time entry edited]', project: '[Time entry edited]' },
'time.deleted': { personal: '[Time entry deleted]', project: '[Time entry deleted]' },
'recurrence.created': { personal: '[Recurrence created]', project: '[Recurrence created]' },
'recurrence.updated': { personal: '[Recurrence edited]', project: '[Recurrence edited]' },
'recurrence.paused': { personal: '[Recurrence paused]', project: '[Recurrence paused]' },
'recurrence.resumed': { personal: '[Recurrence resumed]', project: '[Recurrence resumed]' },
'recurrence.ended': { personal: '[Recurrence ended]', project: '[Recurrence ended]' },
'recurrence.deleted': { personal: '[Recurrence deleted]', project: '[Recurrence deleted]' },
'recurrence.skipped': { personal: '[Occurrence skipped]', project: '[Occurrence skipped]' },
};
export function buildMessagingMessage(args: BuildMessagingMessageArgs): MessagingMessage {
const base = args.titleOverride ?? TITLES[args.event][args.audience];
const title = args.projectName ? `${base} [${args.projectName}]` : base;
return { event: args.event, title, body: args.body, url: args.url, taskId: args.taskId, footer: args.footer, completed: args.completed, actions: args.actions };
}
@@ -0,0 +1,22 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissions, type GoalPermissionType } from '../../../types/auth.types';
function requireProjectPermission(permission: GoalPermissionType) {
return async (req: Request, res: Response, next: NextFunction) => {
const goalId = Number(req.body?.goalId ?? req.query?.goalId);
if (!goalId || Number.isNaN(goalId)) return res.status(400).end();
const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(goalId);
if (!checker.hasPermissions(permission)) return res.status(403).end();
res.locals.messagingGoalId = goalId;
return next();
};
}
export const CanManageProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_MANAGE);
export const CanViewProjectMessaging = requireProjectPermission(GoalPermissions.INTEGRATIONS_CAN_VIEW);
export function getAuthorizedGoalId(res: Response): number {
return res.locals.messagingGoalId as number;
}
@@ -0,0 +1,32 @@
import { createHmac, timingSafeEqual } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import { $logger } from '../../../modules/logget';
const MAX_AGE_SECONDS = 300;
// Verifies inbound Slack requests (slash commands, interactivity) via the app Signing
// Secret: HMAC-SHA256 over `v0:${timestamp}:${rawBody}`, compared timing-safe against the
// X-Slack-Signature header, with a 5-minute timestamp window to blunt replay.
export const VerifySlackRequest = (req: Request, res: Response, next: NextFunction) => {
const secret = process.env.SLACK_SIGNING_SECRET;
if (!secret) {
$logger.error('[Messaging/Slack] SLACK_SIGNING_SECRET is not set — rejecting inbound request');
return res.status(503).end();
}
const signature = req.headers['x-slack-signature'] as string | undefined;
const timestamp = req.headers['x-slack-request-timestamp'] as string | undefined;
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody;
if (!signature || !timestamp || !rawBody) return res.status(401).end();
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) return res.status(401).end();
const expected = `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${rawBody.toString('utf8')}`).digest('hex')}`;
const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
if (expectedBuf.length !== signatureBuf.length || !timingSafeEqual(expectedBuf, signatureBuf)) {
return res.status(401).end();
}
return next();
};
@@ -0,0 +1,35 @@
import { timingSafeEqual } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import { $logger } from '../../../modules/logget';
/**
* Telegram sends the value configured via setWebhook's secret_token in this
* header on every update. Reject anything that doesn't match so the public
* inbound endpoint can't be spoofed.
*/
export function VerifyTelegramWebhook(req: Request, res: Response, next: NextFunction) {
const expected = process.env.TELEGRAM_WEBHOOK_SECRET;
if (!expected) {
$logger.warn('[Messaging/Telegram] webhook rejected: TELEGRAM_WEBHOOK_SECRET not set');
return res.status(503).end();
}
const provided = req.header('X-Telegram-Bot-Api-Secret-Token') ?? '';
if (!safeEqual(provided, expected)) {
$logger.warn({ hasHeader: !!req.header('X-Telegram-Bot-Api-Secret-Token') }, '[Messaging/Telegram] webhook rejected: secret mismatch');
return res.status(401).end();
}
return next();
}
function safeEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
try {
return timingSafeEqual(aBuf, bBuf);
} catch {
return false;
}
}
@@ -0,0 +1,27 @@
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingProviderId } from '../types';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw } from '../types.internal';
/**
* Full contract for a messaging provider (Telegram, Slack, …). A provider owns its
* protocol end-to-end — building connect URLs, exchanging OAuth codes, parsing inbound
* payloads, calling the messenger API. It returns normalized data; the manager does the
* business work (persistence, RBAC) and never branches on the concrete provider.
*/
export interface MessagingProvider {
readonly id: MessagingProviderId;
/** Whether this instance has the credentials needed to operate (env/admin config). */
isConfigured(): boolean;
/** Send one message to a chat/channel. Never throws — failures come back as a result. */
deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult>;
/**
* Everything needed to start connecting this owner: an authorize/deep-link URL, plus
* optionally a token to persist (deep-link flows) or a cookie to set (OAuth anti-CSRF).
*/
startConnect(ctx: ConnectContext): Promise<ConnectStart>;
/** Parse a raw inbound payload (webhook / OAuth callback) into a normalized intent, or null. */
parseInbound(raw: InboundRaw): Promise<InboundIntent | null>;
}
@@ -0,0 +1,8 @@
// Slack Block Kit action / callback identifiers, shared between the provider (which
// renders the buttons/modal) and the inbound handler (which routes interactions by them).
export const SLACK_ACTION_DONE = 'tv_task_done';
export const SLACK_ACTION_REOPEN = 'tv_task_reopen';
export const SLACK_ACTION_ASSIGN = 'tv_task_assign';
export const SLACK_VIEW_ASSIGN_CALLBACK = 'tv_assign_submit';
export const SLACK_ASSIGN_BLOCK = 'tv_assign_block';
export const SLACK_ASSIGN_SELECT_ACTION = 'tv_assign_select';
@@ -0,0 +1,290 @@
import { createHash, randomBytes } from 'crypto';
import axios from 'axios';
import jwt from 'jsonwebtoken';
import { $logger } from '../../../modules/logget';
import { encrypt } from '../../../utils/crypto';
import type { MessagingProvider } from './MessagingProvider';
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
import type { MessagingOwnerType } from '../types';
import { SLACK_ACTION_ASSIGN, SLACK_ACTION_DONE, SLACK_ACTION_REOPEN } from './slack.constants';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, MessagingOAuthState, SlackOAuthAccessResponse, SlackOAuthExchange, SlackOpenModalArgs } from '../types.internal';
import {
OAUTH_NONCE_MAX_AGE_MS,
OAUTH_STATE_TTL,
SLACK_AUTHORIZE_URL,
SLACK_OAUTH_NONCE_COOKIE,
SLACK_POST_MESSAGE_URL,
SLACK_TOKEN_URL,
SLACK_VIEWS_OPEN_URL,
SLACK_WEBHOOK_PREFIX,
} from '../config';
import { escapeSlackText, isSafeUrl } from '../utils';
/**
* Slack provider. Bot credentials are instance-level (env), like GitHub/GitLab.
* Personal connections DM the installing user (bot token + chat.postMessage);
* project connections use an incoming webhook (channel chosen during install).
*/
export class SlackProvider implements MessagingProvider {
readonly id: MessagingProviderId = 'slack';
isConfigured(): boolean {
return !!process.env.SLACK_CLIENT_ID && !!process.env.SLACK_CLIENT_SECRET && !!process.env.SLACK_CALLBACK_URL;
}
getOAuthUrl(state: string, ownerType: MessagingOwnerType): string {
const clientId = process.env.SLACK_CLIENT_ID;
const redirectUri = process.env.SLACK_CALLBACK_URL;
if (!clientId || !redirectUri) {
throw new Error('Slack integration OAuth is not configured');
}
// Personal → bot posts a DM to the installer. Project → channel is picked at
// install via incoming-webhook, and chat:write lets us post + update messages
// (chat.update) and open modals for the interactive buttons via the bot token.
const scope = ownerType === 'project' ? 'incoming-webhook,chat:write' : 'chat:write';
const params = new URLSearchParams({
client_id: clientId,
scope,
redirect_uri: redirectUri,
state,
});
return `${SLACK_AUTHORIZE_URL}?${params.toString()}`;
}
async exchangeCode(code: string): Promise<SlackOAuthExchange> {
const redirectUri = process.env.SLACK_CALLBACK_URL;
const res = await axios.post<SlackOAuthAccessResponse>(
SLACK_TOKEN_URL,
new URLSearchParams({
client_id: process.env.SLACK_CLIENT_ID ?? '',
client_secret: process.env.SLACK_CLIENT_SECRET ?? '',
code,
redirect_uri: redirectUri ?? '',
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
const data = res.data;
if (!data.ok || !data.access_token) {
throw new Error(`Slack code exchange failed: ${data.error ?? 'unknown'}`);
}
return {
botToken: data.access_token,
teamId: data.team?.id ?? null,
teamName: data.team?.name ?? null,
authedUserId: data.authed_user?.id ?? null,
webhookUrl: data.incoming_webhook?.url ?? null,
webhookChannel: data.incoming_webhook?.channel ?? null,
webhookChannelId: data.incoming_webhook?.channel_id ?? null,
};
}
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
// Anti-CSRF: a nonce is set as a cookie on the initiating browser and its hash is
// carried inside the signed state, verified on callback.
const nonce = randomBytes(32).toString('hex');
const state = jwt.sign(
{
provider: 'slack',
ownerType: ctx.ownerType,
ownerId: ctx.ownerId,
userId: ctx.userId,
nonceHash: this.hashNonce(nonce),
returnPath: ctx.returnPath ?? '',
} as MessagingOAuthState,
process.env.JWT_SIGN as string,
{ expiresIn: OAUTH_STATE_TTL },
);
return {
kind: 'oauth',
url: this.getOAuthUrl(state, ctx.ownerType),
setCookie: { name: SLACK_OAUTH_NONCE_COOKIE, value: nonce, maxAgeMs: OAUTH_NONCE_MAX_AGE_MS },
};
}
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
// Slash commands / interactivity are handled by SlackInboundManager (they need
// multi-step Slack UI). Here we only complete the OAuth connect round-trip.
if (raw.source !== 'oauth-callback') return null;
const { code, state } = (raw.payload as { code?: string; state?: string }) ?? {};
if (!code || !state) return null;
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as MessagingOAuthState;
if (payload.provider !== 'slack') throw new Error('Provider mismatch in OAuth state');
// The state's nonce hash must match the cookie set on the initiating browser.
// Enforced only in production: local dev often splits the frontend (localhost) from
// the public callback (tunnel), where a single browser cookie can't bridge origins.
const nonceOk = !!raw.cookie && this.hashNonce(raw.cookie) === payload.nonceHash;
if (!nonceOk) {
if (process.env.NODE_ENV === 'production') throw new Error('OAuth state/nonce mismatch');
$logger.warn('[Messaging/Slack] Skipping OAuth nonce check (non-production; split-origin dev)');
}
const ex = await this.exchangeCode(code);
if (payload.ownerType === 'project') {
if (!ex.webhookChannelId) throw new Error('Slack did not return the chosen channel');
// Store the bot token (not the webhook URL): posting via chat.postMessage also
// lets us update messages and open modals for the interactive buttons.
return {
kind: 'createConnection',
redirect: payload.returnPath,
connection: {
provider: 'slack',
ownerType: 'project',
ownerId: payload.ownerId,
targetChatId: ex.webhookChannelId,
title: ex.webhookChannel,
externalTeamId: ex.teamId,
accessTokenEncrypted: encrypt(ex.botToken),
},
};
}
if (!ex.authedUserId) throw new Error('Slack did not return the installing user');
return {
kind: 'createConnection',
redirect: payload.returnPath,
identity: { userId: payload.userId, externalUserId: ex.authedUserId, externalTeamId: ex.teamId ?? null },
connection: {
provider: 'slack',
ownerType: 'user',
ownerId: payload.userId,
targetChatId: ex.authedUserId,
title: ex.teamName ? `Slack (${ex.teamName})` : 'Slack',
externalTeamId: ex.teamId,
accessTokenEncrypted: encrypt(ex.botToken),
},
};
}
private hashNonce(nonce: string): string {
return createHash('sha256').update(nonce).digest('hex');
}
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
if (!args.accessToken) return { success: false };
const secret = args.accessToken;
const text = this.render(args.message);
const blocks = this.buildTaskBlocks(args.message);
try {
// Legacy project connections store a webhook URL (no bot token → no interactive
// buttons). Newer connections store a bot token and post via chat.postMessage.
if (secret.startsWith(SLACK_WEBHOOK_PREFIX)) {
const res = await axios.post(secret, { text, blocks }, { timeout: 10000 });
return { success: res.status === 200 };
}
const res = await axios.post<{ ok: boolean; error?: string }>(
SLACK_POST_MESSAGE_URL,
{ channel: args.chatId, text, blocks },
{ headers: { Authorization: `Bearer ${secret}` }, timeout: 10000 },
);
if (!res.data.ok) {
$logger.warn(`[Messaging/Slack] chat.postMessage failed: ${res.data.error ?? 'unknown'} (channel=${args.chatId})`);
}
return { success: res.data.ok, errorCode: res.data.ok ? undefined : 400 };
} catch (err) {
// Never log the raw axios error — its config.url (incoming webhook, itself a
// secret) / config.headers.Authorization (bot token) would land in disk logs.
const status = (err as { response?: { status?: number } })?.response?.status;
$logger.error({ status }, `[Messaging/Slack] Delivery failed to ${args.chatId}`);
return { success: false };
}
}
/** Opens a modal (assignee picker). trigger_id from the interaction expires in ~3s. */
async openModal(args: SlackOpenModalArgs): Promise<boolean> {
return this.callApi(SLACK_VIEWS_OPEN_URL, args.botToken, { trigger_id: args.triggerId, view: args.view });
}
/** Ephemeral feedback for an interaction — posts to the payload's response_url (no token). */
async respondEphemeral(responseUrl: string, text: string): Promise<void> {
// response_url is Slack-supplied; pin its host to the configured webhook host so a
// trust regression can never turn this into an SSRF to an arbitrary URL.
let host: string;
try {
host = new URL(responseUrl).host;
} catch {
return;
}
if (host !== new URL(SLACK_WEBHOOK_PREFIX).host) return;
try {
// replace_original:false — post a separate ephemeral note, never overwrite the card
// the button was on (the response_url default is to replace the original message).
await axios.post(responseUrl, { response_type: 'ephemeral', replace_original: false, text }, { timeout: 10000 });
} catch {
// Best-effort user feedback; nothing to recover if Slack's response_url is unreachable.
}
}
private async callApi(url: string, botToken: string, body: Record<string, unknown>): Promise<boolean> {
try {
const res = await axios.post<{ ok: boolean; error?: string }>(url, body, {
headers: { Authorization: `Bearer ${botToken}` },
timeout: 10000,
});
if (!res.data.ok) $logger.warn(`[Messaging/Slack] ${url.split('/').pop()} failed: ${res.data.error ?? 'unknown'}`);
return res.data.ok;
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
$logger.error({ status }, `[Messaging/Slack] API call failed: ${url.split('/').pop()}`);
return false;
}
}
// Block Kit: a text section plus Done/Assign action buttons for task events. The button
// value carries the taskId so the interaction round-trips it back to us.
private buildTaskBlocks(message: MessagingMessage): unknown[] {
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
const blocks: unknown[] = [];
// Task name as a large header (Slack's biggest text). Header is plain_text only — no
// link/markdown — so the clickable label lives in the section below.
if (message.body) {
blocks.push({ type: 'header', text: { type: 'plain_text', text: message.body.slice(0, 150), emoji: true } });
}
// Event + project label; the whole line links to the task.
const label = escapeSlackText(message.title);
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: url ? `*<${url}|${label}>*` : `*${label}*` } });
// Assignees line above the buttons so the actions sit at the very bottom.
if (message.footer) {
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: escapeSlackText(message.footer) }] });
}
if (message.taskId && message.actions !== false) {
// A completed task offers Reopen; an open task offers Done.
const primary = message.completed
? { type: 'button', text: { type: 'plain_text', text: '↩️ Reopen' }, action_id: SLACK_ACTION_REOPEN, value: String(message.taskId) }
: { type: 'button', text: { type: 'plain_text', text: '✅ Done' }, action_id: SLACK_ACTION_DONE, value: String(message.taskId) };
blocks.push({
type: 'actions',
elements: [
primary,
{ type: 'button', text: { type: 'plain_text', text: '👤 Assign' }, action_id: SLACK_ACTION_ASSIGN, value: String(message.taskId) },
],
});
}
return blocks;
}
private render(message: MessagingMessage): string {
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
const lines: string[] = [];
if (url) {
// Slack link syntax <url|text>: hyperlink the description, or the title
// when there is no description.
lines.push(message.body
? `*${escapeSlackText(message.title)}*\n<${url}|${escapeSlackText(message.body)}>`
: `*<${url}|${escapeSlackText(message.title)}>*`);
} else {
lines.push(`*${escapeSlackText(message.title)}*`);
if (message.body) lines.push(escapeSlackText(message.body));
}
if (message.footer) lines.push(escapeSlackText(message.footer));
return lines.join('\n');
}
}
@@ -0,0 +1,105 @@
import { randomBytes } from 'crypto';
import { $logger } from '../../../modules/logget';
import type { MessagingProvider } from './MessagingProvider';
import type { MessagingDeliverArgs, MessagingDeliverResult, MessagingMessage, MessagingProviderId } from '../types';
import type { ConnectContext, ConnectStart, InboundIntent, InboundRaw, TelegramInboundMessage } from '../types.internal';
import { LINK_TTL_MS, TELEGRAM_API } from '../config';
import { escapeHtml, isSafeUrl } from '../utils';
/**
* Telegram bot provider. The bot token is instance-level (env), so the SaaS
* ships an official bot and self-hosted installs supply their own via
* TELEGRAM_BOT_TOKEN — no code fork, only config.
*/
export class TelegramProvider implements MessagingProvider {
readonly id: MessagingProviderId = 'telegram';
isConfigured(): boolean {
return !!process.env.TELEGRAM_BOT_TOKEN && !!process.env.TELEGRAM_BOT_USERNAME;
}
async deliver(args: MessagingDeliverArgs): Promise<MessagingDeliverResult> {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) return { success: false };
try {
const response = await fetch(`${TELEGRAM_API}/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: args.chatId,
text: this.render(args.message),
parse_mode: 'HTML',
disable_web_page_preview: true,
}),
signal: AbortSignal.timeout(10000),
});
return { success: response.ok, errorCode: response.ok ? undefined : response.status };
} catch (err) {
// Log only the message — the raw error can carry the request URL, which
// embeds the bot token.
const message = err instanceof Error ? err.message : 'unknown error';
$logger.error({ message }, `[Messaging/Telegram] Delivery failed to chat=${args.chatId}`);
return { success: false };
}
}
async startConnect(ctx: ConnectContext): Promise<ConnectStart> {
const username = process.env.TELEGRAM_BOT_USERNAME;
const token = randomBytes(24).toString('hex');
const expiresAt = new Date(Date.now() + LINK_TTL_MS);
// Personal → deep-link into a private chat (auto-sends /start <token>).
// Project → startgroup lets the admin add the bot to a group; the bind completes
// when they run /connect <token> there.
const url = ctx.ownerType === 'user'
? `https://t.me/${username}?start=${token}`
: `https://t.me/${username}?startgroup=${token}`;
return { kind: 'deep-link', url, persistToken: { token, expiresAt } };
}
async parseInbound(raw: InboundRaw): Promise<InboundIntent | null> {
if (raw.source !== 'webhook') return null;
const message = (raw.payload as { message?: TelegramInboundMessage })?.message;
if (!message) return null;
const text = typeof message.text === 'string' ? message.text.trim() : '';
const chatId = message.chat?.id;
const chatType = message.chat?.type;
const fromId = message.from?.id;
if (!text || text.length > 4096 || typeof chatId !== 'number' || typeof fromId !== 'number') return null;
// /task <description> — create a task in this chat's project.
const taskMatch = text.match(/^\/task(?:@\w+)?\s+([\s\S]+)$/);
if (taskMatch) {
return { kind: 'command', command: 'createTask', text: taskMatch[1].trim().slice(0, 2000), chatId: String(chatId), externalUserId: String(fromId) };
}
const match = text.match(/^\/(?:start|connect)(?:@\w+)?\s+(\S+)$/);
if (!match) return null;
if (chatType === 'private') {
const title = typeof message.from?.username === 'string' ? `@${message.from.username}` : null;
return { kind: 'bindByToken', token: match[1], scope: 'user', chatId: String(chatId), externalUserId: String(fromId), title };
}
if (chatType === 'group' || chatType === 'supergroup') {
const title = typeof message.chat?.title === 'string' ? message.chat.title : null;
return { kind: 'bindByToken', token: match[1], scope: 'project', chatId: String(chatId), externalUserId: String(fromId), title };
}
return null;
}
private render(message: MessagingMessage): string {
// Mirror the Slack layout: task name (bold) on top, the "[event] [project]" label as
// the link below, assignees last. Telegram has no header size, so name is just bold.
const url = message.url && isSafeUrl(message.url) ? message.url : undefined;
const lines: string[] = [];
if (message.body) lines.push(`<b>${escapeHtml(message.body)}</b>`);
const label = escapeHtml(message.title);
lines.push(url ? `<a href="${escapeHtml(url)}">${label}</a>` : label);
if (message.footer) lines.push(escapeHtml(message.footer));
return lines.join('\n');
}
}
@@ -0,0 +1,209 @@
import type { MessagingConnectionsSchemaTypeForSelect } from 'taskview-db-schemas';
import type { MessagingEvent, MessagingOwnerType, MessagingProviderId } from './types';
export type MessagingConnectionForClient = Omit<MessagingConnectionsSchemaTypeForSelect, 'accessTokenEncrypted'>;
export interface MessagingRecipient {
userId: number;
email: string;
}
export interface SlackOpenModalArgs {
botToken: string;
triggerId: string;
view: unknown;
}
export interface SlackOAuthAccessResponse {
ok: boolean;
error?: string;
access_token?: string;
team?: { id?: string; name?: string };
authed_user?: { id?: string };
incoming_webhook?: { url?: string; channel?: string; channel_id?: string };
}
export interface SlackSlashCommandPayload {
user_id?: string;
channel_id?: string;
team_id?: string;
text?: string;
}
export interface SlackInteractionPayload {
type?: string;
user?: { id?: string };
team?: { id?: string };
trigger_id?: string;
response_url?: string;
channel?: { id?: string };
container?: { message_ts?: string };
message?: { ts?: string; blocks?: { type?: string; text?: { text?: string } }[] };
actions?: { action_id?: string; value?: string }[];
view?: {
callback_id?: string;
private_metadata?: string;
state?: { values?: Record<string, Record<string, { selected_options?: { value?: string }[] }>> };
};
}
export interface SlackEphemeralReply {
response_type: 'ephemeral';
text: string;
}
export interface TelegramInboundMessage {
text?: unknown;
chat?: { id?: unknown; type?: unknown; title?: unknown };
from?: { id?: unknown; username?: unknown };
}
export interface MessagingTaskContext {
id: number;
goalListId: number | null;
description: string | null;
complete: boolean;
}
export interface MessagingDispatchArgs {
event: MessagingEvent;
goalId: number;
personalRecipients: MessagingRecipient[];
initiatorId: number | null;
/** Simple, non-content body (sprint name, member email, …). */
body?: string;
/** Task context: description is RBAC-gated per recipient (COMPONENT_CAN_WATCH_CONTENT) + a task deep-link. */
task?: MessagingTaskContext;
/** Overrides the title (e.g. "[Task reopened]" while still gated by the task.completed subscription). */
titleOverride?: string;
}
/** Slack identities are keyed by workspace; Telegram passes externalTeamId = null. */
export interface MessagingIdentityUpsert {
userId: number;
provider: string;
externalUserId: string;
externalTeamId: string | null;
}
export interface MessagingIdentityLookup {
provider: string;
externalUserId: string;
externalTeamId: string | null;
}
export interface MessagingChannelLookup {
provider: string;
channelId: string;
externalTeamId: string | null;
}
export interface MessagingConnectionCreate {
provider: MessagingProviderId;
ownerType: MessagingOwnerType;
ownerId: number;
targetChatId: string;
title: string | null;
externalTeamId: string | null;
accessTokenEncrypted: string | null;
}
export interface MessagingOwnedRef {
id: number;
ownerType: MessagingOwnerType;
ownerId: number;
}
export interface MessagingOwnedToggle extends MessagingOwnedRef {
isActive: boolean;
}
export interface MessagingLinkTokenCreate {
token: string;
provider: MessagingProviderId;
ownerType: MessagingOwnerType;
ownerId: number;
createdBy: number;
expiresAt: Date;
}
export interface SlackOAuthExchange {
botToken: string;
teamId: string | null;
teamName: string | null;
authedUserId: string | null;
webhookUrl: string | null;
webhookChannel: string | null;
webhookChannelId: string | null;
}
export interface MessagingOAuthState {
provider: MessagingProviderId;
ownerType: MessagingOwnerType;
ownerId: number;
userId: number;
/** SHA-256 of a nonce also stored in an httpOnly cookie — binds the flow to the initiating browser (anti-CSRF). */
nonceHash: string;
/** In-app path the user started from, to return them there after the callback. */
returnPath: string;
}
export interface MessagingConnectLinkResult {
provider: MessagingProviderId;
url: string;
token: string;
expiresAt: string;
}
// ── Provider lifecycle (single MessagingProvider interface) ─────────────────
// Everything a provider needs to start a connection. The manager stays generic:
// it never branches on provider — it just persists what the provider returns.
export interface ConnectContext {
ownerType: MessagingOwnerType;
ownerId: number;
userId: number;
/** In-app path to return to after an OAuth round-trip (Slack); ignored by others. */
returnPath?: string;
}
// Discriminated so the two flows can't be mixed up: a deep-link provider always mints a
// token to persist; an OAuth provider always sets an anti-CSRF cookie. Never both.
export type ConnectStart =
| { kind: 'deep-link'; url: string; persistToken: { token: string; expiresAt: Date } }
| { kind: 'oauth'; url: string; setCookie: { name: string; value: string; maxAgeMs: number } };
/** Raw inbound, tagged by which endpoint received it so the provider can parse accordingly. */
export interface InboundRaw {
source: 'oauth-callback' | 'webhook';
payload: unknown;
/** Cookie value echoed back for verification (OAuth nonce). */
cookie?: string;
}
/** Normalized inbound outcome. The provider parses protocol; the manager does DB + RBAC. */
export type InboundIntent =
| {
kind: 'createConnection';
connection: MessagingConnectionCreate;
/** Personal connections also link the messenger account to a TaskView user. */
identity?: { userId: number; externalUserId: string; externalTeamId: string | null };
/** In-app path to redirect the browser to (OAuth callback). */
redirect?: string;
}
| {
kind: 'bindByToken';
token: string;
scope: MessagingOwnerType;
chatId: string;
externalUserId: string;
title: string | null;
}
| {
/** A command from a chat (e.g. Telegram /task). The manager resolves identity + RBAC. */
kind: 'command';
command: 'createTask';
text: string;
chatId: string;
externalUserId: string;
};
+162
View File
@@ -0,0 +1,162 @@
import { type } from 'arktype';
export const MESSAGING_PROVIDERS = ['telegram', 'slack'] as const;
export type MessagingProviderId = typeof MESSAGING_PROVIDERS[number];
export const MESSAGING_OWNER_TYPES = ['user', 'project', 'organization'] as const;
export type MessagingOwnerType = typeof MESSAGING_OWNER_TYPES[number];
/**
* Provider-agnostic event kinds the module reacts to. They map onto EventBus
* events in MessagingDispatcher and onto notification types for preferences.
*/
/**
* User-selectable events. Task events target the task's assignees; sprint events
* target project members. Each connection subscribes to the subset it wants.
*/
export const MESSAGING_EVENTS = [
// tasks — audience: the task's assignees (task.deleted → project members)
'task.created',
'task.assigned',
'task.statusChanged',
'task.completed',
'task.edited',
'task.addedToSprint',
'task.deleted',
// sprints — audience: project members
'sprint.created',
'sprint.updated',
'sprint.started',
'sprint.reviewStarted',
'sprint.completed',
'sprint.paused',
'sprint.resumed',
'sprint.deleted',
// members — audience: project members
'member.added',
'member.removed',
'member.rolesChanged',
// time tracking — audience: project members
'time.started',
'time.stopped',
'time.logged',
'time.updated',
'time.deleted',
// recurring rules — audience: project members
'recurrence.created',
'recurrence.updated',
'recurrence.paused',
'recurrence.resumed',
'recurrence.ended',
'recurrence.deleted',
'recurrence.skipped',
] as const;
export type MessagingEvent = typeof MESSAGING_EVENTS[number];
export function sanitizeMessagingEvents(events: string[]): MessagingEvent[] {
const allowed = new Set<string>(MESSAGING_EVENTS);
return [...new Set(events.filter((e) => allowed.has(e)))] as MessagingEvent[];
}
/** Normalized message a provider renders into its own format. */
export interface MessagingMessage {
event: MessagingEvent;
title: string;
body?: string;
url?: string;
/** Present for task events — lets the Slack provider attach Done/Assign action buttons. */
taskId?: number;
/** An extra line rendered at the bottom (e.g. assignees) — part of the original message. */
footer?: string;
/** Task's current completion state — the primary button is Reopen when true, else Done. */
completed?: boolean;
/** Whether to render interactive action buttons (Slack). Suppressed for content-hidden channels. */
actions?: boolean;
}
export interface BuildMessagingMessageArgs {
event: MessagingEvent;
audience: 'personal' | 'project';
body: string;
url?: string;
taskId?: number;
projectName?: string | null;
footer?: string;
completed?: boolean;
/** Overrides the title (keeps the subscription event but shows different wording, e.g. reopened). */
titleOverride?: string;
/** Whether to render interactive action buttons. Defaults to shown; false for content-hidden channels. */
actions?: boolean;
}
export interface MessagingDeliverArgs {
chatId: string;
/** Decrypted per-connection secret (Slack bot token / webhook URL); null for Telegram (instance bot token from env). */
accessToken: string | null;
message: MessagingMessage;
}
export interface MessagingDeliverResult {
success: boolean;
errorCode?: number;
}
export interface MessagingDeliverJobData {
connectionId: number;
provider: MessagingProviderId;
chatId: string;
accessTokenEncrypted: string | null;
message: MessagingMessage;
attempt: number;
}
const NumberFromString = type('string|number').pipe((v) => Number(v));
// Derived from MESSAGING_PROVIDERS so adding a provider doesn't silently fail validation.
const MessagingProviderParam = type.enumerated(...MESSAGING_PROVIDERS);
export const MessagingArkTypeConnectLink = type({
provider: MessagingProviderParam,
});
export type MessagingArgConnectLink = typeof MessagingArkTypeConnectLink.infer;
export const MessagingArkTypeById = type({
id: NumberFromString,
});
export type MessagingArgById = typeof MessagingArkTypeById.infer;
export const MessagingArkTypeToggle = type({
id: 'number',
isActive: 'boolean',
});
export type MessagingArgToggle = typeof MessagingArkTypeToggle.infer;
export const MessagingArkTypeUpdateEvents = type({
id: 'number',
events: 'string[]',
});
export type MessagingArgUpdateEvents = typeof MessagingArkTypeUpdateEvents.infer;
// Project routes take goalId from IsProjectGoalOwner (res.locals), not the payload,
// so these schemas validate only the non-authorization fields.
export const MessagingArkTypeProviderParam = type({
provider: MessagingProviderParam,
});
export type MessagingArgProviderParam = typeof MessagingArkTypeProviderParam.infer;
export const MessagingArkTypeProjectToggle = type({
id: 'number',
isActive: 'boolean',
});
export type MessagingArgProjectToggle = typeof MessagingArkTypeProjectToggle.infer;
export const MessagingArkTypeProjectDelete = type({
id: 'number',
});
export type MessagingArgProjectDelete = typeof MessagingArkTypeProjectDelete.infer;
export const MessagingArkTypeProjectPostContent = type({
id: 'number',
postContent: 'boolean',
});
export type MessagingArgProjectPostContent = typeof MessagingArkTypeProjectPostContent.infer;
+38
View File
@@ -0,0 +1,38 @@
import { ALL_TASKS_LIST_ID } from '../../types/tasks.types';
// Reusable text/URL helpers shared across the messaging module.
// Frontend deep-link to a task: /:orgSlug/:projectId/:listId/:taskId. A task with no
// list lives in the virtual "All tasks" list (ALL_TASKS_LIST_ID sentinel, shared with web).
export function buildTaskDeepLink(orgSlug: string | null, goalId: number, taskId: number, goalListId: number | null): string | undefined {
const appUrl = process.env.APP_URL;
if (!appUrl || !orgSlug) return undefined;
const listSegment = goalListId ?? ALL_TASKS_LIST_ID;
return `${appUrl}/${orgSlug}/${goalId}/${listSegment}/${taskId}`;
}
// Only http(s) links are ever rendered — blocks tg://, javascript:, etc.
export function isSafeUrl(url: string): boolean {
try {
const scheme = new URL(url).protocol;
return scheme === 'http:' || scheme === 'https:';
} catch {
return false;
}
}
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
// so a user-controlled value can't break out of a Telegram HTML message.
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Slack mrkdwn requires escaping these three in text (incl. link labels).
export function escapeSlackText(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
@@ -10,6 +10,7 @@ import { NotificationMessages } from './NotificationMessages';
import { NotificationsRepository } from './repositories/NotificationsRepository';
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
import { DeadlineScheduler } from './schedulers/DeadlineScheduler';
import { RecurrenceRepository } from '../recurrence/RecurrenceRepository';
import { NotificationType } from './types';
import { parseUtcTime } from './utils';
import type { Dispatcher } from '../../core/Dispatcher';
@@ -27,6 +28,8 @@ export class NotificationDispatcher implements Dispatcher {
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
eventBus.on('recurrence.created', (data) => this.onRecurrenceScheduled(data));
eventBus.on('recurrence.updated', (data) => this.onRecurrenceScheduled(data));
}
async registerWorkers(): Promise<void> {
@@ -44,9 +47,14 @@ export class NotificationDispatcher implements Dispatcher {
}
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
if (data.task.endDate) {
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
if (!data.task.endDate) return;
// Recurring instances remind about their deadline only when the series
// opted in — otherwise a daily series becomes a daily notification.
if (data.task.recurrenceRuleId) {
const rule = await new RecurrenceRepository().getById(data.task.recurrenceRuleId);
if (rule && !rule.notifyOnOccurrence) return;
}
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
}
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
@@ -73,6 +81,19 @@ export class NotificationDispatcher implements Dispatcher {
}
}
private async onRecurrenceScheduled(data: AppEvents['recurrence.created'] | AppEvents['recurrence.updated']): Promise<void> {
const instance = await new RecurrenceRepository().findOpenInstance(data.rule.id);
if (!instance) return;
this.notificationsRepo.deleteByTaskAndType(instance.id, NotificationType.DEADLINE);
await this.deadlineScheduler.cancel(instance.id);
if (data.rule.notifyOnOccurrence && instance.endDate) {
$logger.info(`[NotificationDispatcher] Scheduling occurrence deadline for task=${instance.id}, rule=${data.rule.id}`);
await this.deadlineScheduler.schedule(instance, data.initiatorId);
}
}
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
if (data.userIds.length === 0) return;
@@ -3,7 +3,8 @@ import { parseUtcTime } from './utils';
export class NotificationMessages {
static deadline(description: string | null, endDate: string, endTime: string | null, timezone: string): NotificationMessage {
const title = `Task: ${description || 'Task'}`;
// description is null for recipients without COMPONENT_CAN_WATCH_CONTENT
const title = description ? `Task: ${description}` : 'Task deadline';
if (endTime) {
const deadline = parseUtcTime(endDate, endTime);
@@ -1,13 +1,16 @@
import { eq, and, or, isNull } from 'drizzle-orm';
import { eq, and, or, isNull, inArray } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { TasksSchema, TasksAssigneeSchema, GoalsSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
import { getJobQueue, cancelJobBySingletonKey } from '../../../core/JobQueue';
import { GoalPermissionsChecker } from '../../../core/GoalPermissionsChecker';
import { GoalPermissionsRepository } from '../../../core/GoalPermissionsRepository';
import { Database } from '../../../modules/db';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { getNotificationService } from '../NotificationService';
import { NotificationMessages } from '../NotificationMessages';
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
import { NotificationType, type DeadlineJobData, type TaskWithDeadline } from '../types';
import { NotificationType, type DeadlineJobData, type DeadlineRecipient, type TaskWithDeadline } from '../types';
import { parseUtcTime, localHourToUtc } from '../utils';
const DEADLINE_JOB = 'deadline-notification';
@@ -86,31 +89,36 @@ export class DeadlineScheduler {
return;
}
const recipientIds = await this.resolveRecipients(db, taskId, goalId, task[0].owner);
if (!recipientIds || recipientIds.length === 0) {
let recipients = await this.resolveRecipients(db, taskId, goalId, task[0].owner);
if (!recipients || recipients.length === 0) {
$logger.info(`[DeadlineScheduler] Task ${taskId}: no recipients`);
return;
}
if (immediate && initiatorId) {
const idx = recipientIds.indexOf(initiatorId);
if (idx !== -1) recipientIds.splice(idx, 1);
recipients = recipients.filter((r) => r.userId !== initiatorId);
}
if (recipientIds.length === 0) return;
if (recipients.length === 0) return;
$logger.info(`[DeadlineScheduler] Task ${taskId}: sending to [${recipientIds.join(',')}]`);
// Description rides in the notification title, but it is gated by
// COMPONENT_CAN_WATCH_CONTENT — split recipients so those without
// the permission get a generic title (no leak over push).
const { canWatch, cannotWatch } = await this.splitByContentPermission(goalId, recipients);
$logger.info(`[DeadlineScheduler] Task ${taskId}: sending to content=[${canWatch.join(',')}] generic=[${cannotWatch.join(',')}]`);
const tz = task[0].owner ? await this.deviceTokensRepo.getTimezoneByUserId(task[0].owner) : 'UTC';
const message = NotificationMessages.deadline(description, endDate, endTime, tz);
const meta = { goalId, goalListId, organizationId: organizationId ?? null };
await getNotificationService().notifyMany(
recipientIds,
NotificationType.DEADLINE,
message,
{ goalId, goalListId, organizationId: organizationId ?? null },
taskId,
);
if (canWatch.length > 0) {
const message = NotificationMessages.deadline(description, endDate, endTime, tz);
await getNotificationService().notifyMany(canWatch, NotificationType.DEADLINE, message, meta, taskId);
}
if (cannotWatch.length > 0) {
const message = NotificationMessages.deadline(null, endDate, endTime, tz);
await getNotificationService().notifyMany(cannotWatch, NotificationType.DEADLINE, message, meta, taskId);
}
});
}
@@ -127,7 +135,7 @@ export class DeadlineScheduler {
return result[0]?.organizationId ?? null;
}
private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise<number[] | null> {
private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise<DeadlineRecipient[] | null> {
const authUsers = alias(UsersSchema, 'auth_users');
try {
@@ -146,13 +154,46 @@ export class DeadlineScheduler {
const ids = new Set<number>();
if (taskOwner) ids.add(taskOwner);
assignees.forEach((r) => ids.add(r.userId));
for (const r of assignees) ids.add(r.userId);
if (goal[0]) ids.add(goal[0].owner);
if (ids.size === 0) return [];
return [...ids];
// Emails are needed to resolve per-recipient goal permissions (role join keys on email).
return await db.dbDrizzle
.select({ userId: UsersSchema.id, email: UsersSchema.email })
.from(UsersSchema)
.where(inArray(UsersSchema.id, [...ids]));
} catch (err) {
$logger.error(err, '[DeadlineScheduler] Failed to resolve recipients');
return null;
}
}
/** Partition recipients into those allowed to see task content and those who are not. */
private async splitByContentPermission(goalId: number,recipients: DeadlineRecipient[]): Promise<{ canWatch: number[]; cannotWatch: number[] }> {
const permissionsRepo = new GoalPermissionsRepository();
const canWatch: number[] = [];
const cannotWatch: number[] = [];
await Promise.all(
recipients.map(async (recipient) => {
// Fail closed per-recipient: a permission-fetch error drops this
// recipient to the generic message, never aborts the whole job.
const permissions = await permissionsRepo
.fetchPermissionsForGoalByUser({ goalId, userId: recipient.userId, email: recipient.email })
.catch((err) => {
$logger.error(err, `[DeadlineScheduler] permission check failed for user=${recipient.userId}`);
return [];
});
const checker = new GoalPermissionsChecker(permissions);
if (checker.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT)) {
canWatch.push(recipient.userId);
} else {
cannotWatch.push(recipient.userId);
}
}),
);
return { canWatch, cannotWatch };
}
}
@@ -121,4 +121,9 @@ export interface TaskWithDeadline {
owner: number | null;
endDate: string | null;
endTime: string | null;
}
export interface DeadlineRecipient {
userId: number;
email: string;
}
@@ -139,7 +139,7 @@ export class OrganizationRepository {
})
.from(OrganizationMembersSchema)
.innerJoin(OrganizationsSchema, eq(OrganizationMembersSchema.organizationId, OrganizationsSchema.id))
.where(eq(OrganizationMembersSchema.email, email))
.where(eq(OrganizationMembersSchema.email, email.toLowerCase()))
)
if (!result) return []
@@ -192,7 +192,7 @@ export class OrganizationRepository {
.where(
and(
eq(OrganizationMembersSchema.organizationId, orgId),
eq(OrganizationMembersSchema.email, email),
eq(OrganizationMembersSchema.email, email.toLowerCase()),
)
)
)
@@ -220,7 +220,7 @@ export class OrganizationRepository {
.where(
and(
eq(OrganizationMembersSchema.organizationId, orgId),
eq(OrganizationMembersSchema.email, email),
eq(OrganizationMembersSchema.email, email.toLowerCase()),
)
)
)
@@ -0,0 +1,72 @@
import { type } from 'arktype';
import type { Request, Response } from 'express';
import {
RecurrenceArkTypeCreate,
RecurrenceArkTypeRuleIdParam,
RecurrenceArkTypeTaskIdParam,
RecurrenceArkTypeUpdate,
} from './types';
import type { RecurrenceErrorCode, RecurrenceResult } from './types';
const codeToStatus: Record<RecurrenceErrorCode, number> = {
not_found: 404,
conflict: 409,
invalid_state: 400,
invalid_rule: 422,
};
export default class RecurrenceController {
private sendResult<T>(res: Response, result: RecurrenceResult<T>) {
if (result.ok) return res.tvJson(result.data);
return res.status(codeToStatus[result.code]).send(result.message ?? result.code);
}
create = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeCreate(req.body);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.createRule(data));
};
getOne = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.getDetails(data.ruleId));
};
getForTask = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeTaskIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.getDetailsForTask(data.taskId));
};
update = async (req: Request, res: Response) => {
// Route params win over body: the ruleId authorized by the middleware must be the one operated on.
const data = RecurrenceArkTypeUpdate({ ...req.body, ruleId: Number(req.params.ruleId) });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.updateRule(data));
};
pause = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.pauseRule(data.ruleId));
};
resume = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.resumeRule(data.ruleId));
};
skip = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.skipCurrent(data.ruleId));
};
remove = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.deleteRule(data.ruleId));
};
}

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