mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e1597c13 | |||
| 3403e1a71d | |||
| e9fb4f6f1e | |||
| a173a870d7 | |||
| 968c3d2eeb | |||
| 171f269ee2 | |||
| f79614ebc5 | |||
| a4c465469d | |||
| f3e2663ebe | |||
| 5ce2fe99f0 | |||
| e11aba4d79 | |||
| 0b6f95ec3d | |||
| a596e023db | |||
| c1685e42b8 | |||
| 9635120e66 | |||
| 19e0212edf | |||
| 5dc5b387de | |||
| 8011da1259 | |||
| a6329e998d | |||
| b372854636 | |||
| a243f9ec56 | |||
| ebd25a94ea | |||
| 4b142619c0 | |||
| 6f576e7297 | |||
| 15050541fa | |||
| 3afdca99d9 | |||
| aaa876412d | |||
| 915e8d9f9f | |||
| 55ded8bac9 | |||
| 784652ef5b | |||
| 1f1a1b770f | |||
| e80ab33dda | |||
| 8c7be7362f | |||
| 57ec7c01b6 | |||
| d0f664f78e | |||
| 7bbb36d45e | |||
| 9934bf06d8 | |||
| fd13b33915 | |||
| 64089fd6e7 | |||
| b7a50049bb | |||
| 504ae503dd | |||
| 263883f32d | |||
| 90b55fd82a | |||
| 2e9a4945ce | |||
| 0d70023fb3 | |||
| 645f2e21e5 | |||
| adce016a05 | |||
| b7f2380d45 | |||
| 58eb93e564 | |||
| 4e5e5fb579 | |||
| 314e5a6377 | |||
| 955697f40f | |||
| e5dbba8e2a | |||
| bb28bac9f7 | |||
| 6c990aff4f | |||
| b296046605 | |||
| 40cd8064f5 | |||
| f4a765f40b | |||
| b9ce7261f0 | |||
| bc08c839bc | |||
| 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 | |||
| 0ba43489b4 | |||
| f143d536b3 | |||
| 8f091ac411 | |||
| acb86d1286 | |||
| affe83751b | |||
| c200826d31 | |||
| 065483f701 | |||
| e323a6e06e | |||
| 8972c70700 | |||
| ff539f00f5 | |||
| eede789d54 | |||
| c532f607aa | |||
| 791b9d1c6e | |||
| da82f3ae0f | |||
| 96c40d5f49 | |||
| dbd5253423 | |||
| c0c0a49c0d | |||
| f0fed9d68b | |||
| ee67342313 | |||
| a4bb9de09f | |||
| 0d01b4d532 | |||
| a5446ea825 | |||
| ec50b31642 | |||
| 1706a3124b | |||
| 6cf26fdd61 | |||
| d081436519 | |||
| 40e54966a8 | |||
| 4527ae6c4b | |||
| 9df7b4a7cd | |||
| 5afcc2d8ec | |||
| d2c9bee0d3 | |||
| c3ff29501b | |||
| f791822c79 | |||
| 344526b1b2 | |||
| 95f024275c | |||
| fd8ea2e328 |
@@ -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,150 +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>
|
||||
|
||||
## Apps
|
||||
* [Docs](https://taskview.tech/docs/)
|
||||
* [Web](https://app.taskview.tech/)
|
||||
* [iOS](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
|
||||
* [Android](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
|
||||
<p align="center">
|
||||
TaskView combines task management, custom workflows, developer integrations,
|
||||
analytics, and AI-assisted automation in a platform you can run on your own infrastructure.
|
||||
</p>
|
||||
|
||||
It is designed for teams and individuals who want:
|
||||
- full control over their data
|
||||
- transparent architecture
|
||||
- predictable workflows
|
||||
- the ability to run the system on their own infrastructure
|
||||
<details>
|
||||
<summary style="font-size: 24px"><strong>View more screenshots</strong></summary>
|
||||
|
||||
## Key Features
|
||||
- Projects, lists, tasks, and subtasks
|
||||
- Kanban boards
|
||||
- Clear responsibility assignment
|
||||
- Permissions and roles
|
||||
- Web and mobile clients
|
||||
- Self-hosted by design
|
||||
<br>
|
||||
|
||||
## Licensing
|
||||
<img src="./assets/taskview/main-light.png" alt="Main dashboard (light theme)" width="100%">
|
||||
<img src="./assets/taskview/main-dark.png" alt="Main dashboard (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/main-collapsed-light.png" alt="Main dashboard with collapsed sidebar (light theme)" width="100%">
|
||||
<img src="./assets/taskview/main-collapsed-dark.png" alt="Main dashboard with collapsed sidebar (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/kanban-light.png" alt="Kanban board (light theme)" width="100%">
|
||||
<img src="./assets/taskview/kanban-dark.png" alt="Kanban board (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/tasks-light.png" alt="Task list view (light theme)" width="100%">
|
||||
<img src="./assets/taskview/tasks-dark.png" alt="Task list view (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/recent-tasks.png" alt="Recent tasks overview" width="100%">
|
||||
<img src="./assets/taskview/sprints.png" alt="Sprint planning" width="100%">
|
||||
<img src="./assets/taskview/time-tracking.png" alt="Time tracking" width="100%">
|
||||
<img src="./assets/taskview/analytics.png" alt="Analytics dashboard" width="100%">
|
||||
<img src="./assets/taskview/members.png" alt="Organization members management" width="100%">
|
||||
<img src="./assets/taskview/permissions-light.png" alt="Role permissions (light theme)" width="100%">
|
||||
<img src="./assets/taskview/permissions-dark.png" alt="Role permissions (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/sso.png" alt="Single Sign-On (SSO) configuration" width="100%">
|
||||
<img src="./assets/taskview/api-tokens.png" alt="API tokens management" width="100%">
|
||||
<img src="./assets/taskview/webhooks.png" alt="Webhooks configuration" width="100%">
|
||||
<img src="./assets/taskview/integrations.png" alt="Integrations" width="100%">
|
||||
<img src="./assets/taskview/ui-customization.png" alt="UI customization" width="100%">
|
||||
<img src="./assets/taskview/settings.png" alt="Settings" width="100%">
|
||||
|
||||
TaskView uses a source-available license.
|
||||
</details>
|
||||
|
||||
You may:
|
||||
- self-host TaskView
|
||||
- modify the source code
|
||||
- use it internally within your organization
|
||||
<p align="center">
|
||||
<a href="https://app.taskview.tech"><strong>TaskView Cloud</strong></a>
|
||||
·
|
||||
<a href="https://taskview.tech/docs/"><strong>Documentation</strong></a>
|
||||
·
|
||||
<a href="https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867"><strong>iOS</strong></a>
|
||||
·
|
||||
<a href="https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app"><strong>Android</strong></a>
|
||||
·
|
||||
<a href="https://github.com/Gimanh/taskview-community/releases">
|
||||
<strong>Releases</strong>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
You may not:
|
||||
- offer TaskView as a hosted SaaS
|
||||
- sell TaskView as a service
|
||||
- create a competing commercial product
|
||||
- use the TaskView name or branding for derivative products
|
||||
<p align="center">
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
|
||||
</a>
|
||||
<a href="https://github.com/Gimanh/taskview-community/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Gimanh/taskview-community?label=release&color=brightgreen" alt="Latest release">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/taskview-mcp">
|
||||
<img src="https://img.shields.io/npm/v/taskview-mcp?label=taskview-mcp&logo=npm&color=CB3837" alt="taskview-mcp on npm">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/taskview-api">
|
||||
<img src="https://img.shields.io/npm/v/taskview-api?label=taskview-api&logo=npm&color=CB3837" alt="taskview-api on npm">
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
|
||||
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
|
||||
</p>
|
||||
|
||||
See LICENSE for full terms.
|
||||
TaskView is a trademark of its authors.
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## About TaskView
|
||||
|
||||
This repository is a monorepo.
|
||||
TaskView is a source-available project and task management platform for small and growing software teams.
|
||||
|
||||
Main product packages:
|
||||
It is designed for teams that need more structure than a simple task board, but do not want the administration overhead and complexity of large enterprise project management systems.
|
||||
|
||||
- `api` – backend (Node.js)
|
||||
- `web` – web client and mobile
|
||||
- `taskview-packages/taskview-api` – API client library for building integrations and making HTTP requests
|
||||
- `taskview-packages/taskview-db-schemas` - Drizzle ORM schemas
|
||||
With TaskView, you can manage projects, build custom workflows, track dependencies and time, connect development tools, and automate project operations through the API, webhooks, and MCP.
|
||||
|
||||
Product packages share a single version.
|
||||
Your projects. Your infrastructure. Your data.
|
||||
|
||||
## Who TaskView is for
|
||||
|
||||
## Versioning
|
||||
TaskView is a good fit for:
|
||||
|
||||
TaskView follows semantic versioning:
|
||||
- `MAJOR` – breaking API or data changes
|
||||
- `MINOR` – new features
|
||||
- `PATCH` – bug fixes
|
||||
The version represents the entire product, not individual packages.
|
||||
* software development teams;
|
||||
* startups and small product companies;
|
||||
* agencies managing internal and client projects;
|
||||
* organizations that require self-hosting and data ownership;
|
||||
* teams looking for a lightweight alternative to complex enterprise platforms;
|
||||
* developers using AI agents to manage and automate project work;
|
||||
* individuals managing technical or personal projects.
|
||||
|
||||
## Why TaskView
|
||||
|
||||
## Status
|
||||
* **Own your data** - run TaskView on your own infrastructure.
|
||||
* **Flexible workflows** - configure projects, statuses, boards, roles, and permissions.
|
||||
* **Built for development teams** - connect GitHub, GitLab, API clients, and webhooks.
|
||||
* **AI-ready** - allow MCP-compatible assistants to work with tasks and projects.
|
||||
* **Available everywhere** - use TaskView from the web, iOS, and Android.
|
||||
* **No advertising or behavioral tracking** - your project data remains under your control.
|
||||
|
||||
TaskView is under active development.
|
||||
Breaking changes may occur between versions.
|
||||
## Features
|
||||
|
||||
## Getting started
|
||||
### Project and task management
|
||||
|
||||
This setup is intended for local development.
|
||||
* Projects, lists, tasks, and nested subtasks
|
||||
* Custom Kanban boards and task statuses
|
||||
* Priorities, tags, deadlines, and assignees
|
||||
* Task dependency graph
|
||||
* Sprints and recurring tasks
|
||||
* Task history and activity tracking
|
||||
* Markdown content
|
||||
* Search and filtering
|
||||
* Reusable project workflows
|
||||
|
||||
### Development
|
||||
### Team collaboration
|
||||
|
||||
**Prerequisites:**
|
||||
- Docker
|
||||
- PostgreSQL
|
||||
- On macOS you can use https://postgresapp.com/
|
||||
- Bun (used for API development)
|
||||
- pnpm
|
||||
* Multiple organizations and projects
|
||||
* User invites and granular permissions
|
||||
* Clear responsibility assignment
|
||||
* Real-time updates
|
||||
* Notifications
|
||||
* Organization-level user management
|
||||
|
||||
### Identity and access management
|
||||
|
||||
* SAML 2.0
|
||||
* OpenID Connect
|
||||
* SCIM provisioning
|
||||
* Scoped API tokens
|
||||
* Project-level access restrictions
|
||||
* Fine-grained token permissions
|
||||
|
||||
### Developer integrations
|
||||
|
||||
* GitHub integration
|
||||
* GitLab integration
|
||||
* Signed webhooks
|
||||
* TypeScript API client
|
||||
* Public HTTP API
|
||||
* MCP server for AI assistants
|
||||
|
||||
### Time tracking and analytics
|
||||
|
||||
* Built-in time tracking
|
||||
* Billable and non-billable time
|
||||
* Project and team workload reports
|
||||
* Productivity analytics
|
||||
* Income and expense tracking
|
||||
* Financial reports
|
||||
|
||||
### Applications
|
||||
|
||||
* Web application
|
||||
* iOS application
|
||||
* Android application
|
||||
* Self-hosted server deployment
|
||||
|
||||
## AI and MCP integration
|
||||
|
||||
TaskView includes an MCP server that allows compatible AI tools to interact with your projects.
|
||||
|
||||
Depending on the permissions assigned to an API token, an AI assistant can:
|
||||
|
||||
* search and inspect projects;
|
||||
* create and update tasks;
|
||||
* manage task statuses;
|
||||
* work with project lists;
|
||||
* retrieve task context;
|
||||
* automate repetitive project operations.
|
||||
|
||||
MCP access can be restricted by permission and by selected projects.
|
||||
|
||||
### Connecting an MCP client
|
||||
|
||||
The MCP server is published as [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) and runs over stdio via `npx` — no install required. You only need a TaskView API token (`tvk_...`) — generate one in your account settings (see [API tokens](https://taskview.tech/docs/features/api-tokens)). Scope the token to the minimum permissions and projects the assistant should reach.
|
||||
|
||||
**Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskview": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "taskview-mcp"],
|
||||
"env": {
|
||||
"TASKVIEW_URL": "https://api.taskview.tech",
|
||||
"TASKVIEW_TOKEN": "tvk_your_token_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Desktop** — add the same `mcpServers` block to `claude_desktop_config.json`.
|
||||
|
||||
**Other MCP clients** (Cursor, Windsurf, etc.) — use the same stdio command `npx -y taskview-mcp` with the `TASKVIEW_URL` and `TASKVIEW_TOKEN` environment variables in that client's MCP configuration.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`, or your self-hosted instance) |
|
||||
| `TASKVIEW_TOKEN` | yes | API token with the `tvk_` prefix |
|
||||
|
||||
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) and the [`taskview-mcp` package README](taskview-packages/taskview-mcp/README.md) for the full tool list and more options.
|
||||
|
||||
## Quick start
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Gimanh/taskview-community.git
|
||||
cd taskview-community
|
||||
```
|
||||
|
||||
The recommended way to run TaskView is with Docker Compose.
|
||||
|
||||
For deployment instructions, environment variables, HTTPS configuration, updates, and backups, see the official documentation:
|
||||
|
||||
[TaskView self-hosting documentation](https://taskview.tech/docs/)
|
||||
|
||||
> Production deployments should use persistent volumes, HTTPS, regular database backups, and securely generated secrets.
|
||||
|
||||
## Repository structure
|
||||
|
||||
TaskView is maintained as a monorepo. Product packages share a single version.
|
||||
|
||||
```text
|
||||
taskview-community/
|
||||
├── api/
|
||||
│ └── TaskView backend and HTTP API
|
||||
├── web/
|
||||
│ └── Vue web application and Capacitor mobile application
|
||||
├── taskview-packages/
|
||||
│ ├── taskview-api/
|
||||
│ │ └── TypeScript API client
|
||||
│ └── taskview-db-schemas/
|
||||
│ │ └── Shared Drizzle ORM schemas
|
||||
│ └── taskview-mcp/
|
||||
│ └── MCP server for AI integrations
|
||||
└── build-dockers.sh
|
||||
└── Docker image build script
|
||||
```
|
||||
|
||||
### Main packages
|
||||
|
||||
| Package | Description |
|
||||
| --------------------------------------- | ----------------------------------------------- |
|
||||
| `api` | Node.js backend and API server |
|
||||
| `web` | Vue web client and Capacitor mobile application |
|
||||
| `taskview-packages/taskview-api` | TypeScript client for the TaskView API |
|
||||
| `taskview-packages/taskview-db-schemas` | Shared Drizzle ORM database schemas |
|
||||
| `taskview-packages/taskview-mcp` | MCP server for AI integrations |
|
||||
|
||||
## Technology stack
|
||||
|
||||
TaskView is primarily built with:
|
||||
|
||||
* Vue
|
||||
* Nuxt UI
|
||||
* TypeScript
|
||||
* Node.js
|
||||
* Express
|
||||
* PostgreSQL
|
||||
* Drizzle ORM
|
||||
* Capacitor
|
||||
* Docker
|
||||
|
||||
## Local development
|
||||
|
||||
These instructions are intended for contributors and local development, not for production deployment.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You need the following tools:
|
||||
|
||||
* Docker
|
||||
* PostgreSQL
|
||||
* Bun
|
||||
* pnpm
|
||||
|
||||
On macOS, PostgreSQL can be installed using [Postgres.app](https://postgresapp.com/).
|
||||
|
||||
### Install dependencies
|
||||
|
||||
From the repository root:
|
||||
|
||||
**Steps:**
|
||||
1. Install dependencies:
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Create an `.env` file in the **api** directory based on `.env.example` and configure your local credentials.
|
||||
|
||||
3. Run the web application:
|
||||
### Configure the API
|
||||
|
||||
Create an environment file in the `api` directory:
|
||||
|
||||
```sh
|
||||
cp api/.env.example api/.env
|
||||
```
|
||||
|
||||
Update `api/.env` with your local database credentials and application configuration.
|
||||
|
||||
### Start the web application
|
||||
|
||||
```sh
|
||||
cd web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
1. Run the API locally (API uses Bun in development mode):
|
||||
### Start the API
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```sh
|
||||
cd api
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Building Docker Images
|
||||
The API uses Bun in the local development environment.
|
||||
|
||||
You can build Docker images using the provided bash script. The image version is automatically taken from the root `package.json`.
|
||||
## Building Docker images
|
||||
|
||||
Docker image versions are taken from the root `package.json`.
|
||||
|
||||
### Build the images
|
||||
|
||||
From the repository root:
|
||||
|
||||
**Steps:**
|
||||
1. Build images:
|
||||
```sh
|
||||
./build-dockers.sh
|
||||
```
|
||||
|
||||
2. Verify built images:
|
||||
### Verify the images
|
||||
|
||||
```sh
|
||||
docker images
|
||||
```
|
||||
You should see the following images:
|
||||
|
||||
`gimanhead/taskview-ce-api-server`
|
||||
`gimanhead/taskview-ce-webapp`
|
||||
`gimanhead/taskview-ce-db-migration`
|
||||
The following images should be available:
|
||||
|
||||
3. Test images locally using Docker Compose:
|
||||
```sh
|
||||
cd api/dev-containers-test
|
||||
docker-compose up
|
||||
```text
|
||||
gimanhead/taskview-ce-api-server
|
||||
gimanhead/taskview-ce-webapp
|
||||
gimanhead/taskview-ce-db-migration
|
||||
```
|
||||
|
||||
Make sure the image versions match the version defined in the root package.json.
|
||||
### Test the images locally
|
||||
|
||||
```sh
|
||||
cd api/dev-containers-test
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Ensure that image versions match the version defined in the root `package.json`.
|
||||
|
||||
## Versioning
|
||||
|
||||
TaskView follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
* `MAJOR` - breaking API, database, or compatibility changes;
|
||||
* `MINOR` - backward-compatible features;
|
||||
* `PATCH` - backward-compatible bug fixes.
|
||||
|
||||
The version represents the entire TaskView product, not individual packages.
|
||||
|
||||
## Project status
|
||||
|
||||
TaskView is under active development.
|
||||
|
||||
New features, database migrations, API changes, and breaking changes may be introduced between releases. Review release notes and back up your database before updating a self-hosted installation.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Plugin / extension system
|
||||
- [X] Migrate to NuxtUI or similar ui library
|
||||
- Enterprise SSO and identity integrations
|
||||
- [X] Redesign
|
||||
- Desktop version
|
||||
Planned areas of development include:
|
||||
|
||||
* [ ] Desktop application
|
||||
* [ ] Plugin and extension system
|
||||
* [ ] Additional third-party integrations
|
||||
* [ ] Expanded automation capabilities
|
||||
* [ ] Continued UI and UX improvements
|
||||
|
||||
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
|
||||
The roadmap may change based on product priorities and community feedback.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome.
|
||||
|
||||
Before contributing:
|
||||
|
||||
1. Read [`CONTRIBUTING.md`](./CONTRIBUTING.md).
|
||||
2. Check existing issues and pull requests.
|
||||
3. Open an issue before starting a large architectural change.
|
||||
4. Follow the project's code style and architecture.
|
||||
5. Sign the Contributor License Agreement when required.
|
||||
|
||||
Contributions are accepted under the project's CLA.
|
||||
|
||||
## Licensing
|
||||
|
||||
TaskView is distributed under the TaskView Source-Available License.
|
||||
|
||||
You may:
|
||||
|
||||
* self-host TaskView;
|
||||
* modify the source code;
|
||||
* use TaskView internally within your organization;
|
||||
* create integrations and internal extensions.
|
||||
|
||||
You may not:
|
||||
|
||||
* offer TaskView as a hosted SaaS;
|
||||
* sell access to TaskView as a service;
|
||||
* create a competing commercial product based on TaskView;
|
||||
* use the TaskView name, logo, or branding for derivative products.
|
||||
|
||||
See [`LICENSE`](./LICENSE) for the complete terms.
|
||||
|
||||
TaskView is **source-available**, not OSI-approved open-source software.
|
||||
|
||||
For commercial licensing questions, hosted service permissions, or other use cases not covered by the license, contact the project maintainer.
|
||||
|
||||
## Security
|
||||
|
||||
Do not publish security vulnerabilities in public GitHub issues.
|
||||
|
||||
Report security issues privately — see [SECURITY.md](SECURITY.md) for the reporting channels, response times, scope, and safe-harbor terms.
|
||||
|
||||
When running TaskView in production:
|
||||
|
||||
* use HTTPS;
|
||||
* generate unique application secrets;
|
||||
* restrict database access;
|
||||
* keep Docker images and dependencies updated;
|
||||
* configure persistent storage;
|
||||
* create regular backups;
|
||||
* restrict API-token permissions to the minimum required scope.
|
||||
|
||||
## Links
|
||||
|
||||
* [TaskView website](https://taskview.tech/)
|
||||
* [TaskView Cloud](https://app.taskview.tech/)
|
||||
* [Documentation](https://taskview.tech/docs/)
|
||||
* [iOS application](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
|
||||
* [Android application](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app)
|
||||
* [GitHub repository](https://github.com/Gimanh/taskview-community)
|
||||
|
||||
---
|
||||
|
||||
TaskView is developed and maintained by Nikolai Giman.
|
||||
TaskView is developed and maintained by **Nikolai Giman**.
|
||||
|
||||
Copyright © 2026 Nikolai Giman
|
||||
TaskView™ and the TaskView logo are trademarks of their respective owner.
|
||||
|
||||
Copyright © 2026 Nikolai Giman.
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
|
||||
|
||||
Report them privately using one of these channels:
|
||||
|
||||
- **GitHub private vulnerability reporting** (preferred): open the **Security** tab of this repository and click **Report a vulnerability**.
|
||||
- **Email**: [support@taskview.tech](mailto:support@taskview.tech) with `[security]` in the subject.
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
- A description of the issue and its impact
|
||||
- Affected component (API, web app, MCP server, mobile app) and version
|
||||
- Steps to reproduce, or a proof of concept
|
||||
- Any suggested mitigation
|
||||
|
||||
## What to expect
|
||||
|
||||
- We will acknowledge your report within **5 business days**.
|
||||
- We will keep you informed about progress and aim to release a fix for confirmed issues within **90 days** of the report, sooner for critical issues.
|
||||
- Once a fix is released, we publish a GitHub Security Advisory for the affected versions and credit the reporter, unless they prefer to stay anonymous.
|
||||
- We ask that you give us a reasonable time to fix the issue before disclosing it publicly.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes are released for the latest minor version line only. Self-hosted installations should upgrade to the latest release to receive them.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- The TaskView API server, web app, MCP server, and mobile app in this repository
|
||||
- The hosted service at `app.taskview.tech`
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Vulnerabilities in third-party dependencies that are not exploitable in TaskView (report them upstream)
|
||||
- Findings that require a compromised admin account or physical access to the server
|
||||
- Missing security headers, rate limiting, or best-practice recommendations without a demonstrated impact
|
||||
- Denial-of-service testing against the hosted service
|
||||
|
||||
## Safe harbor
|
||||
|
||||
We will not pursue legal action against researchers who act in good faith: test only against their own self-hosted instance or their own accounts on the hosted service, avoid accessing or modifying other users' data, and report findings privately as described above.
|
||||
@@ -19,6 +19,18 @@ ACCESS_LIFE_TIME=1d
|
||||
REFRESH_LIFE_TIME=2d
|
||||
JWT_ALG=HS256
|
||||
|
||||
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
|
||||
#SSO_TRUSTED_DOMAINS=company.com,corp.local
|
||||
|
||||
# OAuth 2.1 for third-party MCP clients (ChatGPT, Claude connectors).
|
||||
# Dynamic Client Registration is on by default; a cloud client cannot connect
|
||||
# without it, since it has no way to pre-register with your instance. Turn it
|
||||
# off on a private install that only uses manually seeded clients.
|
||||
#OAUTH_DYNAMIC_REGISTRATION=false
|
||||
# Public URL of this API. Used as the OAuth issuer in the discovery documents,
|
||||
# so it must be the URL clients actually reach — set it behind a proxy.
|
||||
#API_PUBLIC_URL=https://api.taskview.tech
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.domain.com
|
||||
SMTP_PORT=465
|
||||
@@ -27,6 +39,10 @@ SMTP_PASSWORD=your_smtp_password_here
|
||||
SMTP_ENCRYPTION=ssl
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
# Email a person when they are invited to a project (requires SMTP)
|
||||
INVITE_EMAIL_ENABLED=false
|
||||
# Max invite emails one user may trigger per hour (default 30)
|
||||
# INVITE_EMAIL_HOURLY_LIMIT=30
|
||||
|
||||
# Encryption (32-byte hex key for AES-256-GCM)
|
||||
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
@@ -48,10 +64,25 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Gitea Integration OAuth
|
||||
GITEA_INTEGRATION_CLIENT_ID=
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
# For self-hosted Gitea, override these:
|
||||
# GITEA_BASE_URL=https://gitea.yourcompany.com
|
||||
# GITEA_API_URL=https://gitea.yourcompany.com/api/v1
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
# Analytics (optional)
|
||||
# Comma-separated list of section IDs to enable in the analytics page.
|
||||
# When unset, all sections are available. When set, only the listed IDs are
|
||||
# available — in the order they appear here.
|
||||
# Example:
|
||||
# ANALYTICS_SECTIONS=kpi.total_income,kpi.total_expense,chart.income_expense_month,chart.income_expense_per_project
|
||||
|
||||
# Centrifugo (real-time notifications, optional)
|
||||
# CENTRIFUGO_API_URL=http://localhost:8000
|
||||
# CENTRIFUGO_API_KEY=your_centrifugo_api_key_here
|
||||
|
||||
+30
-1
@@ -1,9 +1,38 @@
|
||||
// https://github.com/Gimanh/taskview-community/issues/88
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
instances,
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
autorestart: true,
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.42.5",
|
||||
"version": "1.53.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^22.10.3",
|
||||
"@types/passport-apple": "^2.0.3",
|
||||
"@types/pg": "^8.15.5",
|
||||
@@ -61,6 +62,7 @@
|
||||
"firebase-admin": "^12.7.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"luxon": "^3.7.2",
|
||||
"openid-client": "^6.8.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
@@ -70,7 +72,9 @@
|
||||
"pg-boss": "^12.14.0",
|
||||
"pino": "^9.4.0",
|
||||
"rotating-file-stream": "^3.2.5",
|
||||
"rrule": "^2.8.1",
|
||||
"semver": "^7.6.3",
|
||||
"taskview-api": "workspace:^",
|
||||
"taskview-db-schemas": "workspace:^",
|
||||
"terser": "^5.36.0",
|
||||
"ua-parser-js": "^2.0.9",
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
instances,
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
max_memory_restart: '1G',
|
||||
env_production: {
|
||||
|
||||
+4
-2
@@ -7,8 +7,10 @@ config({ override: true });
|
||||
|
||||
const valid = AppEnvSchema.safeParse(process.env);
|
||||
if (!valid.success) {
|
||||
console.log(valid);
|
||||
console.log(valid.error);
|
||||
const details = valid.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('\n');
|
||||
console.error(`Invalid environment variables:\n${details}`);
|
||||
throw new Error('Invalid environment variables');
|
||||
}
|
||||
|
||||
|
||||
+31
-26
@@ -1,30 +1,25 @@
|
||||
import cors from 'cors';
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { appUserMiddleware } from './middlewares/app-user-middleware';
|
||||
import { corsMiddleware } from './middlewares/cors';
|
||||
import errorHandler from './middlewares/error-handler';
|
||||
import routes from './routes';
|
||||
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
|
||||
import { LoginMethods } from './tv-modules/auth/LoginMethods';
|
||||
import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher';
|
||||
import { PublicApiUrl } from './modules/public-url';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
|
||||
|
||||
const allow = new Set([
|
||||
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
|
||||
// default allowed origins for official TaskView apps
|
||||
"https://app.taskview.tech",
|
||||
"https://taskview.handscream.com",
|
||||
"capacitor://taskview.handscream.com",
|
||||
"capacitor://app.taskview.tech",
|
||||
"https://appleid.apple.com"
|
||||
]),
|
||||
...(process.env.CORS_ALLOWED_ORIGINS?.split(',') || []),
|
||||
]);
|
||||
|
||||
export default class App {
|
||||
public app: express.Application;
|
||||
public port: number;
|
||||
|
||||
constructor(port: number) {
|
||||
LoginMethods.validateOnStartup();
|
||||
PublicApiUrl.validateOnStartup();
|
||||
InviteEmailDispatcher.validateOnStartup();
|
||||
|
||||
this.app = express();
|
||||
this.port = port;
|
||||
|
||||
@@ -43,7 +38,17 @@ export default class App {
|
||||
protected extendApp(): void { }
|
||||
protected extendMiddlewares(): void { }
|
||||
|
||||
private resolveTrustProxy(): boolean | number | string {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
if (!raw || raw.toLowerCase() === 'false') return false;
|
||||
if (raw.toLowerCase() === 'true') return true;
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private initializeMiddlewares() {
|
||||
this.app.set('trust proxy', this.resolveTrustProxy());
|
||||
|
||||
//add tvJson method, clien need response format like {response: data}
|
||||
this.app.use((_req: Request, res: Response, next) => {
|
||||
res.tvJson = function (data: any) {
|
||||
@@ -52,19 +57,9 @@ export default class App {
|
||||
next();
|
||||
});
|
||||
|
||||
this.app.use(cookieParser());
|
||||
this.app.use(appUserMiddleware);
|
||||
|
||||
this.app.use(cors({
|
||||
credentials: true,
|
||||
origin(origin, cb) {
|
||||
if (!origin || origin === 'null') return cb(null, true);
|
||||
if (allow.has(origin)) return cb(null, true);
|
||||
return cb(new Error(`CORS blocked origin: ${origin}`), false);
|
||||
},
|
||||
}));
|
||||
|
||||
this.app.use(helmet());
|
||||
this.app.use(corsMiddleware);
|
||||
this.app.use(cookieParser());
|
||||
this.app.use(express.json({
|
||||
verify: (req: any, _res, buf) => {
|
||||
// Store raw body for webhook signature verification github and gitlab integrations
|
||||
@@ -73,7 +68,17 @@ export default class App {
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
this.app.use(express.urlencoded({
|
||||
extended: true,
|
||||
verify: (req: any, _res, buf) => {
|
||||
// Slack sends slash commands / interactivity as urlencoded; keep the raw body
|
||||
// for HMAC signature verification (VerifySlackRequest).
|
||||
if (req.url?.includes('/messaging/slack/')) {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
},
|
||||
}));
|
||||
this.app.use(appUserMiddleware);
|
||||
}
|
||||
|
||||
private initializeRoutes() {
|
||||
|
||||
@@ -11,7 +11,12 @@ import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsMana
|
||||
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
|
||||
import { OrganizationManager } from '../tv-modules/organizations/OrganizationManager';
|
||||
import { SsoManager } from '../tv-modules/sso/SsoManager';
|
||||
import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
|
||||
import { TasksManager } from '../tv-modules/tasks/TasksManager';
|
||||
import { TimeTrackingManager } from '../tv-modules/time-tracking/TimeTrackingManager';
|
||||
import { UiPreferencesManager } from '../tv-modules/ui-preferences/UiPreferencesManager';
|
||||
import { SprintsManager } from '../tv-modules/sprints/SprintsManager';
|
||||
import { RecurrenceManager } from '../tv-modules/recurrence/RecurrenceManager';
|
||||
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
|
||||
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
|
||||
|
||||
@@ -37,6 +42,11 @@ export class AppUser {
|
||||
public readonly notificationsManager: NotificationsManager;
|
||||
public readonly organizationManager: OrganizationManager;
|
||||
public readonly ssoManager: SsoManager;
|
||||
public readonly analyticsManager: AnalyticsManager;
|
||||
public readonly timeTrackingManager: TimeTrackingManager;
|
||||
public readonly uiPreferencesManager: UiPreferencesManager;
|
||||
public readonly sprintsManager: SprintsManager;
|
||||
public readonly recurrenceManager: RecurrenceManager;
|
||||
|
||||
constructor(userData?: UserJwtPayload) {
|
||||
this.userData = userData;
|
||||
@@ -55,6 +65,11 @@ export class AppUser {
|
||||
this.notificationsManager = new NotificationsManager(this);
|
||||
this.organizationManager = new OrganizationManager(this);
|
||||
this.ssoManager = new SsoManager(this);
|
||||
this.analyticsManager = new AnalyticsManager(this);
|
||||
this.timeTrackingManager = new TimeTrackingManager(this);
|
||||
this.uiPreferencesManager = new UiPreferencesManager(this);
|
||||
this.sprintsManager = new SprintsManager(this);
|
||||
this.recurrenceManager = new RecurrenceManager(this);
|
||||
}
|
||||
|
||||
getTokenId(): number | undefined {
|
||||
|
||||
@@ -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,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
|
||||
import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export interface AppEvents {
|
||||
@@ -7,9 +9,36 @@ export interface AppEvents {
|
||||
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
|
||||
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale };
|
||||
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
|
||||
'time-entry.stopped': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number; durationSeconds: number };
|
||||
'time-entry.created': { entry: TimeEntryWithUser; initiatorId: number };
|
||||
'time-entry.updated': { entry: TimeEntryWithUser; changes: Record<string, unknown>; initiatorId: number };
|
||||
'time-entry.deleted': { entryId: number; taskId: number; goalId: number; userId: number; initiatorId: number };
|
||||
'sprint.created': { sprint: SprintsSchemaTypeForSelect; initiatorId: number };
|
||||
'sprint.updated': { sprint: SprintsSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'sprint.activated': { sprintId: number; goalId: number; initiatorId: number | null };
|
||||
'sprint.reviewStarted': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.completed': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.paused': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.resumed': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.deleted': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'task.assignedToSprint': {
|
||||
taskId: number;
|
||||
sprintId: number | null;
|
||||
prevSprintId: number | null;
|
||||
goalId: number;
|
||||
initiatorId: number;
|
||||
};
|
||||
'recurrence.created': { rule: RecurrenceRulesSchemaTypeForSelect; initiatorId: number };
|
||||
'recurrence.updated': { rule: RecurrenceRulesSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'recurrence.paused': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.resumed': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.ended': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.deleted': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.instanceSkipped': { ruleId: number; goalId: number; date: string; initiatorId: number };
|
||||
}
|
||||
|
||||
type EventName = keyof AppEvents;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -67,4 +67,32 @@ export class GoalPermissionsFetcher {
|
||||
async getCheckerForGoal(goalId: number): Promise<GoalPermissionsChecker> {
|
||||
return await this.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL);
|
||||
}
|
||||
|
||||
async getAccessibleGoalIds(organizationId: number, permissions: string[]): Promise<number[]> {
|
||||
if (permissions.length === 0) return [];
|
||||
|
||||
const tokenPerms = this.user.getTokenPermissions();
|
||||
const effectivePermissions = tokenPerms && tokenPerms.length > 0
|
||||
? permissions.filter((p) => tokenPerms.includes(p))
|
||||
: permissions;
|
||||
|
||||
if (effectivePermissions.length === 0) return [];
|
||||
|
||||
const userData = this.user.getUserData();
|
||||
if (!userData) return [];
|
||||
|
||||
let goalIds = await this.goalPermissionsRepository.fetchGoalIdsWithAnyPermission({
|
||||
userId: userData.id,
|
||||
email: userData.email,
|
||||
organizationId,
|
||||
permissionNames: effectivePermissions,
|
||||
});
|
||||
|
||||
const allowedGoalIds = this.user.getAllowedGoalIds();
|
||||
if (allowedGoalIds && allowedGoalIds.length > 0) {
|
||||
goalIds = goalIds.filter((id) => allowedGoalIds.includes(id));
|
||||
}
|
||||
|
||||
return goalIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { and, eq, exists, inArray, or } from 'drizzle-orm';
|
||||
import {
|
||||
CollaborationPermissionsToRoleSchema,
|
||||
CollaborationRolesSchema,
|
||||
CollaborationUsersSchema,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
CollaborationUsersToRolesSchema,
|
||||
GoalsSchema,
|
||||
PermissionsSchema,
|
||||
} from 'taskview-db-schemas';
|
||||
import { Database } from '../modules/db';
|
||||
import type { GoalPermissionItemsFromDb } from '../types/auth.types';
|
||||
import type { FetchGoalIdsWithAnyPermissionParams, FetchPermissionsForGoalByUserParams, GoalPermissionItemsFromDb } from '../types/auth.types';
|
||||
import type { GoalItemInDb } from '../types/goal.type';
|
||||
import type { ListItemInDb } from '../types/lists.types';
|
||||
import type { TaskItemInDb } from '../types/tasks.types';
|
||||
@@ -23,7 +33,18 @@ export class GoalPermissionsRepository {
|
||||
}
|
||||
|
||||
async fetchPermissionsForGoal(goalId: number, user: AppUser): Promise<GoalPermissionItemsFromDb> {
|
||||
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [goalId]);
|
||||
const userData = user.getUserData();
|
||||
if (!userData) return [];
|
||||
return this.fetchPermissionsForGoalByUser({ goalId, userId: userData.id, email: userData.email });
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission set of an arbitrary user for a goal, without an AppUser/request
|
||||
* context — used by background workers (e.g. deadline notifications) that
|
||||
* must gate content per recipient.
|
||||
*/
|
||||
async fetchPermissionsForGoalByUser(params: FetchPermissionsForGoalByUserParams): Promise<GoalPermissionItemsFromDb> {
|
||||
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [params.goalId]);
|
||||
|
||||
if (goalInfo.rows.length === 0) {
|
||||
return [];
|
||||
@@ -32,19 +53,19 @@ export class GoalPermissionsRepository {
|
||||
let query = '';
|
||||
let args: any = [];
|
||||
|
||||
if (goalInfo.rows[0].owner === user.getUserData()?.id) {
|
||||
if (goalInfo.rows[0].owner === params.userId) {
|
||||
query = `select name as "permissionName", id as "permissionId" from tv_auth.permissions;`;
|
||||
} else {
|
||||
query = `select p.name as "permissionName", p.id as "permissionId"
|
||||
query = `select p.name as "permissionName", p.id as "permissionId"
|
||||
from collaboration.users cu
|
||||
left join collaboration.users_to_goals utg on cu.id = utg.user_id
|
||||
left join tasks.goals tg on utg.goal_id = tg.id
|
||||
left join collaboration.users_to_roles utr on utr.user_id = cu.id
|
||||
left join collaboration.roles rol on utr.role_id = rol.id
|
||||
left join collaboration.roles rol on utr.role_id = rol.id and rol.goal_id = tg.id
|
||||
left join collaboration.permissions_to_role ptr on rol.id = ptr.role_id
|
||||
left join tv_auth.permissions p on ptr.permission_id = p.id
|
||||
where email = $1 and tg.id = $2 and p.name is not null and p.id is not null;`;
|
||||
args = [user.getUserData()?.email, goalId];
|
||||
args = [params.email, params.goalId];
|
||||
}
|
||||
|
||||
const result = await this.db.query<GoalPermissionItemsFromDb[number]>(query, args);
|
||||
@@ -59,4 +80,57 @@ export class GoalPermissionsRepository {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async fetchGoalIdsWithAnyPermission(params: FetchGoalIdsWithAnyPermissionParams): Promise<number[]> {
|
||||
if (params.permissionNames.length === 0) return [];
|
||||
|
||||
const permissionSubquery = this.db.dbDrizzle
|
||||
.select({ one: CollaborationUsersSchema.id })
|
||||
.from(CollaborationUsersSchema)
|
||||
.innerJoin(
|
||||
CollaborationUsersToGoalsSchema,
|
||||
and(
|
||||
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id),
|
||||
eq(CollaborationUsersToGoalsSchema.goalId, GoalsSchema.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
CollaborationUsersToRolesSchema,
|
||||
eq(CollaborationUsersToRolesSchema.userId, CollaborationUsersSchema.id),
|
||||
)
|
||||
.innerJoin(
|
||||
CollaborationRolesSchema,
|
||||
and(
|
||||
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
|
||||
eq(CollaborationRolesSchema.goalId, GoalsSchema.id),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
CollaborationPermissionsToRoleSchema,
|
||||
eq(CollaborationPermissionsToRoleSchema.roleId, CollaborationRolesSchema.id),
|
||||
)
|
||||
.innerJoin(
|
||||
PermissionsSchema,
|
||||
eq(PermissionsSchema.id, CollaborationPermissionsToRoleSchema.permissionId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationUsersSchema.email, params.email),
|
||||
inArray(PermissionsSchema.name, params.permissionNames),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.db.dbDrizzle
|
||||
.select({ id: GoalsSchema.id })
|
||||
.from(GoalsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(GoalsSchema.organizationId, params.organizationId),
|
||||
eq(GoalsSchema.archive, 0),
|
||||
or(eq(GoalsSchema.owner, params.userId), exists(permissionSubquery)),
|
||||
),
|
||||
);
|
||||
|
||||
return result.map((r) => r.id).filter((id): id is number => Number.isInteger(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,21 @@ import type { Dispatcher } from './Dispatcher';
|
||||
import { NotificationDispatcher } from '../tv-modules/notifications/NotificationDispatcher';
|
||||
import { RealtimeDispatcher } from '../tv-modules/realtime/RealtimeDispatcher';
|
||||
import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
|
||||
import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTrackingDispatcher';
|
||||
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
|
||||
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
|
||||
import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher';
|
||||
import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
new RealtimeDispatcher(),
|
||||
new WebhooksDispatcher(),
|
||||
new TimeTrackingDispatcher(),
|
||||
new SprintsDispatcher(),
|
||||
new RecurrenceDispatcher(),
|
||||
new MessagingDispatcher(),
|
||||
new InviteEmailDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { RequireTokenPermission } from '../require-token-permission';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
|
||||
const runWith = (tokenPermissions: string[] | undefined) => {
|
||||
const next = vi.fn();
|
||||
const end = vi.fn();
|
||||
const res = { status: vi.fn(() => ({ end })), end } as unknown as Response;
|
||||
const req = { appUser: { getTokenPermissions: () => tokenPermissions } } as unknown as Request;
|
||||
|
||||
RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)(req, res, next);
|
||||
return { next, res };
|
||||
};
|
||||
|
||||
describe('RequireTokenPermission', () => {
|
||||
it('lets a browser session through — it carries no token permissions', () => {
|
||||
const { next, res } = runWith(undefined);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an unrestricted token through, keeping existing integrations working', () => {
|
||||
const { next } = runWith([]);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a token holding the permission through', () => {
|
||||
const { next } = runWith([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks a restricted token that was not given the permission', () => {
|
||||
const { next, res } = runWith([GoalPermissions.TIMETRACKING_CAN_VIEW]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('does not accept a neighbouring permission from the same group', () => {
|
||||
const { next, res } = runWith([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,17 @@ import { $logger } from '../modules/logget';
|
||||
import AuthController from '../tv-modules/auth/AuthController';
|
||||
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
|
||||
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
|
||||
import { OAUTH_ACCESS_TOKEN_PREFIX } from '../tv-modules/oauth/types';
|
||||
|
||||
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
req.appUser = new AppUser();
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
|
||||
if (token && token.startsWith(TOKEN_PREFIX)) {
|
||||
if (token && (token.startsWith(TOKEN_PREFIX) || token.startsWith(OAUTH_ACCESS_TOKEN_PREFIX))) {
|
||||
const record = await getApiTokensManager().validateToken(token);
|
||||
if (record) {
|
||||
const authManager = new AppUser().authManager;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import cors from 'cors';
|
||||
|
||||
const allow = new Set([
|
||||
...(process.env.CORS_REMOVE_DEFAULT_ALLOWED_ORIGINS === 'true' ? [] : [
|
||||
'https://app.taskview.tech',
|
||||
'https://taskview.handscream.com',
|
||||
'capacitor://taskview.handscream.com',
|
||||
'capacitor://app.taskview.tech',
|
||||
'https://appleid.apple.com',
|
||||
]),
|
||||
...(process.env.CORS_ALLOWED_ORIGINS?.split(',') || []),
|
||||
]);
|
||||
|
||||
export const corsMiddleware = cors({
|
||||
credentials: true,
|
||||
maxAge: 600,
|
||||
origin(origin, cb) {
|
||||
if (!origin || origin === 'null') return cb(null, true);
|
||||
if (allow.has(origin)) return cb(null, true);
|
||||
return cb(new Error(`CORS blocked origin: ${origin}`), false);
|
||||
},
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { corsMiddleware } from './cors';
|
||||
|
||||
interface CustomError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
const errorHandler = (err: CustomError, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const sendError = (err: CustomError, res: Response) => {
|
||||
const statusCode = err.status || 500;
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const userMessage = isProduction ? 'Internal Server Error' : err.message || 'Something went wrong';
|
||||
res.status(statusCode).json({ message: userMessage });
|
||||
};
|
||||
|
||||
res.status(statusCode).json({
|
||||
message: userMessage,
|
||||
});
|
||||
const errorHandler = (err: CustomError, req: Request, res: Response, _next: NextFunction) => {
|
||||
// Re-run the same cors middleware so error responses get CORS headers even when
|
||||
// the original pass was bypassed (error thrown before it ran). Reusing the actual
|
||||
// middleware preserves the allowlist policy — disallowed origins still get no headers.
|
||||
if (res.getHeader('Access-Control-Allow-Origin')) {
|
||||
sendError(err, res);
|
||||
return;
|
||||
}
|
||||
corsMiddleware(req, res, () => sendError(err, res));
|
||||
};
|
||||
|
||||
export default errorHandler;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { GoalPermissionType } from '../types/auth.types';
|
||||
|
||||
/**
|
||||
* Narrows what a restricted API / OAuth token may do on surfaces that are guarded
|
||||
* by an organization role or by project ownership rather than by the project RBAC
|
||||
* — organizations, SSO configuration, webhooks. Those checks never consult
|
||||
* GoalPermissionsFetcher, so without this the scope chosen when the token was
|
||||
* issued would simply not apply to them.
|
||||
*
|
||||
* It only ever removes access. Put it AFTER the role or ownership guard, so that
|
||||
* guard still has the final say on what the human behind the token may do:
|
||||
*
|
||||
* [IsLoggedIn, IsOrgAdmin, RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)]
|
||||
*
|
||||
* A browser session has no token permissions and passes. A token issued with an
|
||||
* empty permission list is unrestricted by design — the same meaning it carries
|
||||
* everywhere else — and also passes, which keeps existing integrations working.
|
||||
*/
|
||||
export const RequireTokenPermission = (permission: GoalPermissionType) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const tokenPermissions = req.appUser.getTokenPermissions();
|
||||
|
||||
if (!tokenPermissions || tokenPermissions.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (tokenPermissions.includes(permission)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
};
|
||||
@@ -472,5 +472,313 @@
|
||||
"description": [
|
||||
"Create personal organizations for users without any organization membership"
|
||||
]
|
||||
},
|
||||
"38": {
|
||||
"version": "1.45.0",
|
||||
"name": "Release 1.45.0",
|
||||
"releaseDate": "20260425",
|
||||
"scripts": [
|
||||
"/1.45.0/0.analytics_indexes.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add composite indexes on tasks.tasks for analytics queries"
|
||||
]
|
||||
},
|
||||
"39": {
|
||||
"version": "1.46.0",
|
||||
"name": "Release 1.46.0",
|
||||
"releaseDate": "20260426",
|
||||
"scripts": [
|
||||
"/1.46.0/0.add-analytics-permission.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add analytics_can_view permission for project-level analytics access"
|
||||
]
|
||||
},
|
||||
"40": {
|
||||
"version": "1.47.0",
|
||||
"name": "Release 1.47.0",
|
||||
"releaseDate": "20260509",
|
||||
"scripts": [
|
||||
"/1.47.0/0.analytics_indexes_v2.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add indexes on tasks_auth.task_assignee, collaboration.users_to_goals, tasks.task_relations(to_task_id) for analytics joins",
|
||||
"Add partial index on tasks.goals(organization_id) WHERE archive = 0 for analytics goal lookup",
|
||||
"Add partial index on tasks.tasks(goal_id) WHERE complete IS NOT TRUE for open-task analytics queries"
|
||||
]
|
||||
},
|
||||
"41": {
|
||||
"version": "1.48.0",
|
||||
"name": "Release 1.48.0",
|
||||
"releaseDate": "20260511",
|
||||
"scripts": [
|
||||
"/1.48.0/0.create-time-entries.sql",
|
||||
"/1.48.0/1.create-time-entries-history.sql",
|
||||
"/1.48.0/2.alter-organizations-add-autostop.sql",
|
||||
"/1.48.0/3.add-timetracking-permissions.sql",
|
||||
"/1.48.0/all-triggers.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added time-tracking module: tasks.time_entries with one-active-timer-per-user partial unique index",
|
||||
"Added history.time_entries audit table with FK ON DELETE CASCADE to time_entries (history removed with the entry)",
|
||||
"Added time_tracking_autostop_hours column to tv_auth.organizations (default 24, NULL = disabled)",
|
||||
"Added timetracking_can_log and timetracking_can_manage_all goal-level permissions (granted to new projects only, matching the 1.46.0 analytics convention)",
|
||||
"all-triggers.sql: updated add_roles_and_permissions function with new permissions; added log_changes_time_entries trigger (BEFORE UPDATE writes JSONB snapshot to history.time_entries)"
|
||||
]
|
||||
},
|
||||
"42": {
|
||||
"version": "1.49.0",
|
||||
"name": "Release 1.49.0",
|
||||
"releaseDate": "20260512",
|
||||
"scripts": [
|
||||
"/1.49.0/0.time_entries_report_indexes.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added compound indexes on tasks.time_entries (goal_id, started_at, user_id) and (goal_id, started_at, task_id) for time-tracking report aggregations",
|
||||
"Added partial index on tasks.time_entries(goal_id, started_at) WHERE billable = TRUE for billable-only reports"
|
||||
]
|
||||
},
|
||||
"43": {
|
||||
"version": "1.50.0",
|
||||
"name": "Release 1.50.0",
|
||||
"releaseDate": "20260515",
|
||||
"scripts": [
|
||||
"/1.50.0/0.update-timetracking-permissions-descriptions.sql"
|
||||
],
|
||||
"description": [
|
||||
"Updated descriptions of timetracking_can_view and timetracking_can_manage_all to document that these permissions also expose contributor emails through the time-entry log",
|
||||
"Tightened timetracking_can_log description: it grants only start/stop/createManual, NOT edit/delete (including own entries). Edit/delete now requires timetracking_can_manage_all — see can-access-time-entry middleware change."
|
||||
]
|
||||
},
|
||||
"44": {
|
||||
"version": "1.51.0",
|
||||
"name": "Release 1.51.0",
|
||||
"releaseDate": "20260517",
|
||||
"scripts": [
|
||||
"/1.51.0/0.tasks_to_tags_indexes.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added btree indexes on tasks.tasks_to_tags(task_id) and (tag_id) — FK constraints do not create indexes in Postgres, so anti-joins (NOT EXISTS for untagged tasks) and inner joins from tasks were seq-scanning. Needed for tag-financial analytics charts."
|
||||
]
|
||||
},
|
||||
"45": {
|
||||
"version": "1.52.0",
|
||||
"name": "Release 1.52.0",
|
||||
"releaseDate": "20260517",
|
||||
"scripts": [
|
||||
"/1.52.0/0.ui_preferences.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tv_auth.ui_preferences table: per-user JSONB storage of UI customization choices (which analytics charts and task detail fields are shown and in what order). One row per user keyed by user_id."
|
||||
]
|
||||
},
|
||||
"46": {
|
||||
"version": "1.53.0",
|
||||
"name": "Release 1.53.0",
|
||||
"releaseDate": "20260528",
|
||||
"scripts": [
|
||||
"/1.53.0/0.create-sprints.sql",
|
||||
"/1.53.0/1.create-sprint-task-outcomes.sql",
|
||||
"/1.53.0/2.create-sprint-user-capacity.sql",
|
||||
"/1.53.0/3.create-sprint-retros.sql",
|
||||
"/1.53.0/4.alter-tasks-add-sprint.sql",
|
||||
"/1.53.0/5.alter-notifications-add-sprint-id.sql",
|
||||
"/1.53.0/6.add-sprint-permissions.sql",
|
||||
"/1.53.0/7.alter-goals-add-estimate-unit.sql",
|
||||
"/1.53.0/8.alter-sprint-task-outcomes-add-estimate.sql",
|
||||
"/1.53.0/9.create-sprint-cadence.sql",
|
||||
"/1.53.0/all-triggers.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tasks.goals.estimate_unit ('hours' | 'points', default 'points') — per-project unit for sprint estimates & capacity display.",
|
||||
"Sprints feature. tasks.sprints: sprint container with lifecycle draft/planned/active/review/completed; one active-or-review sprint per project enforced by a partial unique index.",
|
||||
"tasks.sprint_task_outcomes: per-task result decided at close (accepted/carried-over/dropped/incomplete). tasks.sprint_user_capacity: per-user capacity. tasks.sprint_retros: went well/bad/action items. Sprint capacity column is `capacity` (story points, unitless).",
|
||||
"Extended tasks.tasks with sprint_id (FK SET NULL) and estimate_value (task estimate in story points, unitless), and tasks.notifications with sprint_id. Sprint availability is governed by the sprint_can_view permission (RBAC), not a per-project flag. Sprint burndown reuses the existing trigger-maintained tasks.date_complete column (no new completion column added).",
|
||||
"Added permission group 5 'sprints' and permissions sprint_can_view, sprint_can_manage, sprint_can_assign_tasks, sprint_can_view_analytics; backfilled grants for existing projects' editor/executor roles.",
|
||||
"all-triggers.sql: re-applies all triggers idempotently (drop+create) and updates tasks.add_roles_and_permissions() so NEW projects grant sprint permissions to editor (all four) and executor (view + assign).",
|
||||
"Added tasks.sprint_task_outcomes.estimate_value — story-points snapshot captured at sprint close so a closed sprint's velocity is frozen (unaffected by later estimate edits or moving the task to another sprint).",
|
||||
"Added tasks.sprint_cadence — per-project sprint auto-generation config (Linear-style): enabled, length_days, start_date anchor, lookahead, name_template, last_generated_date. A background job keeps current + lookahead future sprints created."
|
||||
]
|
||||
},
|
||||
"47": {
|
||||
"version": "1.54.0",
|
||||
"name": "Release 1.54.0",
|
||||
"releaseDate": "20260605",
|
||||
"scripts": [
|
||||
"/1.54.0/0.create-recurrence-rules.sql",
|
||||
"/1.54.0/1.create-recurrence-skip-dates.sql",
|
||||
"/1.54.0/2.create-recurrence-template-assignees.sql",
|
||||
"/1.54.0/3.create-recurrence-template-tags.sql",
|
||||
"/1.54.0/4.alter-tasks-add-recurrence.sql"
|
||||
],
|
||||
"description": [
|
||||
"Recurring tasks (lazy materialization, Todoist-like UX). tasks.recurrence_rules: one row per series — RFC 5545 RRULE string, floating wall-clock dtstart, IANA timezone, state active/paused/ended, last_instance_date + instances_created drive next-occurrence computation. Template snapshot columns (description/note/priority/status/list/duration) let the series outlive its origin task.",
|
||||
"tasks.recurrence_skip_dates: per-rule skipped occurrence dates. tasks.recurrence_template_assignees / recurrence_template_tags: snapshot of assignees (collaboration.users) and tags copied onto each materialized instance.",
|
||||
"Extended tasks.tasks with recurrence_rule_id (FK SET NULL — instances survive rule deletion) and recurrence_instance_date. Partial unique index on (recurrence_rule_id, recurrence_instance_date) makes instance materialization idempotent under concurrent triggers/reconciliation.",
|
||||
"Exactly one open instance per series exists at any time: completing it materializes the next occurrence (event-driven, O(1)); a nightly pg-boss reconcile job re-creates the open instance only for series stalled by a crash. No new permissions — recurrence editing is gated by existing task_can_edit_deadline."
|
||||
]
|
||||
},
|
||||
"48": {
|
||||
"version": "1.54.1",
|
||||
"name": "Release 1.54.1",
|
||||
"releaseDate": "20260613",
|
||||
"scripts": [
|
||||
"/1.54.1/0.add-recurrence-template-task-unique.sql"
|
||||
],
|
||||
"description": [
|
||||
"Partial unique index on tasks.recurrence_rules(template_task_id) WHERE state != 'ended' — one live series per origin task. DB-level backstop for the createRule race where two concurrent POSTs both pass the recurrenceRuleId == null check and create two rules for the same task (the orphaned rule would materialize a duplicate card via the nightly reconcile job)."
|
||||
]
|
||||
},
|
||||
"49": {
|
||||
"version": "1.54.2",
|
||||
"name": "Release 1.54.2",
|
||||
"releaseDate": "20260613",
|
||||
"scripts": [
|
||||
"/1.54.2/0.add-recurrence-has-time.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tasks.recurrence_rules.has_time — explicit flag for whether a series is anchored to a wall-clock time or is date-only. Previously the code inferred 'no time' from a midnight dtstart, which silently collapsed an explicit 00:00 series into date-only. Backfill (has_time = dtstart::time <> '00:00:00') reproduces the old inference so existing series keep their behavior; new series carry the flag through from the origin task's start_time (null = date-only, set = timed, including midnight)."
|
||||
]
|
||||
},
|
||||
"50": {
|
||||
"version": "1.55.0",
|
||||
"name": "Release 1.55.0",
|
||||
"releaseDate": "20260628",
|
||||
"scripts": [
|
||||
"/1.55.0/0.alter-goals-add-is-inbox.sql",
|
||||
"/1.55.0/1.backfill-inbox-goals.sql",
|
||||
"/1.55.0/2.unique-inbox-per-org.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tasks.goals.is_inbox (BOOLEAN NOT NULL DEFAULT FALSE) — flags a project as the user's personal Inbox. One Inbox per personal organization, auto-created at signup and guarded against deletion and archival in GoalsManager.",
|
||||
"Backfill: for every personal organization without an Inbox, inserts one tasks.goals row (name='Inbox', is_inbox=true, owner=the org owner, organization_id=the org). The existing AFTER INSERT triggers fully provision it like any project — default kanban statuses, the owner added to collaboration, and the default editor/executor roles with their permissions. Idempotent — skips orgs that already have an Inbox.",
|
||||
"Partial unique index goals_one_inbox_per_org_uidx ON tasks.goals (organization_id) WHERE is_inbox — enforces at most one Inbox per organization at the DB level, hardening the check-then-insert against concurrent races."
|
||||
]
|
||||
},
|
||||
"51": {
|
||||
"version": "1.56.0",
|
||||
"name": "Messaging integrations",
|
||||
"releaseDate": "20260701",
|
||||
"scripts": [
|
||||
"/1.56.0/0.create-messaging-tables.sql",
|
||||
"/1.56.0/1.alter-messaging-add-events.sql"
|
||||
],
|
||||
"description": [
|
||||
"Messaging integrations module (Slack / Telegram)",
|
||||
"Outbound delivery connections for personal and project/org owners",
|
||||
"Pending link tokens for binding + user identity map",
|
||||
"Per-connection event subscription (which events to deliver)"
|
||||
]
|
||||
},
|
||||
"52": {
|
||||
"version": "1.57.0",
|
||||
"name": "Unique project membership",
|
||||
"releaseDate": "20260702",
|
||||
"scripts": [
|
||||
"/1.57.0/0.unique-users-to-goals.sql"
|
||||
],
|
||||
"description": [
|
||||
"Deduplicate collaboration.users_to_goals rows",
|
||||
"Unique index on (user_id, goal_id) — one membership per user per project"
|
||||
]
|
||||
},
|
||||
"53": {
|
||||
"version": "1.58.0",
|
||||
"name": "Messaging channel post-content flag",
|
||||
"releaseDate": "20260702",
|
||||
"scripts": [
|
||||
"/1.58.0/0.alter-messaging-add-post-content.sql"
|
||||
],
|
||||
"description": [
|
||||
"Per-connection opt-out for posting task description to project channels"
|
||||
]
|
||||
},
|
||||
"54": {
|
||||
"version": "1.59.0",
|
||||
"name": "Messaging identity workspace scoping",
|
||||
"releaseDate": "20260705",
|
||||
"scripts": [
|
||||
"/1.59.0/0.alter-messaging-identity-add-team.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
|
||||
]
|
||||
},
|
||||
"55": {
|
||||
"version": "1.60.0",
|
||||
"name": "Recurrence schedule mode",
|
||||
"releaseDate": "20260712",
|
||||
"scripts": [
|
||||
"/1.60.0/0.alter-recurrence-add-schedule-mode.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
|
||||
]
|
||||
},
|
||||
"56": {
|
||||
"version": "1.61.0",
|
||||
"name": "Gitea integration provider",
|
||||
"releaseDate": "20260726",
|
||||
"scripts": [
|
||||
"/1.61.0/0.alter-integrations-provider-check-gitea.sql"
|
||||
],
|
||||
"description": [
|
||||
"Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'"
|
||||
]
|
||||
},
|
||||
"57": {
|
||||
"version": "1.62.0",
|
||||
"name": "Invite email rate limiting",
|
||||
"releaseDate": "20260730",
|
||||
"scripts": [
|
||||
"/1.62.0/0.create-invite-emails.sql"
|
||||
],
|
||||
"description": [
|
||||
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
|
||||
]
|
||||
},
|
||||
"58": {
|
||||
"version": "1.63.0",
|
||||
"name": "SSO domain verification",
|
||||
"releaseDate": "20260813",
|
||||
"scripts": [
|
||||
"/1.63.0/0.sso-domain-verification.sql",
|
||||
"/1.63.0/1.sso-domain-verified-unique.sql"
|
||||
],
|
||||
"description": [
|
||||
"SSO configs require proving ownership of email_domain_restriction before login is allowed: DNS TXT taskview-sso-verify=<token> or https://<domain>/.well-known/taskview-sso-verify.txt. Air-gapped installs can skip this for listed domains via SSO_TRUSTED_DOMAINS.",
|
||||
"Replaces the plain UNIQUE(email_domain_restriction) with a partial unique index over verified configs only, so an unverified config can no longer squat a domain and block its real owner — multiple orgs may hold a pending config for the same domain, but only one can verify it (first-to-verify wins)."
|
||||
]
|
||||
},
|
||||
"59": {
|
||||
"version": "1.64.0",
|
||||
"name": "OAuth 2.1 authorization server",
|
||||
"releaseDate": "20260830",
|
||||
"scripts": [
|
||||
"/1.64.0/0.create-oauth-clients.sql",
|
||||
"/1.64.0/1.create-oauth-auth-codes.sql",
|
||||
"/1.64.0/2.create-oauth-grants.sql",
|
||||
"/1.64.0/3.alter-api-tokens-grant-id.sql"
|
||||
],
|
||||
"description": [
|
||||
"OAuth 2.1 authorization server so third-party MCP clients (ChatGPT, Claude connectors) can act on a user's behalf without the user pasting a permanent tvk_ API token into them.",
|
||||
"tv_auth.oauth_clients holds the client registry (manually seeded or created via RFC 7591 Dynamic Client Registration); public clients carry no secret and are authenticated by PKCE S256 alone.",
|
||||
"tv_auth.oauth_auth_codes holds single-use 60-second authorization codes; tv_auth.oauth_grants is one row per connected app and owns the rotating refresh token, with the previous hash kept to detect replay.",
|
||||
"Access tokens reuse tv_auth.api_tokens (new grant_id column) so validation, permission intersection and RejectApiTokenAuth all keep working unchanged; revoking a grant cascades to its live access tokens."
|
||||
]
|
||||
},
|
||||
"60": {
|
||||
"version": "1.65.0",
|
||||
"name": "Organization-level permissions",
|
||||
"releaseDate": "20260830",
|
||||
"scripts": [
|
||||
"/1.65.0/0.organization-permissions.sql"
|
||||
],
|
||||
"description": [
|
||||
"New permission group 'organization' with org_can_view, org_can_manage, org_can_manage_members, sso_can_manage and webhooks_can_manage.",
|
||||
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
|
||||
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,3 +134,10 @@ values ('task_can_assign_users',
|
||||
('task_can_watch_assigned_users',
|
||||
'User can see assigned users to tasks',
|
||||
4);
|
||||
|
||||
insert into tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
values ('analytics_can_view',
|
||||
'User can view analytics for this goal',
|
||||
2,
|
||||
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb)
|
||||
on conflict (name) do nothing;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
drop index if exists tasks.idx_tasks_goal_id_date_creation;
|
||||
create index idx_tasks_goal_id_date_creation on tasks.tasks (goal_id, date_creation);
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_date_complete;
|
||||
create index idx_tasks_goal_id_date_complete on tasks.tasks (goal_id, date_complete) where complete = true;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_end_date_open;
|
||||
create index idx_tasks_goal_id_end_date_open on tasks.tasks (goal_id, end_date) where complete is not true;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_edit_date_open;
|
||||
create index idx_tasks_goal_id_edit_date_open on tasks.tasks (goal_id, edit_date) where complete is not true;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_transaction_type;
|
||||
create index idx_tasks_goal_id_transaction_type on tasks.tasks (goal_id, transaction_type) where amount is not null;
|
||||
@@ -0,0 +1,8 @@
|
||||
insert into tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
values (
|
||||
'analytics_can_view',
|
||||
'User can view analytics for this goal',
|
||||
2,
|
||||
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb
|
||||
)
|
||||
on conflict (name) do nothing;
|
||||
@@ -0,0 +1,20 @@
|
||||
drop index if exists tasks_auth.idx_task_assignee_task_id;
|
||||
create index idx_task_assignee_task_id on tasks_auth.task_assignee (task_id);
|
||||
|
||||
drop index if exists tasks_auth.idx_task_assignee_user_task;
|
||||
create index idx_task_assignee_user_task on tasks_auth.task_assignee (collab_user_id, task_id);
|
||||
|
||||
drop index if exists collaboration.idx_users_to_goals_user_goal;
|
||||
create index idx_users_to_goals_user_goal on collaboration.users_to_goals (user_id, goal_id);
|
||||
|
||||
drop index if exists collaboration.idx_users_to_goals_goal_id;
|
||||
create index idx_users_to_goals_goal_id on collaboration.users_to_goals (goal_id);
|
||||
|
||||
drop index if exists tasks.idx_task_relations_to_task;
|
||||
create index idx_task_relations_to_task on tasks.task_relations (to_task_id, from_task_id);
|
||||
|
||||
drop index if exists tasks.idx_goals_org_active;
|
||||
create index idx_goals_org_active on tasks.goals (organization_id) where archive = 0;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_open;
|
||||
create index idx_tasks_goal_id_open on tasks.tasks (goal_id) where complete is not true;
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.time_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMP NULL,
|
||||
duration_seconds INTEGER NULL,
|
||||
description VARCHAR(500) NULL,
|
||||
source SMALLINT NOT NULL DEFAULT 0,
|
||||
billable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
auto_stopped BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT time_entries_end_after_start CHECK (ended_at IS NULL OR ended_at > started_at),
|
||||
CONSTRAINT time_entries_duration_non_negative CHECK (duration_seconds IS NULL OR duration_seconds >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_user_started
|
||||
ON tasks.time_entries(user_id, started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started
|
||||
ON tasks.time_entries(goal_id, started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_task
|
||||
ON tasks.time_entries(task_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_entries_active_per_user
|
||||
ON tasks.time_entries(user_id) WHERE ended_at IS NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE SCHEMA IF NOT EXISTS history;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS history.time_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INTEGER NOT NULL REFERENCES tasks.time_entries(id) ON DELETE CASCADE,
|
||||
edit_date TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
entry JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_time_entries_entry
|
||||
ON history.time_entries(entry_id, edit_date DESC);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tv_auth.organizations
|
||||
ADD COLUMN IF NOT EXISTS time_tracking_autostop_hours INTEGER DEFAULT 24;
|
||||
@@ -0,0 +1,21 @@
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES
|
||||
(
|
||||
'timetracking_can_view',
|
||||
'View all time entries on this project — both own and other members''',
|
||||
2,
|
||||
'{"en": "View time entries. User can see all time entries on this project — both own and other members''. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Логировать и редактировать нельзя."}'::jsonb
|
||||
),
|
||||
(
|
||||
'timetracking_can_log',
|
||||
'Start/stop timer and create/edit/delete OWN time entries on this project',
|
||||
2,
|
||||
'{"en": "Track time. User can start/stop the timer, add manual entries, and edit/delete OWN time entries. Does not grant viewing the project log or managing others'' entries.", "ru": "Учёт времени. Пользователь может запускать/останавливать таймер, добавлять записи вручную и редактировать/удалять СВОИ записи. Не даёт права просматривать журнал проекта и управлять чужими записями."}'::jsonb
|
||||
),
|
||||
(
|
||||
'timetracking_can_manage_all',
|
||||
'Full time-tracking access: log own time + view and edit/delete entries of any project member',
|
||||
2,
|
||||
'{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника. Включает права на просмотр и ведение времени."}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
@@ -0,0 +1,635 @@
|
||||
--1.
|
||||
--Trigger set previous version
|
||||
create or replace function app.trigger_set_previous_version()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.prev_version = old.version;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_previous_version on app.version;
|
||||
create trigger trigger_set_previous_version
|
||||
before insert
|
||||
on app.version
|
||||
for each row
|
||||
execute procedure app.trigger_set_previous_version();
|
||||
|
||||
--2.
|
||||
--Trigger for adding owner for taskList from goal
|
||||
create or replace function tasks.trigger_set_owner_for_component()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.owner = (select owner from tasks.goals where id = new.goal_id);
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
|
||||
create trigger trigger_set_owner_for_component
|
||||
before insert
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.trigger_set_owner_for_component();
|
||||
|
||||
--3.
|
||||
--Trigger for updating date_complete for task
|
||||
create or replace function tasks.update_date_complete()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
if new.complete != old.complete
|
||||
then
|
||||
if new.complete = true
|
||||
then
|
||||
update tasks.tasks set date_complete = now() where id = old.id;
|
||||
else
|
||||
update tasks.tasks set date_complete = null where id = old.id;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists tr_update_date_complete on tasks.tasks;
|
||||
create trigger tr_update_date_complete
|
||||
after update
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.update_date_complete();
|
||||
|
||||
--4.
|
||||
-- Delete user from collaboration if not assigned to any goal
|
||||
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
count int;
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from collaboration.users_to_goals
|
||||
where user_id = old.user_id
|
||||
limit 1
|
||||
) then
|
||||
delete from collaboration.users where id = old.user_id;
|
||||
end if;
|
||||
|
||||
return old;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
|
||||
create trigger trigger_delete_user_if_not_assigned_to_goal
|
||||
after delete
|
||||
on collaboration.users_to_goals
|
||||
for each row
|
||||
execute function collaboration.delete_user_if_not_assigned_to_goal();
|
||||
|
||||
--5.
|
||||
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
|
||||
create or replace function tasks.check_task_graph_relation_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
from_goal int;
|
||||
to_goal int;
|
||||
begin
|
||||
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
|
||||
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
|
||||
|
||||
if from_goal is null or to_goal is null then
|
||||
raise exception 'Invalid task reference in relation';
|
||||
end if;
|
||||
|
||||
if from_goal <> to_goal then
|
||||
raise exception 'Relation goal_id must match both tasks'' goal_id';
|
||||
end if;
|
||||
|
||||
new.goal_id := from_goal;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
|
||||
create trigger trigger_task_relation_goal
|
||||
before insert or update on tasks.task_relations
|
||||
for each row execute function tasks.check_task_graph_relation_goal();
|
||||
|
||||
--6.
|
||||
--Trigger for logging changes in taskList to history table
|
||||
create or replace function tasks.log_changes_tasks_goal_lists()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
|
||||
create trigger trigger_log_changes_tasks_goal_lists
|
||||
before update or delete
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goal_lists();
|
||||
|
||||
--7.
|
||||
--Trigger for logging changes in goal to history table
|
||||
create or replace function tasks.log_changes_tasks_goals()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
|
||||
create trigger trigger_log_changes_tasks_goals
|
||||
before update or delete
|
||||
on tasks.goals
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goals();
|
||||
|
||||
--8.
|
||||
--Trigger for logging changes in task to history table
|
||||
create or replace function tasks.log_changes_tasks_tasks()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
|
||||
create trigger trigger_log_changes_tasks_tasks
|
||||
before update or delete
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_tasks();
|
||||
|
||||
--9.
|
||||
--Trigger for setting goal_id default for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
goal_id INT;
|
||||
BEGIN
|
||||
|
||||
SELECT gl.goal_id
|
||||
INTO goal_id
|
||||
FROM tasks.goal_lists gl
|
||||
WHERE gl.id = NEW.goal_list_id;
|
||||
|
||||
IF goal_id IS NOT NULL THEN
|
||||
NEW.goal_id := goal_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER before_insert_set_goal_id_for_task
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
|
||||
|
||||
|
||||
--10.
|
||||
--Trigger for adding default roles and permissions for goal
|
||||
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
editor_role_id INTEGER;
|
||||
executor_role_id INTEGER;
|
||||
BEGIN
|
||||
-- 1. Create role "editor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('editor', NEW.id)
|
||||
RETURNING id INTO editor_role_id;
|
||||
|
||||
-- 2. Create role "executor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('executor', NEW.id)
|
||||
RETURNING id INTO executor_role_id;
|
||||
|
||||
-- 3. Add permissions for role "editor"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT editor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'goal_can_edit',
|
||||
'goal_can_add_task_list',
|
||||
'goal_can_manage_users',
|
||||
'component_can_watch_content',
|
||||
'component_can_edit',
|
||||
'component_can_delete',
|
||||
'component_can_add_tasks',
|
||||
'task_can_edit_deadline',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_recovery_history',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_edit_priority',
|
||||
'task_can_delete',
|
||||
'task_can_watch_details',
|
||||
'task_can_assign_users',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'task_can_access_history',
|
||||
'task_can_edit_tags',
|
||||
'task_can_edit_description',
|
||||
'task_can_edit_status',
|
||||
'task_can_edit_note',
|
||||
'kanban_can_manage',
|
||||
'kanban_can_view',
|
||||
'graph_can_manage',
|
||||
'graph_can_view',
|
||||
'timetracking_can_view',
|
||||
'timetracking_can_manage_all'
|
||||
);
|
||||
|
||||
-- 4. Add permissions for role "viewver"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT executor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'component_can_watch_content',
|
||||
'component_can_add_tasks',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_watch_details',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'timetracking_can_view',
|
||||
'timetracking_can_log'
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists add_roles_after_insert on tasks.goals;
|
||||
|
||||
CREATE TRIGGER add_roles_after_insert
|
||||
AFTER INSERT
|
||||
ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.add_roles_and_permissions();
|
||||
|
||||
|
||||
--11.
|
||||
--Trigger for adjusting start and end dates for task
|
||||
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
start_timestamp TIMESTAMPTZ;
|
||||
end_timestamp TIMESTAMPTZ;
|
||||
BEGIN
|
||||
-- If start_date is NULL, then start_time should be NULL
|
||||
IF NEW.start_date IS NULL THEN
|
||||
NEW.start_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If end_date is NULL, then end_time should be NULL
|
||||
IF NEW.end_date IS NULL THEN
|
||||
NEW.end_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If both dates are set
|
||||
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
|
||||
-- Adjust dates
|
||||
IF NEW.start_date > NEW.end_date THEN
|
||||
-- If start_date is greater than end_date, set end_date to start_date
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- end_time remains unchanged
|
||||
ELSIF NEW.end_date < NEW.start_date THEN
|
||||
-- If end_date is less than start_date, set start_date to end_date
|
||||
NEW.start_date := NEW.end_date;
|
||||
-- start_time remains unchanged
|
||||
END IF;
|
||||
|
||||
-- Prepare timestamps for comparison
|
||||
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
|
||||
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
|
||||
|
||||
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
|
||||
IF start_timestamp > end_timestamp THEN
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- Assign end_time only if start_time is not NULL
|
||||
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
|
||||
NEW.end_time := NEW.start_time;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
|
||||
CREATE TRIGGER adjust_dates_and_times_trigger
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
|
||||
|
||||
--12.
|
||||
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
|
||||
create or replace function tasks.add_self_to_collaboration()
|
||||
returns trigger as $$
|
||||
DECLARE
|
||||
owner_email TEXT;
|
||||
BEGIN
|
||||
|
||||
select email into owner_email
|
||||
from tv_auth.users
|
||||
where id = NEW.owner;
|
||||
|
||||
if owner_email is not null then
|
||||
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
|
||||
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
|
||||
end if;
|
||||
|
||||
return NEW;
|
||||
END;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
|
||||
|
||||
create trigger add_selt_to_collaboration_trg
|
||||
after insert on tasks.goals
|
||||
for each row
|
||||
execute function tasks.add_self_to_collaboration();
|
||||
|
||||
--13.
|
||||
--Trigger for adding default kanban columns for new goal
|
||||
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Add default columns for new goal
|
||||
INSERT INTO tasks.statuses (name, goal_id, view_order)
|
||||
VALUES
|
||||
('TODO', NEW.id, 1),
|
||||
('In Progress', NEW.id, 2),
|
||||
('Done', NEW.id, 3);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
|
||||
|
||||
CREATE TRIGGER kanban_add_default_columns_trg
|
||||
AFTER INSERT ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.kanban_add_default_columns();
|
||||
|
||||
--14.
|
||||
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if there is a record in tasks.statuses with the same goal_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tasks.statuses s
|
||||
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists enforce_task_status_goal on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER enforce_task_status_goal
|
||||
BEFORE INSERT OR UPDATE ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status_id IS NOT NULL)
|
||||
EXECUTE FUNCTION tasks.check_task_status_goal();
|
||||
|
||||
--15.
|
||||
--Trigger for setting default orders value for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_order_value()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.task_order IS NULL THEN
|
||||
NEW.task_order := NEW.id;
|
||||
END IF;
|
||||
IF NEW.kanban_order IS NULL THEN
|
||||
NEW.kanban_order := NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_order_trigger on tasks.tasks;
|
||||
CREATE TRIGGER set_order_trigger
|
||||
BEFORE INSERT ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_order_value();
|
||||
|
||||
--16.
|
||||
--Trigger for setting default view order for new status
|
||||
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_view_order INT;
|
||||
BEGIN
|
||||
-- Determine the next view_order for the given goal_id
|
||||
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
|
||||
FROM tasks.statuses
|
||||
WHERE goal_id = NEW.goal_id;
|
||||
|
||||
-- Assign the calculated value to the view_order field
|
||||
NEW.view_order := new_view_order;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_default_status_view_order on tasks.statuses;
|
||||
|
||||
CREATE TRIGGER set_default_status_view_order
|
||||
BEFORE INSERT ON tasks.statuses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.status_set_default_view_order();
|
||||
|
||||
--17.
|
||||
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks.tasks tt
|
||||
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
|
||||
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
|
||||
) INTO user_exists;
|
||||
|
||||
IF NOT user_exists THEN
|
||||
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
|
||||
|
||||
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
|
||||
BEFORE INSERT ON tasks_auth.task_assignee
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
|
||||
|
||||
--18.
|
||||
--Trigger for adding owner for task, extend owner from goal or taskList
|
||||
|
||||
--delete old function with wrong name
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
drop function if exists tasks.trigger_set_owner_for_task();
|
||||
|
||||
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$body$
|
||||
BEGIN
|
||||
NEW.owner := COALESCE(
|
||||
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
|
||||
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
|
||||
);
|
||||
|
||||
IF NEW.owner IS NULL THEN
|
||||
RAISE EXCEPTION 'Can not insert task without owner';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$body$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
create trigger trigger_set_owner_for_task
|
||||
before insert
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.fn_set_owner_for_task();
|
||||
|
||||
--19.
|
||||
--Trigger for validating that tag and task belong to the same project (goal_id)
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
drop function if exists tasks.check_tag_task_same_goal();
|
||||
|
||||
create or replace function tasks.check_tag_task_same_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
v_tag_goal_id integer;
|
||||
v_task_goal_id integer;
|
||||
begin
|
||||
select goal_id into v_tag_goal_id from tasks.tags where id = new.tag_id;
|
||||
select goal_id into v_task_goal_id from tasks.tasks where id = new.task_id;
|
||||
|
||||
if v_tag_goal_id is null or v_tag_goal_id != v_task_goal_id then
|
||||
raise exception 'Tag (id=%) and task (id=%) belong to different projects', new.tag_id, new.task_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
create trigger trigger_check_tag_task_same_goal
|
||||
before insert on tasks.tasks_to_tags
|
||||
for each row
|
||||
execute function tasks.check_tag_task_same_goal();
|
||||
|
||||
--20.
|
||||
-- Remove user from task assignees when removed from project collaboration
|
||||
|
||||
drop trigger if exists trigger_remove_user_from_task_assignees on collaboration.users_to_goals;
|
||||
drop function if exists collaboration.remove_user_from_task_assignees();
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION collaboration.remove_user_from_task_assignees()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM tasks_auth.task_assignee
|
||||
WHERE collab_user_id = OLD.user_id
|
||||
AND task_id IN (SELECT id FROM tasks.tasks WHERE goal_id = OLD.goal_id);
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_remove_user_from_task_assignees ON collaboration.users_to_goals;
|
||||
|
||||
CREATE TRIGGER trigger_remove_user_from_task_assignees
|
||||
BEFORE DELETE
|
||||
ON collaboration.users_to_goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION collaboration.remove_user_from_task_assignees();
|
||||
|
||||
--21.
|
||||
--Trigger for logging changes in time_entries to history table
|
||||
create or replace function tasks.log_changes_time_entries()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
insert into history.time_entries (entry_id, edit_date, entry)
|
||||
values (old.id, now(), to_jsonb(old));
|
||||
new.edited_at = now();
|
||||
return new;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_time_entries on tasks.time_entries;
|
||||
create trigger trigger_log_changes_time_entries
|
||||
before update
|
||||
on tasks.time_entries
|
||||
for each row
|
||||
execute procedure tasks.log_changes_time_entries();
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_user
|
||||
ON tasks.time_entries(goal_id, started_at DESC, user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_task
|
||||
ON tasks.time_entries(goal_id, started_at DESC, task_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_billable_goal_started
|
||||
ON tasks.time_entries(goal_id, started_at DESC)
|
||||
WHERE billable = TRUE;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
UPDATE tv_auth.permissions
|
||||
SET
|
||||
description = 'View all time entries; exposes emails of contributors via the entry log',
|
||||
description_locales = '{"en": "View time entries. User can see all time entries on this project — both own and other members''. Also exposes the emails of all contributors who have logged time on this project. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Также видны email-адреса всех участников, логировавших время. Логировать и редактировать нельзя."}'::jsonb
|
||||
WHERE name = 'timetracking_can_view';
|
||||
|
||||
UPDATE tv_auth.permissions
|
||||
SET
|
||||
description = 'Start/stop timer and create manual entries; editing/deleting requires manage_all',
|
||||
description_locales = '{"en": "Log time. User can start/stop the timer and add manual entries on this project. Cannot edit or delete entries (including own) — editing requires manage_all permission. Does not grant viewing other members'' entries.", "ru": "Учёт времени. Пользователь может запускать/останавливать таймер и добавлять записи вручную в этом проекте. Редактировать и удалять записи (даже свои) нельзя — для этого нужно право управления. Не даёт права просматривать записи других участников."}'::jsonb
|
||||
WHERE name = 'timetracking_can_log';
|
||||
|
||||
UPDATE tv_auth.permissions
|
||||
SET
|
||||
description = 'Full time-tracking access: log, view, edit, delete entries; exposes contributor emails',
|
||||
description_locales = '{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member (including own). Also exposes the emails of all contributors who have logged time on this project. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника (включая свои). Также видны email-адреса всех участников, логировавших время. Включает права на просмотр и ведение времени."}'::jsonb
|
||||
WHERE name = 'timetracking_can_manage_all';
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_task_id
|
||||
ON tasks.tasks_to_tags(task_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_tag_id
|
||||
ON tasks.tasks_to_tags(tag_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.ui_preferences (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
prefs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.sprints (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
goal_text VARCHAR(2000),
|
||||
goal_achieved BOOLEAN,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
capacity NUMERIC(10, 2),
|
||||
paused_at TIMESTAMP,
|
||||
creator_id INTEGER REFERENCES tv_auth.users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
review_started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
CONSTRAINT sprint_dates_valid CHECK (end_date >= start_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sprints_goal_status ON tasks.sprints(goal_id, status);
|
||||
|
||||
-- At most one active OR in-review sprint per project, enforced at the DB level
|
||||
-- (no race). 'review' also holds the slot: the previous sprint must be closed
|
||||
-- before the next can start.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_sprint_per_goal
|
||||
ON tasks.sprints(goal_id) WHERE status IN ('active', 'review');
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.sprint_task_outcomes (
|
||||
sprint_id INTEGER NOT NULL REFERENCES tasks.sprints(id) ON DELETE CASCADE,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
|
||||
outcome VARCHAR(20) NOT NULL,
|
||||
-- 'accepted' | 'carried-over' | 'dropped' | 'incomplete'
|
||||
carried_over_to INTEGER REFERENCES tasks.sprints(id) ON DELETE SET NULL,
|
||||
decided_by INTEGER REFERENCES tv_auth.users(id),
|
||||
decided_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (sprint_id, task_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sprint_outcomes_task ON tasks.sprint_task_outcomes(task_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.sprint_user_capacity (
|
||||
sprint_id INTEGER NOT NULL REFERENCES tasks.sprints(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
hours NUMERIC(10, 2) NOT NULL,
|
||||
PRIMARY KEY (sprint_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.sprint_retros (
|
||||
sprint_id INTEGER PRIMARY KEY REFERENCES tasks.sprints(id) ON DELETE CASCADE,
|
||||
went_well TEXT,
|
||||
went_bad TEXT,
|
||||
action_items TEXT,
|
||||
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
edited_by INTEGER REFERENCES tv_auth.users(id)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tasks.tasks
|
||||
ADD COLUMN IF NOT EXISTS sprint_id INTEGER REFERENCES tasks.sprints(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS estimate_value NUMERIC(10, 2);
|
||||
-- estimate_value: task estimate in story points (unitless).
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_sprint_id ON tasks.tasks(sprint_id) WHERE sprint_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE tasks.notifications
|
||||
ADD COLUMN IF NOT EXISTS sprint_id INTEGER REFERENCES tasks.sprints(id) ON DELETE CASCADE;
|
||||
-- Lets the client deep-link a notification to "open sprint N" without
|
||||
-- stuffing metadata into the body.
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Permission group 5 = sprints (shown in the role editor; group 1 'app' is hidden there)
|
||||
INSERT INTO tv_auth.permissions_group (id, name)
|
||||
VALUES (5, 'sprints')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- New sprint permissions
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES
|
||||
('sprint_can_view', 'View sprints of the project', 5,
|
||||
'{"en": "View sprints. See the project sprints, their dates and contents.", "ru": "Просмотр спринтов. Видеть спринты проекта, их даты и состав."}'::jsonb),
|
||||
('sprint_can_manage', 'Create, edit, activate, close sprints, save retro', 5,
|
||||
'{"en": "Manage sprints. Create, edit, activate, run review and close sprints; save retros.", "ru": "Управление спринтами. Создавать, редактировать, активировать, проводить ревью и закрывать спринты; сохранять ретро."}'::jsonb),
|
||||
('sprint_can_assign_tasks', 'Move tasks in and out of sprints', 5,
|
||||
'{"en": "Assign tasks to sprints. Move tasks into and out of sprints.", "ru": "Назначение задач в спринты. Перемещать задачи в спринты и из них."}'::jsonb),
|
||||
('sprint_can_view_analytics', 'View sprint burndown and velocity', 5,
|
||||
'{"en": "View sprint analytics. See burndown and velocity charts.", "ru": "Аналитика спринтов. Видеть burndown и velocity."}'::jsonb)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Backfill existing projects: grant sprint permissions to their editor/executor roles.
|
||||
-- (NEW projects get them via tasks.add_roles_and_permissions() — see all-triggers.sql.)
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM collaboration.roles r
|
||||
CROSS JOIN tv_auth.permissions p
|
||||
WHERE r.name = 'editor'
|
||||
AND p.name IN ('sprint_can_view', 'sprint_can_manage', 'sprint_can_assign_tasks', 'sprint_can_view_analytics')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM collaboration.roles r
|
||||
CROSS JOIN tv_auth.permissions p
|
||||
WHERE r.name = 'executor'
|
||||
AND p.name IN ('sprint_can_view', 'sprint_can_assign_tasks')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tasks.goals
|
||||
ADD COLUMN IF NOT EXISTS estimate_unit VARCHAR(10) NOT NULL DEFAULT 'points';
|
||||
-- 'hours' | 'points' — unit the project measures sprint estimates & capacity in.
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tasks.sprint_task_outcomes
|
||||
ADD COLUMN IF NOT EXISTS estimate_value NUMERIC(10, 2);
|
||||
-- Snapshot of tasks.estimate_value taken AT sprint close. Sprint history is
|
||||
-- frozen: later edits to the task's estimate, or moving it to another sprint,
|
||||
-- must NOT change a closed sprint's velocity. Velocity reads this snapshot,
|
||||
-- not the live task estimate.
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.sprint_cadence (
|
||||
goal_id INTEGER PRIMARY KEY REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
length_days INTEGER NOT NULL DEFAULT 14,
|
||||
start_date DATE NOT NULL,
|
||||
lookahead INTEGER NOT NULL DEFAULT 2,
|
||||
name_template VARCHAR(100) NOT NULL DEFAULT 'Sprint {n}',
|
||||
last_generated_date DATE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
edited_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
-- Per-project sprint cadence (Linear-style auto-generation). A background job
|
||||
-- keeps the current + `lookahead` future sprints created, every `length_days`
|
||||
-- starting from `start_date`. `last_generated_date` = start_date of the last
|
||||
-- auto-created sprint, so generation is idempotent and only moves forward.
|
||||
@@ -0,0 +1,641 @@
|
||||
--1.
|
||||
--Trigger set previous version
|
||||
create or replace function app.trigger_set_previous_version()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.prev_version = old.version;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_previous_version on app.version;
|
||||
create trigger trigger_set_previous_version
|
||||
before insert
|
||||
on app.version
|
||||
for each row
|
||||
execute procedure app.trigger_set_previous_version();
|
||||
|
||||
--2.
|
||||
--Trigger for adding owner for taskList from goal
|
||||
create or replace function tasks.trigger_set_owner_for_component()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.owner = (select owner from tasks.goals where id = new.goal_id);
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
|
||||
create trigger trigger_set_owner_for_component
|
||||
before insert
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.trigger_set_owner_for_component();
|
||||
|
||||
--3.
|
||||
--Trigger for updating date_complete for task
|
||||
create or replace function tasks.update_date_complete()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
if new.complete != old.complete
|
||||
then
|
||||
if new.complete = true
|
||||
then
|
||||
update tasks.tasks set date_complete = now() where id = old.id;
|
||||
else
|
||||
update tasks.tasks set date_complete = null where id = old.id;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists tr_update_date_complete on tasks.tasks;
|
||||
create trigger tr_update_date_complete
|
||||
after update
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.update_date_complete();
|
||||
|
||||
--4.
|
||||
-- Delete user from collaboration if not assigned to any goal
|
||||
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
count int;
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from collaboration.users_to_goals
|
||||
where user_id = old.user_id
|
||||
limit 1
|
||||
) then
|
||||
delete from collaboration.users where id = old.user_id;
|
||||
end if;
|
||||
|
||||
return old;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
|
||||
create trigger trigger_delete_user_if_not_assigned_to_goal
|
||||
after delete
|
||||
on collaboration.users_to_goals
|
||||
for each row
|
||||
execute function collaboration.delete_user_if_not_assigned_to_goal();
|
||||
|
||||
--5.
|
||||
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
|
||||
create or replace function tasks.check_task_graph_relation_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
from_goal int;
|
||||
to_goal int;
|
||||
begin
|
||||
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
|
||||
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
|
||||
|
||||
if from_goal is null or to_goal is null then
|
||||
raise exception 'Invalid task reference in relation';
|
||||
end if;
|
||||
|
||||
if from_goal <> to_goal then
|
||||
raise exception 'Relation goal_id must match both tasks'' goal_id';
|
||||
end if;
|
||||
|
||||
new.goal_id := from_goal;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
|
||||
create trigger trigger_task_relation_goal
|
||||
before insert or update on tasks.task_relations
|
||||
for each row execute function tasks.check_task_graph_relation_goal();
|
||||
|
||||
--6.
|
||||
--Trigger for logging changes in taskList to history table
|
||||
create or replace function tasks.log_changes_tasks_goal_lists()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
|
||||
create trigger trigger_log_changes_tasks_goal_lists
|
||||
before update or delete
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goal_lists();
|
||||
|
||||
--7.
|
||||
--Trigger for logging changes in goal to history table
|
||||
create or replace function tasks.log_changes_tasks_goals()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
|
||||
create trigger trigger_log_changes_tasks_goals
|
||||
before update or delete
|
||||
on tasks.goals
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goals();
|
||||
|
||||
--8.
|
||||
--Trigger for logging changes in task to history table
|
||||
create or replace function tasks.log_changes_tasks_tasks()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
|
||||
create trigger trigger_log_changes_tasks_tasks
|
||||
before update or delete
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_tasks();
|
||||
|
||||
--9.
|
||||
--Trigger for setting goal_id default for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
goal_id INT;
|
||||
BEGIN
|
||||
|
||||
SELECT gl.goal_id
|
||||
INTO goal_id
|
||||
FROM tasks.goal_lists gl
|
||||
WHERE gl.id = NEW.goal_list_id;
|
||||
|
||||
IF goal_id IS NOT NULL THEN
|
||||
NEW.goal_id := goal_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER before_insert_set_goal_id_for_task
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
|
||||
|
||||
|
||||
--10.
|
||||
--Trigger for adding default roles and permissions for goal
|
||||
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
editor_role_id INTEGER;
|
||||
executor_role_id INTEGER;
|
||||
BEGIN
|
||||
-- 1. Create role "editor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('editor', NEW.id)
|
||||
RETURNING id INTO editor_role_id;
|
||||
|
||||
-- 2. Create role "executor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('executor', NEW.id)
|
||||
RETURNING id INTO executor_role_id;
|
||||
|
||||
-- 3. Add permissions for role "editor"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT editor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'goal_can_edit',
|
||||
'goal_can_add_task_list',
|
||||
'goal_can_manage_users',
|
||||
'component_can_watch_content',
|
||||
'component_can_edit',
|
||||
'component_can_delete',
|
||||
'component_can_add_tasks',
|
||||
'task_can_edit_deadline',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_recovery_history',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_edit_priority',
|
||||
'task_can_delete',
|
||||
'task_can_watch_details',
|
||||
'task_can_assign_users',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'task_can_access_history',
|
||||
'task_can_edit_tags',
|
||||
'task_can_edit_description',
|
||||
'task_can_edit_status',
|
||||
'task_can_edit_note',
|
||||
'kanban_can_manage',
|
||||
'kanban_can_view',
|
||||
'graph_can_manage',
|
||||
'graph_can_view',
|
||||
'timetracking_can_view',
|
||||
'timetracking_can_manage_all',
|
||||
'sprint_can_view',
|
||||
'sprint_can_manage',
|
||||
'sprint_can_assign_tasks',
|
||||
'sprint_can_view_analytics'
|
||||
);
|
||||
|
||||
-- 4. Add permissions for role "viewver"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT executor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'component_can_watch_content',
|
||||
'component_can_add_tasks',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_watch_details',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'timetracking_can_view',
|
||||
'timetracking_can_log',
|
||||
'sprint_can_view',
|
||||
'sprint_can_assign_tasks'
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists add_roles_after_insert on tasks.goals;
|
||||
|
||||
CREATE TRIGGER add_roles_after_insert
|
||||
AFTER INSERT
|
||||
ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.add_roles_and_permissions();
|
||||
|
||||
|
||||
--11.
|
||||
--Trigger for adjusting start and end dates for task
|
||||
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
start_timestamp TIMESTAMPTZ;
|
||||
end_timestamp TIMESTAMPTZ;
|
||||
BEGIN
|
||||
-- If start_date is NULL, then start_time should be NULL
|
||||
IF NEW.start_date IS NULL THEN
|
||||
NEW.start_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If end_date is NULL, then end_time should be NULL
|
||||
IF NEW.end_date IS NULL THEN
|
||||
NEW.end_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If both dates are set
|
||||
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
|
||||
-- Adjust dates
|
||||
IF NEW.start_date > NEW.end_date THEN
|
||||
-- If start_date is greater than end_date, set end_date to start_date
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- end_time remains unchanged
|
||||
ELSIF NEW.end_date < NEW.start_date THEN
|
||||
-- If end_date is less than start_date, set start_date to end_date
|
||||
NEW.start_date := NEW.end_date;
|
||||
-- start_time remains unchanged
|
||||
END IF;
|
||||
|
||||
-- Prepare timestamps for comparison
|
||||
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
|
||||
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
|
||||
|
||||
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
|
||||
IF start_timestamp > end_timestamp THEN
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- Assign end_time only if start_time is not NULL
|
||||
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
|
||||
NEW.end_time := NEW.start_time;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
|
||||
CREATE TRIGGER adjust_dates_and_times_trigger
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
|
||||
|
||||
--12.
|
||||
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
|
||||
create or replace function tasks.add_self_to_collaboration()
|
||||
returns trigger as $$
|
||||
DECLARE
|
||||
owner_email TEXT;
|
||||
BEGIN
|
||||
|
||||
select email into owner_email
|
||||
from tv_auth.users
|
||||
where id = NEW.owner;
|
||||
|
||||
if owner_email is not null then
|
||||
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
|
||||
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
|
||||
end if;
|
||||
|
||||
return NEW;
|
||||
END;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
|
||||
|
||||
create trigger add_selt_to_collaboration_trg
|
||||
after insert on tasks.goals
|
||||
for each row
|
||||
execute function tasks.add_self_to_collaboration();
|
||||
|
||||
--13.
|
||||
--Trigger for adding default kanban columns for new goal
|
||||
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Add default columns for new goal
|
||||
INSERT INTO tasks.statuses (name, goal_id, view_order)
|
||||
VALUES
|
||||
('TODO', NEW.id, 1),
|
||||
('In Progress', NEW.id, 2),
|
||||
('Done', NEW.id, 3);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
|
||||
|
||||
CREATE TRIGGER kanban_add_default_columns_trg
|
||||
AFTER INSERT ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.kanban_add_default_columns();
|
||||
|
||||
--14.
|
||||
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if there is a record in tasks.statuses with the same goal_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tasks.statuses s
|
||||
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists enforce_task_status_goal on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER enforce_task_status_goal
|
||||
BEFORE INSERT OR UPDATE ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status_id IS NOT NULL)
|
||||
EXECUTE FUNCTION tasks.check_task_status_goal();
|
||||
|
||||
--15.
|
||||
--Trigger for setting default orders value for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_order_value()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.task_order IS NULL THEN
|
||||
NEW.task_order := NEW.id;
|
||||
END IF;
|
||||
IF NEW.kanban_order IS NULL THEN
|
||||
NEW.kanban_order := NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_order_trigger on tasks.tasks;
|
||||
CREATE TRIGGER set_order_trigger
|
||||
BEFORE INSERT ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_order_value();
|
||||
|
||||
--16.
|
||||
--Trigger for setting default view order for new status
|
||||
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_view_order INT;
|
||||
BEGIN
|
||||
-- Determine the next view_order for the given goal_id
|
||||
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
|
||||
FROM tasks.statuses
|
||||
WHERE goal_id = NEW.goal_id;
|
||||
|
||||
-- Assign the calculated value to the view_order field
|
||||
NEW.view_order := new_view_order;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_default_status_view_order on tasks.statuses;
|
||||
|
||||
CREATE TRIGGER set_default_status_view_order
|
||||
BEFORE INSERT ON tasks.statuses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.status_set_default_view_order();
|
||||
|
||||
--17.
|
||||
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks.tasks tt
|
||||
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
|
||||
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
|
||||
) INTO user_exists;
|
||||
|
||||
IF NOT user_exists THEN
|
||||
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
|
||||
|
||||
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
|
||||
BEFORE INSERT ON tasks_auth.task_assignee
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
|
||||
|
||||
--18.
|
||||
--Trigger for adding owner for task, extend owner from goal or taskList
|
||||
|
||||
--delete old function with wrong name
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
drop function if exists tasks.trigger_set_owner_for_task();
|
||||
|
||||
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$body$
|
||||
BEGIN
|
||||
NEW.owner := COALESCE(
|
||||
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
|
||||
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
|
||||
);
|
||||
|
||||
IF NEW.owner IS NULL THEN
|
||||
RAISE EXCEPTION 'Can not insert task without owner';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$body$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
create trigger trigger_set_owner_for_task
|
||||
before insert
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.fn_set_owner_for_task();
|
||||
|
||||
--19.
|
||||
--Trigger for validating that tag and task belong to the same project (goal_id)
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
drop function if exists tasks.check_tag_task_same_goal();
|
||||
|
||||
create or replace function tasks.check_tag_task_same_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
v_tag_goal_id integer;
|
||||
v_task_goal_id integer;
|
||||
begin
|
||||
select goal_id into v_tag_goal_id from tasks.tags where id = new.tag_id;
|
||||
select goal_id into v_task_goal_id from tasks.tasks where id = new.task_id;
|
||||
|
||||
if v_tag_goal_id is null or v_tag_goal_id != v_task_goal_id then
|
||||
raise exception 'Tag (id=%) and task (id=%) belong to different projects', new.tag_id, new.task_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
create trigger trigger_check_tag_task_same_goal
|
||||
before insert on tasks.tasks_to_tags
|
||||
for each row
|
||||
execute function tasks.check_tag_task_same_goal();
|
||||
|
||||
--20.
|
||||
-- Remove user from task assignees when removed from project collaboration
|
||||
|
||||
drop trigger if exists trigger_remove_user_from_task_assignees on collaboration.users_to_goals;
|
||||
drop function if exists collaboration.remove_user_from_task_assignees();
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION collaboration.remove_user_from_task_assignees()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM tasks_auth.task_assignee
|
||||
WHERE collab_user_id = OLD.user_id
|
||||
AND task_id IN (SELECT id FROM tasks.tasks WHERE goal_id = OLD.goal_id);
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_remove_user_from_task_assignees ON collaboration.users_to_goals;
|
||||
|
||||
CREATE TRIGGER trigger_remove_user_from_task_assignees
|
||||
BEFORE DELETE
|
||||
ON collaboration.users_to_goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION collaboration.remove_user_from_task_assignees();
|
||||
|
||||
--21.
|
||||
--Trigger for logging changes in time_entries to history table
|
||||
create or replace function tasks.log_changes_time_entries()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
insert into history.time_entries (entry_id, edit_date, entry)
|
||||
values (old.id, now(), to_jsonb(old));
|
||||
new.edited_at = now();
|
||||
return new;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_time_entries on tasks.time_entries;
|
||||
create trigger trigger_log_changes_time_entries
|
||||
before update
|
||||
on tasks.time_entries
|
||||
for each row
|
||||
execute procedure tasks.log_changes_time_entries();
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.recurrence_rules (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
template_task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
|
||||
template_description VARCHAR(2000),
|
||||
template_note VARCHAR(2000),
|
||||
template_priority_id INTEGER,
|
||||
template_status_id INTEGER,
|
||||
template_goal_list_id INTEGER,
|
||||
template_duration_minutes INTEGER,
|
||||
rrule TEXT NOT NULL,
|
||||
dtstart TIMESTAMP NOT NULL,
|
||||
timezone VARCHAR(50) NOT NULL,
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
last_instance_date DATE NOT NULL,
|
||||
instances_created INTEGER NOT NULL DEFAULT 1,
|
||||
notify_on_occurrence BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
creator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT recurrence_state_valid CHECK (state IN ('active', 'paused', 'ended'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recurrence_rules_goal ON tasks.recurrence_rules(goal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recurrence_rules_active ON tasks.recurrence_rules(id) WHERE state = 'active';
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.recurrence_skip_dates (
|
||||
rule_id INTEGER NOT NULL REFERENCES tasks.recurrence_rules(id) ON DELETE CASCADE,
|
||||
skip_date DATE NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (rule_id, skip_date)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.recurrence_template_assignees (
|
||||
rule_id INTEGER NOT NULL REFERENCES tasks.recurrence_rules(id) ON DELETE CASCADE,
|
||||
collab_user_id INTEGER NOT NULL REFERENCES collaboration.users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (rule_id, collab_user_id)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.recurrence_template_tags (
|
||||
rule_id INTEGER NOT NULL REFERENCES tasks.recurrence_rules(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tasks.tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (rule_id, tag_id)
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE tasks.tasks
|
||||
ADD COLUMN IF NOT EXISTS recurrence_rule_id INTEGER REFERENCES tasks.recurrence_rules(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS recurrence_instance_date DATE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tasks_recurrence_instance
|
||||
ON tasks.tasks(recurrence_rule_id, recurrence_instance_date)
|
||||
WHERE recurrence_rule_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_rule_id
|
||||
ON tasks.tasks(recurrence_rule_id)
|
||||
WHERE recurrence_rule_id IS NOT NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- One live series per origin task: DB-level backstop for the createRule race
|
||||
-- where two concurrent POSTs both pass the recurrenceRuleId == null check and
|
||||
-- insert two rules for the same task. Ended series keep their row but release
|
||||
-- the slot (the origin task itself stays attached to the ended rule anyway).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_recurrence_rules_template_task
|
||||
ON tasks.recurrence_rules(template_task_id)
|
||||
WHERE state != 'ended';
|
||||
@@ -0,0 +1,13 @@
|
||||
-- A series anchored to a wall-clock time (incl. exactly 00:00) vs a date-only
|
||||
-- series ("every day", no time) used to be told apart by inspecting dtstart:
|
||||
-- midnight meant "no time". That collapses an explicit midnight into date-only.
|
||||
-- Store the distinction explicitly instead.
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD COLUMN IF NOT EXISTS has_time BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Backfill reproduces the old inference exactly: any series whose dtstart
|
||||
-- carries a non-midnight wall-clock time was a timed series. Existing
|
||||
-- midnight/date-only series keep has_time = FALSE — no behavior change.
|
||||
UPDATE tasks.recurrence_rules
|
||||
SET has_time = TRUE
|
||||
WHERE dtstart::time <> '00:00:00';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE tasks.goals
|
||||
ADD COLUMN IF NOT EXISTS is_inbox BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
-- Flags a project as the user's personal Inbox. One Inbox per personal organization;
|
||||
-- drives auto-create at signup, the backfill below, and the delete/archive guard.
|
||||
@@ -0,0 +1,17 @@
|
||||
-- For every personal organization that has no Inbox yet, create one.
|
||||
-- The AFTER INSERT triggers on tasks.goals fully provision the goal, exactly like
|
||||
-- a normal project: default kanban statuses (kanban_add_default_columns), the owner
|
||||
-- added to collaboration (add_self_to_collaboration), and the default editor/executor
|
||||
-- roles with their permissions (add_roles_after_insert). The owner also has full
|
||||
-- permissions implicitly.
|
||||
-- Idempotent: orgs that already have an Inbox are skipped.
|
||||
INSERT INTO tasks.goals (name, owner, organization_id, is_inbox)
|
||||
SELECT 'Inbox', o.owner_id, o.id, TRUE
|
||||
FROM tv_auth.organizations o
|
||||
WHERE o.is_personal = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks.goals g
|
||||
WHERE g.organization_id = o.id
|
||||
AND g.is_inbox = TRUE
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Hard guarantee of at most one Inbox per organization. Defends the
|
||||
-- check-then-insert in GoalsRepository.createInboxGoal against concurrent
|
||||
-- signups/calls that could both pass the findInboxGoal precheck and insert.
|
||||
-- Safe to create here: it runs after the idempotent backfill, so no duplicates exist.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS goals_one_inbox_per_org_uidx
|
||||
ON tasks.goals (organization_id)
|
||||
WHERE is_inbox;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Messaging integrations (Slack / Telegram): outbound delivery targets, pending
|
||||
-- binding tokens, and the map between a TaskView user and their external account.
|
||||
-- See promo/integrations.md for the full design.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.messaging_connections (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
owner_type VARCHAR(20) NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
target_chat_id VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
external_team_id VARCHAR(255),
|
||||
access_token_encrypted VARCHAR,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One delivery target per (provider, owner, chat) — re-connecting the same chat updates instead of duplicating.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS messaging_connection_unique
|
||||
ON tasks.messaging_connections (provider, owner_type, owner_id, target_chat_id);
|
||||
|
||||
-- Pending binding intent, redeemed from the messenger. Owner is polymorphic
|
||||
-- (user / project / organization); permission to bind is checked when the token is minted.
|
||||
CREATE TABLE IF NOT EXISTS tasks.messaging_link_tokens (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
token VARCHAR(128) NOT NULL UNIQUE,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
owner_type VARCHAR(20) NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks.messaging_identity_map (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
external_user_id VARCHAR(255),
|
||||
linked_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One identity per provider per user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS messaging_identity_unique
|
||||
ON tasks.messaging_identity_map (user_id, provider);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Per-connection event subscription: which events this messaging connection delivers.
|
||||
-- Default covers the common task events; users opt into sprint events in the UI.
|
||||
ALTER TABLE tasks.messaging_connections
|
||||
ADD COLUMN IF NOT EXISTS events VARCHAR[] NOT NULL
|
||||
DEFAULT ARRAY['task.created','task.assigned','task.statusChanged','task.completed']::VARCHAR[];
|
||||
@@ -0,0 +1,15 @@
|
||||
-- A user is either a member of a project or not — there is no meaning to two
|
||||
-- membership rows for the same (user_id, goal_id). Roles live in a separate table
|
||||
-- (collaboration.users_to_roles), so multi-role membership does not need duplicate rows.
|
||||
-- Dedupe any existing duplicates (the table has no PK, so key off ctid), then enforce
|
||||
-- uniqueness. Insert paths use ON CONFLICT DO NOTHING so re-adding a member is idempotent.
|
||||
|
||||
DELETE FROM collaboration.users_to_goals
|
||||
WHERE ctid NOT IN (
|
||||
SELECT MIN(ctid)
|
||||
FROM collaboration.users_to_goals
|
||||
GROUP BY user_id, goal_id
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_to_goals_user_goal_uidx
|
||||
ON collaboration.users_to_goals (user_id, goal_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Project channels: opt-out flag for including the RBAC-gated task description in the
|
||||
-- channel message. Default TRUE (channel is a deliberate broadcast). Personal DMs are
|
||||
-- unaffected — they always gate the description per recipient (COMPONENT_CAN_WATCH_CONTENT).
|
||||
ALTER TABLE tasks.messaging_connections
|
||||
ADD COLUMN IF NOT EXISTS post_content BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks.messaging_identity_map ADD COLUMN IF NOT EXISTS external_team_id VARCHAR(255);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 'fixed' — occurrences follow the calendar schedule (rrule anchored at dtstart);
|
||||
-- 'after-completion' — the next occurrence is one FREQ/INTERVAL step after the
|
||||
-- day the current instance was completed (Todoist "every!"), no calendar anchor.
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'fixed';
|
||||
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
DROP CONSTRAINT IF EXISTS recurrence_schedule_mode_valid;
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD CONSTRAINT recurrence_schedule_mode_valid CHECK (schedule_mode IN ('fixed', 'after-completion'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.integrations DROP CONSTRAINT IF EXISTS integrations_provider_check;
|
||||
ALTER TABLE tasks.integrations ADD CONSTRAINT integrations_provider_check CHECK (provider IN ('github', 'gitlab', 'gitea'));
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Log of sent project-invite emails, used to rate-limit sending:
|
||||
-- a 24h per-recipient cooldown and an hourly cap per initiator.
|
||||
-- Rows older than 24 hours are pruned opportunistically before each insert.
|
||||
CREATE TABLE IF NOT EXISTS collaboration.invite_emails (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
initiator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
sent_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_initiator_sent ON collaboration.invite_emails(initiator_id, sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_goal_email_sent ON collaboration.invite_emails(goal_id, email, sent_at);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tv_auth.sso_configs
|
||||
ADD COLUMN IF NOT EXISTS domain_verify_token VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS domain_verified_at TIMESTAMP;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tv_auth.sso_configs
|
||||
DROP CONSTRAINT IF EXISTS sso_configs_email_domain_restriction_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sso_configs_verified_domain_uniq
|
||||
ON tv_auth.sso_configs (email_domain_restriction)
|
||||
WHERE domain_verified_at IS NOT NULL;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- OAuth 2.1 client registry. Clients are either seeded manually by an operator
|
||||
-- or created through Dynamic Client Registration (RFC 7591) when it is enabled.
|
||||
-- Public clients (MCP clients such as ChatGPT or Claude) hold no secret and are
|
||||
-- authenticated by PKCE alone, so client_secret_hash stays NULL for them.
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.oauth_clients (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
client_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
client_secret_hash VARCHAR(64),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
redirect_uris VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
created_via VARCHAR(16) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT oauth_clients_created_via_check CHECK (created_via IN ('manual', 'dcr'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON tv_auth.oauth_clients(client_id);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Short-lived, single-use authorization codes issued by the consent screen and
|
||||
-- redeemed once at the token endpoint. Only the hash is stored, mirroring
|
||||
-- tv_auth.api_tokens. used_at is set on redemption: a second redemption of the
|
||||
-- same code is treated as replay and revokes the grant it produced.
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.oauth_auth_codes (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
code_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
redirect_uri VARCHAR NOT NULL,
|
||||
code_challenge VARCHAR(128) NOT NULL,
|
||||
code_challenge_method VARCHAR(8) NOT NULL DEFAULT 'S256',
|
||||
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
|
||||
resource VARCHAR,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used_at TIMESTAMP,
|
||||
grant_id INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT oauth_auth_codes_challenge_method_check CHECK (code_challenge_method = 'S256')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code_hash ON tv_auth.oauth_auth_codes(code_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires_at ON tv_auth.oauth_auth_codes(expires_at);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- One row per (user, client) authorization — this is what the user sees and
|
||||
-- revokes as a "connected app". The refresh token hangs off the grant and is
|
||||
-- rotated on every use; refresh_token_prev_hash keeps the previous value so a
|
||||
-- replayed refresh token can be detected and the whole grant revoked.
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.oauth_grants (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
|
||||
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
|
||||
resource VARCHAR,
|
||||
refresh_token_hash VARCHAR(64) UNIQUE,
|
||||
refresh_token_prev_hash VARCHAR(64),
|
||||
refresh_expires_at TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_grants_user_id ON tv_auth.oauth_grants(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_grants_refresh_token_hash ON tv_auth.oauth_grants(refresh_token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_grants_prev_refresh_hash ON tv_auth.oauth_grants(refresh_token_prev_hash);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'oauth_auth_codes_grant_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE tv_auth.oauth_auth_codes
|
||||
ADD CONSTRAINT oauth_auth_codes_grant_id_fkey
|
||||
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- OAuth access tokens live in tv_auth.api_tokens alongside manually issued
|
||||
-- tvk_ tokens: same opaque-token storage, same validation path, same permission
|
||||
-- intersection. grant_id ties an access token to the OAuth grant that minted it,
|
||||
-- so revoking a connected app deletes its live access tokens immediately.
|
||||
ALTER TABLE tv_auth.api_tokens
|
||||
ADD COLUMN IF NOT EXISTS grant_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_grant_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE tv_auth.api_tokens
|
||||
ADD CONSTRAINT api_tokens_grant_id_fkey
|
||||
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_grant_id ON tv_auth.api_tokens(grant_id);
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Permissions for the surfaces that were previously guarded only by an
|
||||
-- organization role or by project ownership, and therefore ignored the scope of
|
||||
-- an API / OAuth token entirely: a token issued with a single permission could
|
||||
-- still create organizations, add admins, configure SSO and create webhooks.
|
||||
--
|
||||
-- These keys let a token be narrowed on those actions too. They never grant
|
||||
-- anything: the role and ownership checks still run first, and RequireTokenPermission
|
||||
-- only removes what the token was not given.
|
||||
INSERT INTO tv_auth.permissions_group (id, name)
|
||||
VALUES (6, 'organization')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'org_can_view',
|
||||
'View the organization and its members',
|
||||
6,
|
||||
'{
|
||||
"en": "View organization. See the organization and the list of its members.",
|
||||
"ru": "Просмотр организации. Видеть организацию и список её участников.",
|
||||
"de": "Organisation ansehen. Die Organisation und ihre Mitglieder sehen.",
|
||||
"es": "Ver la organización. Ver la organización y la lista de sus miembros.",
|
||||
"pt-BR": "Ver a organização. Ver a organização e a lista de seus membros."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'org_can_manage',
|
||||
'Create, rename and delete organizations',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage organizations. Create, rename and delete organizations.",
|
||||
"ru": "Управление организациями. Создавать, переименовывать и удалять организации.",
|
||||
"de": "Organisationen verwalten. Organisationen erstellen, umbenennen und löschen.",
|
||||
"es": "Gestionar organizaciones. Crear, renombrar y eliminar organizaciones.",
|
||||
"pt-BR": "Gerenciar organizações. Criar, renomear e excluir organizações."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'org_can_manage_members',
|
||||
'Add and remove organization members and change their roles',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage members. Add and remove organization members and change their roles.",
|
||||
"ru": "Управление участниками. Добавлять и удалять участников организации, менять их роли.",
|
||||
"de": "Mitglieder verwalten. Mitglieder hinzufügen, entfernen und deren Rollen ändern.",
|
||||
"es": "Gestionar miembros. Añadir y quitar miembros de la organización y cambiar sus roles.",
|
||||
"pt-BR": "Gerenciar membros. Adicionar e remover membros da organização e alterar seus papéis."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'sso_can_manage',
|
||||
'Create and change the single sign-on configuration',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage SSO. Create and change the single sign-on configuration of the organization.",
|
||||
"ru": "Управление SSO. Создавать и изменять настройки единого входа организации.",
|
||||
"de": "SSO verwalten. Die Single-Sign-on-Konfiguration der Organisation erstellen und ändern.",
|
||||
"es": "Gestionar SSO. Crear y cambiar la configuración de inicio de sesión único de la organización.",
|
||||
"pt-BR": "Gerenciar SSO. Criar e alterar a configuração de login único da organização."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'webhooks_can_manage',
|
||||
'Create, edit and delete project webhooks',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage webhooks. Create, edit and delete webhooks of a project.",
|
||||
"ru": "Управление вебхуками. Создавать, изменять и удалять вебхуки проекта.",
|
||||
"de": "Webhooks verwalten. Webhooks eines Projekts erstellen, bearbeiten und löschen.",
|
||||
"es": "Gestionar webhooks. Crear, editar y eliminar webhooks de un proyecto.",
|
||||
"pt-BR": "Gerenciar webhooks. Criar, editar e excluir webhooks de um projeto."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
+12
-1
@@ -8,13 +8,24 @@ export class Database {
|
||||
public dbDrizzle: ReturnType<typeof drizzle>;
|
||||
|
||||
private constructor() {
|
||||
const poolMaxRaw = Number(process.env.DB_POOL_MAX);
|
||||
const poolMax = Number.isFinite(poolMaxRaw) && poolMaxRaw > 0 ? poolMaxRaw : 20;
|
||||
|
||||
this.pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: +process.env.DB_PORT!,
|
||||
max: poolMax,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
});
|
||||
|
||||
this.pool.on('connect', (client) => {
|
||||
client.query("SET TIME ZONE 'UTC'").catch((err) => {
|
||||
console.error('Failed to set session timezone to UTC:', err);
|
||||
});
|
||||
});
|
||||
|
||||
this.dbDrizzle = drizzle({ client: this.pool, casing: 'camelCase' });
|
||||
@@ -48,7 +59,7 @@ export class Database {
|
||||
try {
|
||||
const res = await client.query(text, params);
|
||||
const duration = Date.now() - start;
|
||||
$logger.info('executed query', { text, duration, rows: res.rowCount });
|
||||
$logger.info({ text, duration, rows: res.rowCount }, 'executed query');
|
||||
// console.log('executed query', { text, duration, rows: res.rowCount });
|
||||
return res;
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export class PublicApiUrl {
|
||||
static configured(): string | null {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
return raw.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
static base(req: Request): string {
|
||||
return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw.trim());
|
||||
} catch {
|
||||
throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import GraphRoutes from '../tv-modules/graph/GraphRoutes';
|
||||
import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes';
|
||||
import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes';
|
||||
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
|
||||
import MessagingRoutes from '../tv-modules/messaging/MessagingRoutes';
|
||||
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
|
||||
import OAuthRoutes from '../tv-modules/oauth/OAuthRoutes';
|
||||
import OAuthWellKnownRoutes from '../tv-modules/oauth/OAuthWellKnownRoutes';
|
||||
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
|
||||
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
|
||||
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
|
||||
@@ -16,6 +19,11 @@ import TasksRoutes from '../tv-modules/tasks/TasksRoutes';
|
||||
import OrganizationRoutes from '../tv-modules/organizations/OrganizationRoutes';
|
||||
import SsoRoutes from '../tv-modules/sso/SsoRoutes';
|
||||
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
|
||||
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
|
||||
import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
|
||||
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
|
||||
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
|
||||
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
|
||||
import type { Routable } from '../types/routable.type';
|
||||
|
||||
type RoutableConstructor = new (...args: any[]) => Routable;
|
||||
@@ -34,11 +42,19 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
'/module/messaging': MessagingRoutes,
|
||||
'/module/api-tokens': ApiTokensRoutes,
|
||||
'/module/oauth': OAuthRoutes,
|
||||
'/module/sessions': SessionsRoutes,
|
||||
'/module/organizations': OrganizationRoutes,
|
||||
'/module/sso': SsoRoutes,
|
||||
'/module/analytics': AnalyticsRoutes,
|
||||
'/module/time-tracking': TimeTrackingRoutes,
|
||||
'/module/ui-preferences': UiPreferencesRoutes,
|
||||
'/module/sprints': SprintsRoutes,
|
||||
'/module/recurrence': RecurrenceRoutes,
|
||||
'/scim/v2': ScimRoutes,
|
||||
'/.well-known': OAuthWellKnownRoutes,
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { type } from 'arktype'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import type { AnalyticsScope } from 'taskview-api'
|
||||
import { $logger } from '../../modules/logget'
|
||||
import { parseDrillDownMeta, resolveRange } from './helpers'
|
||||
import { AnalyticsDrillDownArkType, AnalyticsFetchSectionsArkType } from './types'
|
||||
|
||||
export class AnalyticsController {
|
||||
fetchCatalog = async (req: Request, res: Response) => {
|
||||
const catalog = req.appUser.analyticsManager.getCatalog()
|
||||
return res.tvJson(catalog)
|
||||
}
|
||||
|
||||
fetchSections = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const out = AnalyticsFetchSectionsArkType(req.query)
|
||||
if (out instanceof type.errors) {
|
||||
$logger.warn(`analytics: validation failed: ${out.summary}`)
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
let scope: AnalyticsScope
|
||||
if (out.scope === 'project') {
|
||||
if (out.goalId === undefined) {
|
||||
return res.status(400).send('goalId is required for project scope')
|
||||
}
|
||||
scope = { kind: 'project', goalId: out.goalId }
|
||||
} else {
|
||||
scope = { kind: out.scope }
|
||||
}
|
||||
|
||||
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
|
||||
if (!range) return res.status(400).send('invalid range')
|
||||
|
||||
const sectionIds = out.sections
|
||||
? out.sections.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const data = await req.appUser.analyticsManager.buildSections({
|
||||
scope,
|
||||
organizationId: out.organizationId,
|
||||
period: out.period,
|
||||
range,
|
||||
timezone: out.timezone,
|
||||
sectionIds,
|
||||
})
|
||||
return res.tvJson(data)
|
||||
} catch (err) {
|
||||
$logger.error({
|
||||
err,
|
||||
userId: req.appUser.getUserData()?.id,
|
||||
organizationId: out.organizationId,
|
||||
scope,
|
||||
period: out.period,
|
||||
}, 'Analytics fetchSections failed')
|
||||
return next(err)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDrillDown = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const sectionId = req.params.sectionId
|
||||
if (!sectionId) return res.status(400).send('missing sectionId')
|
||||
|
||||
const out = AnalyticsDrillDownArkType(req.query)
|
||||
if (out instanceof type.errors) {
|
||||
$logger.warn(`analytics drill-down: validation failed: ${out.summary}`)
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
|
||||
let scope: AnalyticsScope
|
||||
if (out.scope === 'project') {
|
||||
if (out.goalId === undefined) {
|
||||
return res.status(400).send('goalId is required for project scope')
|
||||
}
|
||||
scope = { kind: 'project', goalId: out.goalId }
|
||||
} else {
|
||||
scope = { kind: out.scope }
|
||||
}
|
||||
|
||||
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
|
||||
if (!range) return res.status(400).send('invalid range')
|
||||
|
||||
const meta = parseDrillDownMeta(out.meta)
|
||||
const index = Math.min(out.index ?? 0, 10000)
|
||||
|
||||
try {
|
||||
const data = await req.appUser.analyticsManager.drillDown({
|
||||
sectionId,
|
||||
scope,
|
||||
organizationId: out.organizationId,
|
||||
period: out.period,
|
||||
range,
|
||||
arg: {
|
||||
bucket: out.bucket ?? '',
|
||||
index,
|
||||
datasetId: out.datasetId ?? '',
|
||||
meta,
|
||||
},
|
||||
})
|
||||
return res.tvJson(data)
|
||||
} catch (err) {
|
||||
$logger.error({
|
||||
err,
|
||||
sectionId,
|
||||
userId: req.appUser.getUserData()?.id,
|
||||
organizationId: out.organizationId,
|
||||
scope,
|
||||
}, 'Analytics fetchDrillDown failed')
|
||||
return next(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
AnalyticsAvailableGoal,
|
||||
AnalyticsDrillDownResponse,
|
||||
AnalyticsScope,
|
||||
AnalyticsSection,
|
||||
AnalyticsSectionCatalogEntry,
|
||||
AnalyticsSectionsResponse,
|
||||
} from 'taskview-api'
|
||||
import type { AppUser } from '../../core/AppUser'
|
||||
import { $logger } from '../../modules/logget'
|
||||
import { GoalPermissions } from '../../types/auth.types'
|
||||
import { AnalyticsRepository } from './AnalyticsRepository'
|
||||
import { formatDateInZone } from './helpers'
|
||||
import { SectionRegistry } from './sections/SectionRegistry'
|
||||
import type {
|
||||
AnalyticsArgBuildSections,
|
||||
AnalyticsArgDrillDown,
|
||||
BuilderContext,
|
||||
SectionBuilder,
|
||||
} from './types'
|
||||
|
||||
export class AnalyticsManager {
|
||||
public readonly repository: AnalyticsRepository
|
||||
private readonly registry: SectionRegistry
|
||||
private readonly user: AppUser
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user
|
||||
this.repository = new AnalyticsRepository()
|
||||
this.registry = new SectionRegistry()
|
||||
}
|
||||
|
||||
getCatalog(): AnalyticsSectionCatalogEntry[] {
|
||||
return this.registry.catalog()
|
||||
}
|
||||
|
||||
async getAccessibleGoalIds(organizationId: number): Promise<number[]> {
|
||||
return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW])
|
||||
}
|
||||
|
||||
async getDrillDownGoalIds(organizationId: number): Promise<number[]> {
|
||||
return this.fetchGoalIds(organizationId, [
|
||||
GoalPermissions.ANALYTICS_CAN_VIEW,
|
||||
GoalPermissions.TASKS_CAN_WATCH_DETAILS,
|
||||
])
|
||||
}
|
||||
|
||||
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
|
||||
const { scope, organizationId, period, range, timezone, sectionIds } = params
|
||||
|
||||
const allAccessible = await this.getAccessibleGoalIds(organizationId)
|
||||
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
|
||||
|
||||
const ctx: BuilderContext = {
|
||||
appUser: this.user,
|
||||
scope,
|
||||
period,
|
||||
range,
|
||||
accessibleGoalIds,
|
||||
repository: this.repository,
|
||||
}
|
||||
|
||||
const builders = this.registry.filterByIds(sectionIds)
|
||||
const eligible = builders.filter(b => this.isBuilderEligible(b, scope))
|
||||
|
||||
const settled = await Promise.allSettled(eligible.map(b => b.build(ctx)))
|
||||
|
||||
const sections: AnalyticsSection[] = []
|
||||
const failedSectionIds: string[] = []
|
||||
settled.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
sections.push(r.value)
|
||||
} else if (r.status === 'rejected') {
|
||||
const sectionId = eligible[i].id
|
||||
failedSectionIds.push(sectionId)
|
||||
$logger.error({
|
||||
err: r.reason,
|
||||
sectionId,
|
||||
userId: this.user.getUserData()?.id,
|
||||
organizationId,
|
||||
}, 'Analytics section failed')
|
||||
}
|
||||
})
|
||||
|
||||
const availableGoals = await this.fetchAvailableGoals(allAccessible)
|
||||
|
||||
return {
|
||||
scope,
|
||||
period,
|
||||
range: { from: formatDateInZone(range.from, timezone), to: formatDateInZone(range.to, timezone) },
|
||||
sections,
|
||||
availableGoals,
|
||||
failedSectionIds,
|
||||
}
|
||||
}
|
||||
|
||||
async drillDown(params: AnalyticsArgDrillDown): Promise<AnalyticsDrillDownResponse> {
|
||||
const { sectionId, scope, organizationId, period, range, arg } = params
|
||||
|
||||
const builder = this.registry.get(sectionId)
|
||||
if (!builder || !builder.drillDown) {
|
||||
return { sectionId, tasks: [], total: 0 }
|
||||
}
|
||||
|
||||
const aggregateGoalIds = this.narrowToScope(
|
||||
await this.getAccessibleGoalIds(organizationId),
|
||||
scope,
|
||||
)
|
||||
const accessibleGoalIds = this.narrowToScope(
|
||||
await this.getDrillDownGoalIds(organizationId),
|
||||
scope,
|
||||
)
|
||||
|
||||
if (aggregateGoalIds.length > 0 && accessibleGoalIds.length === 0) {
|
||||
return { sectionId, tasks: [], total: 0, denied: true }
|
||||
}
|
||||
|
||||
const ctx: BuilderContext = {
|
||||
appUser: this.user,
|
||||
scope,
|
||||
period,
|
||||
range,
|
||||
accessibleGoalIds,
|
||||
repository: this.repository,
|
||||
}
|
||||
|
||||
const tasks = await builder.drillDown(ctx, arg).catch((err) => {
|
||||
$logger.error(`Analytics drill-down ${sectionId} failed: ${err}`)
|
||||
return []
|
||||
})
|
||||
|
||||
return { sectionId, tasks, total: tasks.length }
|
||||
}
|
||||
|
||||
private async fetchGoalIds(organizationId: number, permissions: string[]): Promise<number[]> {
|
||||
const userData = this.user.getUserData()
|
||||
if (!userData?.id || !userData?.email) return []
|
||||
|
||||
const ids = await this.user.organizationManager.isCurrentUserOrgOwner(organizationId)
|
||||
? await this.repository.fetchAllGoalIdsInOrg(organizationId)
|
||||
: await this.repository.fetchGoalIdsWithPermissions(
|
||||
userData.id,
|
||||
userData.email,
|
||||
organizationId,
|
||||
permissions,
|
||||
)
|
||||
|
||||
return this.applyTokenFilter(ids)
|
||||
}
|
||||
|
||||
private applyTokenFilter(ids: number[]): number[] {
|
||||
const tokenAllowed = this.user.getAllowedGoalIds()
|
||||
if (tokenAllowed && tokenAllowed.length > 0) {
|
||||
return ids.filter(id => tokenAllowed.includes(id))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
private narrowToScope(allAccessible: number[], scope: AnalyticsScope): number[] {
|
||||
if (scope.kind === 'project') {
|
||||
return allAccessible.includes(scope.goalId) ? [scope.goalId] : []
|
||||
}
|
||||
return allAccessible
|
||||
}
|
||||
|
||||
private isBuilderEligible(builder: SectionBuilder, scope: AnalyticsScope): boolean {
|
||||
if (builder.requiresGoalScope && scope.kind !== 'project') return false
|
||||
return true
|
||||
}
|
||||
|
||||
private async fetchAvailableGoals(goalIds: number[]): Promise<AnalyticsAvailableGoal[]> {
|
||||
if (goalIds.length === 0) return []
|
||||
return await this.repository.getGoalsForIds(goalIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
|
||||
import { GoalsSchema } from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
import { toIntArraySql } from './helpers'
|
||||
import type {
|
||||
ActiveProjectsSectionRow,
|
||||
AgingOpenTasksSectionRow,
|
||||
AmountCoverageKpiRow,
|
||||
AmountPerProjectMonthSectionRow,
|
||||
AmountPerTagMonthSectionRow,
|
||||
BlockedByDependenciesSectionRow,
|
||||
CompletedTasksKpiRow,
|
||||
CreatedTasksKpiRow,
|
||||
CycleTimeHistogramSectionRow,
|
||||
CycleTimeKpiRow,
|
||||
CycleTimePerProjectSectionRow,
|
||||
IncomeExpenseMonthSectionRow,
|
||||
IncomeExpensePerProjectSectionRow,
|
||||
NetProfitKpiRow,
|
||||
OverdueByAgeSectionRow,
|
||||
OverdueKpiRow,
|
||||
PlannedExpenseKpiRow,
|
||||
PlannedIncomeKpiRow,
|
||||
PriorityMixOverTimeSectionRow,
|
||||
StaleTasksSectionRow,
|
||||
StatusDistributionSectionRow,
|
||||
ThroughputSectionRow,
|
||||
TimeInKanbanStatusSectionRow,
|
||||
TopProjectsByAmountSectionRow,
|
||||
TotalExpenseKpiRow,
|
||||
TotalIncomeKpiRow,
|
||||
WorkloadByAssigneeSectionRow,
|
||||
} from './sections/row.types'
|
||||
import { UNTAGGED_TAG_ID } from './types'
|
||||
import type { AnalyticsRange, DrillDownTaskRow, FetchAmountPerProjectMonthArgs, FetchAmountPerTagMonthArgs } from './types'
|
||||
|
||||
type Bucket = 'day' | 'week' | 'month'
|
||||
|
||||
const BUCKET_SQL: Record<Bucket, { trunc: SQL, interval: SQL }> = {
|
||||
day: { trunc: sql.raw("'day'"), interval: sql.raw("'1 day'::interval") },
|
||||
week: { trunc: sql.raw("'week'"), interval: sql.raw("'1 week'::interval") },
|
||||
month: { trunc: sql.raw("'month'"), interval: sql.raw("'1 month'::interval") },
|
||||
}
|
||||
|
||||
function bucketLiterals(bucket: Bucket): { trunc: SQL, interval: SQL } {
|
||||
const lit = BUCKET_SQL[bucket]
|
||||
if (!lit) throw new Error(`Invalid bucket: ${String(bucket)}`)
|
||||
return lit
|
||||
}
|
||||
|
||||
const DRILL_DOWN_LIMIT = 200
|
||||
|
||||
const DRILL_DOWN_TASK_FIELDS = sql`
|
||||
t.id::int as "id",
|
||||
t.description as "description",
|
||||
t.goal_id::int as "goalId",
|
||||
g.name as "goalName",
|
||||
coalesce(t.complete, false) as "complete",
|
||||
t.priority_id::int as "priorityId",
|
||||
t.end_date::text as "endDate",
|
||||
t.date_creation::text as "date_creation",
|
||||
t.date_complete::text as "date_complete"
|
||||
`
|
||||
|
||||
export class AnalyticsRepository {
|
||||
private readonly db: Database
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
// ================== Goal lookups ==================
|
||||
|
||||
async fetchAllGoalIdsInOrg(organizationId: number): Promise<number[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ id: GoalsSchema.id })
|
||||
.from(GoalsSchema)
|
||||
.where(and(
|
||||
eq(GoalsSchema.organizationId, organizationId),
|
||||
eq(GoalsSchema.archive, 0),
|
||||
)),
|
||||
)
|
||||
return (result ?? []).map(r => r.id).filter((id): id is number => id !== null)
|
||||
}
|
||||
|
||||
async fetchGoalIdsWithPermissions(
|
||||
userId: number,
|
||||
email: string,
|
||||
organizationId: number,
|
||||
permissionNames: string[],
|
||||
): Promise<number[]> {
|
||||
if (permissionNames.length === 0) return []
|
||||
|
||||
const result = await this.db.dbDrizzle.execute<{ id: number }>(sql`
|
||||
select g.id from tasks.goals g
|
||||
where g.organization_id = ${organizationId}
|
||||
and g.archive = 0
|
||||
and (
|
||||
g.owner = ${userId}
|
||||
or (
|
||||
select count(distinct p.name)
|
||||
from collaboration.users cu
|
||||
join collaboration.users_to_goals utg on utg.user_id = cu.id and utg.goal_id = g.id
|
||||
join collaboration.users_to_roles utr on utr.user_id = cu.id
|
||||
join collaboration.roles r on r.id = utr.role_id and r.goal_id = g.id
|
||||
join collaboration.permissions_to_role ptr on ptr.role_id = r.id
|
||||
join tv_auth.permissions p on p.id = ptr.permission_id
|
||||
where cu.email = ${email}
|
||||
and p.name = any(${sql`ARRAY[${sql.join(permissionNames.map(n => sql`${n}`), sql`, `)}]::text[]`})
|
||||
) = ${permissionNames.length}
|
||||
)
|
||||
`)
|
||||
return result.rows.map(r => Number(r.id)).filter(id => Number.isInteger(id))
|
||||
}
|
||||
|
||||
async getGoalsForIds(ids: number[]) {
|
||||
if (ids.length === 0) return []
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({
|
||||
id: GoalsSchema.id,
|
||||
name: GoalsSchema.name,
|
||||
})
|
||||
.from(GoalsSchema)
|
||||
.where(inArray(GoalsSchema.id, ids)),
|
||||
)
|
||||
return (result ?? []).map(r => ({ id: r.id ?? 0, name: r.name ?? '' }))
|
||||
}
|
||||
|
||||
// ================== KPI ==================
|
||||
|
||||
async countCreated(goalIds: number[], range: AnalyticsRange): Promise<number> {
|
||||
const result = await this.db.dbDrizzle.execute<CreatedTasksKpiRow>(sql`
|
||||
select count(*)::int as count
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and date_creation >= ${range.from.toISOString()}
|
||||
and date_creation < ${range.to.toISOString()}
|
||||
`)
|
||||
return Number(result.rows[0]?.count ?? 0)
|
||||
}
|
||||
|
||||
async countCompleted(goalIds: number[], range: AnalyticsRange): Promise<number> {
|
||||
const result = await this.db.dbDrizzle.execute<CompletedTasksKpiRow>(sql`
|
||||
select count(*)::int as count
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and complete = true
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
`)
|
||||
return Number(result.rows[0]?.count ?? 0)
|
||||
}
|
||||
|
||||
async countOverdue(goalIds: number[]): Promise<number> {
|
||||
const result = await this.db.dbDrizzle.execute<OverdueKpiRow>(sql`
|
||||
select count(*)::int as count
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (complete is null or complete = false)
|
||||
and end_date is not null
|
||||
and end_date < current_date
|
||||
`)
|
||||
return Number(result.rows[0]?.count ?? 0)
|
||||
}
|
||||
|
||||
async medianCycleTime(goalIds: number[], range: AnalyticsRange): Promise<number | null> {
|
||||
const result = await this.db.dbDrizzle.execute<CycleTimeKpiRow>(sql`
|
||||
select percentile_cont(0.5) within group (
|
||||
order by extract(epoch from (date_complete - date_creation)) / 86400
|
||||
)::float as median
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and complete = true
|
||||
and date_complete is not null
|
||||
and date_creation is not null
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
`)
|
||||
const m = result.rows[0]?.median
|
||||
return m === null || m === undefined ? null : Number(m)
|
||||
}
|
||||
|
||||
async sumIncome(goalIds: number[], range: AnalyticsRange): Promise<number> {
|
||||
const result = await this.db.dbDrizzle.execute<TotalIncomeKpiRow>(sql`
|
||||
select coalesce(sum(amount), 0)::float as total
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and complete = true
|
||||
and transaction_type = 1
|
||||
and amount is not null
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
`)
|
||||
return Number(result.rows[0]?.total ?? 0)
|
||||
}
|
||||
|
||||
async sumExpense(goalIds: number[], range: AnalyticsRange): Promise<number> {
|
||||
const result = await this.db.dbDrizzle.execute<TotalExpenseKpiRow>(sql`
|
||||
select coalesce(sum(amount), 0)::float as total
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and complete = true
|
||||
and transaction_type = 0
|
||||
and amount is not null
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
`)
|
||||
return Number(result.rows[0]?.total ?? 0)
|
||||
}
|
||||
|
||||
async sumIncomeAndExpense(goalIds: number[], range: AnalyticsRange): Promise<{ income: number, expense: number }> {
|
||||
const result = await this.db.dbDrizzle.execute<NetProfitKpiRow>(sql`
|
||||
select
|
||||
coalesce(sum(case when transaction_type = 1 then amount else 0 end), 0)::float as income,
|
||||
coalesce(sum(case when transaction_type = 0 then amount else 0 end), 0)::float as expense
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and complete = true
|
||||
and transaction_type in (0, 1)
|
||||
and amount is not null
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
`)
|
||||
const r = result.rows[0] ?? { income: 0, expense: 0 }
|
||||
return { income: Number(r.income), expense: Number(r.expense) }
|
||||
}
|
||||
|
||||
async sumPlannedIncome(goalIds: number[]): Promise<number> {
|
||||
const result = await this.db.dbDrizzle.execute<PlannedIncomeKpiRow>(sql`
|
||||
select coalesce(sum(amount), 0)::float as total
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (complete is null or complete = false)
|
||||
and transaction_type = 1
|
||||
and amount is not null
|
||||
`)
|
||||
return Number(result.rows[0]?.total ?? 0)
|
||||
}
|
||||
|
||||
async sumPlannedExpense(goalIds: number[]): Promise<number> {
|
||||
const result = await this.db.dbDrizzle.execute<PlannedExpenseKpiRow>(sql`
|
||||
select coalesce(sum(amount), 0)::float as total
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (complete is null or complete = false)
|
||||
and transaction_type = 0
|
||||
and amount is not null
|
||||
`)
|
||||
return Number(result.rows[0]?.total ?? 0)
|
||||
}
|
||||
|
||||
async amountCoverage(goalIds: number[]): Promise<{ total: number, withAmount: number }> {
|
||||
const result = await this.db.dbDrizzle.execute<AmountCoverageKpiRow>(sql`
|
||||
select
|
||||
count(*)::int as total,
|
||||
count(*) filter (where amount is not null)::int as with_amount
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
`)
|
||||
const r = result.rows[0] ?? { total: 0, with_amount: 0 }
|
||||
return { total: Number(r.total), withAmount: Number(r.with_amount) }
|
||||
}
|
||||
|
||||
// ================== Productivity ==================
|
||||
|
||||
async fetchThroughput(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<ThroughputSectionRow[]> {
|
||||
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
|
||||
const goalIdsSql = toIntArraySql(goalIds)
|
||||
|
||||
const result = await this.db.dbDrizzle.execute<ThroughputSectionRow>(sql`
|
||||
with buckets as (
|
||||
select generate_series(
|
||||
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
|
||||
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
|
||||
${intervalSql}
|
||||
) as bucket
|
||||
),
|
||||
created as (
|
||||
select date_trunc(${bucketSql}, date_creation) as bucket, count(*)::int as count
|
||||
from tasks.tasks
|
||||
where goal_id = any(${goalIdsSql})
|
||||
and date_creation >= ${range.from.toISOString()}
|
||||
and date_creation < ${range.to.toISOString()}
|
||||
group by date_trunc(${bucketSql}, date_creation)
|
||||
),
|
||||
completed as (
|
||||
select date_trunc(${bucketSql}, date_complete) as bucket, count(*)::int as count
|
||||
from tasks.tasks
|
||||
where goal_id = any(${goalIdsSql})
|
||||
and complete = true
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
group by date_trunc(${bucketSql}, date_complete)
|
||||
)
|
||||
select
|
||||
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
|
||||
coalesce(c.count, 0)::int as created,
|
||||
coalesce(d.count, 0)::int as completed
|
||||
from buckets b
|
||||
left join created c on c.bucket = b.bucket
|
||||
left join completed d on d.bucket = b.bucket
|
||||
order by b.bucket asc
|
||||
`)
|
||||
return result.rows as ThroughputSectionRow[]
|
||||
}
|
||||
|
||||
async fetchPriorityMix(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<PriorityMixOverTimeSectionRow[]> {
|
||||
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
|
||||
const goalIdsSql = toIntArraySql(goalIds)
|
||||
|
||||
const result = await this.db.dbDrizzle.execute<PriorityMixOverTimeSectionRow>(sql`
|
||||
with buckets as (
|
||||
select generate_series(
|
||||
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
|
||||
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
|
||||
${intervalSql}
|
||||
) as bucket
|
||||
),
|
||||
created as (
|
||||
select date_trunc(${bucketSql}, date_creation) as bucket, priority_id
|
||||
from tasks.tasks
|
||||
where goal_id = any(${goalIdsSql})
|
||||
and date_creation >= ${range.from.toISOString()}
|
||||
and date_creation < ${range.to.toISOString()}
|
||||
)
|
||||
select
|
||||
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
|
||||
coalesce(sum(case when c.priority_id = 3 then 1 else 0 end), 0)::int as high,
|
||||
coalesce(sum(case when c.priority_id = 2 then 1 else 0 end), 0)::int as medium,
|
||||
coalesce(sum(case when c.priority_id = 1 then 1 else 0 end), 0)::int as low,
|
||||
coalesce(sum(case when c.priority_id is null then 1 else 0 end), 0)::int as none
|
||||
from buckets b
|
||||
left join created c on c.bucket = b.bucket
|
||||
group by b.bucket
|
||||
order by b.bucket asc
|
||||
`)
|
||||
return result.rows as PriorityMixOverTimeSectionRow[]
|
||||
}
|
||||
|
||||
// ================== Workload ==================
|
||||
|
||||
async fetchWorkloadByAssignee(goalIds: number[]): Promise<WorkloadByAssigneeSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<WorkloadByAssigneeSectionRow>(sql`
|
||||
select * from (
|
||||
select
|
||||
cu.id as user_id,
|
||||
coalesce(cu.email, 'Unknown') as user_name,
|
||||
sum(case when t.priority_id = 3 then 1 else 0 end)::int as high,
|
||||
sum(case when t.priority_id = 2 then 1 else 0 end)::int as medium,
|
||||
sum(case when t.priority_id = 1 then 1 else 0 end)::int as low,
|
||||
sum(case when t.priority_id is null then 1 else 0 end)::int as no_priority
|
||||
from tasks.tasks t
|
||||
join tasks_auth.task_assignee ta on ta.task_id = t.id
|
||||
join collaboration.users cu on cu.id = ta.collab_user_id
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
group by cu.id, cu.email
|
||||
) s
|
||||
order by (s.high * 3 + s.medium * 2 + s.low + s.no_priority) desc
|
||||
limit 30
|
||||
`)
|
||||
return result.rows as WorkloadByAssigneeSectionRow[]
|
||||
}
|
||||
|
||||
async fetchBlockedByDeps(goalIds: number[]): Promise<BlockedByDependenciesSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<BlockedByDependenciesSectionRow>(sql`
|
||||
select
|
||||
g.id as goal_id,
|
||||
g.name as goal_name,
|
||||
count(distinct t.id)::int as blocked
|
||||
from tasks.goals g
|
||||
join tasks.tasks t on t.goal_id = g.id
|
||||
join tasks.task_relations r on r.to_task_id = t.id
|
||||
join tasks.tasks src on src.id = r.from_task_id
|
||||
where g.id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and (src.complete is null or src.complete = false)
|
||||
group by g.id, g.name
|
||||
order by blocked desc
|
||||
`)
|
||||
return result.rows as BlockedByDependenciesSectionRow[]
|
||||
}
|
||||
|
||||
async fetchTimeInKanbanStatus(goalId: number, accessibleGoalIds: number[]): Promise<TimeInKanbanStatusSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<TimeInKanbanStatusSectionRow>(sql`
|
||||
select
|
||||
s.id as status_id,
|
||||
coalesce(s.name, 'Без статуса') as status_name,
|
||||
avg(extract(epoch from (now() - coalesce(t.edit_date, t.date_creation))) / 86400.0)::float as avg_days,
|
||||
count(t.id)::int as task_count
|
||||
from tasks.tasks t
|
||||
left join tasks.statuses s on s.id = t.status_id
|
||||
where t.goal_id = ${goalId}
|
||||
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
group by s.id, s.name, s.view_order
|
||||
order by s.view_order nulls last, s.name
|
||||
`)
|
||||
return result.rows as TimeInKanbanStatusSectionRow[]
|
||||
}
|
||||
|
||||
async fetchAgingOpenTasks(goalIds: number[]): Promise<AgingOpenTasksSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<AgingOpenTasksSectionRow>(sql`
|
||||
select
|
||||
cu.id as user_id,
|
||||
coalesce(cu.email, 'Unknown') as user_name,
|
||||
avg(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as avg_age,
|
||||
max(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as max_age,
|
||||
count(distinct t.id)::int as task_count
|
||||
from tasks.tasks t
|
||||
join tasks_auth.task_assignee ta on ta.task_id = t.id
|
||||
join collaboration.users cu on cu.id = ta.collab_user_id
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
group by cu.id, cu.email
|
||||
having count(distinct t.id) > 0
|
||||
order by avg_age desc nulls last
|
||||
limit 20
|
||||
`)
|
||||
return result.rows as AgingOpenTasksSectionRow[]
|
||||
}
|
||||
|
||||
// ================== Quality ==================
|
||||
|
||||
async fetchOverdueByAge(goalIds: number[]): Promise<OverdueByAgeSectionRow> {
|
||||
const result = await this.db.dbDrizzle.execute<OverdueByAgeSectionRow>(sql`
|
||||
select
|
||||
sum(case when (current_date - end_date) between 1 and 3 then 1 else 0 end)::int as bucket_1_3,
|
||||
sum(case when (current_date - end_date) between 4 and 7 then 1 else 0 end)::int as bucket_4_7,
|
||||
sum(case when (current_date - end_date) between 8 and 14 then 1 else 0 end)::int as bucket_8_14,
|
||||
sum(case when (current_date - end_date) > 14 then 1 else 0 end)::int as bucket_15_plus
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (complete is null or complete = false)
|
||||
and end_date is not null
|
||||
and end_date < current_date
|
||||
`)
|
||||
return (result.rows[0] ?? {
|
||||
bucket_1_3: 0, bucket_4_7: 0, bucket_8_14: 0, bucket_15_plus: 0,
|
||||
}) as OverdueByAgeSectionRow
|
||||
}
|
||||
|
||||
async fetchCycleTimeHistogram(goalIds: number[], range: AnalyticsRange): Promise<CycleTimeHistogramSectionRow> {
|
||||
const result = await this.db.dbDrizzle.execute<CycleTimeHistogramSectionRow>(sql`
|
||||
with durations as (
|
||||
select extract(epoch from (date_complete - date_creation)) / 86400.0 as days
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and complete = true
|
||||
and date_complete is not null
|
||||
and date_creation is not null
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
)
|
||||
select
|
||||
sum(case when days < 1 then 1 else 0 end)::int as bucket_0_1,
|
||||
sum(case when days >= 1 and days < 3 then 1 else 0 end)::int as bucket_1_3,
|
||||
sum(case when days >= 3 and days < 7 then 1 else 0 end)::int as bucket_3_7,
|
||||
sum(case when days >= 7 and days < 14 then 1 else 0 end)::int as bucket_7_14,
|
||||
sum(case when days >= 14 and days < 30 then 1 else 0 end)::int as bucket_14_30,
|
||||
sum(case when days >= 30 then 1 else 0 end)::int as bucket_30_plus
|
||||
from durations
|
||||
`)
|
||||
return (result.rows[0] ?? {
|
||||
bucket_0_1: 0, bucket_1_3: 0, bucket_3_7: 0,
|
||||
bucket_7_14: 0, bucket_14_30: 0, bucket_30_plus: 0,
|
||||
}) as CycleTimeHistogramSectionRow
|
||||
}
|
||||
|
||||
async fetchStaleTasks(goalIds: number[]): Promise<StaleTasksSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<StaleTasksSectionRow>(sql`
|
||||
select
|
||||
g.id as goal_id,
|
||||
g.name as goal_name,
|
||||
count(t.id)::int as stale
|
||||
from tasks.goals g
|
||||
join tasks.tasks t on t.goal_id = g.id
|
||||
where g.id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
|
||||
group by g.id, g.name
|
||||
order by stale desc
|
||||
`)
|
||||
return result.rows as StaleTasksSectionRow[]
|
||||
}
|
||||
|
||||
async fetchCycleTimePerProject(goalIds: number[], range: AnalyticsRange): Promise<CycleTimePerProjectSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<CycleTimePerProjectSectionRow>(sql`
|
||||
select
|
||||
g.id as goal_id,
|
||||
g.name as goal_name,
|
||||
percentile_cont(0.5) within group (
|
||||
order by extract(epoch from (t.date_complete - t.date_creation)) / 86400.0
|
||||
)::float as median_days,
|
||||
count(t.id)::int as completed
|
||||
from tasks.goals g
|
||||
join tasks.tasks t on t.goal_id = g.id
|
||||
where g.id = any(${toIntArraySql(goalIds)})
|
||||
and t.complete = true
|
||||
and t.date_complete is not null
|
||||
and t.date_creation is not null
|
||||
and t.date_complete >= ${range.from.toISOString()}
|
||||
and t.date_complete < ${range.to.toISOString()}
|
||||
group by g.id, g.name
|
||||
having count(t.id) > 0
|
||||
order by median_days desc nulls last
|
||||
`)
|
||||
return result.rows as CycleTimePerProjectSectionRow[]
|
||||
}
|
||||
|
||||
// ================== Usage ==================
|
||||
|
||||
async fetchStatusDistribution(goalId: number, accessibleGoalIds: number[]): Promise<StatusDistributionSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<StatusDistributionSectionRow>(sql`
|
||||
select
|
||||
s.id as status_id,
|
||||
coalesce(s.name, 'No status') as status_name,
|
||||
count(t.id)::int as count
|
||||
from tasks.tasks t
|
||||
left join tasks.statuses s on s.id = t.status_id
|
||||
where t.goal_id = ${goalId}
|
||||
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
group by s.id, s.name
|
||||
having count(t.id) > 0
|
||||
order by count desc
|
||||
`)
|
||||
return result.rows as StatusDistributionSectionRow[]
|
||||
}
|
||||
|
||||
async fetchActiveProjects(goalIds: number[]): Promise<ActiveProjectsSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<ActiveProjectsSectionRow>(sql`
|
||||
with last_activity as (
|
||||
select g.id as goal_id,
|
||||
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
|
||||
from tasks.goals g
|
||||
left join tasks.tasks t on t.goal_id = g.id
|
||||
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
|
||||
group by g.id
|
||||
)
|
||||
select status_key, count(*)::int as count
|
||||
from (
|
||||
select
|
||||
case
|
||||
when last_at is null then 'empty'
|
||||
when last_at >= now() - interval '14 days' then 'active'
|
||||
when last_at >= now() - interval '30 days' then 'fading'
|
||||
else 'dead'
|
||||
end as status_key
|
||||
from last_activity
|
||||
) s
|
||||
group by status_key
|
||||
order by case status_key
|
||||
when 'active' then 1
|
||||
when 'fading' then 2
|
||||
when 'dead' then 3
|
||||
when 'empty' then 4
|
||||
end
|
||||
`)
|
||||
return result.rows as ActiveProjectsSectionRow[]
|
||||
}
|
||||
|
||||
// ================== Financial ==================
|
||||
|
||||
async fetchIncomeExpenseMonth(goalIds: number[], range: AnalyticsRange): Promise<IncomeExpenseMonthSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<IncomeExpenseMonthSectionRow>(sql`
|
||||
with months as (
|
||||
select generate_series(
|
||||
date_trunc('month', ${range.from.toISOString()}::timestamp),
|
||||
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
|
||||
'1 month'::interval
|
||||
) as month
|
||||
),
|
||||
totals as (
|
||||
select
|
||||
date_trunc('month', date_complete) as month,
|
||||
sum(case when transaction_type = 1 then coalesce(amount, 0) else 0 end)::float as income,
|
||||
sum(case when transaction_type = 0 then coalesce(amount, 0) else 0 end)::float as expense
|
||||
from tasks.tasks
|
||||
where goal_id = any(${toIntArraySql(goalIds)})
|
||||
and complete = true
|
||||
and date_complete is not null
|
||||
and amount is not null
|
||||
and transaction_type in (0, 1)
|
||||
and date_complete >= ${range.from.toISOString()}
|
||||
and date_complete < ${range.to.toISOString()}
|
||||
group by date_trunc('month', date_complete)
|
||||
)
|
||||
select
|
||||
to_char(m.month, 'YYYY-MM') as month,
|
||||
coalesce(t.income, 0)::float as income,
|
||||
coalesce(t.expense, 0)::float as expense
|
||||
from months m
|
||||
left join totals t on t.month = m.month
|
||||
order by m.month asc
|
||||
`)
|
||||
return result.rows as IncomeExpenseMonthSectionRow[]
|
||||
}
|
||||
|
||||
async fetchIncomeExpensePerProject(goalIds: number[]): Promise<IncomeExpensePerProjectSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<IncomeExpensePerProjectSectionRow>(sql`
|
||||
select
|
||||
g.id as goal_id,
|
||||
g.name as goal_name,
|
||||
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
|
||||
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense,
|
||||
(sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)
|
||||
- sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end))::float as net
|
||||
from tasks.goals g
|
||||
join tasks.tasks t on t.goal_id = g.id
|
||||
where g.id = any(${toIntArraySql(goalIds)})
|
||||
and t.amount is not null
|
||||
and t.transaction_type in (0, 1)
|
||||
group by g.id, g.name
|
||||
having sum(coalesce(t.amount, 0)) > 0
|
||||
order by net desc
|
||||
limit 20
|
||||
`)
|
||||
return result.rows as IncomeExpensePerProjectSectionRow[]
|
||||
}
|
||||
|
||||
async fetchAmountPerTagMonth(args: FetchAmountPerTagMonthArgs): Promise<AmountPerTagMonthSectionRow[]> {
|
||||
const { goalIds, range, transactionType } = args
|
||||
const result = await this.db.dbDrizzle.execute<AmountPerTagMonthSectionRow>(sql`
|
||||
with months as (
|
||||
select generate_series(
|
||||
date_trunc('month', ${range.from.toISOString()}::timestamp),
|
||||
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
|
||||
'1 month'::interval
|
||||
) as month
|
||||
),
|
||||
period_tasks as (
|
||||
select t.id,
|
||||
coalesce(t.amount, 0)::float as amount,
|
||||
date_trunc('month', t.date_complete) as month
|
||||
from tasks.tasks t
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and t.complete = true
|
||||
and t.date_complete is not null
|
||||
and t.amount is not null
|
||||
and t.transaction_type = ${transactionType}
|
||||
and t.date_complete >= ${range.from.toISOString()}
|
||||
and t.date_complete < ${range.to.toISOString()}
|
||||
),
|
||||
task_buckets as (
|
||||
select pt.id, pt.amount, pt.month, tg.id as tag_id, tg.name as tag_name
|
||||
from period_tasks pt
|
||||
join tasks.tasks_to_tags tt on tt.task_id = pt.id
|
||||
join tasks.tags tg on tg.id = tt.tag_id
|
||||
union all
|
||||
select pt.id, pt.amount, pt.month, ${UNTAGGED_TAG_ID} as tag_id, '' as tag_name
|
||||
from period_tasks pt
|
||||
where not exists (
|
||||
select 1 from tasks.tasks_to_tags tt where tt.task_id = pt.id
|
||||
)
|
||||
),
|
||||
tag_totals as (
|
||||
select tag_id, max(tag_name) as tag_name, sum(amount) as total
|
||||
from task_buckets
|
||||
group by tag_id
|
||||
having sum(amount) > 0
|
||||
order by total desc
|
||||
),
|
||||
monthly as (
|
||||
select tb.month, tb.tag_id, sum(tb.amount)::float as amount
|
||||
from task_buckets tb
|
||||
join tag_totals tt on tt.tag_id = tb.tag_id
|
||||
group by tb.month, tb.tag_id
|
||||
)
|
||||
select
|
||||
to_char(m.month, 'YYYY-MM') as month,
|
||||
tt.tag_id::int as tag_id,
|
||||
tt.tag_name as tag_name,
|
||||
coalesce(mn.amount, 0)::float as amount
|
||||
from months m
|
||||
cross join tag_totals tt
|
||||
left join monthly mn on mn.month = m.month and mn.tag_id = tt.tag_id
|
||||
order by tt.total desc, m.month asc
|
||||
`)
|
||||
return result.rows as AmountPerTagMonthSectionRow[]
|
||||
}
|
||||
|
||||
async fetchAmountPerProjectMonth(args: FetchAmountPerProjectMonthArgs): Promise<AmountPerProjectMonthSectionRow[]> {
|
||||
const { goalIds, range, transactionType } = args
|
||||
const result = await this.db.dbDrizzle.execute<AmountPerProjectMonthSectionRow>(sql`
|
||||
with months as (
|
||||
select generate_series(
|
||||
date_trunc('month', ${range.from.toISOString()}::timestamp),
|
||||
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
|
||||
'1 month'::interval
|
||||
) as month
|
||||
),
|
||||
filtered as (
|
||||
select date_trunc('month', t.date_complete) as month,
|
||||
g.id as goal_id,
|
||||
g.name as goal_name,
|
||||
t.amount::float as amount
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
where g.id = any(${toIntArraySql(goalIds)})
|
||||
and t.complete = true
|
||||
and t.date_complete is not null
|
||||
and t.amount is not null
|
||||
and t.transaction_type = ${transactionType}
|
||||
and t.date_complete >= ${range.from.toISOString()}
|
||||
and t.date_complete < ${range.to.toISOString()}
|
||||
),
|
||||
project_totals as (
|
||||
select goal_id, max(goal_name) as goal_name, sum(amount) as total
|
||||
from filtered
|
||||
group by goal_id
|
||||
having sum(amount) > 0
|
||||
order by total desc
|
||||
),
|
||||
monthly as (
|
||||
select f.month, f.goal_id, sum(f.amount)::float as amount
|
||||
from filtered f
|
||||
join project_totals pt on pt.goal_id = f.goal_id
|
||||
group by f.month, f.goal_id
|
||||
)
|
||||
select
|
||||
to_char(m.month, 'YYYY-MM') as month,
|
||||
pt.goal_id::int as goal_id,
|
||||
pt.goal_name as goal_name,
|
||||
coalesce(mn.amount, 0)::float as amount
|
||||
from months m
|
||||
cross join project_totals pt
|
||||
left join monthly mn on mn.month = m.month and mn.goal_id = pt.goal_id
|
||||
order by pt.total desc, m.month asc
|
||||
`)
|
||||
return result.rows as AmountPerProjectMonthSectionRow[]
|
||||
}
|
||||
|
||||
async fetchTopProjectsByAmount(goalIds: number[]): Promise<TopProjectsByAmountSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<TopProjectsByAmountSectionRow>(sql`
|
||||
select * from (
|
||||
select
|
||||
g.id as goal_id,
|
||||
g.name as goal_name,
|
||||
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
|
||||
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense
|
||||
from tasks.goals g
|
||||
join tasks.tasks t on t.goal_id = g.id
|
||||
where g.id = any(${toIntArraySql(goalIds)})
|
||||
and t.amount is not null
|
||||
and t.transaction_type in (0, 1)
|
||||
group by g.id, g.name
|
||||
having sum(coalesce(t.amount, 0)) > 0
|
||||
) s
|
||||
order by (s.income + s.expense) desc
|
||||
limit 15
|
||||
`)
|
||||
return result.rows as TopProjectsByAmountSectionRow[]
|
||||
}
|
||||
|
||||
// ================== Drill-down ==================
|
||||
|
||||
async fetchOverdueTasks(goalIds: number[]): Promise<DrillDownTaskRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
select ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and t.end_date is not null
|
||||
and t.end_date < current_date
|
||||
order by t.end_date asc
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
|
||||
async fetchOverdueTasksInRange(
|
||||
goalIds: number[],
|
||||
minDays: number,
|
||||
maxDays: number | null,
|
||||
): Promise<DrillDownTaskRow[]> {
|
||||
const maxClause = maxDays !== null
|
||||
? sql`and (current_date - t.end_date) <= ${maxDays}`
|
||||
: sql``
|
||||
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
select ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and t.end_date is not null
|
||||
and (current_date - t.end_date) >= ${minDays}
|
||||
${maxClause}
|
||||
order by t.end_date asc
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
|
||||
async fetchStaleTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
select ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
where t.goal_id = ${goalId}
|
||||
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
|
||||
order by coalesce(t.edit_date, t.date_creation) asc
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
|
||||
async fetchBlockedTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
join tasks.task_relations r on r.to_task_id = t.id
|
||||
join tasks.tasks src on src.id = r.from_task_id
|
||||
where t.goal_id = ${goalId}
|
||||
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and (src.complete is null or src.complete = false)
|
||||
order by t.id
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
|
||||
async fetchOpenTasksAssignedTo(goalIds: number[], userId: number): Promise<DrillDownTaskRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
join tasks_auth.task_assignee ta on ta.task_id = t.id
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and ta.collab_user_id = ${userId}
|
||||
order by t.id, t.date_creation asc
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
|
||||
async fetchOpenTasksAssignedWithPriority(
|
||||
goalIds: number[],
|
||||
userId: number,
|
||||
priorityFilter: number | 'null' | undefined,
|
||||
): Promise<DrillDownTaskRow[]> {
|
||||
const priorityClause: SQL = priorityFilter === undefined
|
||||
? sql``
|
||||
: priorityFilter === 'null'
|
||||
? sql`and t.priority_id is null`
|
||||
: sql`and t.priority_id = ${priorityFilter}`
|
||||
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
join tasks_auth.task_assignee ta on ta.task_id = t.id
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and ta.collab_user_id = ${userId}
|
||||
${priorityClause}
|
||||
order by t.id, t.date_creation desc
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
|
||||
async fetchOpenTasksInActiveProjects(
|
||||
goalIds: number[],
|
||||
statusKey: 'active' | 'fading' | 'dead',
|
||||
): Promise<DrillDownTaskRow[]> {
|
||||
let activityClause: SQL
|
||||
if (statusKey === 'active') {
|
||||
activityClause = sql`last_at >= now() - interval '14 days'`
|
||||
} else if (statusKey === 'fading') {
|
||||
activityClause = sql`last_at >= now() - interval '30 days' and last_at < now() - interval '14 days'`
|
||||
} else {
|
||||
activityClause = sql`last_at < now() - interval '30 days'`
|
||||
}
|
||||
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
with last_activity as (
|
||||
select g.id as goal_id,
|
||||
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
|
||||
from tasks.goals g
|
||||
left join tasks.tasks t on t.goal_id = g.id
|
||||
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
|
||||
group by g.id
|
||||
),
|
||||
matching_goals as (
|
||||
select goal_id from last_activity where ${activityClause}
|
||||
)
|
||||
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
join matching_goals m on m.goal_id = t.goal_id
|
||||
where (t.complete is null or t.complete = false)
|
||||
order by t.id, coalesce(t.edit_date, t.date_creation) desc
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
|
||||
async fetchPlannedTasksByType(goalIds: number[], transactionType: 0 | 1): Promise<DrillDownTaskRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
|
||||
select ${DRILL_DOWN_TASK_FIELDS}
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and (t.complete is null or t.complete = false)
|
||||
and t.transaction_type = ${transactionType}
|
||||
and t.amount is not null
|
||||
order by t.amount desc
|
||||
limit ${DRILL_DOWN_LIMIT}
|
||||
`)
|
||||
return result.rows as DrillDownTaskRow[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { AnalyticsController } from './AnalyticsController'
|
||||
import { CanAccessAnalytics } from './middlewares/CanAccessAnalytics'
|
||||
|
||||
export default class AnalyticsRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: AnalyticsController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new AnalyticsController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
const guards = [IsLoggedIn, RejectApiTokenAuth, CanAccessAnalytics]
|
||||
this.router.get('/sections', guards, this.controller.fetchSections)
|
||||
this.router.get('/sections-catalog', [IsLoggedIn], this.controller.fetchCatalog)
|
||||
this.router.get('/drilldown/:sectionId', guards, this.controller.fetchDrillDown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type } from 'arktype'
|
||||
import { sql, type SQL } from 'drizzle-orm'
|
||||
import { DateTime } from 'luxon'
|
||||
import type { AnalyticsPeriod } from 'taskview-api'
|
||||
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta, type ResolveRangeArgs } from './types'
|
||||
|
||||
const MAX_INT32 = 2147483647
|
||||
|
||||
export function toIntArraySql(ids: ReadonlyArray<number>): SQL {
|
||||
const safe = ids.filter(
|
||||
(id): id is number =>
|
||||
typeof id === 'number' && Number.isInteger(id) && id > 0 && id < MAX_INT32,
|
||||
)
|
||||
return sql.raw(`ARRAY[${safe.join(',')}]::int[]`)
|
||||
}
|
||||
|
||||
export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const result = DrillDownMetaArkType(JSON.parse(raw))
|
||||
return result instanceof type.errors ? {} : result
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const DAYS_BY_PERIOD: Record<Exclude<AnalyticsPeriod, 'custom' | 'month'>, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
'180d': 180,
|
||||
'365d': 365,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a period into a concrete UTC instant range, with all calendar
|
||||
* boundaries ("this month", a custom day) computed in the *viewer's* IANA
|
||||
* timezone — not the server's. The returned Dates are real instants used for
|
||||
* SQL filtering; the user-facing 'YYYY-MM-DD' labels come from
|
||||
* {@link formatDateInZone} with the same timezone.
|
||||
*/
|
||||
export function resolveRange(args: ResolveRangeArgs): AnalyticsRange | null {
|
||||
const { period, from, to } = args
|
||||
const zone = args.timezone && DateTime.now().setZone(args.timezone).isValid ? args.timezone : 'utc'
|
||||
const now = DateTime.now().setZone(zone)
|
||||
|
||||
if (period === 'custom') {
|
||||
if (!from || !to) return null
|
||||
const fromDt = DateTime.fromISO(from.slice(0, 10), { zone }).startOf('day')
|
||||
const toDt = DateTime.fromISO(to.slice(0, 10), { zone }).endOf('day')
|
||||
if (!fromDt.isValid || !toDt.isValid) return null
|
||||
if (fromDt > toDt) return null
|
||||
const maxRangeMs = 366 * 24 * 60 * 60 * 1000
|
||||
if (toDt.toMillis() - fromDt.toMillis() > maxRangeMs) return null
|
||||
return { from: fromDt.toJSDate(), to: toDt.toJSDate() }
|
||||
}
|
||||
|
||||
if (period === 'month') {
|
||||
return { from: now.startOf('month').toJSDate(), to: now.toJSDate() }
|
||||
}
|
||||
|
||||
return { from: now.minus({ days: DAYS_BY_PERIOD[period] }).toJSDate(), to: now.toJSDate() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a range boundary as a 'YYYY-MM-DD' calendar day in the viewer's
|
||||
* timezone. Slicing a UTC ISO string instead would shift the day across the
|
||||
* date line for offset timezones (e.g. a "this month" range rendering from the
|
||||
* 31st of the prior month).
|
||||
*/
|
||||
export function formatDateInZone(date: Date, timezone?: string): string {
|
||||
const dt = DateTime.fromJSDate(date).setZone(timezone && DateTime.fromJSDate(date).setZone(timezone).isValid ? timezone : 'utc')
|
||||
return dt.toISODate() as string
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher'
|
||||
import { $logger } from '../../../modules/logget'
|
||||
import { GoalPermissions } from '../../../types/auth.types'
|
||||
import { logError } from '../../../utils/api'
|
||||
import { parsePositiveInt } from '../../../utils/helpers'
|
||||
|
||||
export const CanAccessAnalytics = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const orgId = parsePositiveInt(req.query?.organizationId)
|
||||
if (orgId === null) return res.status(400).end()
|
||||
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
|
||||
if (!member) return res.status(403).end()
|
||||
|
||||
if (await req.appUser.organizationManager.isCurrentUserOrgOwner(orgId)) return next()
|
||||
|
||||
const scope = req.query?.scope
|
||||
|
||||
if (scope === 'project') {
|
||||
const goalId = parsePositiveInt(req.query?.goalId)
|
||||
if (goalId === null) return res.status(400).end()
|
||||
|
||||
const checker = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError)
|
||||
if (!checker) {
|
||||
$logger.error('Can not get permissions for CanAccessAnalytics middleware')
|
||||
return res.status(500).end()
|
||||
}
|
||||
if (!checker.hasPermissions(GoalPermissions.ANALYTICS_CAN_VIEW)) return res.status(403).end()
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
const accessibleGoalIds = await req.appUser.analyticsManager
|
||||
.getAccessibleGoalIds(orgId)
|
||||
.catch(logError)
|
||||
if (!accessibleGoalIds || accessibleGoalIds.length === 0) return res.status(403).end()
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { SectionBuilder } from '../types'
|
||||
import { CreatedTasksKpi } from './kpi/CreatedTasksKpi'
|
||||
import { CompletedTasksKpi } from './kpi/CompletedTasksKpi'
|
||||
import { OverdueKpi } from './kpi/OverdueKpi'
|
||||
import { ThroughputSection } from './productivity/ThroughputSection'
|
||||
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
|
||||
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
|
||||
import { StaleTasksSection } from './quality/StaleTasksSection'
|
||||
import { StatusDistributionSection } from './usage/StatusDistributionSection'
|
||||
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
|
||||
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
|
||||
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
|
||||
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
|
||||
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
|
||||
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
|
||||
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
|
||||
import { NetProfitKpi } from './financial/NetProfitKpi'
|
||||
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
|
||||
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
|
||||
import type { AnalyticsSectionCatalogEntry } from 'taskview-api'
|
||||
import { parseEnabledSectionIds } from './sectionEnv'
|
||||
import { sectionLocales } from './locales'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disabled sections — re-enable once the underlying data model can support them
|
||||
// honestly. Until then they would report misleading numbers.
|
||||
//
|
||||
// 1. AgingOpenTasksSection (chart.aging_open_tasks)
|
||||
// Measures (now - date_creation) for every open task per assignee. Inflates
|
||||
// the number with backlog tasks nobody has started working on yet.
|
||||
// Needs: a boolean is_in_progress column on tasks.tasks, OR a "wip" flag
|
||||
// on tasks.statuses, so SQL can count only tasks that have been picked up.
|
||||
//
|
||||
// 2. CycleTimeKpi (kpi.cycle_time)
|
||||
// 3. CycleTimeHistogramSection (chart.cycle_time_histogram)
|
||||
// 4. CycleTimePerProjectSection (chart.cycle_time_per_project)
|
||||
// All three measure (date_complete - date_creation) for completed tasks.
|
||||
// That is actually lead time (includes time the task sat in backlog), not
|
||||
// cycle time. A task that spent 6 months in backlog and was then finished
|
||||
// in 2 days shows up as a 6-month "cycle".
|
||||
// Needs: a started_at timestamp on tasks.tasks (set when the task moves
|
||||
// into an in-progress status) so SQL can compute (date_complete - started_at).
|
||||
// Trigger or app logic must set started_at on the first transition into a
|
||||
// "wip" status.
|
||||
//
|
||||
// 5. TimeInKanbanStatusSection (chart.time_in_kanban_status)
|
||||
// Misleadingly named — it averages (now - edit_date) for currently-open
|
||||
// tasks grouped by their current status. It does NOT measure how long
|
||||
// each task has actually been in its current status; any unrelated edit
|
||||
// (description change, assignee change) resets the clock for the metric.
|
||||
// Needs: a tasks.status_history table tracking (task_id, status_id,
|
||||
// entered_at, exited_at). Then "time in status X" = avg(exited_at OR now
|
||||
// - entered_at) for rows in that status. Without a transition log, this
|
||||
// metric cannot be computed correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
|
||||
// import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
|
||||
// import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
|
||||
// import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
|
||||
// import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
|
||||
// import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
|
||||
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
|
||||
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
|
||||
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
|
||||
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
|
||||
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
|
||||
|
||||
const builders: SectionBuilder[] = [
|
||||
// KPI
|
||||
new CreatedTasksKpi(),
|
||||
new CompletedTasksKpi(),
|
||||
new OverdueKpi(),
|
||||
// new CycleTimeKpi(), // disabled — see top-of-file comment
|
||||
new TotalIncomeKpi(),
|
||||
new TotalExpenseKpi(),
|
||||
new NetProfitKpi(),
|
||||
new PlannedIncomeKpi(),
|
||||
new PlannedExpenseKpi(),
|
||||
new AmountCoverageKpi(),
|
||||
// Productivity
|
||||
new ThroughputSection(),
|
||||
new PriorityMixOverTimeSection(),
|
||||
// Workload
|
||||
new WorkloadByAssigneeSection(),
|
||||
// new BlockedByDependenciesSection(), // disabled
|
||||
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
|
||||
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
|
||||
// Quality
|
||||
// new OverdueByAgeSection(), // disabled
|
||||
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
|
||||
new StaleTasksSection(),
|
||||
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
|
||||
// Usage
|
||||
new StatusDistributionSection(),
|
||||
// new ActiveProjectsSection(), // disabled
|
||||
// Financial
|
||||
new IncomeExpenseMonthSection(),
|
||||
new IncomeExpensePerProjectSection(),
|
||||
new IncomePerProjectMonthSection(),
|
||||
new ExpensePerProjectMonthSection(),
|
||||
// new IncomePerTagMonthSection(), // disabled
|
||||
// new ExpensePerTagMonthSection(), // disabled
|
||||
// new TopProjectsByAmountSection(), // disabled
|
||||
]
|
||||
|
||||
export class SectionRegistry {
|
||||
private readonly byId: Map<string, SectionBuilder>
|
||||
private readonly enabledOrder: SectionBuilder[]
|
||||
|
||||
constructor() {
|
||||
this.byId = new Map(builders.map(b => [b.id, b]))
|
||||
|
||||
const enabledIds = parseEnabledSectionIds(process.env.ANALYTICS_SECTIONS)
|
||||
if (enabledIds && enabledIds.length > 0) {
|
||||
this.enabledOrder = enabledIds
|
||||
.map((id: string) => this.byId.get(id))
|
||||
.filter((b: SectionBuilder | undefined): b is SectionBuilder => !!b)
|
||||
} else {
|
||||
this.enabledOrder = [...builders]
|
||||
}
|
||||
}
|
||||
|
||||
all(): SectionBuilder[] {
|
||||
return [...this.enabledOrder]
|
||||
}
|
||||
|
||||
get(id: string): SectionBuilder | undefined {
|
||||
const builder = this.byId.get(id)
|
||||
if (!builder) return undefined
|
||||
return this.enabledOrder.includes(builder) ? builder : undefined
|
||||
}
|
||||
|
||||
filterByIds(ids?: string[]): SectionBuilder[] {
|
||||
if (!ids || ids.length === 0) return this.all()
|
||||
const enabledSet = new Set(this.enabledOrder)
|
||||
return ids
|
||||
.map(id => this.byId.get(id))
|
||||
.filter((b): b is SectionBuilder => !!b && enabledSet.has(b))
|
||||
}
|
||||
|
||||
catalog(): AnalyticsSectionCatalogEntry[] {
|
||||
const locales = sectionLocales as Record<string, { title?: { ru: string, en: string } }>
|
||||
return this.enabledOrder.map(b => ({
|
||||
id: b.id,
|
||||
group: b.group,
|
||||
payloadKind: b.defaultChartType === null ? 'kpi' : 'series',
|
||||
title: locales[b.id]?.title ?? { ru: b.id, en: b.id },
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class AmountCoverageKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.amount_coverage'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { total, withAmount } = await ctx.repository.amountCoverage(ctx.accessibleGoalIds)
|
||||
const percent = total === 0 ? 0 : Math.round((withAmount / total) * 100)
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: percent,
|
||||
unit: 'percent',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'percent' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildProjectAmountPayload } from './projectAmountPayload'
|
||||
|
||||
export class ExpensePerProjectMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.expense_per_project_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerProjectMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 0,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildProjectAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildTagAmountPayload } from './tagAmountPayload'
|
||||
|
||||
export class ExpensePerTagMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.expense_per_tag_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerTagMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 0,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildTagAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class IncomeExpenseMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_expense_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['bar', 'line', 'area', 'stackedArea'] as const
|
||||
readonly defaultChartType = 'bar' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchIncomeExpenseMonth(ctx.accessibleGoalIds, ctx.range)
|
||||
const loc = this.loc
|
||||
|
||||
const payload: AnalyticsSeriesPayload = {
|
||||
kind: 'series',
|
||||
labels: rows.map(r => r.month),
|
||||
labelKind: 'category',
|
||||
datasets: [
|
||||
{
|
||||
id: 'income',
|
||||
label: loc.datasets!.income,
|
||||
values: rows.map(r => Number(r.income)),
|
||||
colorToken: 'success',
|
||||
},
|
||||
{
|
||||
id: 'expense',
|
||||
label: loc.datasets!.expense,
|
||||
values: rows.map(r => Number(r.expense)),
|
||||
colorToken: 'danger',
|
||||
},
|
||||
],
|
||||
unit: 'currency',
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class IncomeExpensePerProjectSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_expense_per_project'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['bar', 'area'] as const
|
||||
readonly defaultChartType = 'bar' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchIncomeExpensePerProject(ctx.accessibleGoalIds)
|
||||
const loc = this.loc
|
||||
const goalIds = rows.map(r => r.goal_id)
|
||||
|
||||
const payload: AnalyticsSeriesPayload = {
|
||||
kind: 'series',
|
||||
labels: rows.map(r => r.goal_name),
|
||||
labelKind: 'category',
|
||||
datasets: [
|
||||
{
|
||||
id: 'income',
|
||||
label: loc.datasets!.income,
|
||||
values: rows.map(r => Number(r.income)),
|
||||
colorToken: 'success',
|
||||
meta: { goalIds },
|
||||
},
|
||||
{
|
||||
id: 'expense',
|
||||
label: loc.datasets!.expense,
|
||||
values: rows.map(r => Number(r.expense)),
|
||||
colorToken: 'danger',
|
||||
meta: { goalIds },
|
||||
},
|
||||
],
|
||||
unit: 'currency',
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildProjectAmountPayload } from './projectAmountPayload'
|
||||
|
||||
export class IncomePerProjectMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_per_project_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerProjectMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 1,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildProjectAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildTagAmountPayload } from './tagAmountPayload'
|
||||
|
||||
export class IncomePerTagMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_per_tag_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerTagMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 1,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildTagAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class NetProfitKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.net_profit'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const cur = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
const current = cur.income - cur.expense
|
||||
const previous = prev.income - prev.expense
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(current),
|
||||
unit: 'currency',
|
||||
delta: this.buildDelta(current, previous),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return {
|
||||
value: 100,
|
||||
direction: current > 0 ? ('up' as const) : ('down' as const),
|
||||
isGood: current >= 0,
|
||||
}
|
||||
}
|
||||
const pct = Math.round(((current - prev) / Math.abs(prev)) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class PlannedExpenseKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.planned_expense'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const value = await ctx.repository.sumPlannedExpense(ctx.accessibleGoalIds)
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(value),
|
||||
unit: 'currency',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
drillDown: { kind: 'tasks' },
|
||||
}
|
||||
}
|
||||
|
||||
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return []
|
||||
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 0)
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class PlannedIncomeKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.planned_income'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const value = await ctx.repository.sumPlannedIncome(ctx.accessibleGoalIds)
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(value),
|
||||
unit: 'currency',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
drillDown: { kind: 'tasks' },
|
||||
}
|
||||
}
|
||||
|
||||
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return []
|
||||
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 1)
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class TopProjectsByAmountSection implements SectionBuilder {
|
||||
readonly id = 'chart.top_projects_by_amount'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
|
||||
readonly defaultChartType = 'stackedBar' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchTopProjectsByAmount(ctx.accessibleGoalIds)
|
||||
const loc = this.loc
|
||||
const goalIds = rows.map(r => r.goal_id)
|
||||
|
||||
const payload: AnalyticsSeriesPayload = {
|
||||
kind: 'series',
|
||||
labels: rows.map(r => r.goal_name),
|
||||
labelKind: 'category',
|
||||
datasets: [
|
||||
{
|
||||
id: 'income',
|
||||
label: loc.datasets!.income,
|
||||
values: rows.map(r => Number(r.income)),
|
||||
colorToken: 'success',
|
||||
stack: 'amount',
|
||||
meta: { goalIds },
|
||||
},
|
||||
{
|
||||
id: 'expense',
|
||||
label: loc.datasets!.expense,
|
||||
values: rows.map(r => Number(r.expense)),
|
||||
colorToken: 'danger',
|
||||
stack: 'amount',
|
||||
meta: { goalIds },
|
||||
},
|
||||
],
|
||||
unit: 'currency',
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class TotalExpenseKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.total_expense'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const current = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(current),
|
||||
unit: 'currency',
|
||||
delta: this.buildDelta(current, prev),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return { value: 100, direction: 'up' as const, isGood: false }
|
||||
}
|
||||
const pct = Math.round(((current - prev) / prev) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct <= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class TotalIncomeKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.total_income'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const current = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(current),
|
||||
unit: 'currency',
|
||||
delta: this.buildDelta(current, prev),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return { value: 100, direction: 'up' as const, isGood: true }
|
||||
}
|
||||
const pct = Math.round(((current - prev) / prev) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
|
||||
import type { AmountPerProjectMonthSectionRow } from '../row.types'
|
||||
|
||||
export type BuildProjectAmountPayloadArgs = {
|
||||
rows: AmountPerProjectMonthSectionRow[]
|
||||
xAxisLabel?: LocalizedText
|
||||
yAxisLabel?: LocalizedText
|
||||
}
|
||||
|
||||
export function buildProjectAmountPayload(args: BuildProjectAmountPayloadArgs): AnalyticsSeriesPayload {
|
||||
const { rows, xAxisLabel, yAxisLabel } = args
|
||||
|
||||
const monthSet = new Set<string>()
|
||||
const projectOrder: number[] = []
|
||||
const projectNameById = new Map<number, string>()
|
||||
const valuesByProject = new Map<number, Map<string, number>>()
|
||||
|
||||
for (const row of rows) {
|
||||
monthSet.add(row.month)
|
||||
if (!projectNameById.has(row.goal_id)) {
|
||||
projectNameById.set(row.goal_id, row.goal_name)
|
||||
projectOrder.push(row.goal_id)
|
||||
}
|
||||
let perMonth = valuesByProject.get(row.goal_id)
|
||||
if (!perMonth) {
|
||||
perMonth = new Map()
|
||||
valuesByProject.set(row.goal_id, perMonth)
|
||||
}
|
||||
perMonth.set(row.month, Number(row.amount))
|
||||
}
|
||||
|
||||
const labels = [...monthSet].sort()
|
||||
|
||||
const datasets: AnalyticsDataset[] = projectOrder.map((goalId) => {
|
||||
const perMonth = valuesByProject.get(goalId) ?? new Map<string, number>()
|
||||
const name = projectNameById.get(goalId) ?? `#${goalId}`
|
||||
return {
|
||||
id: `project_${goalId}`,
|
||||
label: { ru: name, en: name },
|
||||
values: labels.map(month => perMonth.get(month) ?? 0),
|
||||
meta: { goalIds: [goalId] },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 'series',
|
||||
labels,
|
||||
labelKind: 'date',
|
||||
datasets,
|
||||
unit: 'currency',
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', de: 'Ohne Tags', es: 'Sin etiquetas' }
|
||||
|
||||
export type BuildTagAmountPayloadArgs = {
|
||||
rows: AmountPerTagMonthSectionRow[]
|
||||
xAxisLabel?: LocalizedText
|
||||
yAxisLabel?: LocalizedText
|
||||
}
|
||||
|
||||
export function buildTagAmountPayload(args: BuildTagAmountPayloadArgs): AnalyticsSeriesPayload {
|
||||
const { rows, xAxisLabel, yAxisLabel } = args
|
||||
|
||||
const monthSet = new Set<string>()
|
||||
const tagOrder: number[] = []
|
||||
const tagNameById = new Map<number, LocalizedText>()
|
||||
const valuesByTag = new Map<number, Map<string, number>>()
|
||||
|
||||
for (const row of rows) {
|
||||
monthSet.add(row.month)
|
||||
if (!tagNameById.has(row.tag_id)) {
|
||||
tagNameById.set(
|
||||
row.tag_id,
|
||||
row.tag_id === UNTAGGED_TAG_ID ? UNTAGGED_LABEL : { ru: row.tag_name, en: row.tag_name },
|
||||
)
|
||||
tagOrder.push(row.tag_id)
|
||||
}
|
||||
let perMonth = valuesByTag.get(row.tag_id)
|
||||
if (!perMonth) {
|
||||
perMonth = new Map()
|
||||
valuesByTag.set(row.tag_id, perMonth)
|
||||
}
|
||||
perMonth.set(row.month, Number(row.amount))
|
||||
}
|
||||
|
||||
const labels = [...monthSet].sort()
|
||||
|
||||
const datasets: AnalyticsDataset[] = tagOrder.map((tagId) => {
|
||||
const perMonth = valuesByTag.get(tagId) ?? new Map<string, number>()
|
||||
const isUntagged = tagId === UNTAGGED_TAG_ID
|
||||
return {
|
||||
id: isUntagged ? 'untagged' : `tag_${tagId}`,
|
||||
label: tagNameById.get(tagId) ?? { ru: '', en: '' },
|
||||
values: labels.map(month => perMonth.get(month) ?? 0),
|
||||
colorToken: isUntagged ? 'neutral' : undefined,
|
||||
meta: isUntagged ? { untagged: true } : { tagIds: [tagId] },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 'series',
|
||||
labels,
|
||||
labelKind: 'date',
|
||||
datasets,
|
||||
unit: 'currency',
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class CompletedTasksKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.completed_tasks'
|
||||
readonly group = 'kpi' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 300
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const current = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: current,
|
||||
unit: 'count',
|
||||
delta: this.buildDelta(current, prev),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return { value: 100, direction: 'up' as const, isGood: true }
|
||||
}
|
||||
const pct = Math.round(((current - prev) / prev) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'kpi',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'count' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class CreatedTasksKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.created_tasks'
|
||||
readonly group = 'kpi' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 300
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const current = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: current,
|
||||
unit: 'count',
|
||||
delta: this.buildDelta(current, prev),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return { value: 100, direction: 'up' as const, isGood: true }
|
||||
}
|
||||
const pct = Math.round(((current - prev) / prev) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'kpi',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'count' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class CycleTimeKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.cycle_time'
|
||||
readonly group = 'kpi' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 300
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const median = await ctx.repository.medianCycleTime(ctx.accessibleGoalIds, ctx.range)
|
||||
const value = median === null ? 0 : Math.round(median * 10) / 10
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value,
|
||||
unit: 'days',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'kpi',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'days' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user