mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-12 05:49:01 +00:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f5c3fe2bc8 | |||
| bc8264b979 | |||
| 6c8d03c35f | |||
| 7ced0a54e1 | |||
| 32a2819ebf | |||
| 9bba904c16 | |||
| b24f829a6b | |||
| 1be3dac90e | |||
| af63530390 | |||
| 8d9276830f | |||
| 8eab7c2573 | |||
| d06266fb93 | |||
| 4ec6b143ca | |||
| e3e896e159 | |||
| de26871aff |
@@ -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 using the contact information provided in the repository or on the TaskView website.
|
||||
|
||||
When running TaskView in production:
|
||||
|
||||
* use HTTPS;
|
||||
* generate unique application secrets;
|
||||
* restrict database access;
|
||||
* keep Docker images and dependencies updated;
|
||||
* configure persistent storage;
|
||||
* create regular backups;
|
||||
* restrict API-token permissions to the minimum required scope.
|
||||
|
||||
## Links
|
||||
|
||||
* [TaskView website](https://taskview.tech/)
|
||||
* [TaskView Cloud](https://app.taskview.tech/)
|
||||
* [Documentation](https://taskview.tech/docs/)
|
||||
* [iOS application](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
|
||||
* [Android application](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app)
|
||||
* [GitHub repository](https://github.com/Gimanh/taskview-community)
|
||||
|
||||
---
|
||||
|
||||
TaskView is developed and maintained by Nikolai Giman.
|
||||
TaskView is developed and maintained by **Nikolai Giman**.
|
||||
|
||||
Copyright © 2026 Nikolai Giman
|
||||
TaskView™ and the TaskView logo are trademarks of their respective owner.
|
||||
|
||||
Copyright © 2026 Nikolai Giman.
|
||||
|
||||
@@ -27,6 +27,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 +52,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,
|
||||
|
||||
+9
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.41.0",
|
||||
"version": "1.51.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",
|
||||
@@ -42,13 +43,15 @@
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-saml/node-saml": "^5.1.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-github2": "^1.2.9",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@vitejs/plugin-legacy": "^5.4.2",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"axios": "^1.7.7",
|
||||
"@xmldom/xmldom": "^0.9.9",
|
||||
"axios": "1.13.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -59,6 +62,8 @@
|
||||
"firebase-admin": "^12.7.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"luxon": "^3.7.2",
|
||||
"openid-client": "^6.8.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
"passport-github2": "^0.1.12",
|
||||
@@ -67,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) 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() {
|
||||
|
||||
@@ -9,7 +9,14 @@ import { StartManager } from '../tv-modules/start/StartManager';
|
||||
import { TagsManager } from '../tv-modules/tags/TagsManager';
|
||||
import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsManager';
|
||||
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
|
||||
import { OrganizationManager } from '../tv-modules/organizations/OrganizationManager';
|
||||
import { SsoManager } from '../tv-modules/sso/SsoManager';
|
||||
import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
|
||||
import { TasksManager } from '../tv-modules/tasks/TasksManager';
|
||||
import { TimeTrackingManager } from '../tv-modules/time-tracking/TimeTrackingManager';
|
||||
import { UiPreferencesManager } from '../tv-modules/ui-preferences/UiPreferencesManager';
|
||||
import { SprintsManager } from '../tv-modules/sprints/SprintsManager';
|
||||
import { RecurrenceManager } from '../tv-modules/recurrence/RecurrenceManager';
|
||||
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
|
||||
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
|
||||
|
||||
@@ -33,6 +40,13 @@ export class AppUser {
|
||||
public readonly graphManager: GraphManager;
|
||||
public readonly integrationsManager: IntegrationsManager;
|
||||
public readonly notificationsManager: NotificationsManager;
|
||||
public readonly organizationManager: OrganizationManager;
|
||||
public readonly ssoManager: SsoManager;
|
||||
public readonly analyticsManager: AnalyticsManager;
|
||||
public readonly timeTrackingManager: TimeTrackingManager;
|
||||
public readonly uiPreferencesManager: UiPreferencesManager;
|
||||
public readonly sprintsManager: SprintsManager;
|
||||
public readonly recurrenceManager: RecurrenceManager;
|
||||
|
||||
constructor(userData?: UserJwtPayload) {
|
||||
this.userData = userData;
|
||||
@@ -49,6 +63,13 @@ export class AppUser {
|
||||
this.graphManager = new GraphManager(this);
|
||||
this.integrationsManager = new IntegrationsManager(this);
|
||||
this.notificationsManager = new NotificationsManager(this);
|
||||
this.organizationManager = new OrganizationManager(this);
|
||||
this.ssoManager = new SsoManager(this);
|
||||
this.analyticsManager = new AnalyticsManager(this);
|
||||
this.timeTrackingManager = new TimeTrackingManager(this);
|
||||
this.uiPreferencesManager = new UiPreferencesManager(this);
|
||||
this.sprintsManager = new SprintsManager(this);
|
||||
this.recurrenceManager = new RecurrenceManager(this);
|
||||
}
|
||||
|
||||
getTokenId(): number | undefined {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -6,6 +6,11 @@ import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
|
||||
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
|
||||
|
||||
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
req.appUser = new AppUser();
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
|
||||
if (token && token.startsWith(TOKEN_PREFIX)) {
|
||||
|
||||
@@ -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,15 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
export const IsOrgMemberIfProvided = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const organizationId = Number(
|
||||
req.query.organizationId || req.body.organizationId || req.params.organizationId
|
||||
)
|
||||
|
||||
if (!organizationId) return next()
|
||||
|
||||
const member = await req.appUser.organizationManager.getCurrentUserMember(organizationId)
|
||||
|
||||
if (!member) return res.status(403).end()
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -429,5 +429,313 @@
|
||||
"Remove JWT storage from user_tokens, add session metadata (device_name, user_agent, last_used_at)",
|
||||
"Add trigger to remove user from task assignees when removed from project collaboration"
|
||||
]
|
||||
},
|
||||
"35": {
|
||||
"version": "1.32.0",
|
||||
"name": "Release 1.32.0",
|
||||
"releaseDate": "20260404",
|
||||
"scripts": [
|
||||
"/1.32.0/0.1.32.0.sql",
|
||||
"/1.32.0/1.migrate-organizations.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added organizations and organization_members tables",
|
||||
"Migrate existing users to personal workspaces",
|
||||
"Added organization_id column to goals"
|
||||
]
|
||||
},
|
||||
"36": {
|
||||
"version": "1.33.0",
|
||||
"name": "Release 1.33.0",
|
||||
"releaseDate": "20260411",
|
||||
"scripts": [
|
||||
"/1.33.0/0.1.33.0.sql",
|
||||
"/1.33.0/1.add-saml-signing-fields.sql",
|
||||
"/1.33.0/2.add-saml-logout-url.sql",
|
||||
"/1.33.0/3.add-scim-fields.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added SSO support (SAML 2.0 + OIDC)",
|
||||
"Added sso_configs, sso_identities, saml_request_cache tables",
|
||||
"Added SAML AuthnRequest signing support",
|
||||
"Added SAML logout URL for SLO",
|
||||
"Added SCIM provisioning support"
|
||||
]
|
||||
},
|
||||
"37": {
|
||||
"version": "1.44.0",
|
||||
"name": "Release 1.44.0",
|
||||
"releaseDate": "20260418",
|
||||
"scripts": [
|
||||
"/1.44.0/0.create-missing-personal-orgs.sql"
|
||||
],
|
||||
"description": [
|
||||
"Create personal organizations for users without any organization membership"
|
||||
]
|
||||
},
|
||||
"38": {
|
||||
"version": "1.45.0",
|
||||
"name": "Release 1.45.0",
|
||||
"releaseDate": "20260425",
|
||||
"scripts": [
|
||||
"/1.45.0/0.analytics_indexes.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add composite indexes on tasks.tasks for analytics queries"
|
||||
]
|
||||
},
|
||||
"39": {
|
||||
"version": "1.46.0",
|
||||
"name": "Release 1.46.0",
|
||||
"releaseDate": "20260426",
|
||||
"scripts": [
|
||||
"/1.46.0/0.add-analytics-permission.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add analytics_can_view permission for project-level analytics access"
|
||||
]
|
||||
},
|
||||
"40": {
|
||||
"version": "1.47.0",
|
||||
"name": "Release 1.47.0",
|
||||
"releaseDate": "20260509",
|
||||
"scripts": [
|
||||
"/1.47.0/0.analytics_indexes_v2.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add indexes on tasks_auth.task_assignee, collaboration.users_to_goals, tasks.task_relations(to_task_id) for analytics joins",
|
||||
"Add partial index on tasks.goals(organization_id) WHERE archive = 0 for analytics goal lookup",
|
||||
"Add partial index on tasks.tasks(goal_id) WHERE complete IS NOT TRUE for open-task analytics queries"
|
||||
]
|
||||
},
|
||||
"41": {
|
||||
"version": "1.48.0",
|
||||
"name": "Release 1.48.0",
|
||||
"releaseDate": "20260511",
|
||||
"scripts": [
|
||||
"/1.48.0/0.create-time-entries.sql",
|
||||
"/1.48.0/1.create-time-entries-history.sql",
|
||||
"/1.48.0/2.alter-organizations-add-autostop.sql",
|
||||
"/1.48.0/3.add-timetracking-permissions.sql",
|
||||
"/1.48.0/all-triggers.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added time-tracking module: tasks.time_entries with one-active-timer-per-user partial unique index",
|
||||
"Added history.time_entries audit table with FK ON DELETE CASCADE to time_entries (history removed with the entry)",
|
||||
"Added time_tracking_autostop_hours column to tv_auth.organizations (default 24, NULL = disabled)",
|
||||
"Added timetracking_can_log and timetracking_can_manage_all goal-level permissions (granted to new projects only, matching the 1.46.0 analytics convention)",
|
||||
"all-triggers.sql: updated add_roles_and_permissions function with new permissions; added log_changes_time_entries trigger (BEFORE UPDATE writes JSONB snapshot to history.time_entries)"
|
||||
]
|
||||
},
|
||||
"42": {
|
||||
"version": "1.49.0",
|
||||
"name": "Release 1.49.0",
|
||||
"releaseDate": "20260512",
|
||||
"scripts": [
|
||||
"/1.49.0/0.time_entries_report_indexes.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added compound indexes on tasks.time_entries (goal_id, started_at, user_id) and (goal_id, started_at, task_id) for time-tracking report aggregations",
|
||||
"Added partial index on tasks.time_entries(goal_id, started_at) WHERE billable = TRUE for billable-only reports"
|
||||
]
|
||||
},
|
||||
"43": {
|
||||
"version": "1.50.0",
|
||||
"name": "Release 1.50.0",
|
||||
"releaseDate": "20260515",
|
||||
"scripts": [
|
||||
"/1.50.0/0.update-timetracking-permissions-descriptions.sql"
|
||||
],
|
||||
"description": [
|
||||
"Updated descriptions of timetracking_can_view and timetracking_can_manage_all to document that these permissions also expose contributor emails through the time-entry log",
|
||||
"Tightened timetracking_can_log description: it grants only start/stop/createManual, NOT edit/delete (including own entries). Edit/delete now requires timetracking_can_manage_all — see can-access-time-entry middleware change."
|
||||
]
|
||||
},
|
||||
"44": {
|
||||
"version": "1.51.0",
|
||||
"name": "Release 1.51.0",
|
||||
"releaseDate": "20260517",
|
||||
"scripts": [
|
||||
"/1.51.0/0.tasks_to_tags_indexes.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added btree indexes on tasks.tasks_to_tags(task_id) and (tag_id) — FK constraints do not create indexes in Postgres, so anti-joins (NOT EXISTS for untagged tasks) and inner joins from tasks were seq-scanning. Needed for tag-financial analytics charts."
|
||||
]
|
||||
},
|
||||
"45": {
|
||||
"version": "1.52.0",
|
||||
"name": "Release 1.52.0",
|
||||
"releaseDate": "20260517",
|
||||
"scripts": [
|
||||
"/1.52.0/0.ui_preferences.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tv_auth.ui_preferences table: per-user JSONB storage of UI customization choices (which analytics charts and task detail fields are shown and in what order). One row per user keyed by user_id."
|
||||
]
|
||||
},
|
||||
"46": {
|
||||
"version": "1.53.0",
|
||||
"name": "Release 1.53.0",
|
||||
"releaseDate": "20260528",
|
||||
"scripts": [
|
||||
"/1.53.0/0.create-sprints.sql",
|
||||
"/1.53.0/1.create-sprint-task-outcomes.sql",
|
||||
"/1.53.0/2.create-sprint-user-capacity.sql",
|
||||
"/1.53.0/3.create-sprint-retros.sql",
|
||||
"/1.53.0/4.alter-tasks-add-sprint.sql",
|
||||
"/1.53.0/5.alter-notifications-add-sprint-id.sql",
|
||||
"/1.53.0/6.add-sprint-permissions.sql",
|
||||
"/1.53.0/7.alter-goals-add-estimate-unit.sql",
|
||||
"/1.53.0/8.alter-sprint-task-outcomes-add-estimate.sql",
|
||||
"/1.53.0/9.create-sprint-cadence.sql",
|
||||
"/1.53.0/all-triggers.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tasks.goals.estimate_unit ('hours' | 'points', default 'points') — per-project unit for sprint estimates & capacity display.",
|
||||
"Sprints feature. tasks.sprints: sprint container with lifecycle draft/planned/active/review/completed; one active-or-review sprint per project enforced by a partial unique index.",
|
||||
"tasks.sprint_task_outcomes: per-task result decided at close (accepted/carried-over/dropped/incomplete). tasks.sprint_user_capacity: per-user capacity. tasks.sprint_retros: went well/bad/action items. Sprint capacity column is `capacity` (story points, unitless).",
|
||||
"Extended tasks.tasks with sprint_id (FK SET NULL) and estimate_value (task estimate in story points, unitless), and tasks.notifications with sprint_id. Sprint availability is governed by the sprint_can_view permission (RBAC), not a per-project flag. Sprint burndown reuses the existing trigger-maintained tasks.date_complete column (no new completion column added).",
|
||||
"Added permission group 5 'sprints' and permissions sprint_can_view, sprint_can_manage, sprint_can_assign_tasks, sprint_can_view_analytics; backfilled grants for existing projects' editor/executor roles.",
|
||||
"all-triggers.sql: re-applies all triggers idempotently (drop+create) and updates tasks.add_roles_and_permissions() so NEW projects grant sprint permissions to editor (all four) and executor (view + assign).",
|
||||
"Added tasks.sprint_task_outcomes.estimate_value — story-points snapshot captured at sprint close so a closed sprint's velocity is frozen (unaffected by later estimate edits or moving the task to another sprint).",
|
||||
"Added tasks.sprint_cadence — per-project sprint auto-generation config (Linear-style): enabled, length_days, start_date anchor, lookahead, name_template, last_generated_date. A background job keeps current + lookahead future sprints created."
|
||||
]
|
||||
},
|
||||
"47": {
|
||||
"version": "1.54.0",
|
||||
"name": "Release 1.54.0",
|
||||
"releaseDate": "20260605",
|
||||
"scripts": [
|
||||
"/1.54.0/0.create-recurrence-rules.sql",
|
||||
"/1.54.0/1.create-recurrence-skip-dates.sql",
|
||||
"/1.54.0/2.create-recurrence-template-assignees.sql",
|
||||
"/1.54.0/3.create-recurrence-template-tags.sql",
|
||||
"/1.54.0/4.alter-tasks-add-recurrence.sql"
|
||||
],
|
||||
"description": [
|
||||
"Recurring tasks (lazy materialization, Todoist-like UX). tasks.recurrence_rules: one row per series — RFC 5545 RRULE string, floating wall-clock dtstart, IANA timezone, state active/paused/ended, last_instance_date + instances_created drive next-occurrence computation. Template snapshot columns (description/note/priority/status/list/duration) let the series outlive its origin task.",
|
||||
"tasks.recurrence_skip_dates: per-rule skipped occurrence dates. tasks.recurrence_template_assignees / recurrence_template_tags: snapshot of assignees (collaboration.users) and tags copied onto each materialized instance.",
|
||||
"Extended tasks.tasks with recurrence_rule_id (FK SET NULL — instances survive rule deletion) and recurrence_instance_date. Partial unique index on (recurrence_rule_id, recurrence_instance_date) makes instance materialization idempotent under concurrent triggers/reconciliation.",
|
||||
"Exactly one open instance per series exists at any time: completing it materializes the next occurrence (event-driven, O(1)); a nightly pg-boss reconcile job re-creates the open instance only for series stalled by a crash. No new permissions — recurrence editing is gated by existing task_can_edit_deadline."
|
||||
]
|
||||
},
|
||||
"48": {
|
||||
"version": "1.54.1",
|
||||
"name": "Release 1.54.1",
|
||||
"releaseDate": "20260613",
|
||||
"scripts": [
|
||||
"/1.54.1/0.add-recurrence-template-task-unique.sql"
|
||||
],
|
||||
"description": [
|
||||
"Partial unique index on tasks.recurrence_rules(template_task_id) WHERE state != 'ended' — one live series per origin task. DB-level backstop for the createRule race where two concurrent POSTs both pass the recurrenceRuleId == null check and create two rules for the same task (the orphaned rule would materialize a duplicate card via the nightly reconcile job)."
|
||||
]
|
||||
},
|
||||
"49": {
|
||||
"version": "1.54.2",
|
||||
"name": "Release 1.54.2",
|
||||
"releaseDate": "20260613",
|
||||
"scripts": [
|
||||
"/1.54.2/0.add-recurrence-has-time.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tasks.recurrence_rules.has_time — explicit flag for whether a series is anchored to a wall-clock time or is date-only. Previously the code inferred 'no time' from a midnight dtstart, which silently collapsed an explicit 00:00 series into date-only. Backfill (has_time = dtstart::time <> '00:00:00') reproduces the old inference so existing series keep their behavior; new series carry the flag through from the origin task's start_time (null = date-only, set = timed, including midnight)."
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,50 @@
|
||||
-- Organizations
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.organizations (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name VARCHAR NOT NULL,
|
||||
slug VARCHAR NOT NULL UNIQUE,
|
||||
owner_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
logo_url VARCHAR,
|
||||
is_personal INTEGER NOT NULL DEFAULT 0,
|
||||
plan VARCHAR NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_owner_id ON tv_auth.organizations(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_slug ON tv_auth.organizations(slug);
|
||||
|
||||
-- Organization members (by email, not user_id)
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.organization_members (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
|
||||
email VARCHAR NOT NULL,
|
||||
role VARCHAR NOT NULL DEFAULT 'member',
|
||||
invited_by INTEGER REFERENCES tv_auth.users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(organization_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_members_organization_id ON tv_auth.organization_members(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_org_members_email ON tv_auth.organization_members(email);
|
||||
|
||||
-- Add organization_id to goals (nullable for migration, will be set NOT NULL after data migration)
|
||||
ALTER TABLE tasks.goals ADD COLUMN IF NOT EXISTS organization_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'goals_organization_id_fkey'
|
||||
AND table_schema = 'tasks'
|
||||
AND table_name = 'goals'
|
||||
) THEN
|
||||
ALTER TABLE tasks.goals
|
||||
ADD CONSTRAINT goals_organization_id_fkey
|
||||
FOREIGN KEY (organization_id)
|
||||
REFERENCES tv_auth.organizations(id)
|
||||
ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_organization_id ON tasks.goals(organization_id);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Step 1: Create personal organization for each user who owns goals
|
||||
INSERT INTO tv_auth.organizations (name, slug, owner_id, is_personal)
|
||||
SELECT
|
||||
u.login || '''s workspace',
|
||||
'org-' || substr(md5(random()::text), 1, 8),
|
||||
u.id,
|
||||
1
|
||||
FROM tv_auth.users u
|
||||
WHERE EXISTS (SELECT 1 FROM tasks.goals g WHERE g.owner = u.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Step 2: Link goals to their owner's organization
|
||||
UPDATE tasks.goals g
|
||||
SET organization_id = o.id
|
||||
FROM tv_auth.organizations o
|
||||
WHERE g.owner = o.owner_id
|
||||
AND g.organization_id IS NULL;
|
||||
|
||||
-- Step 3: Add owner as org member with role 'owner' (by email)
|
||||
INSERT INTO tv_auth.organization_members (organization_id, email, role)
|
||||
SELECT o.id, u.email, 'owner'
|
||||
FROM tv_auth.organizations o
|
||||
JOIN tv_auth.users u ON u.id = o.owner_id
|
||||
ON CONFLICT (organization_id, email) DO NOTHING;
|
||||
|
||||
-- Step 4: Add existing goal collaborators as org members (by email)
|
||||
INSERT INTO tv_auth.organization_members (organization_id, email, role)
|
||||
SELECT DISTINCT g.organization_id, cu.email, 'member'
|
||||
FROM collaboration.users_to_goals cutg
|
||||
JOIN collaboration.users cu ON cu.id = cutg.user_id
|
||||
JOIN tasks.goals g ON g.id = cutg.goal_id
|
||||
WHERE g.organization_id IS NOT NULL
|
||||
ON CONFLICT (organization_id, email) DO NOTHING;
|
||||
|
||||
-- Step 5: Make organization_id NOT NULL
|
||||
ALTER TABLE tasks.goals ALTER COLUMN organization_id SET NOT NULL;
|
||||
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.sso_configs (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
|
||||
protocol VARCHAR NOT NULL,
|
||||
display_name VARCHAR NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
saml_entry_point VARCHAR,
|
||||
saml_issuer VARCHAR,
|
||||
saml_cert TEXT,
|
||||
saml_callback_url VARCHAR,
|
||||
|
||||
oidc_issuer VARCHAR,
|
||||
oidc_client_id VARCHAR,
|
||||
oidc_client_secret VARCHAR,
|
||||
oidc_callback_url VARCHAR,
|
||||
oidc_scope VARCHAR,
|
||||
|
||||
default_org_role VARCHAR NOT NULL DEFAULT 'member',
|
||||
email_domain_restriction VARCHAR NOT NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(email_domain_restriction)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.sso_identities (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
sso_config_id INTEGER NOT NULL REFERENCES tv_auth.sso_configs(id) ON DELETE CASCADE,
|
||||
external_id VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(sso_config_id, external_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sso_identities_user_id ON tv_auth.sso_identities(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sso_identities_email ON tv_auth.sso_identities(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.saml_request_cache (
|
||||
key VARCHAR PRIMARY KEY,
|
||||
value VARCHAR NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_signing_key TEXT;
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_signing_cert TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS saml_logout_url VARCHAR;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_token VARCHAR;
|
||||
ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Create personal organizations for users who don't have any organization membership
|
||||
-- This covers the default seed user (login: 'user', email: 'test@mail.dest')
|
||||
-- and any other users who may exist without an organization
|
||||
INSERT INTO tv_auth.organizations (name, slug, owner_id, is_personal)
|
||||
SELECT
|
||||
u.login || '''s workspace',
|
||||
'org-' || substr(md5(random()::text), 1, 8),
|
||||
u.id,
|
||||
1
|
||||
FROM tv_auth.users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tv_auth.organization_members om WHERE om.email = u.email
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Add these users as owners of their new personal organizations
|
||||
INSERT INTO tv_auth.organization_members (organization_id, email, role)
|
||||
SELECT o.id, u.email, 'owner'
|
||||
FROM tv_auth.organizations o
|
||||
JOIN tv_auth.users u ON u.id = o.owner_id
|
||||
WHERE o.is_personal = 1
|
||||
ON CONFLICT (organization_id, email) DO NOTHING;
|
||||
@@ -0,0 +1,14 @@
|
||||
drop index if exists tasks.idx_tasks_goal_id_date_creation;
|
||||
create index idx_tasks_goal_id_date_creation on tasks.tasks (goal_id, date_creation);
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_date_complete;
|
||||
create index idx_tasks_goal_id_date_complete on tasks.tasks (goal_id, date_complete) where complete = true;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_end_date_open;
|
||||
create index idx_tasks_goal_id_end_date_open on tasks.tasks (goal_id, end_date) where complete is not true;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_edit_date_open;
|
||||
create index idx_tasks_goal_id_edit_date_open on tasks.tasks (goal_id, edit_date) where complete is not true;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_transaction_type;
|
||||
create index idx_tasks_goal_id_transaction_type on tasks.tasks (goal_id, transaction_type) where amount is not null;
|
||||
@@ -0,0 +1,8 @@
|
||||
insert into tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
values (
|
||||
'analytics_can_view',
|
||||
'User can view analytics for this goal',
|
||||
2,
|
||||
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb
|
||||
)
|
||||
on conflict (name) do nothing;
|
||||
@@ -0,0 +1,20 @@
|
||||
drop index if exists tasks_auth.idx_task_assignee_task_id;
|
||||
create index idx_task_assignee_task_id on tasks_auth.task_assignee (task_id);
|
||||
|
||||
drop index if exists tasks_auth.idx_task_assignee_user_task;
|
||||
create index idx_task_assignee_user_task on tasks_auth.task_assignee (collab_user_id, task_id);
|
||||
|
||||
drop index if exists collaboration.idx_users_to_goals_user_goal;
|
||||
create index idx_users_to_goals_user_goal on collaboration.users_to_goals (user_id, goal_id);
|
||||
|
||||
drop index if exists collaboration.idx_users_to_goals_goal_id;
|
||||
create index idx_users_to_goals_goal_id on collaboration.users_to_goals (goal_id);
|
||||
|
||||
drop index if exists tasks.idx_task_relations_to_task;
|
||||
create index idx_task_relations_to_task on tasks.task_relations (to_task_id, from_task_id);
|
||||
|
||||
drop index if exists tasks.idx_goals_org_active;
|
||||
create index idx_goals_org_active on tasks.goals (organization_id) where archive = 0;
|
||||
|
||||
drop index if exists tasks.idx_tasks_goal_id_open;
|
||||
create index idx_tasks_goal_id_open on tasks.tasks (goal_id) where complete is not true;
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks.time_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMP NULL,
|
||||
duration_seconds INTEGER NULL,
|
||||
description VARCHAR(500) NULL,
|
||||
source SMALLINT NOT NULL DEFAULT 0,
|
||||
billable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
auto_stopped BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
edited_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT time_entries_end_after_start CHECK (ended_at IS NULL OR ended_at > started_at),
|
||||
CONSTRAINT time_entries_duration_non_negative CHECK (duration_seconds IS NULL OR duration_seconds >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_user_started
|
||||
ON tasks.time_entries(user_id, started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started
|
||||
ON tasks.time_entries(goal_id, started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_task
|
||||
ON tasks.time_entries(task_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_entries_active_per_user
|
||||
ON tasks.time_entries(user_id) WHERE ended_at IS NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE SCHEMA IF NOT EXISTS history;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS history.time_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INTEGER NOT NULL REFERENCES tasks.time_entries(id) ON DELETE CASCADE,
|
||||
edit_date TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
entry JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_time_entries_entry
|
||||
ON history.time_entries(entry_id, edit_date DESC);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tv_auth.organizations
|
||||
ADD COLUMN IF NOT EXISTS time_tracking_autostop_hours INTEGER DEFAULT 24;
|
||||
@@ -0,0 +1,21 @@
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES
|
||||
(
|
||||
'timetracking_can_view',
|
||||
'View all time entries on this project — both own and other members''',
|
||||
2,
|
||||
'{"en": "View time entries. User can see all time entries on this project — both own and other members''. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Логировать и редактировать нельзя."}'::jsonb
|
||||
),
|
||||
(
|
||||
'timetracking_can_log',
|
||||
'Start/stop timer and create/edit/delete OWN time entries on this project',
|
||||
2,
|
||||
'{"en": "Track time. User can start/stop the timer, add manual entries, and edit/delete OWN time entries. Does not grant viewing the project log or managing others'' entries.", "ru": "Учёт времени. Пользователь может запускать/останавливать таймер, добавлять записи вручную и редактировать/удалять СВОИ записи. Не даёт права просматривать журнал проекта и управлять чужими записями."}'::jsonb
|
||||
),
|
||||
(
|
||||
'timetracking_can_manage_all',
|
||||
'Full time-tracking access: log own time + view and edit/delete entries of any project member',
|
||||
2,
|
||||
'{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника. Включает права на просмотр и ведение времени."}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
@@ -0,0 +1,635 @@
|
||||
--1.
|
||||
--Trigger set previous version
|
||||
create or replace function app.trigger_set_previous_version()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.prev_version = old.version;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_previous_version on app.version;
|
||||
create trigger trigger_set_previous_version
|
||||
before insert
|
||||
on app.version
|
||||
for each row
|
||||
execute procedure app.trigger_set_previous_version();
|
||||
|
||||
--2.
|
||||
--Trigger for adding owner for taskList from goal
|
||||
create or replace function tasks.trigger_set_owner_for_component()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
new.owner = (select owner from tasks.goals where id = new.goal_id);
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_component on tasks.goal_lists;
|
||||
create trigger trigger_set_owner_for_component
|
||||
before insert
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.trigger_set_owner_for_component();
|
||||
|
||||
--3.
|
||||
--Trigger for updating date_complete for task
|
||||
create or replace function tasks.update_date_complete()
|
||||
returns trigger as
|
||||
$date_complete$
|
||||
begin
|
||||
if new.complete != old.complete
|
||||
then
|
||||
if new.complete = true
|
||||
then
|
||||
update tasks.tasks set date_complete = now() where id = old.id;
|
||||
else
|
||||
update tasks.tasks set date_complete = null where id = old.id;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$date_complete$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists tr_update_date_complete on tasks.tasks;
|
||||
create trigger tr_update_date_complete
|
||||
after update
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.update_date_complete();
|
||||
|
||||
--4.
|
||||
-- Delete user from collaboration if not assigned to any goal
|
||||
create or replace function collaboration.delete_user_if_not_assigned_to_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
count int;
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from collaboration.users_to_goals
|
||||
where user_id = old.user_id
|
||||
limit 1
|
||||
) then
|
||||
delete from collaboration.users where id = old.user_id;
|
||||
end if;
|
||||
|
||||
return old;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_delete_user_if_not_assigned_to_goal on collaboration.users_to_goals;
|
||||
create trigger trigger_delete_user_if_not_assigned_to_goal
|
||||
after delete
|
||||
on collaboration.users_to_goals
|
||||
for each row
|
||||
execute function collaboration.delete_user_if_not_assigned_to_goal();
|
||||
|
||||
--5.
|
||||
--Trigger for checking task graph relation goal to avoid connection between tasks from different goals
|
||||
create or replace function tasks.check_task_graph_relation_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
from_goal int;
|
||||
to_goal int;
|
||||
begin
|
||||
select goal_id into from_goal from tasks.tasks where id = new.from_task_id;
|
||||
select goal_id into to_goal from tasks.tasks where id = new.to_task_id;
|
||||
|
||||
if from_goal is null or to_goal is null then
|
||||
raise exception 'Invalid task reference in relation';
|
||||
end if;
|
||||
|
||||
if from_goal <> to_goal then
|
||||
raise exception 'Relation goal_id must match both tasks'' goal_id';
|
||||
end if;
|
||||
|
||||
new.goal_id := from_goal;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_task_relation_goal on tasks.task_relations;
|
||||
create trigger trigger_task_relation_goal
|
||||
before insert or update on tasks.task_relations
|
||||
for each row execute function tasks.check_task_graph_relation_goal();
|
||||
|
||||
--6.
|
||||
--Trigger for logging changes in taskList to history table
|
||||
create or replace function tasks.log_changes_tasks_goal_lists()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goal_lists (goal_list_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goal_lists on tasks.goal_lists;
|
||||
create trigger trigger_log_changes_tasks_goal_lists
|
||||
before update or delete
|
||||
on tasks.goal_lists
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goal_lists();
|
||||
|
||||
--7.
|
||||
--Trigger for logging changes in goal to history table
|
||||
create or replace function tasks.log_changes_tasks_goals()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_goals (goal_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_goals on tasks.goals;
|
||||
create trigger trigger_log_changes_tasks_goals
|
||||
before update or delete
|
||||
on tasks.goals
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_goals();
|
||||
|
||||
--8.
|
||||
--Trigger for logging changes in task to history table
|
||||
create or replace function tasks.log_changes_tasks_tasks()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted) values (old.id, now(), to_jsonb(old), 1);
|
||||
return old;
|
||||
elseif tg_op = 'UPDATE' then
|
||||
insert into history.tasks_tasks (task_id, edit_date, task, deleted)
|
||||
VALUES (old.id, new.date_creation, to_jsonb(old), 0);
|
||||
new.edit_date = now();
|
||||
return new;
|
||||
end if;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_tasks_tasks on tasks.tasks;
|
||||
create trigger trigger_log_changes_tasks_tasks
|
||||
before update or delete
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.log_changes_tasks_tasks();
|
||||
|
||||
--9.
|
||||
--Trigger for setting goal_id default for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_goal_id_default_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
goal_id INT;
|
||||
BEGIN
|
||||
|
||||
SELECT gl.goal_id
|
||||
INTO goal_id
|
||||
FROM tasks.goal_lists gl
|
||||
WHERE gl.id = NEW.goal_list_id;
|
||||
|
||||
IF goal_id IS NOT NULL THEN
|
||||
NEW.goal_id := goal_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists before_insert_set_goal_id_for_task on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER before_insert_set_goal_id_for_task
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_goal_id_default_for_task();
|
||||
|
||||
|
||||
--10.
|
||||
--Trigger for adding default roles and permissions for goal
|
||||
CREATE OR REPLACE FUNCTION tasks.add_roles_and_permissions()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
editor_role_id INTEGER;
|
||||
executor_role_id INTEGER;
|
||||
BEGIN
|
||||
-- 1. Create role "editor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('editor', NEW.id)
|
||||
RETURNING id INTO editor_role_id;
|
||||
|
||||
-- 2. Create role "executor"
|
||||
INSERT INTO collaboration.roles (name, goal_id)
|
||||
VALUES ('executor', NEW.id)
|
||||
RETURNING id INTO executor_role_id;
|
||||
|
||||
-- 3. Add permissions for role "editor"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT editor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'goal_can_edit',
|
||||
'goal_can_add_task_list',
|
||||
'goal_can_manage_users',
|
||||
'component_can_watch_content',
|
||||
'component_can_edit',
|
||||
'component_can_delete',
|
||||
'component_can_add_tasks',
|
||||
'task_can_edit_deadline',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_recovery_history',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_edit_priority',
|
||||
'task_can_delete',
|
||||
'task_can_watch_details',
|
||||
'task_can_assign_users',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'task_can_access_history',
|
||||
'task_can_edit_tags',
|
||||
'task_can_edit_description',
|
||||
'task_can_edit_status',
|
||||
'task_can_edit_note',
|
||||
'kanban_can_manage',
|
||||
'kanban_can_view',
|
||||
'graph_can_manage',
|
||||
'graph_can_view',
|
||||
'timetracking_can_view',
|
||||
'timetracking_can_manage_all'
|
||||
);
|
||||
|
||||
-- 4. Add permissions for role "viewver"
|
||||
INSERT INTO collaboration.permissions_to_role (role_id, permission_id)
|
||||
SELECT executor_role_id, id
|
||||
FROM tv_auth.permissions
|
||||
WHERE name IN (
|
||||
'goal_can_watch_content',
|
||||
'component_can_watch_content',
|
||||
'component_can_add_tasks',
|
||||
'task_can_watch_subtasks',
|
||||
'task_can_watch_note',
|
||||
'task_can_watch_assigned_users',
|
||||
'task_can_watch_details',
|
||||
'task_can_add_subtasks',
|
||||
'task_can_watch_tags',
|
||||
'task_can_watch_priority',
|
||||
'timetracking_can_view',
|
||||
'timetracking_can_log'
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists add_roles_after_insert on tasks.goals;
|
||||
|
||||
CREATE TRIGGER add_roles_after_insert
|
||||
AFTER INSERT
|
||||
ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.add_roles_and_permissions();
|
||||
|
||||
|
||||
--11.
|
||||
--Trigger for adjusting start and end dates for task
|
||||
CREATE OR REPLACE FUNCTION tasks.adjust_start_and_end_dates()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
start_timestamp TIMESTAMPTZ;
|
||||
end_timestamp TIMESTAMPTZ;
|
||||
BEGIN
|
||||
-- If start_date is NULL, then start_time should be NULL
|
||||
IF NEW.start_date IS NULL THEN
|
||||
NEW.start_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If end_date is NULL, then end_time should be NULL
|
||||
IF NEW.end_date IS NULL THEN
|
||||
NEW.end_time := NULL;
|
||||
END IF;
|
||||
|
||||
-- If both dates are set
|
||||
IF NEW.start_date IS NOT NULL AND NEW.end_date IS NOT NULL THEN
|
||||
-- Adjust dates
|
||||
IF NEW.start_date > NEW.end_date THEN
|
||||
-- If start_date is greater than end_date, set end_date to start_date
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- end_time remains unchanged
|
||||
ELSIF NEW.end_date < NEW.start_date THEN
|
||||
-- If end_date is less than start_date, set start_date to end_date
|
||||
NEW.start_date := NEW.end_date;
|
||||
-- start_time remains unchanged
|
||||
END IF;
|
||||
|
||||
-- Prepare timestamps for comparison
|
||||
start_timestamp := (NEW.start_date::text || ' ' || COALESCE(NEW.start_time::text, '00:00:00+00'))::timestamptz;
|
||||
end_timestamp := (NEW.end_date::text || ' ' || COALESCE(NEW.end_time::text, '00:00:00+00'))::timestamptz;
|
||||
|
||||
-- If start_timestamp is greater than end_timestamp, adjust end_date and end_time
|
||||
IF start_timestamp > end_timestamp THEN
|
||||
NEW.end_date := NEW.start_date;
|
||||
-- Assign end_time only if start_time is not NULL
|
||||
IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
|
||||
NEW.end_time := NEW.start_time;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
drop trigger if exists adjust_dates_and_times_trigger on tasks.tasks;
|
||||
CREATE TRIGGER adjust_dates_and_times_trigger
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.adjust_start_and_end_dates();
|
||||
|
||||
--12.
|
||||
--Trigger for adding self/owner to collaboration table to be able to assign tasks to self
|
||||
create or replace function tasks.add_self_to_collaboration()
|
||||
returns trigger as $$
|
||||
DECLARE
|
||||
owner_email TEXT;
|
||||
BEGIN
|
||||
|
||||
select email into owner_email
|
||||
from tv_auth.users
|
||||
where id = NEW.owner;
|
||||
|
||||
if owner_email is not null then
|
||||
insert into collaboration.users (email) values (owner_email) ON CONFLICT (email) DO NOTHING;
|
||||
insert into collaboration.users_to_goals (goal_id, user_id) values (NEW.id, (select id from collaboration.users where email = owner_email));
|
||||
end if;
|
||||
|
||||
return NEW;
|
||||
END;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists add_selt_to_collaboration_trg on tasks.goals;
|
||||
|
||||
create trigger add_selt_to_collaboration_trg
|
||||
after insert on tasks.goals
|
||||
for each row
|
||||
execute function tasks.add_self_to_collaboration();
|
||||
|
||||
--13.
|
||||
--Trigger for adding default kanban columns for new goal
|
||||
CREATE OR REPLACE FUNCTION tasks.kanban_add_default_columns()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Add default columns for new goal
|
||||
INSERT INTO tasks.statuses (name, goal_id, view_order)
|
||||
VALUES
|
||||
('TODO', NEW.id, 1),
|
||||
('In Progress', NEW.id, 2),
|
||||
('Done', NEW.id, 3);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS kanban_add_default_columns_trg ON tasks.goals;
|
||||
|
||||
CREATE TRIGGER kanban_add_default_columns_trg
|
||||
AFTER INSERT ON tasks.goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.kanban_add_default_columns();
|
||||
|
||||
--14.
|
||||
--Trigger for validating the correct statusId for the inserted value. To avoid assigning a status that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks.check_task_status_goal()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if there is a record in tasks.statuses with the same goal_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tasks.statuses s
|
||||
WHERE s.id = NEW.status_id AND s.goal_id = NEW.goal_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Status ID % is not valid for goal ID %', NEW.status_id, NEW.goal_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists enforce_task_status_goal on tasks.tasks;
|
||||
|
||||
CREATE TRIGGER enforce_task_status_goal
|
||||
BEFORE INSERT OR UPDATE ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status_id IS NOT NULL)
|
||||
EXECUTE FUNCTION tasks.check_task_status_goal();
|
||||
|
||||
--15.
|
||||
--Trigger for setting default orders value for task
|
||||
CREATE OR REPLACE FUNCTION tasks.set_order_value()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.task_order IS NULL THEN
|
||||
NEW.task_order := NEW.id;
|
||||
END IF;
|
||||
IF NEW.kanban_order IS NULL THEN
|
||||
NEW.kanban_order := NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_order_trigger on tasks.tasks;
|
||||
CREATE TRIGGER set_order_trigger
|
||||
BEFORE INSERT ON tasks.tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.set_order_value();
|
||||
|
||||
--16.
|
||||
--Trigger for setting default view order for new status
|
||||
CREATE OR REPLACE FUNCTION tasks.status_set_default_view_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_view_order INT;
|
||||
BEGIN
|
||||
-- Determine the next view_order for the given goal_id
|
||||
SELECT COALESCE(MAX(view_order), 0) + 1 INTO new_view_order
|
||||
FROM tasks.statuses
|
||||
WHERE goal_id = NEW.goal_id;
|
||||
|
||||
-- Assign the calculated value to the view_order field
|
||||
NEW.view_order := new_view_order;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists set_default_status_view_order on tasks.statuses;
|
||||
|
||||
CREATE TRIGGER set_default_status_view_order
|
||||
BEFORE INSERT ON tasks.statuses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks.status_set_default_view_order();
|
||||
|
||||
--17.
|
||||
--Trigger for validating the correct user_id for the inserted value. To avoid assigning a user that does not belong to the goal.
|
||||
CREATE OR REPLACE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks.tasks tt
|
||||
LEFT JOIN collaboration.users_to_goals utg ON utg.goal_id = tt.goal_id
|
||||
WHERE tt.id = NEW.task_id AND utg.user_id = NEW.collab_user_id
|
||||
) INTO user_exists;
|
||||
|
||||
IF NOT user_exists THEN
|
||||
RAISE EXCEPTION 'User % is not associated with the goal of task %', NEW.collab_user_id, NEW.task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_control_user_id_is_from_same_goal_as_task on tasks_auth.task_assignee;
|
||||
|
||||
CREATE TRIGGER trigger_control_user_id_is_from_same_goal_as_task
|
||||
BEFORE INSERT ON tasks_auth.task_assignee
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION tasks_auth.control_user_id_is_from_same_goal_as_task();
|
||||
|
||||
--18.
|
||||
--Trigger for adding owner for task, extend owner from goal or taskList
|
||||
|
||||
--delete old function with wrong name
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
drop function if exists tasks.trigger_set_owner_for_task();
|
||||
|
||||
CREATE OR REPLACE FUNCTION tasks.fn_set_owner_for_task()
|
||||
RETURNS TRIGGER AS
|
||||
$body$
|
||||
BEGIN
|
||||
NEW.owner := COALESCE(
|
||||
(SELECT owner FROM tasks.goal_lists WHERE id = NEW.goal_list_id),
|
||||
(SELECT owner FROM tasks.goals WHERE id = NEW.goal_id)
|
||||
);
|
||||
|
||||
IF NEW.owner IS NULL THEN
|
||||
RAISE EXCEPTION 'Can not insert task without owner';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$body$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
drop trigger if exists trigger_set_owner_for_task on tasks.tasks;
|
||||
create trigger trigger_set_owner_for_task
|
||||
before insert
|
||||
on tasks.tasks
|
||||
for each row
|
||||
execute procedure tasks.fn_set_owner_for_task();
|
||||
|
||||
--19.
|
||||
--Trigger for validating that tag and task belong to the same project (goal_id)
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
drop function if exists tasks.check_tag_task_same_goal();
|
||||
|
||||
create or replace function tasks.check_tag_task_same_goal()
|
||||
returns trigger as $$
|
||||
declare
|
||||
v_tag_goal_id integer;
|
||||
v_task_goal_id integer;
|
||||
begin
|
||||
select goal_id into v_tag_goal_id from tasks.tags where id = new.tag_id;
|
||||
select goal_id into v_task_goal_id from tasks.tasks where id = new.task_id;
|
||||
|
||||
if v_tag_goal_id is null or v_tag_goal_id != v_task_goal_id then
|
||||
raise exception 'Tag (id=%) and task (id=%) belong to different projects', new.tag_id, new.task_id;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_check_tag_task_same_goal on tasks.tasks_to_tags;
|
||||
create trigger trigger_check_tag_task_same_goal
|
||||
before insert on tasks.tasks_to_tags
|
||||
for each row
|
||||
execute function tasks.check_tag_task_same_goal();
|
||||
|
||||
--20.
|
||||
-- Remove user from task assignees when removed from project collaboration
|
||||
|
||||
drop trigger if exists trigger_remove_user_from_task_assignees on collaboration.users_to_goals;
|
||||
drop function if exists collaboration.remove_user_from_task_assignees();
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION collaboration.remove_user_from_task_assignees()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM tasks_auth.task_assignee
|
||||
WHERE collab_user_id = OLD.user_id
|
||||
AND task_id IN (SELECT id FROM tasks.tasks WHERE goal_id = OLD.goal_id);
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_remove_user_from_task_assignees ON collaboration.users_to_goals;
|
||||
|
||||
CREATE TRIGGER trigger_remove_user_from_task_assignees
|
||||
BEFORE DELETE
|
||||
ON collaboration.users_to_goals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION collaboration.remove_user_from_task_assignees();
|
||||
|
||||
--21.
|
||||
--Trigger for logging changes in time_entries to history table
|
||||
create or replace function tasks.log_changes_time_entries()
|
||||
returns trigger as
|
||||
$body$
|
||||
begin
|
||||
insert into history.time_entries (entry_id, edit_date, entry)
|
||||
values (old.id, now(), to_jsonb(old));
|
||||
new.edited_at = now();
|
||||
return new;
|
||||
end
|
||||
$body$
|
||||
language plpgsql;
|
||||
|
||||
drop trigger if exists trigger_log_changes_time_entries on tasks.time_entries;
|
||||
create trigger trigger_log_changes_time_entries
|
||||
before update
|
||||
on tasks.time_entries
|
||||
for each row
|
||||
execute procedure tasks.log_changes_time_entries();
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_user
|
||||
ON tasks.time_entries(goal_id, started_at DESC, user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_goal_started_task
|
||||
ON tasks.time_entries(goal_id, started_at DESC, task_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_billable_goal_started
|
||||
ON tasks.time_entries(goal_id, started_at DESC)
|
||||
WHERE billable = TRUE;
|
||||
+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);
|
||||
+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,6 +6,7 @@ import GraphRoutes from '../tv-modules/graph/GraphRoutes';
|
||||
import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes';
|
||||
import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes';
|
||||
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
|
||||
import MessagingRoutes from '../tv-modules/messaging/MessagingRoutes';
|
||||
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
|
||||
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
|
||||
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
|
||||
@@ -13,6 +14,14 @@ import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
|
||||
import StartRoutes from '../tv-modules/start/StartRoutes';
|
||||
import TagsRouter from '../tv-modules/tags/TagsRouter';
|
||||
import TasksRoutes from '../tv-modules/tasks/TasksRoutes';
|
||||
import OrganizationRoutes from '../tv-modules/organizations/OrganizationRoutes';
|
||||
import SsoRoutes from '../tv-modules/sso/SsoRoutes';
|
||||
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
|
||||
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
|
||||
import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
|
||||
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
|
||||
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
|
||||
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
|
||||
import type { Routable } from '../types/routable.type';
|
||||
|
||||
type RoutableConstructor = new (...args: any[]) => Routable;
|
||||
@@ -31,8 +40,17 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
'/module/messaging': MessagingRoutes,
|
||||
'/module/api-tokens': ApiTokensRoutes,
|
||||
'/module/sessions': SessionsRoutes,
|
||||
'/module/organizations': OrganizationRoutes,
|
||||
'/module/sso': SsoRoutes,
|
||||
'/module/analytics': AnalyticsRoutes,
|
||||
'/module/time-tracking': TimeTrackingRoutes,
|
||||
'/module/ui-preferences': UiPreferencesRoutes,
|
||||
'/module/sprints': SprintsRoutes,
|
||||
'/module/recurrence': RecurrenceRoutes,
|
||||
'/scim/v2': ScimRoutes,
|
||||
};
|
||||
|
||||
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 { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
|
||||
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
|
||||
import { StaleTasksSection } from './quality/StaleTasksSection'
|
||||
import { StatusDistributionSection } from './usage/StatusDistributionSection'
|
||||
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
|
||||
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
|
||||
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
|
||||
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
|
||||
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
|
||||
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
|
||||
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
|
||||
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
|
||||
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
|
||||
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
|
||||
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
|
||||
import { NetProfitKpi } from './financial/NetProfitKpi'
|
||||
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
|
||||
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
|
||||
import type { AnalyticsSectionCatalogEntry } from 'taskview-api'
|
||||
import { parseEnabledSectionIds } from './sectionEnv'
|
||||
import { sectionLocales } from './locales'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disabled sections — re-enable once the underlying data model can support them
|
||||
// honestly. Until then they would report misleading numbers.
|
||||
//
|
||||
// 1. AgingOpenTasksSection (chart.aging_open_tasks)
|
||||
// Measures (now - date_creation) for every open task per assignee. Inflates
|
||||
// the number with backlog tasks nobody has started working on yet.
|
||||
// Needs: a boolean is_in_progress column on tasks.tasks, OR a "wip" flag
|
||||
// on tasks.statuses, so SQL can count only tasks that have been picked up.
|
||||
//
|
||||
// 2. CycleTimeKpi (kpi.cycle_time)
|
||||
// 3. CycleTimeHistogramSection (chart.cycle_time_histogram)
|
||||
// 4. CycleTimePerProjectSection (chart.cycle_time_per_project)
|
||||
// All three measure (date_complete - date_creation) for completed tasks.
|
||||
// That is actually lead time (includes time the task sat in backlog), not
|
||||
// cycle time. A task that spent 6 months in backlog and was then finished
|
||||
// in 2 days shows up as a 6-month "cycle".
|
||||
// Needs: a started_at timestamp on tasks.tasks (set when the task moves
|
||||
// into an in-progress status) so SQL can compute (date_complete - started_at).
|
||||
// Trigger or app logic must set started_at on the first transition into a
|
||||
// "wip" status.
|
||||
//
|
||||
// 5. TimeInKanbanStatusSection (chart.time_in_kanban_status)
|
||||
// Misleadingly named — it averages (now - edit_date) for currently-open
|
||||
// tasks grouped by their current status. It does NOT measure how long
|
||||
// each task has actually been in its current status; any unrelated edit
|
||||
// (description change, assignee change) resets the clock for the metric.
|
||||
// Needs: a tasks.status_history table tracking (task_id, status_id,
|
||||
// entered_at, exited_at). Then "time in status X" = avg(exited_at OR now
|
||||
// - entered_at) for rows in that status. Without a transition log, this
|
||||
// metric cannot be computed correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
|
||||
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
|
||||
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
|
||||
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
|
||||
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
|
||||
|
||||
const builders: SectionBuilder[] = [
|
||||
// KPI
|
||||
new CreatedTasksKpi(),
|
||||
new CompletedTasksKpi(),
|
||||
new OverdueKpi(),
|
||||
// new CycleTimeKpi(), // disabled — see top-of-file comment
|
||||
new TotalIncomeKpi(),
|
||||
new TotalExpenseKpi(),
|
||||
new NetProfitKpi(),
|
||||
new PlannedIncomeKpi(),
|
||||
new PlannedExpenseKpi(),
|
||||
new AmountCoverageKpi(),
|
||||
// Productivity
|
||||
new ThroughputSection(),
|
||||
new PriorityMixOverTimeSection(),
|
||||
// Workload
|
||||
new WorkloadByAssigneeSection(),
|
||||
new BlockedByDependenciesSection(),
|
||||
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
|
||||
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
|
||||
// Quality
|
||||
new OverdueByAgeSection(),
|
||||
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
|
||||
new StaleTasksSection(),
|
||||
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
|
||||
// Usage
|
||||
new StatusDistributionSection(),
|
||||
new ActiveProjectsSection(),
|
||||
// Financial
|
||||
new IncomeExpenseMonthSection(),
|
||||
new IncomeExpensePerProjectSection(),
|
||||
new IncomePerProjectMonthSection(),
|
||||
new ExpensePerProjectMonthSection(),
|
||||
new IncomePerTagMonthSection(),
|
||||
new ExpensePerTagMonthSection(),
|
||||
new TopProjectsByAmountSection(),
|
||||
]
|
||||
|
||||
export class SectionRegistry {
|
||||
private readonly byId: Map<string, SectionBuilder>
|
||||
private readonly enabledOrder: SectionBuilder[]
|
||||
|
||||
constructor() {
|
||||
this.byId = new Map(builders.map(b => [b.id, b]))
|
||||
|
||||
const enabledIds = parseEnabledSectionIds(process.env.ANALYTICS_SECTIONS)
|
||||
if (enabledIds && enabledIds.length > 0) {
|
||||
this.enabledOrder = enabledIds
|
||||
.map((id: string) => this.byId.get(id))
|
||||
.filter((b: SectionBuilder | undefined): b is SectionBuilder => !!b)
|
||||
} else {
|
||||
this.enabledOrder = [...builders]
|
||||
}
|
||||
}
|
||||
|
||||
all(): SectionBuilder[] {
|
||||
return [...this.enabledOrder]
|
||||
}
|
||||
|
||||
get(id: string): SectionBuilder | undefined {
|
||||
const builder = this.byId.get(id)
|
||||
if (!builder) return undefined
|
||||
return this.enabledOrder.includes(builder) ? builder : undefined
|
||||
}
|
||||
|
||||
filterByIds(ids?: string[]): SectionBuilder[] {
|
||||
if (!ids || ids.length === 0) return this.all()
|
||||
const enabledSet = new Set(this.enabledOrder)
|
||||
return ids
|
||||
.map(id => this.byId.get(id))
|
||||
.filter((b): b is SectionBuilder => !!b && enabledSet.has(b))
|
||||
}
|
||||
|
||||
catalog(): AnalyticsSectionCatalogEntry[] {
|
||||
const locales = sectionLocales as Record<string, { title?: { ru: string, en: string } }>
|
||||
return this.enabledOrder.map(b => ({
|
||||
id: b.id,
|
||||
group: b.group,
|
||||
payloadKind: b.defaultChartType === null ? 'kpi' : 'series',
|
||||
title: locales[b.id]?.title ?? { ru: b.id, en: b.id },
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class AmountCoverageKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.amount_coverage'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { total, withAmount } = await ctx.repository.amountCoverage(ctx.accessibleGoalIds)
|
||||
const percent = total === 0 ? 0 : Math.round((withAmount / total) * 100)
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: percent,
|
||||
unit: 'percent',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'percent' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildProjectAmountPayload } from './projectAmountPayload'
|
||||
|
||||
export class ExpensePerProjectMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.expense_per_project_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerProjectMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 0,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildProjectAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildTagAmountPayload } from './tagAmountPayload'
|
||||
|
||||
export class ExpensePerTagMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.expense_per_tag_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerTagMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 0,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildTagAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class IncomeExpenseMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_expense_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['bar', 'line', 'area', 'stackedArea'] as const
|
||||
readonly defaultChartType = 'bar' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchIncomeExpenseMonth(ctx.accessibleGoalIds, ctx.range)
|
||||
const loc = this.loc
|
||||
|
||||
const payload: AnalyticsSeriesPayload = {
|
||||
kind: 'series',
|
||||
labels: rows.map(r => r.month),
|
||||
labelKind: 'category',
|
||||
datasets: [
|
||||
{
|
||||
id: 'income',
|
||||
label: loc.datasets!.income,
|
||||
values: rows.map(r => Number(r.income)),
|
||||
colorToken: 'success',
|
||||
},
|
||||
{
|
||||
id: 'expense',
|
||||
label: loc.datasets!.expense,
|
||||
values: rows.map(r => Number(r.expense)),
|
||||
colorToken: 'danger',
|
||||
},
|
||||
],
|
||||
unit: 'currency',
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class IncomeExpensePerProjectSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_expense_per_project'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['bar', 'area'] as const
|
||||
readonly defaultChartType = 'bar' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchIncomeExpensePerProject(ctx.accessibleGoalIds)
|
||||
const loc = this.loc
|
||||
const goalIds = rows.map(r => r.goal_id)
|
||||
|
||||
const payload: AnalyticsSeriesPayload = {
|
||||
kind: 'series',
|
||||
labels: rows.map(r => r.goal_name),
|
||||
labelKind: 'category',
|
||||
datasets: [
|
||||
{
|
||||
id: 'income',
|
||||
label: loc.datasets!.income,
|
||||
values: rows.map(r => Number(r.income)),
|
||||
colorToken: 'success',
|
||||
meta: { goalIds },
|
||||
},
|
||||
{
|
||||
id: 'expense',
|
||||
label: loc.datasets!.expense,
|
||||
values: rows.map(r => Number(r.expense)),
|
||||
colorToken: 'danger',
|
||||
meta: { goalIds },
|
||||
},
|
||||
],
|
||||
unit: 'currency',
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildProjectAmountPayload } from './projectAmountPayload'
|
||||
|
||||
export class IncomePerProjectMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_per_project_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerProjectMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 1,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildProjectAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildTagAmountPayload } from './tagAmountPayload'
|
||||
|
||||
export class IncomePerTagMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_per_tag_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerTagMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 1,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildTagAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class NetProfitKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.net_profit'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const cur = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
const current = cur.income - cur.expense
|
||||
const previous = prev.income - prev.expense
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(current),
|
||||
unit: 'currency',
|
||||
delta: this.buildDelta(current, previous),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return {
|
||||
value: 100,
|
||||
direction: current > 0 ? ('up' as const) : ('down' as const),
|
||||
isGood: current >= 0,
|
||||
}
|
||||
}
|
||||
const pct = Math.round(((current - prev) / Math.abs(prev)) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class PlannedExpenseKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.planned_expense'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const value = await ctx.repository.sumPlannedExpense(ctx.accessibleGoalIds)
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(value),
|
||||
unit: 'currency',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
drillDown: { kind: 'tasks' },
|
||||
}
|
||||
}
|
||||
|
||||
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return []
|
||||
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 0)
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class PlannedIncomeKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.planned_income'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const value = await ctx.repository.sumPlannedIncome(ctx.accessibleGoalIds)
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(value),
|
||||
unit: 'currency',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
drillDown: { kind: 'tasks' },
|
||||
}
|
||||
}
|
||||
|
||||
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return []
|
||||
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 1)
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class TopProjectsByAmountSection implements SectionBuilder {
|
||||
readonly id = 'chart.top_projects_by_amount'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
|
||||
readonly defaultChartType = 'stackedBar' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchTopProjectsByAmount(ctx.accessibleGoalIds)
|
||||
const loc = this.loc
|
||||
const goalIds = rows.map(r => r.goal_id)
|
||||
|
||||
const payload: AnalyticsSeriesPayload = {
|
||||
kind: 'series',
|
||||
labels: rows.map(r => r.goal_name),
|
||||
labelKind: 'category',
|
||||
datasets: [
|
||||
{
|
||||
id: 'income',
|
||||
label: loc.datasets!.income,
|
||||
values: rows.map(r => Number(r.income)),
|
||||
colorToken: 'success',
|
||||
stack: 'amount',
|
||||
meta: { goalIds },
|
||||
},
|
||||
{
|
||||
id: 'expense',
|
||||
label: loc.datasets!.expense,
|
||||
values: rows.map(r => Number(r.expense)),
|
||||
colorToken: 'danger',
|
||||
stack: 'amount',
|
||||
meta: { goalIds },
|
||||
},
|
||||
],
|
||||
unit: 'currency',
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class TotalExpenseKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.total_expense'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const current = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(current),
|
||||
unit: 'currency',
|
||||
delta: this.buildDelta(current, prev),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return { value: 100, direction: 'up' as const, isGood: false }
|
||||
}
|
||||
const pct = Math.round(((current - prev) / prev) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct <= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class TotalIncomeKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.total_income'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = [] as const
|
||||
readonly defaultChartType = null
|
||||
readonly cacheTtlSec = 600
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const { from, to } = ctx.range
|
||||
const windowMs = to.getTime() - from.getTime()
|
||||
const prevFrom = new Date(from.getTime() - windowMs)
|
||||
|
||||
const current = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from, to })
|
||||
const prev = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from: prevFrom, to: from })
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value: Math.round(current),
|
||||
unit: 'currency',
|
||||
delta: this.buildDelta(current, prev),
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
description: this.loc.description,
|
||||
help: this.loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private buildDelta(current: number, prev: number) {
|
||||
if (prev === 0 && current === 0) {
|
||||
return { value: 0, direction: 'flat' as const, isGood: true }
|
||||
}
|
||||
if (prev === 0) {
|
||||
return { value: 100, direction: 'up' as const, isGood: true }
|
||||
}
|
||||
const pct = Math.round(((current - prev) / prev) * 100)
|
||||
return {
|
||||
value: Math.abs(pct),
|
||||
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
|
||||
isGood: pct >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: 'financial',
|
||||
allowedChartTypes: [],
|
||||
defaultChartType: null,
|
||||
payload: { kind: 'kpi', value: 0, unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
|
||||
import type { AmountPerProjectMonthSectionRow } from '../row.types'
|
||||
|
||||
export type BuildProjectAmountPayloadArgs = {
|
||||
rows: AmountPerProjectMonthSectionRow[]
|
||||
xAxisLabel?: LocalizedText
|
||||
yAxisLabel?: LocalizedText
|
||||
}
|
||||
|
||||
export function buildProjectAmountPayload(args: BuildProjectAmountPayloadArgs): AnalyticsSeriesPayload {
|
||||
const { rows, xAxisLabel, yAxisLabel } = args
|
||||
|
||||
const monthSet = new Set<string>()
|
||||
const projectOrder: number[] = []
|
||||
const projectNameById = new Map<number, string>()
|
||||
const valuesByProject = new Map<number, Map<string, number>>()
|
||||
|
||||
for (const row of rows) {
|
||||
monthSet.add(row.month)
|
||||
if (!projectNameById.has(row.goal_id)) {
|
||||
projectNameById.set(row.goal_id, row.goal_name)
|
||||
projectOrder.push(row.goal_id)
|
||||
}
|
||||
let perMonth = valuesByProject.get(row.goal_id)
|
||||
if (!perMonth) {
|
||||
perMonth = new Map()
|
||||
valuesByProject.set(row.goal_id, perMonth)
|
||||
}
|
||||
perMonth.set(row.month, Number(row.amount))
|
||||
}
|
||||
|
||||
const labels = [...monthSet].sort()
|
||||
|
||||
const datasets: AnalyticsDataset[] = projectOrder.map((goalId) => {
|
||||
const perMonth = valuesByProject.get(goalId) ?? new Map<string, number>()
|
||||
const name = projectNameById.get(goalId) ?? `#${goalId}`
|
||||
return {
|
||||
id: `project_${goalId}`,
|
||||
label: { ru: name, en: name },
|
||||
values: labels.map(month => perMonth.get(month) ?? 0),
|
||||
meta: { goalIds: [goalId] },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 'series',
|
||||
labels,
|
||||
labelKind: 'date',
|
||||
datasets,
|
||||
unit: 'currency',
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
|
||||
export class OverdueKpi implements SectionBuilder {
|
||||
readonly id = 'kpi.overdue'
|
||||
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 value = await ctx.repository.countOverdue(ctx.accessibleGoalIds)
|
||||
|
||||
const payload: AnalyticsKpiPayload = {
|
||||
kind: 'kpi',
|
||||
value,
|
||||
unit: 'count',
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
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.fetchOverdueTasks(ctx.accessibleGoalIds)
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user