mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9ce7261f0 | |||
| 83979ac47c | |||
| a9be0d987a | |||
| 30274069a0 | |||
| 0a8497af59 | |||
| 284a49ce8c | |||
| 64a227303e | |||
| ae88d0f42a | |||
| d40cc0aa1b | |||
| e99d9d5515 | |||
| 9b38e3cd9d | |||
| 9c6d33cefe | |||
| d008fa4f78 | |||
| 8ff7241645 | |||
| 9bdfe679bd | |||
| 4e5a330a27 | |||
| e5860a3816 | |||
| 0f4c484674 | |||
| e025c212e5 | |||
| 9b08c82548 | |||
| 3d9cb5336a | |||
| 8eb94d0d2b | |||
| 78c910f789 | |||
| e1a8bb71a0 | |||
| f1ff303e58 | |||
| 22dc24f8e0 | |||
| 58e3cdcbb0 | |||
| 28e944f05e | |||
| 8f21a52ca6 | |||
| 96ac9f4d7a | |||
| 4c92aaf03d | |||
| 10a0d01719 | |||
| eb0aba02e9 | |||
| 9beb2ff9e5 | |||
| 76b38b8ac1 | |||
| b7b91e16d3 | |||
| d14398faae | |||
| 02b9f8cd30 | |||
| 6f8e01b446 | |||
| 673cc868f8 | |||
| eb7565debb | |||
| e09b6e2c0f | |||
| 1dd779555a | |||
| 20fc40d597 | |||
| 913690d230 | |||
| b950b15061 | |||
| f5a76b3b2a |
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ ReleasesBuilds/*
|
||||
iconcreator
|
||||
*.prod.*
|
||||
.DS_Store
|
||||
.cursor
|
||||
.claude
|
||||
node_modules
|
||||
__APP_BUILD__
|
||||
ssl-create
|
||||
|
||||
@@ -1,162 +1,458 @@
|
||||
# TaskView™
|
||||
|
||||

|
||||
<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)
|
||||
[]()
|
||||
|
||||
[**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.
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.44.0",
|
||||
"version": "1.50.1",
|
||||
"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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ 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';
|
||||
|
||||
@@ -43,6 +45,8 @@ export class AppUser {
|
||||
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;
|
||||
@@ -64,6 +68,8 @@ export class AppUser {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -572,5 +572,137 @@
|
||||
"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,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);
|
||||
@@ -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';
|
||||
@@ -19,6 +20,8 @@ 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;
|
||||
@@ -37,6 +40,7 @@ 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,
|
||||
@@ -44,6 +48,8 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/analytics': AnalyticsRoutes,
|
||||
'/module/time-tracking': TimeTrackingRoutes,
|
||||
'/module/ui-preferences': UiPreferencesRoutes,
|
||||
'/module/sprints': SprintsRoutes,
|
||||
'/module/recurrence': RecurrenceRoutes,
|
||||
'/scim/v2': ScimRoutes,
|
||||
};
|
||||
|
||||
|
||||
@@ -28,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
|
||||
@@ -41,6 +41,7 @@ export class AnalyticsController {
|
||||
organizationId: out.organizationId,
|
||||
period: out.period,
|
||||
range,
|
||||
timezone: out.timezone,
|
||||
sectionIds,
|
||||
})
|
||||
return res.tvJson(data)
|
||||
@@ -76,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)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -45,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)
|
||||
@@ -86,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,
|
||||
|
||||
@@ -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,7 +2,7 @@ import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'ta
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', de: 'Ohne Tags', es: 'Sin etiquetas' }
|
||||
|
||||
export type BuildTagAmountPayloadArgs = {
|
||||
rows: AmountPerTagMonthSectionRow[]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 { 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,18 @@ 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);
|
||||
this.router.post('/login-by-code', [RequireLoginMethod('magic-link')], 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 +40,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 +51,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 +60,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),
|
||||
|
||||
@@ -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,20 @@
|
||||
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 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;
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? ''}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Slack mrkdwn requires escaping these three in text (incl. link labels).
|
||||
export function escapeSlackText(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
CollaborationUsersSchema,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
GoalsSchema,
|
||||
UsersSchema,
|
||||
} from 'taskview-db-schemas';
|
||||
import { getCentrifugoClient } from '../../core/CentrifugoClient';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import type { AppEvents } from '../../core/EventBus';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { RecurrenceGenerator } from './RecurrenceGenerator';
|
||||
import { RecurrenceRepository } from './RecurrenceRepository';
|
||||
|
||||
export const RECURRENCE_RECONCILE_JOB = 'recurrence-reconcile';
|
||||
const RECURRENCE_RT_EVENT = 'recurrence.instanceCreated';
|
||||
|
||||
export class RecurrenceDispatcher implements Dispatcher {
|
||||
private readonly generator = new RecurrenceGenerator();
|
||||
private readonly repository = new RecurrenceRepository();
|
||||
|
||||
register(): void {
|
||||
// The heart of the lazy model: completing the open instance materializes the next one.
|
||||
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
|
||||
// Push the freshly materialized instance to goal members so the next card appears without a refresh.
|
||||
eventBus.on('task.created', (data) => this.onTaskCreated(data));
|
||||
// An ex-collaborator must not keep being auto-assigned to new instances:
|
||||
// project removal deletes users_to_goals, not collaboration.users, so the
|
||||
// FK CASCADE on the snapshot never fires — clean it up explicitly.
|
||||
eventBus.on('collaboration.userRemoved', (data) => this.onCollaboratorRemoved(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(RECURRENCE_RECONCILE_JOB);
|
||||
// Safety net only: re-creates the open instance for series stalled by a
|
||||
// crash between the complete commit and the event handler. Normally a no-op.
|
||||
await boss.schedule(RECURRENCE_RECONCILE_JOB, '0 3 * * *');
|
||||
await boss.work(RECURRENCE_RECONCILE_JOB, async () => {
|
||||
const stalled = await this.repository.findStalledActiveRules();
|
||||
for (const rule of stalled) {
|
||||
await this.generator
|
||||
.materializeNext({ ruleId: rule.id, initiatorId: rule.creatorId })
|
||||
.catch((e) => $logger.error(e, `[RecurrenceDispatcher] reconcile rule=${rule.id}`));
|
||||
}
|
||||
if (stalled.length > 0) {
|
||||
$logger.info(`[RecurrenceDispatcher] reconcile recovered ${stalled.length} stalled series`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
|
||||
if (!data.task.recurrenceRuleId) return;
|
||||
if (data.changes.complete === true) {
|
||||
await this.generator.materializeNext({
|
||||
ruleId: data.task.recurrenceRuleId,
|
||||
initiatorId: data.initiatorId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.syncTemplateFromOpenInstance(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* In the lazy model the open instance IS the series in the user's mind
|
||||
* (Todoist mental model): renaming the card must rename future occurrences
|
||||
* too, otherwise the next instance "reverts" to the stale snapshot.
|
||||
* Completed instances are history and never touch the template.
|
||||
*/
|
||||
private async syncTemplateFromOpenInstance(data: AppEvents['task.updated']): Promise<void> {
|
||||
if (data.task.complete) return;
|
||||
|
||||
const patch: Parameters<RecurrenceRepository['patch']>[0]['patch'] = {};
|
||||
if (data.changes.description !== undefined && data.task.description !== null) {
|
||||
patch.templateDescription = data.task.description;
|
||||
}
|
||||
if (data.changes.note !== undefined) patch.templateNote = data.task.note;
|
||||
if (data.changes.priorityId !== undefined) patch.templatePriorityId = data.task.priorityId;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
await this.repository.patch({ ruleId: data.task.recurrenceRuleId as number, patch });
|
||||
}
|
||||
|
||||
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
|
||||
if (!data.task.recurrenceRuleId) return;
|
||||
try {
|
||||
const memberIds = await this.resolveGoalMemberIds(data.task.goalId);
|
||||
if (memberIds.length === 0) return;
|
||||
const centrifugo = getCentrifugoClient();
|
||||
// Task fields are gated per role (TaskFieldPermissionsForWatching),
|
||||
// and recipients have different roles — so the broadcast carries ids
|
||||
// only, never content (same thin-event convention as goals.changed).
|
||||
// Each client fetches the task through REST, where fields are
|
||||
// cleaned for that user (fail closed).
|
||||
await Promise.all(
|
||||
memberIds.map((userId) =>
|
||||
centrifugo.publishToUser(userId, RECURRENCE_RT_EVENT, {
|
||||
goalId: data.task.goalId,
|
||||
ruleId: data.task.recurrenceRuleId,
|
||||
taskId: data.task.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
$logger.error(err, '[RecurrenceDispatcher] real-time publish failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async onCollaboratorRemoved(data: AppEvents['collaboration.userRemoved']): Promise<void> {
|
||||
await this.repository.removeTemplateAssigneeFromGoal({
|
||||
goalId: data.goalId,
|
||||
collabUserId: data.collaborationUserId,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveGoalMemberIds(goalId: number): Promise<number[]> {
|
||||
const db = Database.getInstance();
|
||||
const [ownerRows, collabRows] = await Promise.all([
|
||||
db.dbDrizzle.select({ id: GoalsSchema.owner }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1),
|
||||
db.dbDrizzle
|
||||
.select({ id: UsersSchema.id })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.innerJoin(
|
||||
CollaborationUsersSchema,
|
||||
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id)
|
||||
)
|
||||
.innerJoin(UsersSchema, eq(CollaborationUsersSchema.email, UsersSchema.email))
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId)),
|
||||
]);
|
||||
|
||||
const ids = new Set<number>();
|
||||
if (ownerRows[0]?.id) ids.add(ownerRows[0].id);
|
||||
for (const row of collabRows) ids.add(row.id);
|
||||
return [...ids];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { and, asc, eq, ne, sql } from 'drizzle-orm';
|
||||
import {
|
||||
GoalsListSchema,
|
||||
RecurrenceRulesSchema,
|
||||
type RecurrenceRulesSchemaTypeForSelect,
|
||||
RecurrenceSkipDatesSchema,
|
||||
RecurrenceTemplateAssigneesSchema,
|
||||
RecurrenceTemplateTagsSchema,
|
||||
TasksAssigneeSchema,
|
||||
TasksSchema,
|
||||
type TasksSchemaTypeForSelect,
|
||||
TasksStatusesSchema,
|
||||
TasksToTagsSchema,
|
||||
} from 'taskview-db-schemas';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
import { RecurrenceParser } from './RecurrenceParser';
|
||||
import type { MaterializeNextArgs } from './types';
|
||||
|
||||
/**
|
||||
* Materializes the next instance of a series. The lazy model invariant — at
|
||||
* most one open instance per rule — is protected on two levels: the rule row
|
||||
* is locked FOR UPDATE for the duration of the transaction (serializes the
|
||||
* complete-trigger against the reconcile job and rule edits), and the partial
|
||||
* unique index on (recurrence_rule_id, recurrence_instance_date) makes the
|
||||
* insert idempotent even across processes.
|
||||
*/
|
||||
export class RecurrenceGenerator {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async materializeNext(args: MaterializeNextArgs): Promise<TasksSchemaTypeForSelect | null> {
|
||||
const db = this.db.dbDrizzle;
|
||||
|
||||
const outcome = await db
|
||||
.transaction(async (tx) => {
|
||||
const ruleRows = await tx
|
||||
.select()
|
||||
.from(RecurrenceRulesSchema)
|
||||
.where(eq(RecurrenceRulesSchema.id, args.ruleId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
const rule = ruleRows[0];
|
||||
if (!rule || rule.state !== 'active') return null;
|
||||
|
||||
// Lazy-model invariant: at most one open instance per rule. Without
|
||||
// this guard a duplicate trigger (repeated PATCH complete=true, or
|
||||
// reconcile racing the event handler) would materialize a SECOND
|
||||
// open instance on the next date — the unique index only catches
|
||||
// same-date duplicates.
|
||||
const openRows = await tx
|
||||
.select({ id: TasksSchema.id })
|
||||
.from(TasksSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(TasksSchema.recurrenceRuleId, rule.id),
|
||||
ne(sql`COALESCE(${TasksSchema.complete}, false)`, sql`true`)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (openRows[0]) return null;
|
||||
|
||||
// "N materialized instances" cap for COUNT-series.
|
||||
const count = RecurrenceParser.getCount(rule.rrule);
|
||||
if (count !== null && rule.instancesCreated >= count) {
|
||||
await tx
|
||||
.update(RecurrenceRulesSchema)
|
||||
.set({ state: 'ended', editedAt: new Date() })
|
||||
.where(eq(RecurrenceRulesSchema.id, rule.id));
|
||||
return { ended: rule } as const;
|
||||
}
|
||||
|
||||
const skipRows = await tx
|
||||
.select({ skipDate: RecurrenceSkipDatesSchema.skipDate })
|
||||
.from(RecurrenceSkipDatesSchema)
|
||||
.where(eq(RecurrenceSkipDatesSchema.ruleId, rule.id));
|
||||
const skipDates = new Set(skipRows.map((r) => r.skipDate));
|
||||
|
||||
// Completed late → next from today, not a pile of overdue copies (Todoist behavior).
|
||||
const today = RecurrenceParser.todayInTimezone(rule.timezone);
|
||||
const afterDate = rule.lastInstanceDate > today ? rule.lastInstanceDate : today;
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: rule.rrule,
|
||||
dtstart: rule.dtstart,
|
||||
afterDate,
|
||||
skipDates,
|
||||
});
|
||||
if (!nextDate) {
|
||||
await tx
|
||||
.update(RecurrenceRulesSchema)
|
||||
.set({ state: 'ended', editedAt: new Date() })
|
||||
.where(eq(RecurrenceRulesSchema.id, rule.id));
|
||||
return { ended: rule } as const;
|
||||
}
|
||||
|
||||
const insertedRows = await tx
|
||||
.insert(TasksSchema)
|
||||
.values(await this.buildInstanceValues({ rule, instanceDate: nextDate, tx }))
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const instance = insertedRows[0];
|
||||
// Conflict on (rule_id, instance_date): a concurrent run already materialized it.
|
||||
if (!instance) return null;
|
||||
|
||||
const [assignees, tags] = await Promise.all([
|
||||
tx
|
||||
.select({ collabUserId: RecurrenceTemplateAssigneesSchema.collabUserId })
|
||||
.from(RecurrenceTemplateAssigneesSchema)
|
||||
.where(eq(RecurrenceTemplateAssigneesSchema.ruleId, rule.id)),
|
||||
tx
|
||||
.select({ tagId: RecurrenceTemplateTagsSchema.tagId })
|
||||
.from(RecurrenceTemplateTagsSchema)
|
||||
.where(eq(RecurrenceTemplateTagsSchema.ruleId, rule.id)),
|
||||
]);
|
||||
if (assignees.length > 0) {
|
||||
await tx
|
||||
.insert(TasksAssigneeSchema)
|
||||
.values(assignees.map((a) => ({ taskId: instance.id, collabUserId: a.collabUserId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
await tx
|
||||
.insert(TasksToTagsSchema)
|
||||
.values(tags.map((t) => ({ taskId: instance.id, tagId: t.tagId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(RecurrenceRulesSchema)
|
||||
.set({
|
||||
lastInstanceDate: nextDate,
|
||||
instancesCreated: rule.instancesCreated + 1,
|
||||
editedAt: new Date(),
|
||||
})
|
||||
.where(eq(RecurrenceRulesSchema.id, rule.id));
|
||||
|
||||
return { instance, rule } as const;
|
||||
})
|
||||
.catch((err) => {
|
||||
$logger.error(err, `[RecurrenceGenerator] materializeNext failed rule=${args.ruleId}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!outcome) return null;
|
||||
|
||||
if ('ended' in outcome) {
|
||||
eventBus.emit('recurrence.ended', {
|
||||
ruleId: outcome.ended.id,
|
||||
goalId: outcome.ended.goalId,
|
||||
initiatorId: args.initiatorId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard event so DeadlineScheduler / webhooks / realtime treat the instance as a normal new task.
|
||||
eventBus.emit('task.created', { task: outcome.instance, initiatorId: args.initiatorId });
|
||||
return outcome.instance;
|
||||
}
|
||||
|
||||
private async buildInstanceValues(args: {
|
||||
rule: RecurrenceRulesSchemaTypeForSelect;
|
||||
instanceDate: string;
|
||||
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
|
||||
}): Promise<typeof TasksSchema.$inferInsert> {
|
||||
const { rule, instanceDate, tx } = args;
|
||||
|
||||
const statusId = await this.resolveStatusId({ rule, tx });
|
||||
const goalListId = await this.resolveGoalListId({ rule, tx });
|
||||
const kanbanOrder = await this.nextKanbanOrder({ goalId: rule.goalId, tx });
|
||||
|
||||
// Tasks store UTC instants; the series is anchored to wall-clock time in
|
||||
// its timezone, so the instant is recomputed per occurrence (DST-aware).
|
||||
const window = RecurrenceParser.instanceWindowUtc({
|
||||
occurrenceDate: instanceDate,
|
||||
dtstart: rule.dtstart,
|
||||
hasTime: rule.hasTime,
|
||||
timezone: rule.timezone,
|
||||
durationMinutes: rule.templateDurationMinutes,
|
||||
});
|
||||
|
||||
return {
|
||||
goalId: rule.goalId,
|
||||
description: rule.templateDescription ?? '',
|
||||
note: rule.templateNote,
|
||||
complete: false,
|
||||
priorityId: rule.templatePriorityId,
|
||||
statusId,
|
||||
goalListId,
|
||||
creatorId: rule.creatorId,
|
||||
kanbanOrder,
|
||||
startDate: window.startDate,
|
||||
startTime: window.startTime,
|
||||
endDate: window.endDate,
|
||||
endTime: window.endTime,
|
||||
recurrenceRuleId: rule.id,
|
||||
recurrenceInstanceDate: instanceDate,
|
||||
};
|
||||
}
|
||||
|
||||
/** Snapshot kanban column if it still exists, otherwise the first column of the goal. */
|
||||
private async resolveStatusId(args: {
|
||||
rule: RecurrenceRulesSchemaTypeForSelect;
|
||||
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
|
||||
}): Promise<number | null> {
|
||||
if (args.rule.templateStatusId !== null) {
|
||||
const rows = await args.tx
|
||||
.select({ id: TasksStatusesSchema.id })
|
||||
.from(TasksStatusesSchema)
|
||||
.where(and(eq(TasksStatusesSchema.id, args.rule.templateStatusId), eq(TasksStatusesSchema.goalId, args.rule.goalId)))
|
||||
.limit(1);
|
||||
if (rows[0]) return rows[0].id;
|
||||
}
|
||||
const fallback = await args.tx
|
||||
.select({ id: TasksStatusesSchema.id })
|
||||
.from(TasksStatusesSchema)
|
||||
.where(eq(TasksStatusesSchema.goalId, args.rule.goalId))
|
||||
.orderBy(asc(TasksStatusesSchema.id))
|
||||
.limit(1);
|
||||
return fallback[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** Snapshot list if it still exists, otherwise no list. */
|
||||
private async resolveGoalListId(args: {
|
||||
rule: RecurrenceRulesSchemaTypeForSelect;
|
||||
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
|
||||
}): Promise<number | null> {
|
||||
if (args.rule.templateGoalListId === null) return null;
|
||||
const rows = await args.tx
|
||||
.select({ id: GoalsListSchema.id })
|
||||
.from(GoalsListSchema)
|
||||
.where(and(eq(GoalsListSchema.id, args.rule.templateGoalListId), eq(GoalsListSchema.goalId, args.rule.goalId)))
|
||||
.limit(1);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** Same top-of-board convention as manual creation (getNextKanbanOrder), but inside the transaction. */
|
||||
private async nextKanbanOrder(args: {
|
||||
goalId: number;
|
||||
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
|
||||
}): Promise<number> {
|
||||
const rows = await args.tx
|
||||
.select({ minKanbanOrder: sql<number | null>`MIN(${TasksSchema.kanbanOrder})` })
|
||||
.from(TasksSchema)
|
||||
.where(eq(TasksSchema.goalId, args.goalId));
|
||||
return (rows[0]?.minKanbanOrder ?? 0) - TasksRepository.KANBAN_ORDER_GAP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { RecurrenceRulesSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { KanbanRepository } from '../kanban/KanbanRepository';
|
||||
import { GoalListsRepository } from '../lists/GoalListsRepository';
|
||||
import { TaskFieldPermissionsForWatching } from '../tasks/tasks.server.types';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
import { RecurrenceGenerator } from './RecurrenceGenerator';
|
||||
import { RecurrenceParser } from './RecurrenceParser';
|
||||
import { RecurrenceRepository } from './RecurrenceRepository';
|
||||
import type {
|
||||
RecurrenceCreateArgs,
|
||||
RecurrenceErrorCode,
|
||||
RecurrenceResult,
|
||||
RecurrenceRuleDetails,
|
||||
RecurrenceUpdateArgs,
|
||||
} from './types';
|
||||
|
||||
const ok = <T>(data: T): RecurrenceResult<T> => ({ ok: true, data });
|
||||
const fail = (code: RecurrenceErrorCode, message?: string): RecurrenceResult<never> => ({ ok: false, code, message });
|
||||
|
||||
// Template fields mirror task fields, so the task-field permission map stays
|
||||
// the single source of truth for which permission gates which field.
|
||||
const RuleTemplateFieldPermissionsForWatching = {
|
||||
templateDescription: TaskFieldPermissionsForWatching.description,
|
||||
templateNote: TaskFieldPermissionsForWatching.note,
|
||||
templatePriorityId: TaskFieldPermissionsForWatching.priorityId,
|
||||
templateStatusId: TaskFieldPermissionsForWatching.statusId,
|
||||
templateGoalListId: TaskFieldPermissionsForWatching.goalListId,
|
||||
} as const;
|
||||
|
||||
export class RecurrenceManager {
|
||||
private readonly user: AppUser;
|
||||
public readonly repository: RecurrenceRepository;
|
||||
private readonly generator: RecurrenceGenerator;
|
||||
private readonly tasksRepository: TasksRepository;
|
||||
private readonly kanbanRepository: KanbanRepository;
|
||||
private readonly goalListsRepository: GoalListsRepository;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new RecurrenceRepository();
|
||||
this.generator = new RecurrenceGenerator();
|
||||
this.tasksRepository = new TasksRepository();
|
||||
this.kanbanRepository = new KanbanRepository();
|
||||
this.goalListsRepository = new GoalListsRepository();
|
||||
}
|
||||
|
||||
private get initiatorId(): number {
|
||||
return this.user.getUserData()?.id as number;
|
||||
}
|
||||
|
||||
async createRule(args: RecurrenceCreateArgs): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
|
||||
const task = await this.repository.getTaskById(args.taskId);
|
||||
if (!task) return fail('not_found', 'task not found');
|
||||
if (task.recurrenceRuleId) return fail('conflict', 'task is already part of a series');
|
||||
if (task.parentId) return fail('invalid_state', 'subtasks can not be recurring');
|
||||
if (task.complete) return fail('invalid_state', 'completed tasks can not start a series');
|
||||
|
||||
if (!RecurrenceParser.isValidTimezone(args.timezone)) {
|
||||
return fail('invalid_rule', 'timezone must be a valid IANA name');
|
||||
}
|
||||
|
||||
let dtstart: Date;
|
||||
let hasTime: boolean;
|
||||
try {
|
||||
RecurrenceParser.validateRuleString(args.rrule);
|
||||
({ date: dtstart, hasTime } = RecurrenceParser.parseDtstart(args.dtstart));
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
}
|
||||
|
||||
const originInstanceDate = RecurrenceParser.firstOccurrenceDate({ rrule: args.rrule, dtstart });
|
||||
if (!originInstanceDate) return fail('invalid_rule', 'rule produces no occurrences');
|
||||
|
||||
const templateDurationMinutes = this.durationFromTask({
|
||||
startDate: task.startDate ?? originInstanceDate,
|
||||
startTime: task.startTime,
|
||||
endDate: task.endDate,
|
||||
endTime: task.endTime,
|
||||
});
|
||||
|
||||
// One transaction: rule + origin attachment + snapshot. The origin task
|
||||
// becomes the first (and only open) instance of the series, its window
|
||||
// normalized into the same UTC frame future instances will use —
|
||||
// otherwise a series created through a non-browser client (MCP, raw API)
|
||||
// could leave the origin and its successors in different time frames.
|
||||
const outcome = await this.repository.createWithOriginTask({
|
||||
rule: {
|
||||
goalId: task.goalId,
|
||||
templateTaskId: task.id,
|
||||
templateDescription: task.description ?? '',
|
||||
templateNote: task.note,
|
||||
templatePriorityId: task.priorityId,
|
||||
templateStatusId: task.statusId,
|
||||
templateGoalListId: task.goalListId,
|
||||
templateDurationMinutes,
|
||||
rrule: args.rrule,
|
||||
dtstart,
|
||||
hasTime,
|
||||
timezone: args.timezone,
|
||||
lastInstanceDate: originInstanceDate,
|
||||
notifyOnOccurrence: args.notifyOnOccurrence ?? false,
|
||||
creatorId: this.initiatorId,
|
||||
},
|
||||
originTaskId: task.id,
|
||||
originInstanceDate,
|
||||
window: RecurrenceParser.instanceWindowUtc({
|
||||
occurrenceDate: originInstanceDate,
|
||||
dtstart,
|
||||
hasTime,
|
||||
timezone: args.timezone,
|
||||
durationMinutes: templateDurationMinutes,
|
||||
}),
|
||||
});
|
||||
if ('error' in outcome) {
|
||||
if (outcome.error === 'not_found') return fail('not_found', 'task not found');
|
||||
if (outcome.error === 'conflict') return fail('conflict', 'task is already part of a series');
|
||||
return fail('invalid_state', 'could not create rule');
|
||||
}
|
||||
|
||||
eventBus.emit('recurrence.created', { rule: outcome.rule, initiatorId: this.initiatorId });
|
||||
return ok(await this.cleanRuleFieldsRegardPermissions(outcome.rule));
|
||||
}
|
||||
|
||||
/**
|
||||
* Task fields are permission-gated for reading (TaskFieldPermissionsForWatching,
|
||||
* applied by cleanTaskFieldsRegardPermissions in the task API) — no series
|
||||
* endpoint may become a side door to them, so every response carrying a rule
|
||||
* strips the template fields the caller is not allowed to watch.
|
||||
*/
|
||||
private async cleanRuleFieldsRegardPermissions(rule: RecurrenceRulesSchemaTypeForSelect): Promise<RecurrenceRulesSchemaTypeForSelect> {
|
||||
const checker = await this.user.permissionsFetcher.getCheckerForGoal(rule.goalId).catch(() => null);
|
||||
const cleaned = { ...rule };
|
||||
(Object.keys(RuleTemplateFieldPermissionsForWatching) as (keyof typeof RuleTemplateFieldPermissionsForWatching)[]).forEach((field) => {
|
||||
if (!checker?.hasPermissions(RuleTemplateFieldPermissionsForWatching[field])) {
|
||||
cleaned[field] = null;
|
||||
}
|
||||
});
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
async getDetails(ruleId: number): Promise<RecurrenceResult<RecurrenceRuleDetails>> {
|
||||
const rule = await this.repository.getById(ruleId);
|
||||
if (!rule) return fail('not_found');
|
||||
const [skipDates, openInstance] = await Promise.all([
|
||||
this.repository.getSkipDates(ruleId),
|
||||
this.repository.findOpenInstance(ruleId),
|
||||
]);
|
||||
|
||||
// The open instance is an ordinary task — gate its fields exactly like
|
||||
// the task API does. Fail closed: better no instance than a leak.
|
||||
const cleanedInstance = openInstance
|
||||
? await this.user.tasksManager.cleanTaskFieldsRegardPermissions(openInstance).catch(() => null)
|
||||
: null;
|
||||
|
||||
return ok({
|
||||
rule: await this.cleanRuleFieldsRegardPermissions(rule),
|
||||
skipDates,
|
||||
openInstance: cleanedInstance,
|
||||
});
|
||||
}
|
||||
|
||||
async getDetailsForTask(taskId: number): Promise<RecurrenceResult<RecurrenceRuleDetails>> {
|
||||
const rule = await this.repository.getByTaskId(taskId);
|
||||
if (!rule) return fail('not_found');
|
||||
return this.getDetails(rule.id);
|
||||
}
|
||||
|
||||
async updateRule(args: RecurrenceUpdateArgs): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
|
||||
const rule = await this.repository.getById(args.ruleId);
|
||||
if (!rule) return fail('not_found');
|
||||
if (rule.state === 'ended') return fail('invalid_state', 'ended series are read-only');
|
||||
|
||||
const patch: Parameters<RecurrenceRepository['patch']>[0]['patch'] = {};
|
||||
if (args.rrule !== undefined) {
|
||||
try {
|
||||
RecurrenceParser.validateRuleString(args.rrule);
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
}
|
||||
patch.rrule = args.rrule;
|
||||
}
|
||||
if (args.dtstart !== undefined) {
|
||||
try {
|
||||
const parsed = RecurrenceParser.parseDtstart(args.dtstart);
|
||||
patch.dtstart = parsed.date;
|
||||
patch.hasTime = parsed.hasTime;
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
}
|
||||
}
|
||||
if (args.timezone !== undefined) {
|
||||
if (!RecurrenceParser.isValidTimezone(args.timezone)) {
|
||||
return fail('invalid_rule', 'timezone must be a valid IANA name');
|
||||
}
|
||||
patch.timezone = args.timezone;
|
||||
}
|
||||
if (patch.rrule !== undefined || patch.dtstart !== undefined) {
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: patch.rrule ?? rule.rrule,
|
||||
dtstart: patch.dtstart ?? rule.dtstart,
|
||||
afterDate: RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone),
|
||||
skipDates: new Set<string>(),
|
||||
});
|
||||
if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences');
|
||||
}
|
||||
if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence;
|
||||
if (args.templateOverrides) {
|
||||
const o = args.templateOverrides;
|
||||
// Foreign/dead ids must fail here, not get stored and silently
|
||||
// fall back at materialization time (null clears the override).
|
||||
if (o.statusId !== undefined && o.statusId !== null) {
|
||||
const belongs = await this.kanbanRepository.statusBelongsToGoal({ statusId: o.statusId, goalId: rule.goalId });
|
||||
if (!belongs) return fail('invalid_rule', 'status does not belong to the goal');
|
||||
}
|
||||
if (o.goalListId !== undefined && o.goalListId !== null) {
|
||||
const belongs = await this.goalListsRepository.listBelongsToGoal({ listId: o.goalListId, goalId: rule.goalId });
|
||||
if (!belongs) return fail('invalid_rule', 'list does not belong to the goal');
|
||||
}
|
||||
if (o.description !== undefined) patch.templateDescription = o.description;
|
||||
if (o.note !== undefined) patch.templateNote = o.note;
|
||||
if (o.priorityId !== undefined) patch.templatePriorityId = o.priorityId;
|
||||
if (o.statusId !== undefined) patch.templateStatusId = o.statusId;
|
||||
if (o.goalListId !== undefined) patch.templateGoalListId = o.goalListId;
|
||||
if (o.durationMinutes !== undefined) patch.templateDurationMinutes = o.durationMinutes;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return ok(await this.cleanRuleFieldsRegardPermissions(rule));
|
||||
|
||||
const updated = await this.repository.patch({ ruleId: args.ruleId, patch });
|
||||
if (!updated) return fail('invalid_state');
|
||||
|
||||
if (patch.dtstart !== undefined || patch.timezone !== undefined || patch.rrule !== undefined) {
|
||||
const openInstance = await this.repository.findOpenInstance(args.ruleId);
|
||||
if (openInstance?.recurrenceInstanceDate) {
|
||||
await this.repository.updateInstanceWindow({
|
||||
taskId: openInstance.id,
|
||||
window: RecurrenceParser.instanceWindowUtc({
|
||||
occurrenceDate: openInstance.recurrenceInstanceDate,
|
||||
dtstart: updated.dtstart,
|
||||
hasTime: updated.hasTime,
|
||||
timezone: updated.timezone,
|
||||
durationMinutes: updated.templateDurationMinutes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.emit('recurrence.updated', { rule: updated, changes: patch, initiatorId: this.initiatorId });
|
||||
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
|
||||
}
|
||||
|
||||
async pauseRule(ruleId: number): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
|
||||
const rule = await this.repository.getById(ruleId);
|
||||
if (!rule) return fail('not_found');
|
||||
if (rule.state !== 'active') return fail('invalid_state', `can not pause a ${rule.state} series`);
|
||||
|
||||
const updated = await this.repository.patch({ ruleId, patch: { state: 'paused' } });
|
||||
if (!updated) return fail('invalid_state');
|
||||
eventBus.emit('recurrence.paused', { ruleId, goalId: rule.goalId, initiatorId: this.initiatorId });
|
||||
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
|
||||
}
|
||||
|
||||
async resumeRule(ruleId: number): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
|
||||
const rule = await this.repository.getById(ruleId);
|
||||
if (!rule) return fail('not_found');
|
||||
if (rule.state !== 'paused') return fail('invalid_state', `can not resume a ${rule.state} series`);
|
||||
|
||||
const updated = await this.repository.patch({ ruleId, patch: { state: 'active' } });
|
||||
if (!updated) return fail('invalid_state');
|
||||
|
||||
// Occurrences missed while paused are not backfilled; if the open
|
||||
// instance was completed during the pause, restart the chain from today.
|
||||
const openInstance = await this.repository.findOpenInstance(ruleId);
|
||||
if (!openInstance) {
|
||||
await this.generator.materializeNext({ ruleId, initiatorId: this.initiatorId });
|
||||
}
|
||||
|
||||
eventBus.emit('recurrence.resumed', { ruleId, goalId: rule.goalId, initiatorId: this.initiatorId });
|
||||
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
|
||||
}
|
||||
|
||||
/** Skip the current occurrence: the open instance is removed and the card "jumps" to the next date. */
|
||||
async skipCurrent(ruleId: number): Promise<RecurrenceResult<RecurrenceRuleDetails>> {
|
||||
const rule = await this.repository.getById(ruleId);
|
||||
if (!rule) return fail('not_found');
|
||||
if (rule.state !== 'active') return fail('invalid_state', `can not skip in a ${rule.state} series`);
|
||||
|
||||
const openInstance = await this.repository.findOpenInstance(ruleId);
|
||||
if (!openInstance || !openInstance.recurrenceInstanceDate) return fail('invalid_state', 'series has no open instance');
|
||||
|
||||
await this.repository.addSkipDate({ ruleId, skipDate: openInstance.recurrenceInstanceDate });
|
||||
await this.tasksRepository.deleteTaskNew({ taskId: openInstance.id });
|
||||
eventBus.emit('task.deleted', { taskId: openInstance.id, goalId: rule.goalId, initiatorId: this.initiatorId });
|
||||
eventBus.emit('recurrence.instanceSkipped', {
|
||||
ruleId,
|
||||
goalId: rule.goalId,
|
||||
date: openInstance.recurrenceInstanceDate,
|
||||
initiatorId: this.initiatorId,
|
||||
});
|
||||
|
||||
await this.generator.materializeNext({ ruleId, initiatorId: this.initiatorId });
|
||||
return this.getDetails(ruleId);
|
||||
}
|
||||
|
||||
/** Delete the series; existing instances stay as ordinary tasks (FK SET NULL). */
|
||||
async deleteRule(ruleId: number): Promise<RecurrenceResult<{ deleted: true }>> {
|
||||
const rule = await this.repository.getById(ruleId);
|
||||
if (!rule) return fail('not_found');
|
||||
|
||||
const deleted = await this.repository.deleteById(ruleId);
|
||||
if (!deleted) return fail('invalid_state');
|
||||
eventBus.emit('recurrence.deleted', { ruleId, goalId: rule.goalId, initiatorId: this.initiatorId });
|
||||
return ok({ deleted: true });
|
||||
}
|
||||
|
||||
/** Wall-clock difference between the task's start and end, treating missing times as midnight. */
|
||||
private durationFromTask(args: {
|
||||
startDate: string;
|
||||
startTime: string | null;
|
||||
endDate: string | null;
|
||||
endTime: string | null;
|
||||
}): number | null {
|
||||
if (!args.endDate) return null;
|
||||
const start = new Date(`${args.startDate}T${args.startTime ?? '00:00:00'}Z`).getTime();
|
||||
const end = new Date(`${args.endDate}T${args.endTime ?? '00:00:00'}Z`).getTime();
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
||||
const minutes = Math.round((end - start) / 60_000);
|
||||
return minutes > 0 ? minutes : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { RRule } from 'rrule';
|
||||
import type { InstanceWindow, InstanceWindowArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
|
||||
|
||||
const ALLOWED_FREQUENCIES = new Set<number>([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]);
|
||||
const MAX_COUNT = 10000;
|
||||
|
||||
/**
|
||||
* All recurrence math happens in a single floating wall-clock frame:
|
||||
* `dtstart` is a Date whose UTC components equal the wall-clock components of
|
||||
* the series (the API runs with TZ=UTC, so naive DB timestamps read back this
|
||||
* way). Occurrences returned by `rrule` carry the same convention and are
|
||||
* written component-wise into tasks.start_date / start_time. The IANA timezone
|
||||
* of the rule is only used to resolve "today" for the user.
|
||||
*/
|
||||
export class RecurrenceParser {
|
||||
/** Throws a human-readable Error if the RRULE string is unsupported. */
|
||||
static validateRuleString(rruleString: string): void {
|
||||
let options: ReturnType<typeof RRule.parseString>;
|
||||
try {
|
||||
options = RRule.parseString(rruleString);
|
||||
} catch {
|
||||
throw new Error('Invalid RRULE string');
|
||||
}
|
||||
if (options.freq === undefined || !ALLOWED_FREQUENCIES.has(options.freq)) {
|
||||
throw new Error('FREQ must be one of DAILY, WEEKLY, MONTHLY, YEARLY');
|
||||
}
|
||||
if (options.interval !== undefined && (!Number.isInteger(options.interval) || options.interval < 1)) {
|
||||
throw new Error('INTERVAL must be a positive integer');
|
||||
}
|
||||
if (options.count !== undefined && options.count !== null && (options.count < 1 || options.count > MAX_COUNT)) {
|
||||
throw new Error(`COUNT must be between 1 and ${MAX_COUNT}`);
|
||||
}
|
||||
if (options.count && options.until) {
|
||||
throw new Error('COUNT and UNTIL are mutually exclusive (RFC 5545)');
|
||||
}
|
||||
}
|
||||
|
||||
/** COUNT encoded in the RRULE string, if any. */
|
||||
static getCount(rruleString: string): number | null {
|
||||
return RRule.parseString(rruleString).count ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* First occurrence date strictly after `afterDate`, skipping explicit skip
|
||||
* dates. COUNT is intentionally stripped: the cap is "N materialized
|
||||
* instances" enforced by the caller via instances_created, not "N calendar
|
||||
* positions" (a series completed late must not silently lose remaining runs).
|
||||
* Returns 'YYYY-MM-DD' or null when the series is over (UNTIL passed / no
|
||||
* more occurrences within the search horizon).
|
||||
*/
|
||||
static nextOccurrenceDate(args: NextOccurrenceArgs): string | null {
|
||||
const options = RRule.parseString(args.rrule);
|
||||
delete options.count;
|
||||
const rule = new RRule({ ...options, dtstart: args.dtstart });
|
||||
|
||||
// End of the boundary day in the floating frame: "strictly after that day".
|
||||
let searchFrom = new Date(`${args.afterDate}T23:59:59.999Z`);
|
||||
// Skip dates form a finite set; each loop pass moves searchFrom forward, so this terminates.
|
||||
for (;;) {
|
||||
const occurrence = rule.after(searchFrom, false);
|
||||
if (!occurrence) return null;
|
||||
const isoDate = RecurrenceParser.toIsoDate(occurrence);
|
||||
if (!args.skipDates.has(isoDate)) return isoDate;
|
||||
searchFrom = new Date(`${isoDate}T23:59:59.999Z`);
|
||||
}
|
||||
}
|
||||
|
||||
/** First occurrence on or after the dtstart day — the instance date of the origin task. */
|
||||
static firstOccurrenceDate(args: ParseRuleArgs): string | null {
|
||||
const options = RRule.parseString(args.rrule);
|
||||
delete options.count;
|
||||
const rule = new RRule({ ...options, dtstart: args.dtstart });
|
||||
const occurrence = rule.after(new Date(args.dtstart.getTime() - 1), true);
|
||||
return occurrence ? RecurrenceParser.toIsoDate(occurrence) : null;
|
||||
}
|
||||
|
||||
/** Today's date in the rule's IANA timezone. */
|
||||
static todayInTimezone(timezone: string): string {
|
||||
const today = DateTime.now().setZone(timezone);
|
||||
return today.isValid ? (today.toISODate() as string) : (DateTime.utc().toISODate() as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* 'HH:mm:ss' wall-clock time of day of the series, or null for a date-only
|
||||
* series. The distinction is carried explicitly by `hasTime` (rule column)
|
||||
* — never inferred from a midnight dtstart, otherwise an explicit 00:00
|
||||
* series would be indistinguishable from "no time".
|
||||
*/
|
||||
static timeOfDay(args: { dtstart: Date; hasTime: boolean }): string | null {
|
||||
if (!args.hasTime) return null;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(args.dtstart.getUTCHours())}:${pad(args.dtstart.getUTCMinutes())}:00`;
|
||||
}
|
||||
|
||||
static isValidTimezone(timezone: string): boolean {
|
||||
return DateTime.now().setZone(timezone).isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC start/end window of a single occurrence. The series is anchored to
|
||||
* wall-clock time in its IANA timezone ("every day at 9:00 in Berlin"),
|
||||
* while tasks store UTC instants — so the instant is recomputed for every
|
||||
* occurrence date with the offset valid on that day (DST-aware): a summer
|
||||
* occurrence lands on 07:00 UTC, a winter one on 08:00 UTC, and the user
|
||||
* always sees 9:00 on the wall. Non-existent wall times on spring-forward
|
||||
* days are pushed forward by luxon to the nearest valid time.
|
||||
*/
|
||||
static instanceWindowUtc(args: InstanceWindowArgs): InstanceWindow {
|
||||
const wallTime = RecurrenceParser.timeOfDay({ dtstart: args.dtstart, hasTime: args.hasTime });
|
||||
|
||||
// Date-only series: calendar dates pass through untouched (no instant semantics).
|
||||
if (!wallTime) {
|
||||
const days = args.durationMinutes ? Math.floor(args.durationMinutes / (24 * 60)) : 0;
|
||||
// An occurrence is due on its own date — a series without an explicit
|
||||
// end is not a "no deadline" task, the deadline IS the occurrence date.
|
||||
const endDate =
|
||||
days > 0
|
||||
? RecurrenceParser.toIsoDate(new Date(new Date(`${args.occurrenceDate}T00:00:00Z`).getTime() + days * 24 * 60 * 60_000))
|
||||
: args.occurrenceDate;
|
||||
return { startDate: args.occurrenceDate, startTime: null, endDate, endTime: null };
|
||||
}
|
||||
|
||||
const [year, month, day] = args.occurrenceDate.split('-').map(Number);
|
||||
let start = DateTime.fromObject(
|
||||
{ year, month, day, hour: args.dtstart.getUTCHours(), minute: args.dtstart.getUTCMinutes() },
|
||||
{ zone: args.timezone }
|
||||
);
|
||||
if (!start.isValid) {
|
||||
start = DateTime.fromISO(`${args.occurrenceDate}T${wallTime}`, { zone: 'utc' });
|
||||
}
|
||||
const startUtc = start.toUTC();
|
||||
const window: InstanceWindow = {
|
||||
startDate: startUtc.toISODate() as string,
|
||||
startTime: startUtc.toFormat('HH:mm:ss'),
|
||||
// No explicit duration → due at the occurrence moment itself.
|
||||
endDate: startUtc.toISODate() as string,
|
||||
endTime: startUtc.toFormat('HH:mm:ss'),
|
||||
};
|
||||
if (args.durationMinutes !== null && args.durationMinutes > 0) {
|
||||
const endUtc = startUtc.plus({ minutes: args.durationMinutes });
|
||||
window.endDate = endUtc.toISODate() as string;
|
||||
window.endTime = endUtc.toFormat('HH:mm:ss');
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
/**
|
||||
* dtstart in RFC 5545 DATE or DATE-TIME shape → Date (+ whether a time was
|
||||
* given). `YYYY-MM-DD` is date-only (`hasTime: false`); `YYYY-MM-DDTHH:mm:ss`
|
||||
* carries a wall-clock time (`hasTime: true`), including an explicit
|
||||
* `T00:00:00`. Either way the Date holds floating wall-clock UTC components.
|
||||
*/
|
||||
static parseDtstart(dtstart: string): { date: Date; hasTime: boolean } {
|
||||
const hasTime = dtstart.includes('T');
|
||||
const date = new Date(`${hasTime ? dtstart : `${dtstart}T00:00:00`}Z`);
|
||||
if (Number.isNaN(date.getTime())) throw new Error('Invalid dtstart');
|
||||
return { date, hasTime };
|
||||
}
|
||||
|
||||
static toIsoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { and, eq, inArray, isNotNull, ne, notExists, sql } from 'drizzle-orm';
|
||||
import {
|
||||
RecurrenceRulesSchema,
|
||||
type RecurrenceRulesSchemaTypeForSelect,
|
||||
RecurrenceSkipDatesSchema,
|
||||
RecurrenceTemplateAssigneesSchema,
|
||||
RecurrenceTemplateTagsSchema,
|
||||
TasksAssigneeSchema,
|
||||
TasksSchema,
|
||||
type TasksSchemaTypeForSelect,
|
||||
TasksToTagsSchema,
|
||||
} from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type {
|
||||
AddSkipDateArgs,
|
||||
CreateRuleWithOriginArgs,
|
||||
CreateRuleWithOriginResult,
|
||||
RecurrenceRulePatchArgs,
|
||||
RemoveTemplateAssigneeFromGoalArgs,
|
||||
UpdateInstanceWindowArgs,
|
||||
} from './types';
|
||||
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
export class RecurrenceRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async getById(ruleId: number): Promise<RecurrenceRulesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(RecurrenceRulesSchema).where(eq(RecurrenceRulesSchema.id, ruleId)).limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async getByTaskId(taskId: number): Promise<RecurrenceRulesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ rule: RecurrenceRulesSchema })
|
||||
.from(TasksSchema)
|
||||
.innerJoin(RecurrenceRulesSchema, eq(TasksSchema.recurrenceRuleId, RecurrenceRulesSchema.id))
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0]?.rule ?? null;
|
||||
}
|
||||
|
||||
async patch(args: RecurrenceRulePatchArgs): Promise<RecurrenceRulesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(RecurrenceRulesSchema)
|
||||
.set({ ...args.patch, editedAt: new Date() })
|
||||
.where(eq(RecurrenceRulesSchema.id, args.ruleId))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteById(ruleId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(RecurrenceRulesSchema).where(eq(RecurrenceRulesSchema.id, ruleId))
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async getSkipDates(ruleId: number): Promise<string[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ skipDate: RecurrenceSkipDatesSchema.skipDate })
|
||||
.from(RecurrenceSkipDatesSchema)
|
||||
.where(eq(RecurrenceSkipDatesSchema.ruleId, ruleId))
|
||||
);
|
||||
return result?.map((r) => r.skipDate) ?? [];
|
||||
}
|
||||
|
||||
async addSkipDate(args: AddSkipDateArgs): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(RecurrenceSkipDatesSchema)
|
||||
.values({ ruleId: args.ruleId, skipDate: args.skipDate })
|
||||
.onConflictDoNothing()
|
||||
);
|
||||
}
|
||||
|
||||
/** Drops an ex-collaborator from the assignee snapshot of every rule in the goal. */
|
||||
async removeTemplateAssigneeFromGoal(args: RemoveTemplateAssigneeFromGoalArgs): Promise<void> {
|
||||
const goalRules = this.db.dbDrizzle
|
||||
.select({ id: RecurrenceRulesSchema.id })
|
||||
.from(RecurrenceRulesSchema)
|
||||
.where(eq(RecurrenceRulesSchema.goalId, args.goalId));
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.delete(RecurrenceTemplateAssigneesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(RecurrenceTemplateAssigneesSchema.collabUserId, args.collabUserId),
|
||||
inArray(RecurrenceTemplateAssigneesSchema.ruleId, goalRules)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getTaskById(taskId: number): Promise<TasksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, taskId)).limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic series creation. The origin task row is locked FOR UPDATE, so
|
||||
* concurrent creates on the same task serialize: the loser waits on the
|
||||
* lock, then sees recurrenceRuleId already set and reports a conflict.
|
||||
* The partial unique index uniq_recurrence_rules_template_task backs this
|
||||
* up on the DB level. Everything — rule insert, origin attachment (with
|
||||
* the window normalized to the series frame) and the assignee/tag
|
||||
* snapshot — commits or rolls back together.
|
||||
*/
|
||||
async createWithOriginTask(args: CreateRuleWithOriginArgs): Promise<CreateRuleWithOriginResult> {
|
||||
try {
|
||||
return await this.db.dbDrizzle.transaction(async (tx) => {
|
||||
const taskRows = await tx
|
||||
.select({ recurrenceRuleId: TasksSchema.recurrenceRuleId })
|
||||
.from(TasksSchema)
|
||||
.where(eq(TasksSchema.id, args.originTaskId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
const task = taskRows[0];
|
||||
if (!task) return { error: 'not_found' as const };
|
||||
if (task.recurrenceRuleId) return { error: 'conflict' as const };
|
||||
|
||||
const ruleRows = await tx.insert(RecurrenceRulesSchema).values(args.rule).returning();
|
||||
const rule = ruleRows[0];
|
||||
|
||||
await tx
|
||||
.update(TasksSchema)
|
||||
.set({
|
||||
recurrenceRuleId: rule.id,
|
||||
recurrenceInstanceDate: args.originInstanceDate,
|
||||
startDate: args.window.startDate,
|
||||
startTime: args.window.startTime,
|
||||
endDate: args.window.endDate,
|
||||
endTime: args.window.endTime,
|
||||
})
|
||||
.where(eq(TasksSchema.id, args.originTaskId));
|
||||
|
||||
const [assignees, tags] = await Promise.all([
|
||||
tx
|
||||
.select({ collabUserId: TasksAssigneeSchema.collabUserId })
|
||||
.from(TasksAssigneeSchema)
|
||||
.where(eq(TasksAssigneeSchema.taskId, args.originTaskId)),
|
||||
tx
|
||||
.select({ tagId: TasksToTagsSchema.tagId })
|
||||
.from(TasksToTagsSchema)
|
||||
.where(eq(TasksToTagsSchema.taskId, args.originTaskId)),
|
||||
]);
|
||||
if (assignees.length > 0) {
|
||||
await tx
|
||||
.insert(RecurrenceTemplateAssigneesSchema)
|
||||
.values(assignees.map((a) => ({ ruleId: rule.id, collabUserId: a.collabUserId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
await tx
|
||||
.insert(RecurrenceTemplateTagsSchema)
|
||||
.values(tags.map((t) => ({ ruleId: rule.id, tagId: t.tagId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
return { rule };
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string } | null)?.code === PG_UNIQUE_VIOLATION) {
|
||||
return { error: 'conflict' };
|
||||
}
|
||||
$logger.error(error, '[RecurrenceRepository] createWithOriginTask failed');
|
||||
return { error: 'failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/** The single not-completed instance of the rule (the lazy model keeps at most one). */
|
||||
async findOpenInstance(ruleId: number): Promise<TasksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(eq(TasksSchema.recurrenceRuleId, ruleId), ne(sql`COALESCE(${TasksSchema.complete}, false)`, sql`true`)))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** Re-stamp an open instance's deadline window after the series time/timezone changed. */
|
||||
async updateInstanceWindow(args: UpdateInstanceWindowArgs): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(TasksSchema)
|
||||
.set({
|
||||
startDate: args.window.startDate,
|
||||
startTime: args.window.startTime,
|
||||
endDate: args.window.endDate,
|
||||
endTime: args.window.endTime,
|
||||
})
|
||||
.where(eq(TasksSchema.id, args.taskId))
|
||||
);
|
||||
}
|
||||
|
||||
/** Active rules with no open instance — the reconcile sweep input. Empty in normal operation. */
|
||||
async findStalledActiveRules(): Promise<RecurrenceRulesSchemaTypeForSelect[]> {
|
||||
const openInstance = this.db.dbDrizzle
|
||||
.select({ one: sql`1` })
|
||||
.from(TasksSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(TasksSchema.recurrenceRuleId, RecurrenceRulesSchema.id),
|
||||
isNotNull(TasksSchema.recurrenceRuleId),
|
||||
ne(sql`COALESCE(${TasksSchema.complete}, false)`, sql`true`)
|
||||
)
|
||||
);
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(RecurrenceRulesSchema)
|
||||
.where(and(eq(RecurrenceRulesSchema.state, 'active'), notExists(openInstance)))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import RecurrenceController from './RecurrenceController';
|
||||
import {
|
||||
goalIdFromRuleParam,
|
||||
goalIdFromTaskBody,
|
||||
goalIdFromTaskParam,
|
||||
requireRecurrencePermission,
|
||||
} from './middlewares/require-recurrence-permission';
|
||||
|
||||
export default class RecurrenceRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: RecurrenceController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new RecurrenceController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
// Recurrence is a schedule attribute of a task, so editing reuses the existing deadline permission.
|
||||
this.router.post('', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromTaskBody)], this.controller.create);
|
||||
|
||||
// Read access mirrors reading the task itself.
|
||||
this.router.get('/task/:taskId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT, goalIdFromTaskParam)], this.controller.getForTask);
|
||||
this.router.get('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT, goalIdFromRuleParam)], this.controller.getOne);
|
||||
|
||||
this.router.patch('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.update);
|
||||
this.router.post('/:ruleId/pause', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.pause);
|
||||
this.router.post('/:ruleId/resume', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.resume);
|
||||
// Skip is the one schedule operation that physically deletes the open
|
||||
// instance (with its subtasks and tracked time) — so on top of the
|
||||
// schedule permission it requires the same right as DELETE /tasks.
|
||||
this.router.post('/:ruleId/skip', [IsLoggedIn, requireRecurrencePermission([GoalPermissions.TASKS_CAN_EDIT_DEADLINE, GoalPermissions.TASKS_CAN_DELETE], goalIdFromRuleParam)], this.controller.skip);
|
||||
this.router.delete('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.remove);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import type { GoalPermissionType } from '../../../types/auth.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
import { RecurrenceRepository } from '../RecurrenceRepository';
|
||||
|
||||
type GoalIdResolver = (req: Request) => Promise<number | null> | number | null;
|
||||
|
||||
/** goalId for POST /module/recurrence — resolved through the task being made recurring. */
|
||||
export const goalIdFromTaskBody: GoalIdResolver = async (req) => {
|
||||
const taskId = Number(req.body?.taskId);
|
||||
if (!taskId) return null;
|
||||
const task = await new RecurrenceRepository().getTaskById(taskId);
|
||||
return task?.goalId ?? null;
|
||||
};
|
||||
|
||||
/** goalId for /module/recurrence/:ruleId routes — resolved through the rule. */
|
||||
export const goalIdFromRuleParam: GoalIdResolver = async (req) => {
|
||||
const ruleId = Number(req.params?.ruleId);
|
||||
if (!ruleId) return null;
|
||||
const rule = await new RecurrenceRepository().getById(ruleId);
|
||||
return rule?.goalId ?? null;
|
||||
};
|
||||
|
||||
/** goalId for GET /module/recurrence/task/:taskId. */
|
||||
export const goalIdFromTaskParam: GoalIdResolver = async (req) => {
|
||||
const taskId = Number(req.params?.taskId);
|
||||
if (!taskId) return null;
|
||||
const task = await new RecurrenceRepository().getTaskById(taskId);
|
||||
return task?.goalId ?? null;
|
||||
};
|
||||
|
||||
/** A single permission or a list — the caller must hold ALL of them. */
|
||||
export function requireRecurrencePermission(permission: GoalPermissionType | GoalPermissionType[], resolveGoalId: GoalIdResolver) {
|
||||
const required = Array.isArray(permission) ? permission : [permission];
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = await resolveGoalId(req);
|
||||
if (!goalId) return res.status(404).end();
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not resolve recurrence permissions');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (required.every((p) => permissions.hasPermissions(p))) return next();
|
||||
return res.status(403).end();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { type } from 'arktype';
|
||||
import type {
|
||||
RecurrenceRulesSchemaTypeForInsert,
|
||||
RecurrenceRulesSchemaTypeForSelect,
|
||||
TasksSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
|
||||
/** Request validators (ArkType) */
|
||||
|
||||
export const RecurrenceArkTypeCreate = type({
|
||||
taskId: 'number',
|
||||
rrule: 'string > 0',
|
||||
dtstart: 'string', // 'YYYY-MM-DDTHH:mm:ss' floating wall-clock, no TZ suffix
|
||||
timezone: 'string > 0', // IANA name, e.g. 'Europe/Moscow'
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
});
|
||||
|
||||
export const RecurrenceArkTypeUpdate = type({
|
||||
ruleId: 'number',
|
||||
'rrule?': 'string > 0',
|
||||
'dtstart?': 'string',
|
||||
'timezone?': 'string > 0',
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
'templateOverrides?': type({
|
||||
'description?': 'string',
|
||||
'note?': 'string | null',
|
||||
'priorityId?': '1 | 2 | 3 | null',
|
||||
'statusId?': 'number | null',
|
||||
'goalListId?': 'number | null',
|
||||
'durationMinutes?': 'number | null',
|
||||
}),
|
||||
});
|
||||
|
||||
export const RecurrenceArkTypeRuleIdParam = type({
|
||||
ruleId: type('string | number').pipe((v) => Number(v)),
|
||||
});
|
||||
|
||||
export const RecurrenceArkTypeTaskIdParam = type({
|
||||
taskId: type('string | number').pipe((v) => Number(v)),
|
||||
});
|
||||
|
||||
/** Inferred argument types (args-as-object) */
|
||||
|
||||
export type RecurrenceCreateArgs = typeof RecurrenceArkTypeCreate.infer;
|
||||
export type RecurrenceUpdateArgs = typeof RecurrenceArkTypeUpdate.infer;
|
||||
export type RecurrenceTemplateOverrides = NonNullable<RecurrenceUpdateArgs['templateOverrides']>;
|
||||
|
||||
/** Generator args */
|
||||
|
||||
export type MaterializeNextArgs = {
|
||||
ruleId: number;
|
||||
/** Who triggered materialization (instance completer, resume initiator or rule creator for the reconcile job). */
|
||||
initiatorId: number;
|
||||
};
|
||||
|
||||
/** Parser args */
|
||||
|
||||
export type ParseRuleArgs = { rrule: string; dtstart: Date };
|
||||
export type NextOccurrenceArgs = {
|
||||
rrule: string;
|
||||
dtstart: Date;
|
||||
/** 'YYYY-MM-DD' — next occurrence is searched strictly after this day. */
|
||||
afterDate: string;
|
||||
skipDates: Set<string>;
|
||||
};
|
||||
export type InstanceWindowArgs = {
|
||||
/** 'YYYY-MM-DD' wall-clock occurrence date in the rule's timezone. */
|
||||
occurrenceDate: string;
|
||||
dtstart: Date;
|
||||
/** False → date-only occurrence (no start/end time). */
|
||||
hasTime: boolean;
|
||||
timezone: string;
|
||||
durationMinutes: number | null;
|
||||
};
|
||||
export type InstanceWindow = {
|
||||
startDate: string;
|
||||
startTime: string | null;
|
||||
endDate: string | null;
|
||||
endTime: string | null;
|
||||
};
|
||||
|
||||
/** Repository args */
|
||||
|
||||
export type UpdateInstanceWindowArgs = {
|
||||
taskId: number;
|
||||
window: InstanceWindow;
|
||||
};
|
||||
|
||||
export type RecurrenceRulePatchArgs = {
|
||||
ruleId: number;
|
||||
patch: Partial<{
|
||||
rrule: string;
|
||||
dtstart: Date;
|
||||
hasTime: boolean;
|
||||
timezone: string;
|
||||
state: 'active' | 'paused' | 'ended';
|
||||
lastInstanceDate: string;
|
||||
instancesCreated: number;
|
||||
notifyOnOccurrence: boolean;
|
||||
templateDescription: string;
|
||||
templateNote: string | null;
|
||||
templatePriorityId: 1 | 2 | 3 | null;
|
||||
templateStatusId: number | null;
|
||||
templateGoalListId: number | null;
|
||||
templateDurationMinutes: number | null;
|
||||
templateTaskId: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AddSkipDateArgs = { ruleId: number; skipDate: string };
|
||||
export type RemoveTemplateAssigneeFromGoalArgs = { goalId: number; collabUserId: number };
|
||||
|
||||
/**
|
||||
* Atomic series creation: rule insert + origin task attachment (with its
|
||||
* window normalized to the series frame) + assignee/tag snapshot — one
|
||||
* transaction with the origin task row locked FOR UPDATE, so concurrent
|
||||
* creates on the same task serialize instead of producing two rules.
|
||||
*/
|
||||
export type CreateRuleWithOriginArgs = {
|
||||
rule: RecurrenceRulesSchemaTypeForInsert;
|
||||
originTaskId: number;
|
||||
originInstanceDate: string;
|
||||
window: InstanceWindow;
|
||||
};
|
||||
export type CreateRuleWithOriginResult =
|
||||
| { rule: RecurrenceRulesSchemaTypeForSelect }
|
||||
| { error: 'not_found' | 'conflict' | 'failed' };
|
||||
|
||||
/** Detail shape returned by GET endpoints */
|
||||
|
||||
export type RecurrenceRuleDetails = {
|
||||
rule: RecurrenceRulesSchemaTypeForSelect;
|
||||
skipDates: string[];
|
||||
openInstance: TasksSchemaTypeForSelect | null;
|
||||
};
|
||||
|
||||
export type RecurrenceErrorCode = 'not_found' | 'conflict' | 'invalid_state' | 'invalid_rule';
|
||||
export type RecurrenceResult<T> = { ok: true; data: T } | { ok: false; code: RecurrenceErrorCode; message?: string };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { getJobQueue } from '../../core/JobQueue';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { SprintsRepository } from './SprintsRepository';
|
||||
|
||||
export const SPRINT_CADENCE_JOB = 'sprint-cadence-generate';
|
||||
|
||||
export class SprintScheduler {
|
||||
private readonly repo = new SprintsRepository();
|
||||
|
||||
/**
|
||||
* Ensure the current + `lookahead` future sprints exist for a goal whose
|
||||
* cadence is enabled. Windows run every `length_days` from `start_date`.
|
||||
* Idempotent: skips windows whose start date already has a live (non-completed)
|
||||
* sprint, so manual sprints and repeated runs never duplicate. All generated
|
||||
* sprints are created as `planned` — activation is always manual.
|
||||
* Returns the number of sprints created.
|
||||
*/
|
||||
async generateCadenceForGoal(goalId: number): Promise<number> {
|
||||
const cadence = await this.repo.getCadence(goalId);
|
||||
if (!cadence || !cadence.enabled) return 0;
|
||||
|
||||
const lengthDays = cadence.lengthDays > 0 ? cadence.lengthDays : 14;
|
||||
const lookahead = cadence.lookahead >= 0 ? cadence.lookahead : 0;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const offset = this.daysBetween(cadence.startDate, today);
|
||||
const currentIdx = offset < 0 ? 0 : Math.floor(offset / lengthDays);
|
||||
const lastIdx = currentIdx + lookahead;
|
||||
|
||||
let created = 0;
|
||||
let maxStart = cadence.lastGeneratedDate ?? null;
|
||||
|
||||
for (let idx = currentIdx; idx <= lastIdx; idx++) {
|
||||
const winStart = this.addDays(cadence.startDate, idx * lengthDays);
|
||||
const winEnd = this.addDays(winStart, lengthDays - 1);
|
||||
|
||||
const existing = await this.repo.findByGoalAndStartDate({ goalId, startDate: winStart });
|
||||
if (existing) {
|
||||
if (!maxStart || winStart > maxStart) maxStart = winStart;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = cadence.nameTemplate.includes('{n}')
|
||||
? cadence.nameTemplate.replace('{n}', String(idx + 1))
|
||||
: `${cadence.nameTemplate} ${idx + 1}`;
|
||||
|
||||
const sprint = await this.repo.createCadenceSprint({ goalId, name, startDate: winStart, endDate: winEnd });
|
||||
if (!sprint) continue;
|
||||
created++;
|
||||
if (!maxStart || winStart > maxStart) maxStart = winStart;
|
||||
}
|
||||
|
||||
if (maxStart) await this.repo.setCadenceLastGenerated({ goalId, lastGeneratedDate: maxStart });
|
||||
$logger.info(`[SprintScheduler] cadence goal=${goalId} created=${created} windows=[${currentIdx}..${lastIdx}]`);
|
||||
return created;
|
||||
}
|
||||
|
||||
async registerCadenceWorker(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(SPRINT_CADENCE_JOB);
|
||||
await boss.schedule(SPRINT_CADENCE_JOB, '0 1 * * *');
|
||||
|
||||
await boss.work(SPRINT_CADENCE_JOB, async () => {
|
||||
const cadences = await this.repo.getEnabledCadences();
|
||||
for (const c of cadences) {
|
||||
await this.generateCadenceForGoal(c.goalId).catch((e) =>
|
||||
$logger.error(e, `[SprintScheduler] cadence sweep goal=${c.goalId}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private addDays(dateStr: string, days: number): string {
|
||||
const d = new Date(`${dateStr}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private daysBetween(from: string, to: string): number {
|
||||
const a = new Date(`${from}T00:00:00Z`).getTime();
|
||||
const b = new Date(`${to}T00:00:00Z`).getTime();
|
||||
return Math.floor((b - a) / 86_400_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SprintStatus } from 'taskview-db-schemas';
|
||||
import { logError } from '../../utils/api';
|
||||
import {
|
||||
SprintArkTypeClose,
|
||||
SprintArkTypeCreate,
|
||||
SprintArkTypeGoalIdParam,
|
||||
SprintArkTypeListQuery,
|
||||
SprintArkTypePlanningQuery,
|
||||
SprintArkTypeSaveRetro,
|
||||
SprintArkTypeSetCadence,
|
||||
SprintArkTypeSetTask,
|
||||
SprintArkTypeSprintIdParam,
|
||||
SprintArkTypeUpdate,
|
||||
SprintArkTypeVelocityQuery,
|
||||
} from './types';
|
||||
import type { SprintErrorCode, SprintResult } from './types';
|
||||
|
||||
const codeToStatus: Record<SprintErrorCode, number> = {
|
||||
not_found: 404,
|
||||
conflict: 409,
|
||||
invalid_state: 400,
|
||||
forbidden: 403,
|
||||
};
|
||||
|
||||
const VALID_STATUSES: SprintStatus[] = ['draft', 'planned', 'active', 'review', 'completed'];
|
||||
|
||||
export default class SprintsController {
|
||||
private sendResult<T>(res: Response, result: SprintResult<T>) {
|
||||
if (result.ok) return res.tvJson(result.data);
|
||||
return res.status(codeToStatus[result.code]).send(result.message ?? result.code);
|
||||
}
|
||||
|
||||
listForGoal = async (req: Request, res: Response) => {
|
||||
// Route params win over query: the goalId authorized by the middleware must
|
||||
// be the one operated on (a query-supplied goalId must not override it).
|
||||
const data = SprintArkTypeListQuery({ ...req.query, ...req.params });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
|
||||
const statuses = data.status
|
||||
? (data.status.split(',').map((s) => s.trim()).filter((s) => VALID_STATUSES.includes(s as SprintStatus)) as SprintStatus[])
|
||||
: undefined;
|
||||
|
||||
return res.tvJson(await req.appUser.sprintsManager.listSprints({ goalId: data.goalId, statuses }).catch(logError));
|
||||
};
|
||||
|
||||
getOne = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSprintIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return res.tvJson(await req.appUser.sprintsManager.getSprint(data.sprintId).catch(logError));
|
||||
};
|
||||
|
||||
create = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeCreate(req.body);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.createSprint(data));
|
||||
};
|
||||
|
||||
update = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeUpdate({ ...req.body, sprintId: Number(req.params.sprintId) });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.updateSprint(data));
|
||||
};
|
||||
|
||||
activate = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSprintIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.activateSprint(data.sprintId));
|
||||
};
|
||||
|
||||
review = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSprintIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.startReview(data.sprintId));
|
||||
};
|
||||
|
||||
close = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeClose({ ...req.body, sprintId: Number(req.params.sprintId) });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.closeSprint(data));
|
||||
};
|
||||
|
||||
pause = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSprintIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.pauseSprint(data.sprintId));
|
||||
};
|
||||
|
||||
resume = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSprintIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.resumeSprint(data.sprintId));
|
||||
};
|
||||
|
||||
remove = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSprintIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.deleteSprint(data.sprintId));
|
||||
};
|
||||
|
||||
saveRetro = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSaveRetro({ ...req.body, sprintId: Number(req.params.sprintId) });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.saveRetro(data));
|
||||
};
|
||||
|
||||
setTaskSprint = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSetTask({ taskId: Number(req.params.taskId), sprintId: req.body.sprintId });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.setTaskSprint(data));
|
||||
};
|
||||
|
||||
burndown = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSprintIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return res.tvJson(await req.appUser.sprintsManager.getBurndown(data.sprintId).catch(logError));
|
||||
};
|
||||
|
||||
planning = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypePlanningQuery({ ...req.query, ...req.params });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return res.tvJson(
|
||||
await req.appUser.sprintsManager
|
||||
.getPlanningTasks({
|
||||
sprintId: data.sprintId,
|
||||
scope: data.scope,
|
||||
cursor: data.cursor ?? null,
|
||||
limit: data.limit ?? 30,
|
||||
})
|
||||
.catch(logError)
|
||||
);
|
||||
};
|
||||
|
||||
velocity = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeVelocityQuery({ ...req.query, ...req.params });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return res.tvJson(
|
||||
await req.appUser.sprintsManager.getVelocity({ goalId: data.goalId, lastN: data.lastN ?? 6 }).catch(logError)
|
||||
);
|
||||
};
|
||||
|
||||
getCadence = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeGoalIdParam(req.params);
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return res.tvJson(await req.appUser.sprintsManager.getCadence(data.goalId).catch(logError));
|
||||
};
|
||||
|
||||
setCadence = async (req: Request, res: Response) => {
|
||||
const data = SprintArkTypeSetCadence({ ...req.body, goalId: Number(req.params.goalId) });
|
||||
if (data instanceof type.errors) return res.status(400).send(data.summary);
|
||||
return this.sendResult(res, await req.appUser.sprintsManager.setCadence(data));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
CollaborationUsersSchema,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
GoalsSchema,
|
||||
UsersSchema,
|
||||
} from 'taskview-db-schemas';
|
||||
import { getCentrifugoClient } from '../../core/CentrifugoClient';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { SprintScheduler } from './SprintScheduler';
|
||||
|
||||
const SPRINT_RT_EVENT = 'sprints.changed';
|
||||
|
||||
export class SprintsDispatcher implements Dispatcher {
|
||||
private readonly scheduler = new SprintScheduler();
|
||||
|
||||
register(): void {
|
||||
eventBus.on('sprint.created', (d) => this.notifyGoalMembers(d.sprint.goalId, { sprintId: d.sprint.id }));
|
||||
eventBus.on('sprint.updated', (d) => this.notifyGoalMembers(d.sprint.goalId, { sprintId: d.sprint.id }));
|
||||
eventBus.on('sprint.activated', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
|
||||
eventBus.on('sprint.reviewStarted', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
|
||||
eventBus.on('sprint.completed', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
|
||||
eventBus.on('sprint.paused', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
|
||||
eventBus.on('sprint.resumed', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
|
||||
eventBus.on('sprint.deleted', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
|
||||
eventBus.on('task.assignedToSprint', (d) =>
|
||||
this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId, prevSprintId: d.prevSprintId, taskId: d.taskId })
|
||||
);
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
await this.scheduler.registerCadenceWorker();
|
||||
}
|
||||
|
||||
private async notifyGoalMembers(goalId: number, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const memberIds = await this.resolveGoalMemberIds(goalId);
|
||||
if (memberIds.length === 0) return;
|
||||
const centrifugo = getCentrifugoClient();
|
||||
await Promise.all(
|
||||
memberIds.map((userId) => centrifugo.publishToUser(userId, SPRINT_RT_EVENT, { goalId, ...payload }))
|
||||
);
|
||||
} catch (err) {
|
||||
$logger.error(err, '[SprintsDispatcher] real-time publish failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGoalMemberIds(goalId: number): Promise<number[]> {
|
||||
const db = Database.getInstance();
|
||||
const [ownerRows, collabRows] = await Promise.all([
|
||||
db.dbDrizzle.select({ id: GoalsSchema.owner }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1),
|
||||
db.dbDrizzle
|
||||
.select({ id: UsersSchema.id })
|
||||
.from(CollaborationUsersToGoalsSchema)
|
||||
.innerJoin(
|
||||
CollaborationUsersSchema,
|
||||
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id)
|
||||
)
|
||||
.innerJoin(UsersSchema, eq(CollaborationUsersSchema.email, UsersSchema.email))
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId)),
|
||||
]);
|
||||
|
||||
const ids = new Set<number>();
|
||||
if (ownerRows[0]?.id) ids.add(ownerRows[0].id);
|
||||
collabRows.forEach((r) => ids.add(r.id));
|
||||
return [...ids];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import type {
|
||||
SprintCadenceSchemaTypeForSelect,
|
||||
SprintsSchemaTypeForInsert,
|
||||
SprintsSchemaTypeForSelect,
|
||||
SprintStatus,
|
||||
TasksSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
import { eventBus } from '../../core/EventBus';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { SprintScheduler } from './SprintScheduler';
|
||||
import { SprintsRepository } from './SprintsRepository';
|
||||
import type {
|
||||
BurndownPoint,
|
||||
SprintCloseArgs,
|
||||
SprintCreateArgs,
|
||||
SprintErrorCode,
|
||||
SprintListFilter,
|
||||
SprintPlanningManagerArgs,
|
||||
SprintPlanningPage,
|
||||
SprintResult,
|
||||
SprintSaveRetroArgs,
|
||||
SprintSetCadenceArgs,
|
||||
SprintSetTaskArgs,
|
||||
SprintUpdateArgs,
|
||||
SprintVelocityArgs,
|
||||
} from './types';
|
||||
|
||||
const ok = <T>(data: T): SprintResult<T> => ({ ok: true, data });
|
||||
const fail = (code: SprintErrorCode, message?: string): SprintResult<never> => ({ ok: false, code, message });
|
||||
|
||||
export class SprintsManager {
|
||||
private readonly user: AppUser;
|
||||
public readonly repository: SprintsRepository;
|
||||
private readonly scheduler: SprintScheduler;
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user;
|
||||
this.repository = new SprintsRepository();
|
||||
this.scheduler = new SprintScheduler();
|
||||
}
|
||||
|
||||
private get initiatorId(): number {
|
||||
return this.user.getUserData()?.id as number;
|
||||
}
|
||||
|
||||
private today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async listSprints(filter: SprintListFilter): Promise<SprintsSchemaTypeForSelect[]> {
|
||||
return this.repository.listForGoal(filter);
|
||||
}
|
||||
|
||||
async getSprint(sprintId: number) {
|
||||
const sprint = await this.repository.getById(sprintId);
|
||||
if (!sprint) return null;
|
||||
const retro = await this.repository.getRetro(sprintId);
|
||||
return {
|
||||
...sprint,
|
||||
retro: retro
|
||||
? { wentWell: retro.wentWell, wentBad: retro.wentBad, actionItems: retro.actionItems }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async createSprint(args: SprintCreateArgs): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
|
||||
if (args.endDate < args.startDate) return fail('invalid_state', 'endDate must be >= startDate');
|
||||
const status: SprintStatus = args.startDate > this.today() ? 'planned' : 'draft';
|
||||
const sprint = await this.repository.create({ ...args, creatorId: this.initiatorId }, status);
|
||||
if (!sprint) return fail('invalid_state', 'could not create sprint');
|
||||
eventBus.emit('sprint.created', { sprint, initiatorId: this.initiatorId });
|
||||
return ok(sprint);
|
||||
}
|
||||
|
||||
async updateSprint(args: SprintUpdateArgs): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
|
||||
const sprint = await this.repository.getById(args.sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
if (sprint.status === 'completed') return fail('invalid_state', 'completed sprints are read-only');
|
||||
|
||||
const patch: Partial<SprintsSchemaTypeForInsert> = {};
|
||||
if (args.name !== undefined) patch.name = args.name;
|
||||
if (args.startDate !== undefined) patch.startDate = args.startDate;
|
||||
if (args.endDate !== undefined) patch.endDate = args.endDate;
|
||||
if (args.goalText !== undefined) patch.goalText = args.goalText;
|
||||
if (args.capacity !== undefined) {
|
||||
patch.capacity = args.capacity != null ? String(args.capacity) : null;
|
||||
}
|
||||
|
||||
const newStart = args.startDate ?? sprint.startDate;
|
||||
const newEnd = args.endDate ?? sprint.endDate;
|
||||
if (newEnd < newStart) return fail('invalid_state', 'endDate must be >= startDate');
|
||||
|
||||
const updated = await this.repository.patch({ sprintId: args.sprintId, patch });
|
||||
if (!updated) return fail('invalid_state');
|
||||
|
||||
eventBus.emit('sprint.updated', { sprint: updated, changes: patch, initiatorId: this.initiatorId });
|
||||
return ok(updated);
|
||||
}
|
||||
|
||||
async activateSprint(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
|
||||
const sprint = await this.repository.getById(sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
if (sprint.status !== 'draft' && sprint.status !== 'planned') {
|
||||
return fail('invalid_state', 'only draft or planned sprints can be activated');
|
||||
}
|
||||
const conflict = await this.repository.findActiveOrReview(sprint.goalId, sprint.id);
|
||||
if (conflict) return fail('conflict', 'another sprint is already active or in review');
|
||||
|
||||
const updated = await this.repository.patch({ sprintId, patch: { status: 'active' } });
|
||||
if (!updated) return fail('invalid_state');
|
||||
eventBus.emit('sprint.activated', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
|
||||
return ok(updated);
|
||||
}
|
||||
|
||||
async startReview(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
|
||||
const sprint = await this.repository.getById(sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
if (sprint.status !== 'active') return fail('invalid_state', 'only an active sprint can enter review');
|
||||
|
||||
const updated = await this.repository.patch({
|
||||
sprintId,
|
||||
patch: { status: 'review', reviewStartedAt: new Date() },
|
||||
});
|
||||
if (!updated) return fail('invalid_state');
|
||||
eventBus.emit('sprint.reviewStarted', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
|
||||
return ok(updated);
|
||||
}
|
||||
|
||||
async closeSprint(args: SprintCloseArgs): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
|
||||
const sprint = await this.repository.getById(args.sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
if (sprint.status !== 'review') return fail('invalid_state', 'sprint must be in review before closing');
|
||||
|
||||
for (const o of args.outcomes) {
|
||||
if (o.outcome === 'carried-over' && o.carriedOverTo != null) {
|
||||
if (o.carriedOverTo === sprint.id) return fail('invalid_state', 'cannot carry over to the same sprint');
|
||||
const target = await this.repository.getById(o.carriedOverTo);
|
||||
if (!target || target.goalId !== sprint.goalId) {
|
||||
return fail('invalid_state', 'carry-over target sprint not found in this project');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const closed = await this.repository.applyClose({ ...args, initiatorId: this.initiatorId });
|
||||
if (!closed) return fail('invalid_state');
|
||||
eventBus.emit('sprint.completed', { sprintId: args.sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
|
||||
return ok(closed);
|
||||
}
|
||||
|
||||
async pauseSprint(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
|
||||
const sprint = await this.repository.getById(sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
if (sprint.status !== 'active') return fail('invalid_state', 'only an active sprint can be paused');
|
||||
if (sprint.pausedAt) return fail('invalid_state', 'sprint is already paused');
|
||||
const updated = await this.repository.patch({ sprintId, patch: { pausedAt: new Date() } });
|
||||
if (!updated) return fail('invalid_state');
|
||||
eventBus.emit('sprint.paused', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
|
||||
return ok(updated);
|
||||
}
|
||||
|
||||
async resumeSprint(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
|
||||
const sprint = await this.repository.getById(sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
if (sprint.status !== 'active' || !sprint.pausedAt) return fail('invalid_state', 'sprint is not paused');
|
||||
const updated = await this.repository.patch({ sprintId, patch: { pausedAt: null } });
|
||||
if (!updated) return fail('invalid_state');
|
||||
eventBus.emit('sprint.resumed', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
|
||||
return ok(updated);
|
||||
}
|
||||
|
||||
async deleteSprint(sprintId: number): Promise<SprintResult<true>> {
|
||||
const sprint = await this.repository.getById(sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
// A sprint of any status can be deleted (including completed) — useful for
|
||||
// cleaning up an accidentally closed sprint. The DB FK sets tasks.sprint_id
|
||||
// to NULL (tasks return to the backlog) and cascades outcomes/retros/capacity.
|
||||
const deleted = await this.repository.delete(sprintId);
|
||||
if (!deleted) return fail('invalid_state');
|
||||
eventBus.emit('sprint.deleted', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
|
||||
return ok(true);
|
||||
}
|
||||
|
||||
async saveRetro(args: SprintSaveRetroArgs): Promise<SprintResult<SprintSaveRetroArgs>> {
|
||||
const sprint = await this.repository.getById(args.sprintId);
|
||||
if (!sprint) return fail('not_found');
|
||||
const saved = await this.repository.saveRetro({ ...args, editedBy: this.initiatorId });
|
||||
if (!saved) return fail('invalid_state');
|
||||
return ok(args);
|
||||
}
|
||||
|
||||
async setTaskSprint(args: SprintSetTaskArgs): Promise<SprintResult<{ taskId: number; sprintId: number | null }>> {
|
||||
const taskMeta = await this.repository.getTaskMeta(args.taskId);
|
||||
if (!taskMeta) return fail('not_found', 'task not found');
|
||||
|
||||
if (args.sprintId !== null) {
|
||||
const sprint = await this.repository.getById(args.sprintId);
|
||||
if (!sprint) return fail('not_found', 'sprint not found');
|
||||
if (sprint.goalId !== taskMeta.goalId) {
|
||||
return fail('forbidden', 'task and sprint belong to different projects');
|
||||
}
|
||||
if (sprint.status === 'completed') {
|
||||
return fail('invalid_state', 'cannot move tasks into a completed sprint');
|
||||
}
|
||||
}
|
||||
|
||||
const prevSprintId = taskMeta.sprintId;
|
||||
const done = await this.repository.setTaskSprint(args);
|
||||
if (!done) return fail('invalid_state');
|
||||
eventBus.emit('task.assignedToSprint', {
|
||||
taskId: args.taskId,
|
||||
sprintId: args.sprintId,
|
||||
prevSprintId,
|
||||
goalId: taskMeta.goalId,
|
||||
initiatorId: this.initiatorId,
|
||||
});
|
||||
return ok({ taskId: args.taskId, sprintId: args.sprintId });
|
||||
}
|
||||
|
||||
async getBurndown(sprintId: number): Promise<{ total: number; points: BurndownPoint[] } | null> {
|
||||
const sprint = await this.repository.getById(sprintId);
|
||||
if (!sprint) return null;
|
||||
const tasks = await this.repository.getSprintTaskEstimates(sprintId);
|
||||
const total = tasks.reduce((sum, t) => sum + (t.estimateValue ? Number(t.estimateValue) : 0), 0);
|
||||
|
||||
const days = this.enumerateDays(sprint.startDate, sprint.endDate);
|
||||
const n = days.length;
|
||||
const points: BurndownPoint[] = days.map((date, i) => {
|
||||
const dayEnd = new Date(`${date}T23:59:59.999Z`);
|
||||
let remaining = 0;
|
||||
for (const t of tasks) {
|
||||
const est = t.estimateValue ? Number(t.estimateValue) : 0;
|
||||
const isRemaining = t.complete !== true || (t.dateComplete != null && t.dateComplete > dayEnd);
|
||||
if (isRemaining) remaining += est;
|
||||
}
|
||||
const ideal = n > 1 ? total * (1 - i / (n - 1)) : 0;
|
||||
return {
|
||||
date,
|
||||
remainingHours: Math.round(remaining * 100) / 100,
|
||||
idealHours: Math.max(0, Math.round(ideal * 100) / 100),
|
||||
};
|
||||
});
|
||||
return { total: Math.round(total * 100) / 100, points };
|
||||
}
|
||||
|
||||
async getVelocity(args: SprintVelocityArgs) {
|
||||
return this.repository.velocity({ goalId: args.goalId, lastN: args.lastN || 6 });
|
||||
}
|
||||
|
||||
async getCadence(goalId: number): Promise<SprintCadenceSchemaTypeForSelect | null> {
|
||||
return this.repository.getCadence(goalId);
|
||||
}
|
||||
|
||||
async setCadence(args: SprintSetCadenceArgs): Promise<SprintResult<SprintCadenceSchemaTypeForSelect>> {
|
||||
const existing = await this.repository.getCadence(args.goalId);
|
||||
|
||||
const lengthDays = args.lengthDays ?? existing?.lengthDays ?? 14;
|
||||
const lookahead = args.lookahead ?? existing?.lookahead ?? 2;
|
||||
if (lengthDays < 1 || lengthDays > 90) return fail('invalid_state', 'lengthDays must be 1..90');
|
||||
if (lookahead < 0 || lookahead > 12) return fail('invalid_state', 'lookahead must be 0..12');
|
||||
|
||||
const startDate = args.startDate ?? existing?.startDate ?? this.today();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) return fail('invalid_state', 'startDate must be YYYY-MM-DD');
|
||||
|
||||
const nameTemplate = args.nameTemplate?.trim() || existing?.nameTemplate || 'Sprint {n}';
|
||||
|
||||
const saved = await this.repository.upsertCadence({
|
||||
goalId: args.goalId,
|
||||
enabled: args.enabled,
|
||||
lengthDays,
|
||||
startDate,
|
||||
lookahead,
|
||||
nameTemplate,
|
||||
});
|
||||
if (!saved) return fail('invalid_state', 'could not save cadence');
|
||||
|
||||
if (saved.enabled) {
|
||||
await this.scheduler
|
||||
.generateCadenceForGoal(args.goalId)
|
||||
.catch((e) => $logger.error(e, '[Sprints] cadence generate'));
|
||||
}
|
||||
return ok(saved);
|
||||
}
|
||||
|
||||
async getPlanningTasks(args: SprintPlanningManagerArgs): Promise<SprintPlanningPage | null> {
|
||||
const sprint = await this.repository.getById(args.sprintId);
|
||||
if (!sprint) return null;
|
||||
|
||||
if (args.scope === 'sprint') {
|
||||
const rows = await this.repository.getSprintTasksForPlanning({
|
||||
sprintId: args.sprintId,
|
||||
cursor: args.cursor,
|
||||
limit: args.limit,
|
||||
});
|
||||
const { tasks, nextCursor } = this.paginate(rows, args.limit);
|
||||
const totalPoints = await this.repository.sumEstimateForSprint(args.sprintId);
|
||||
return { tasks, nextCursor, totalPoints };
|
||||
}
|
||||
|
||||
const rows = await this.repository.getBacklogTasksForPlanning({
|
||||
goalId: sprint.goalId,
|
||||
sprintId: args.sprintId,
|
||||
cursor: args.cursor,
|
||||
limit: args.limit,
|
||||
});
|
||||
return this.paginate(rows, args.limit);
|
||||
}
|
||||
|
||||
private paginate(
|
||||
rows: TasksSchemaTypeForSelect[],
|
||||
limit: number
|
||||
): { tasks: TasksSchemaTypeForSelect[]; nextCursor: number | null } {
|
||||
const hasMore = rows.length > limit;
|
||||
const tasks = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? tasks[tasks.length - 1].id : null;
|
||||
return { tasks, nextCursor };
|
||||
}
|
||||
|
||||
private enumerateDays(start: string, end: string): string[] {
|
||||
const days: string[] = [];
|
||||
const cur = new Date(`${start}T00:00:00Z`);
|
||||
const last = new Date(`${end}T00:00:00Z`);
|
||||
// hard cap to avoid pathological ranges
|
||||
let guard = 0;
|
||||
while (cur <= last && guard < 400) {
|
||||
days.push(cur.toISOString().slice(0, 10));
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
guard++;
|
||||
}
|
||||
return days;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, ne, or, sql } from 'drizzle-orm';
|
||||
import {
|
||||
SprintCadenceSchema,
|
||||
SprintsSchema,
|
||||
SprintTaskOutcomesSchema,
|
||||
SprintUserCapacitySchema,
|
||||
SprintRetrosSchema,
|
||||
TasksSchema,
|
||||
type SprintCadenceSchemaTypeForSelect,
|
||||
type SprintStatus,
|
||||
type SprintsSchemaTypeForInsert,
|
||||
type SprintsSchemaTypeForSelect,
|
||||
type TasksSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type {
|
||||
SprintCadenceFindByStartArgs,
|
||||
SprintCadenceSprintCreateArgs,
|
||||
SprintCadenceTouchArgs,
|
||||
SprintCadenceUpsertRepoArgs,
|
||||
SprintCloseManagerArgs,
|
||||
SprintCreateRepoArgs,
|
||||
SprintListFilter,
|
||||
SprintPlanningPageArgs,
|
||||
SprintSaveRetroManagerArgs,
|
||||
VelocityPoint,
|
||||
} from './types';
|
||||
|
||||
export class SprintsRepository {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
async create(args: SprintCreateRepoArgs, status: SprintStatus): Promise<SprintsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(SprintsSchema)
|
||||
.values({
|
||||
goalId: args.goalId,
|
||||
name: args.name,
|
||||
startDate: args.startDate,
|
||||
endDate: args.endDate,
|
||||
goalText: args.goalText ?? null,
|
||||
capacity: args.capacity != null ? String(args.capacity) : null,
|
||||
status,
|
||||
creatorId: args.creatorId,
|
||||
})
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async getById(sprintId: number): Promise<SprintsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** Cadence: per-project auto-generation config (Linear-style). */
|
||||
async getCadence(goalId: number): Promise<SprintCadenceSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(SprintCadenceSchema).where(eq(SprintCadenceSchema.goalId, goalId)).limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertCadence(args: SprintCadenceUpsertRepoArgs): Promise<SprintCadenceSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(SprintCadenceSchema)
|
||||
.values({
|
||||
goalId: args.goalId,
|
||||
enabled: args.enabled,
|
||||
lengthDays: args.lengthDays,
|
||||
startDate: args.startDate,
|
||||
lookahead: args.lookahead,
|
||||
nameTemplate: args.nameTemplate,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: SprintCadenceSchema.goalId,
|
||||
set: {
|
||||
enabled: args.enabled,
|
||||
lengthDays: args.lengthDays,
|
||||
startDate: args.startDate,
|
||||
lookahead: args.lookahead,
|
||||
nameTemplate: args.nameTemplate,
|
||||
editedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async getEnabledCadences(): Promise<SprintCadenceSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(SprintCadenceSchema).where(eq(SprintCadenceSchema.enabled, true))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async findByGoalAndStartDate(args: SprintCadenceFindByStartArgs): Promise<SprintsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SprintsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(SprintsSchema.goalId, args.goalId),
|
||||
eq(SprintsSchema.startDate, args.startDate),
|
||||
ne(SprintsSchema.status, 'completed')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async setCadenceLastGenerated(args: SprintCadenceTouchArgs): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(SprintCadenceSchema)
|
||||
.set({ lastGeneratedDate: args.lastGeneratedDate, editedAt: new Date() })
|
||||
.where(eq(SprintCadenceSchema.goalId, args.goalId))
|
||||
);
|
||||
}
|
||||
|
||||
/** Create an auto-generated (cadence) sprint — no creator, always starts planned. */
|
||||
async createCadenceSprint(args: SprintCadenceSprintCreateArgs): Promise<SprintsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(SprintsSchema)
|
||||
.values({
|
||||
goalId: args.goalId,
|
||||
name: args.name,
|
||||
startDate: args.startDate,
|
||||
endDate: args.endDate,
|
||||
status: 'planned',
|
||||
creatorId: null,
|
||||
})
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async listForGoal(filter: SprintListFilter): Promise<SprintsSchemaTypeForSelect[]> {
|
||||
const conditions = [eq(SprintsSchema.goalId, filter.goalId)];
|
||||
if (filter.statuses && filter.statuses.length > 0) {
|
||||
conditions.push(inArray(SprintsSchema.status, filter.statuses));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SprintsSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(SprintsSchema.startDate))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
/** The single active OR in-review sprint of a goal, if any. */
|
||||
async findActiveOrReview(goalId: number, excludeSprintId?: number): Promise<SprintsSchemaTypeForSelect | null> {
|
||||
const conditions = [
|
||||
eq(SprintsSchema.goalId, goalId),
|
||||
inArray(SprintsSchema.status, ['active', 'review'] as SprintStatus[]),
|
||||
];
|
||||
if (excludeSprintId) {
|
||||
conditions.push(ne(SprintsSchema.id, excludeSprintId));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SprintsSchema)
|
||||
.where(and(...conditions))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async patch(args: {
|
||||
sprintId: number;
|
||||
patch: Partial<SprintsSchemaTypeForInsert>;
|
||||
}): Promise<SprintsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(SprintsSchema)
|
||||
.set({ ...args.patch, editedAt: new Date() })
|
||||
.where(eq(SprintsSchema.id, args.sprintId))
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(sprintId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).returning()
|
||||
);
|
||||
return !!result?.length;
|
||||
}
|
||||
|
||||
async getTaskMeta(taskId: number): Promise<{ goalId: number; sprintId: number | null } | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ goalId: TasksSchema.goalId, sprintId: TasksSchema.sprintId })
|
||||
.from(TasksSchema)
|
||||
.where(eq(TasksSchema.id, taskId))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async setTaskSprint(args: { taskId: number; sprintId: number | null }): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(TasksSchema)
|
||||
.set({ sprintId: args.sprintId })
|
||||
.where(eq(TasksSchema.id, args.taskId))
|
||||
.returning()
|
||||
);
|
||||
return !!result?.length;
|
||||
}
|
||||
|
||||
/** Estimate rows of all tasks currently in the sprint — input for burndown. */
|
||||
async getSprintTaskEstimates(
|
||||
sprintId: number
|
||||
): Promise<{ estimateValue: string | null; complete: boolean | null; dateComplete: Date | null }[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({
|
||||
estimateValue: TasksSchema.estimateValue,
|
||||
complete: TasksSchema.complete,
|
||||
// trigger-maintained completion moment (tasks.update_date_complete)
|
||||
dateComplete: TasksSchema.dateComplete,
|
||||
})
|
||||
.from(TasksSchema)
|
||||
.where(eq(TasksSchema.sprintId, sprintId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async getOutcomes(sprintId: number) {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SprintTaskOutcomesSchema)
|
||||
.where(eq(SprintTaskOutcomesSchema.sprintId, sprintId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async getUserCapacities(sprintId: number) {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SprintUserCapacitySchema)
|
||||
.where(eq(SprintUserCapacitySchema.sprintId, sprintId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async getRetro(sprintId: number) {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(SprintRetrosSchema)
|
||||
.where(eq(SprintRetrosSchema.sprintId, sprintId))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async saveRetro(args: SprintSaveRetroManagerArgs) {
|
||||
const set = {
|
||||
wentWell: args.wentWell ?? null,
|
||||
wentBad: args.wentBad ?? null,
|
||||
actionItems: args.actionItems ?? null,
|
||||
editedBy: args.editedBy,
|
||||
editedAt: new Date(),
|
||||
};
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(SprintRetrosSchema)
|
||||
.values({ sprintId: args.sprintId, ...set })
|
||||
.onConflictDoUpdate({ target: SprintRetrosSchema.sprintId, set })
|
||||
.returning()
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close transaction: set sprint completed, record per-task outcomes
|
||||
* (untouched tasks -> 'incomplete'), apply task sprint moves.
|
||||
*/
|
||||
async applyClose(args: SprintCloseManagerArgs): Promise<SprintsSchemaTypeForSelect | null> {
|
||||
return this.db.dbDrizzle.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(SprintsSchema)
|
||||
.set({
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
goalAchieved: args.goalAchieved,
|
||||
editedAt: new Date(),
|
||||
})
|
||||
.where(eq(SprintsSchema.id, args.sprintId))
|
||||
.returning();
|
||||
|
||||
const tasks = await tx
|
||||
.select({ id: TasksSchema.id, complete: TasksSchema.complete, estimateValue: TasksSchema.estimateValue })
|
||||
.from(TasksSchema)
|
||||
.where(eq(TasksSchema.sprintId, args.sprintId));
|
||||
|
||||
const explicit = new Map(args.outcomes.map((o) => [o.taskId, o]));
|
||||
|
||||
// Resolve a final outcome per task, ENFORCING the invariant:
|
||||
// a completed task is always 'accepted' (and stays in the sprint);
|
||||
// an unfinished task can only be 'carried-over' or 'dropped' (anything
|
||||
// else falls back to 'incomplete'). This keeps velocity consistent
|
||||
// regardless of what the client sent — you can't carry over / drop
|
||||
// already-done work, nor accept unfinished work.
|
||||
const resolved = tasks.map((t) => {
|
||||
// Snapshot the task's estimate at close — frozen, independent of later edits.
|
||||
const estimateValue = t.estimateValue;
|
||||
if (t.complete) {
|
||||
return { taskId: t.id, outcome: 'accepted' as const, carriedOverTo: null as number | null, estimateValue };
|
||||
}
|
||||
const decided = explicit.get(t.id);
|
||||
if (decided?.outcome === 'carried-over') {
|
||||
return { taskId: t.id, outcome: 'carried-over' as const, carriedOverTo: decided.carriedOverTo ?? null, estimateValue };
|
||||
}
|
||||
if (decided?.outcome === 'dropped') {
|
||||
return { taskId: t.id, outcome: 'dropped' as const, carriedOverTo: null as number | null, estimateValue };
|
||||
}
|
||||
return { taskId: t.id, outcome: 'incomplete' as const, carriedOverTo: null as number | null, estimateValue };
|
||||
});
|
||||
|
||||
if (resolved.length > 0) {
|
||||
await tx
|
||||
.insert(SprintTaskOutcomesSchema)
|
||||
.values(
|
||||
resolved.map((r) => ({
|
||||
sprintId: args.sprintId,
|
||||
taskId: r.taskId,
|
||||
outcome: r.outcome,
|
||||
carriedOverTo: r.carriedOverTo,
|
||||
decidedBy: args.initiatorId,
|
||||
estimateValue: r.estimateValue,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// Move only unfinished tasks out of the sprint; completed ('accepted')
|
||||
// and untouched ('incomplete') tasks stay in the closed sprint.
|
||||
for (const r of resolved) {
|
||||
if (r.outcome === 'carried-over') {
|
||||
await tx
|
||||
.update(TasksSchema)
|
||||
.set({ sprintId: r.carriedOverTo })
|
||||
.where(eq(TasksSchema.id, r.taskId));
|
||||
} else if (r.outcome === 'dropped') {
|
||||
await tx.update(TasksSchema).set({ sprintId: null }).where(eq(TasksSchema.id, r.taskId));
|
||||
}
|
||||
}
|
||||
|
||||
return updated[0] ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async velocity(args: { goalId: number; lastN: number }): Promise<VelocityPoint[]> {
|
||||
const sprints = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ id: SprintsSchema.id, name: SprintsSchema.name })
|
||||
.from(SprintsSchema)
|
||||
.where(and(eq(SprintsSchema.goalId, args.goalId), eq(SprintsSchema.status, 'completed' as SprintStatus)))
|
||||
.orderBy(desc(SprintsSchema.completedAt))
|
||||
.limit(args.lastN)
|
||||
);
|
||||
if (!sprints || sprints.length === 0) return [];
|
||||
|
||||
const points: VelocityPoint[] = [];
|
||||
for (const s of sprints) {
|
||||
const rows = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
// Read the FROZEN snapshot, not the live task estimate.
|
||||
.select({ outcome: SprintTaskOutcomesSchema.outcome, estimate: SprintTaskOutcomesSchema.estimateValue })
|
||||
.from(SprintTaskOutcomesSchema)
|
||||
.where(eq(SprintTaskOutcomesSchema.sprintId, s.id))
|
||||
);
|
||||
let accepted = 0;
|
||||
let planned = 0;
|
||||
(rows ?? []).forEach((r) => {
|
||||
const est = r.estimate ? Number(r.estimate) : 0;
|
||||
planned += est;
|
||||
if (r.outcome === 'accepted') accepted += est;
|
||||
});
|
||||
points.push({ sprintId: s.id, name: s.name, acceptedHours: accepted, plannedHours: planned });
|
||||
}
|
||||
return points.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backlog tasks for sprint planning: top-level, incomplete, not yet in any sprint.
|
||||
* Cursor-paginated by ascending task id; returns up to `limit + 1` rows so the caller
|
||||
* can detect a next page.
|
||||
*/
|
||||
async getBacklogTasksForPlanning(args: SprintPlanningPageArgs & { goalId: number }): Promise<TasksSchemaTypeForSelect[]> {
|
||||
const conditions = [
|
||||
eq(TasksSchema.goalId, args.goalId),
|
||||
isNull(TasksSchema.sprintId),
|
||||
or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete)),
|
||||
isNull(TasksSchema.parentId),
|
||||
];
|
||||
if (args.cursor != null) {
|
||||
conditions.push(gt(TasksSchema.id, args.cursor));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(TasksSchema.id))
|
||||
.limit(args.limit + 1)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks currently in the sprint for planning: top-level tasks of the sprint
|
||||
* (completed included — they belong to the sprint). Cursor-paginated by ascending id.
|
||||
*/
|
||||
async getSprintTasksForPlanning(args: SprintPlanningPageArgs): Promise<TasksSchemaTypeForSelect[]> {
|
||||
const conditions = [eq(TasksSchema.sprintId, args.sprintId), isNull(TasksSchema.parentId)];
|
||||
if (args.cursor != null) {
|
||||
conditions.push(gt(TasksSchema.id, args.cursor));
|
||||
}
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(TasksSchema.id))
|
||||
.limit(args.limit + 1)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
/** SUM(estimate_value) over ALL top-level tasks in the sprint — for the capacity counter. */
|
||||
async sumEstimateForSprint(sprintId: number): Promise<number> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ total: sql<string | null>`COALESCE(SUM(${TasksSchema.estimateValue}), 0)` })
|
||||
.from(TasksSchema)
|
||||
.where(and(eq(TasksSchema.sprintId, sprintId), isNull(TasksSchema.parentId)))
|
||||
);
|
||||
return result?.[0]?.total ? Number(result[0].total) : 0;
|
||||
}
|
||||
|
||||
/** Active sprint id of a goal — for the kanban `sprint=current` filter. */
|
||||
async getActiveSprintId(goalId: number): Promise<number | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ id: SprintsSchema.id })
|
||||
.from(SprintsSchema)
|
||||
.where(and(eq(SprintsSchema.goalId, goalId), eq(SprintsSchema.status, 'active' as SprintStatus)))
|
||||
.limit(1)
|
||||
);
|
||||
return result?.[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Router } from 'express';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import SprintsController from './SprintsController';
|
||||
import { canAssignSprintTasks } from './middlewares/can-assign-sprint-tasks';
|
||||
import { requireSprintPermission } from './middlewares/require-sprint-permission';
|
||||
import { goalIdFromBody, goalIdFromParam, goalIdFromSprint } from './middlewares/goal-id-resolvers';
|
||||
|
||||
export default class SprintsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: SprintsController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new SprintsController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
// Create — goal comes from the request body (the creation target).
|
||||
this.router.post('', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromBody)], this.controller.create);
|
||||
|
||||
// Analytics
|
||||
this.router.get('/sprint/:sprintId/burndown', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW_ANALYTICS, goalIdFromSprint)], this.controller.burndown);
|
||||
this.router.get('/goal/:goalId/velocity', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW_ANALYTICS, goalIdFromParam)], this.controller.velocity);
|
||||
|
||||
// Cadence: per-project auto-generation config (Linear-style) — goal from the URL param.
|
||||
this.router.get('/goal/:goalId/cadence', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromParam)], this.controller.getCadence);
|
||||
this.router.put('/goal/:goalId/cadence', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromParam)], this.controller.setCadence);
|
||||
|
||||
// Cursor-paginated planning task lists (?scope=backlog|sprint&cursor=&limit=)
|
||||
this.router.get('/sprint/:sprintId/planning', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromSprint)], this.controller.planning);
|
||||
|
||||
// Single sprint detail + lifecycle — goal is always derived from the sprint.
|
||||
this.router.get('/sprint/:sprintId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromSprint)], this.controller.getOne);
|
||||
this.router.patch('/sprint/:sprintId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.update);
|
||||
this.router.post('/sprint/:sprintId/activate', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.activate);
|
||||
this.router.post('/sprint/:sprintId/review', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.review);
|
||||
this.router.post('/sprint/:sprintId/close', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.close);
|
||||
this.router.post('/sprint/:sprintId/pause', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.pause);
|
||||
this.router.post('/sprint/:sprintId/resume', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.resume);
|
||||
this.router.delete('/sprint/:sprintId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.remove);
|
||||
this.router.put('/sprint/:sprintId/retro', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.saveRetro);
|
||||
|
||||
// Assign a task to / out of a sprint — authorized against the task's goal.
|
||||
this.router.patch('/task/:taskId/sprint', [IsLoggedIn, canAssignSprintTasks], this.controller.setTaskSprint);
|
||||
|
||||
// List sprints of a project (supports ?status=active,planned) — goal from the URL param.
|
||||
this.router.get('/:goalId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromParam)], this.controller.listForGoal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
|
||||
export const canAssignSprintTasks = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const taskId = req.params.taskId ?? req.body?.taskId;
|
||||
if (!taskId) return res.status(400).end();
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for canAssignSprintTasks middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.SPRINT_CAN_ASSIGN_TASKS)) return next();
|
||||
return res.status(403).end();
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user