Compare commits

...

24 Commits

Author SHA1 Message Date
Nikolai Giman e1a8bb71a0 fix: reload main screen when become active 2026-06-28 15:02:07 +02:00
Nikolai Giman f1ff303e58 fix: ipad safe area issues 2026-06-28 00:04:42 +02:00
Nikolai Giman 22dc24f8e0 fix: ui and recurring issues 2026-06-27 22:51:29 +02:00
Nikolai Giman 58e3cdcbb0 wip: mobile area 2026-06-21 23:47:43 +02:00
Nikolai Giman 28e944f05e wip: search width 2026-06-21 23:22:33 +02:00
Nikolai Giman 8f21a52ca6 wip: style 2026-06-21 23:18:01 +02:00
Nikolai Giman 96ac9f4d7a feat: ui improvements 2026-06-21 23:02:31 +02:00
Nikolai Giman 4c92aaf03d Merge pull request #66 from Gimanh/feat/recur-tasks
feat: recur tasks
2026-06-14 17:29:53 +02:00
Nikolai Giman 10a0d01719 chore: version 2026-06-14 17:29:03 +02:00
Nikolai Giman eb0aba02e9 chore: version 2026-06-14 17:26:02 +02:00
Nikolai Giman 9beb2ff9e5 fix: recurring task 2026-06-13 22:55:03 +02:00
Nikolai Giman 76b38b8ac1 feat: recur tasks 2026-06-08 00:11:56 +02:00
Nikolai Giman b7b91e16d3 Merge pull request #65 from Gimanh/feat/sprints
feat: sprints
2026-06-05 09:12:52 +02:00
Nikolai Giman d14398faae fix: middlewares for sprint 2026-06-04 22:12:10 +02:00
Nikolai Giman 02b9f8cd30 feat: calendar settings 2026-06-04 03:43:39 +02:00
Nikolai Giman 6f8e01b446 fix: confirm closing 2026-05-31 23:17:52 +02:00
Nikolai Giman 673cc868f8 feat: sprints 2026-05-31 22:50:03 +02:00
Nikolai Giman eb7565debb Merge pull request #64 from Gimanh/fix/63
fix: #63
2026-05-28 22:02:30 +02:00
Nikolai Giman e09b6e2c0f fix: #63 2026-05-28 22:01:43 +02:00
Nikolai Giman 1dd779555a Merge pull request #61 from Gimanh/chore/version-1-44-1
chore: version
2026-05-27 19:24:28 +02:00
Nikolai Giman 20fc40d597 chore: version 2026-05-27 19:23:14 +02:00
Nikolai Giman 913690d230 Merge pull request #60 from Gimanh/fix/59
fix: https://github.com/Gimanh/taskview-community/issues/59
2026-05-27 19:10:46 +02:00
Nikolai Giman b950b15061 fix: https://github.com/Gimanh/taskview-community/issues/59 2026-05-27 19:10:11 +02:00
Nikolai Giman f5a76b3b2a Merge pull request #58 from Gimanh/chore/vesrion-1-44
chore: version
2026-05-25 21:36:34 +02:00
303 changed files with 15779 additions and 926 deletions
+354 -97
View File
@@ -1,162 +1,419 @@
# TaskView™
![TaskView Logo](./assets/taskview-logo.png)
<p align="center">
<img src="./assets/taskview/kanban-dark.png" alt="TaskView logo" width="320">
</p>
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
<p align="center">
<strong>Self-hosted project management for software teams.</strong>
</p>
[![License](https://img.shields.io/badge/license-Source--Available-blue)](./LICENSE)
[![Active Development](https://img.shields.io/badge/status-active-brightgreen)]()
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**iOS**](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867) · [**Android**](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
<p align="center">
TaskView combines task management, custom workflows, developer integrations,
analytics, and AI-assisted automation in a platform you can run on your own infrastructure.
</p>
## Apps
* [Docs](https://taskview.tech/docs/)
* [Web](https://app.taskview.tech/)
* [iOS](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
* [Android](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
<details>
<summary><strong>View more screenshots</strong></summary>
It is designed for teams and individuals who want:
- full control over their data
- transparent architecture
- predictable workflows
- the ability to run the system on their own infrastructure
<br>
## Key Features
- Projects, lists, tasks, and subtasks
- Kanban boards
- Clear responsibility assignment
- Permissions and roles
- Web and mobile clients
- Self-hosted by design
<img src="./assets/taskview/main-light.png" alt="Main dashboard (light theme)" width="100%">
<img src="./assets/taskview/main-dark.png" alt="Main dashboard (dark theme)" width="100%">
<img src="./assets/taskview/main-collapsed-light.png" alt="Main dashboard with collapsed sidebar (light theme)" width="100%">
<img src="./assets/taskview/main-collapsed-dark.png" alt="Main dashboard with collapsed sidebar (dark theme)" width="100%">
<img src="./assets/taskview/kanban-light.png" alt="Kanban board (light theme)" width="100%">
<img src="./assets/taskview/kanban-dark.png" alt="Kanban board (dark theme)" width="100%">
<img src="./assets/taskview/tasks-light.png" alt="Task list view (light theme)" width="100%">
<img src="./assets/taskview/tasks-dark.png" alt="Task list view (dark theme)" width="100%">
<img src="./assets/taskview/recent-tasks.png" alt="Recent tasks overview" width="100%">
<img src="./assets/taskview/sprints.png" alt="Sprint planning" width="100%">
<img src="./assets/taskview/time-tracking.png" alt="Time tracking" width="100%">
<img src="./assets/taskview/analytics.png" alt="Analytics dashboard" width="100%">
<img src="./assets/taskview/members.png" alt="Organization members management" width="100%">
<img src="./assets/taskview/permissions-light.png" alt="Role permissions (light theme)" width="100%">
<img src="./assets/taskview/permissions-dark.png" alt="Role permissions (dark theme)" width="100%">
<img src="./assets/taskview/sso.png" alt="Single Sign-On (SSO) configuration" width="100%">
<img src="./assets/taskview/api-tokens.png" alt="API tokens management" width="100%">
<img src="./assets/taskview/webhooks.png" alt="Webhooks configuration" width="100%">
<img src="./assets/taskview/integrations.png" alt="Integrations" width="100%">
<img src="./assets/taskview/ui-customization.png" alt="UI customization" width="100%">
<img src="./assets/taskview/settings.png" alt="Settings" width="100%">
## Licensing
</details>
TaskView uses a source-available license.
<p align="center">
<a href="https://app.taskview.tech"><strong>TaskView Cloud</strong></a>
·
<a href="https://taskview.tech/docs/"><strong>Documentation</strong></a>
·
<a href="https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867"><strong>iOS</strong></a>
·
<a href="https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app"><strong>Android</strong></a>
·
<a href="https://github.com/Gimanh/taskview-community/releases">
<strong>Releases</strong>
</a>
</p>
You may:
- self-host TaskView
- modify the source code
- use it internally within your organization
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
</a>
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
</p>
You may not:
- offer TaskView as a hosted SaaS
- sell TaskView as a service
- create a competing commercial product
- use the TaskView name or branding for derivative products
---
See LICENSE for full terms.
TaskView is a trademark of its authors.
## About TaskView
## Architecture
TaskView is a source-available project and task management platform for small and growing software teams.
This repository is a monorepo.
It is designed for teams that need more structure than a simple task board, but do not want the administration overhead and complexity of large enterprise project management systems.
Main product packages:
With TaskView, you can manage projects, build custom workflows, track dependencies and time, connect development tools, and automate project operations through the API, webhooks, and MCP.
- `api` backend (Node.js)
- `web` web client and mobile
- `taskview-packages/taskview-api` API client library for building integrations and making HTTP requests
- `taskview-packages/taskview-db-schemas` - Drizzle ORM schemas
Your projects. Your infrastructure. Your data.
Product packages share a single version.
## Who TaskView is for
TaskView is a good fit for:
## Versioning
* software development teams;
* startups and small product companies;
* agencies managing internal and client projects;
* organizations that require self-hosting and data ownership;
* teams looking for a lightweight alternative to complex enterprise platforms;
* developers using AI agents to manage and automate project work;
* individuals managing technical or personal projects.
TaskView follows semantic versioning:
- `MAJOR` breaking API or data changes
- `MINOR` new features
- `PATCH` bug fixes
The version represents the entire product, not individual packages.
## Why TaskView
* **Own your data** - run TaskView on your own infrastructure.
* **Flexible workflows** - configure projects, statuses, boards, roles, and permissions.
* **Built for development teams** - connect GitHub, GitLab, API clients, and webhooks.
* **AI-ready** - allow MCP-compatible assistants to work with tasks and projects.
* **Available everywhere** - use TaskView from the web, iOS, and Android.
* **No advertising or behavioral tracking** - your project data remains under your control.
## Status
## Features
TaskView is under active development.
Breaking changes may occur between versions.
### Project and task management
## Getting started
* Projects, lists, tasks, and nested subtasks
* Custom Kanban boards and task statuses
* Priorities, tags, deadlines, and assignees
* Task dependency graph
* Sprints and recurring tasks
* Task history and activity tracking
* Markdown content
* Search and filtering
* Reusable project workflows
This setup is intended for local development.
### Team collaboration
### Development
* Multiple organizations and projects
* User invites and granular permissions
* Clear responsibility assignment
* Real-time updates
* Notifications
* Organization-level user management
**Prerequisites:**
- Docker
- PostgreSQL
- On macOS you can use https://postgresapp.com/
- Bun (used for API development)
- pnpm
### Identity and access management
* SAML 2.0
* OpenID Connect
* SCIM provisioning
* Scoped API tokens
* Project-level access restrictions
* Fine-grained token permissions
### Developer integrations
* GitHub integration
* GitLab integration
* Signed webhooks
* TypeScript API client
* Public HTTP API
* MCP server for AI assistants
### Time tracking and analytics
* Built-in time tracking
* Billable and non-billable time
* Project and team workload reports
* Productivity analytics
* Income and expense tracking
* Financial reports
### Applications
* Web application
* iOS application
* Android application
* Self-hosted server deployment
## AI and MCP integration
TaskView includes an MCP server that allows compatible AI tools to interact with your projects.
Depending on the permissions assigned to an API token, an AI assistant can:
* search and inspect projects;
* create and update tasks;
* manage task statuses;
* work with project lists;
* retrieve task context;
* automate repetitive project operations.
MCP access can be restricted by permission and by selected projects.
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) for configuration examples.
## Quick start
Clone the repository:
```sh
git clone https://github.com/Gimanh/taskview-community.git
cd taskview-community
```
The recommended way to run TaskView is with Docker Compose.
For deployment instructions, environment variables, HTTPS configuration, updates, and backups, see the official documentation:
[TaskView self-hosting documentation](https://taskview.tech/docs/)
> Production deployments should use persistent volumes, HTTPS, regular database backups, and securely generated secrets.
## Repository structure
TaskView is maintained as a monorepo. Product packages share a single version.
```text
taskview-community/
├── api/
│ └── TaskView backend and HTTP API
├── web/
│ └── Vue web application and Capacitor mobile application
├── taskview-packages/
│ ├── taskview-api/
│ │ └── TypeScript API client
│ └── taskview-db-schemas/
│ │ └── Shared Drizzle ORM schemas
│ └── taskview-mcp/
│ └── MCP server for AI integrations
└── build-dockers.sh
└── Docker image build script
```
### Main packages
| Package | Description |
| --------------------------------------- | ----------------------------------------------- |
| `api` | Node.js backend and API server |
| `web` | Vue web client and Capacitor mobile application |
| `taskview-packages/taskview-api` | TypeScript client for the TaskView API |
| `taskview-packages/taskview-db-schemas` | Shared Drizzle ORM database schemas |
| `taskview-packages/taskview-mcp` | MCP server for AI integrations |
## Technology stack
TaskView is primarily built with:
* Vue
* Nuxt UI
* TypeScript
* Node.js
* Express
* PostgreSQL
* Drizzle ORM
* Capacitor
* Docker
## Local development
These instructions are intended for contributors and local development, not for production deployment.
### Prerequisites
You need the following tools:
* Docker
* PostgreSQL
* Bun
* pnpm
On macOS, PostgreSQL can be installed using [Postgres.app](https://postgresapp.com/).
### Install dependencies
From the repository root:
**Steps:**
1. Install dependencies:
```sh
pnpm install
```
2. Create an `.env` file in the **api** directory based on `.env.example` and configure your local credentials.
3. Run the web application:
### Configure the API
Create an environment file in the `api` directory:
```sh
cp api/.env.example api/.env
```
Update `api/.env` with your local database credentials and application configuration.
### Start the web application
```sh
cd web
pnpm dev
```
1. Run the API locally (API uses Bun in development mode):
### Start the API
In a separate terminal:
```sh
cd api
pnpm start
```
## Building Docker Images
The API uses Bun in the local development environment.
You can build Docker images using the provided bash script. The image version is automatically taken from the root `package.json`.
## Building Docker images
Docker image versions are taken from the root `package.json`.
### Build the images
From the repository root:
**Steps:**
1. Build images:
```sh
./build-dockers.sh
```
2. Verify built images:
### Verify the images
```sh
docker images
```
You should see the following images:
`gimanhead/taskview-ce-api-server`
`gimanhead/taskview-ce-webapp`
`gimanhead/taskview-ce-db-migration`
The following images should be available:
3. Test images locally using Docker Compose:
```sh
cd api/dev-containers-test
docker-compose up
```text
gimanhead/taskview-ce-api-server
gimanhead/taskview-ce-webapp
gimanhead/taskview-ce-db-migration
```
Make sure the image versions match the version defined in the root package.json.
### Test the images locally
```sh
cd api/dev-containers-test
docker compose up
```
Ensure that image versions match the version defined in the root `package.json`.
## Versioning
TaskView follows [Semantic Versioning](https://semver.org/):
* `MAJOR` - breaking API, database, or compatibility changes;
* `MINOR` - backward-compatible features;
* `PATCH` - backward-compatible bug fixes.
The version represents the entire TaskView product, not individual packages.
## Project status
TaskView is under active development.
New features, database migrations, API changes, and breaking changes may be introduced between releases. Review release notes and back up your database before updating a self-hosted installation.
## Roadmap
Planned areas of development include:
- [X] Migrate to NuxtUI or similar ui library
- [X] Enterprise SSO and identity integrations
- [X] Redesign
- [X] API tokens
- [X] Webhooks
- [X] MCP server
- [X] Notifications
- [X] Analytics
- [X] API client
- [ ] Desktop version
- [ ] Plugin / extension system
* [ ] Desktop application
* [ ] Plugin and extension system
* [ ] Additional third-party integrations
* [ ] Expanded automation capabilities
* [ ] Continued UI and UX improvements
The roadmap may change based on product priorities and community feedback.
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
## Contributing
Contributions are welcome.
Before contributing:
1. Read [`CONTRIBUTING.md`](./CONTRIBUTING.md).
2. Check existing issues and pull requests.
3. Open an issue before starting a large architectural change.
4. Follow the project's code style and architecture.
5. Sign the Contributor License Agreement when required.
Contributions are accepted under the project's CLA.
## Licensing
TaskView is distributed under the TaskView Source-Available License.
You may:
* self-host TaskView;
* modify the source code;
* use TaskView internally within your organization;
* create integrations and internal extensions.
You may not:
* offer TaskView as a hosted SaaS;
* sell access to TaskView as a service;
* create a competing commercial product based on TaskView;
* use the TaskView name, logo, or branding for derivative products.
See [`LICENSE`](./LICENSE) for the complete terms.
TaskView is **source-available**, not OSI-approved open-source software.
For commercial licensing questions, hosted service permissions, or other use cases not covered by the license, contact the project maintainer.
## Security
Do not publish security vulnerabilities in public GitHub issues.
Report security issues privately using the contact information provided in the repository or on the TaskView website.
When running TaskView in production:
* use HTTPS;
* generate unique application secrets;
* restrict database access;
* keep Docker images and dependencies updated;
* configure persistent storage;
* create regular backups;
* restrict API-token permissions to the minimum required scope.
## Links
* [TaskView website](https://taskview.tech/)
* [TaskView Cloud](https://app.taskview.tech/)
* [Documentation](https://taskview.tech/docs/)
* [iOS application](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
* [Android application](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app)
* [GitHub repository](https://github.com/Gimanh/taskview-community)
---
TaskView is developed and maintained by Nikolai Giman.
TaskView is developed and maintained by **Nikolai Giman**.
Copyright © 2026 Nikolai Giman
TaskView™ and the TaskView logo are trademarks of their respective owner.
Copyright © 2026 Nikolai Giman.
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.44.0",
"version": "1.48.4",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -24,6 +24,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/luxon": "^3.7.1",
"@types/node": "^22.10.3",
"@types/passport-apple": "^2.0.3",
"@types/pg": "^8.15.5",
@@ -61,6 +62,7 @@
"firebase-admin": "^12.7.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"luxon": "^3.7.2",
"openid-client": "^6.8.2",
"passport": "^0.7.0",
"passport-apple": "^2.0.2",
@@ -70,6 +72,7 @@
"pg-boss": "^12.14.0",
"pino": "^9.4.0",
"rotating-file-stream": "^3.2.5",
"rrule": "^2.8.1",
"semver": "^7.6.3",
"taskview-api": "workspace:^",
"taskview-db-schemas": "workspace:^",
@@ -80,4 +83,4 @@
"engines": {
"node": ">=24 <25"
}
}
}
+4 -2
View File
@@ -7,8 +7,10 @@ config({ override: true });
const valid = AppEnvSchema.safeParse(process.env);
if (!valid.success) {
console.log(valid);
console.log(valid.error);
const details = valid.error.issues
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('\n');
console.error(`Invalid environment variables:\n${details}`);
throw new Error('Invalid environment variables');
}
+6
View File
@@ -15,6 +15,8 @@ import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
import { TasksManager } from '../tv-modules/tasks/TasksManager';
import { TimeTrackingManager } from '../tv-modules/time-tracking/TimeTrackingManager';
import { UiPreferencesManager } from '../tv-modules/ui-preferences/UiPreferencesManager';
import { SprintsManager } from '../tv-modules/sprints/SprintsManager';
import { RecurrenceManager } from '../tv-modules/recurrence/RecurrenceManager';
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
@@ -43,6 +45,8 @@ export class AppUser {
public readonly analyticsManager: AnalyticsManager;
public readonly timeTrackingManager: TimeTrackingManager;
public readonly uiPreferencesManager: UiPreferencesManager;
public readonly sprintsManager: SprintsManager;
public readonly recurrenceManager: RecurrenceManager;
constructor(userData?: UserJwtPayload) {
this.userData = userData;
@@ -64,6 +68,8 @@ export class AppUser {
this.analyticsManager = new AnalyticsManager(this);
this.timeTrackingManager = new TimeTrackingManager(this);
this.uiPreferencesManager = new UiPreferencesManager(this);
this.sprintsManager = new SprintsManager(this);
this.recurrenceManager = new RecurrenceManager(this);
}
getTokenId(): number | undefined {
+5
View File
@@ -3,6 +3,11 @@ import { $logger } from '../modules/logget';
export class Email {
static async send(data: Message | MessageHeaders): Promise<boolean> {
if (!process.env.SMTP_HOST) {
$logger.warn('SMTP is not configured — skipping email send');
return false;
}
try {
const client = new SMTPClient({
user: process.env.SMTP_USERNAME,
+23 -1
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events';
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
import { $logger } from '../modules/logget';
@@ -16,6 +16,28 @@ export interface AppEvents {
'time-entry.created': { entry: TimeEntryWithUser; initiatorId: number };
'time-entry.updated': { entry: TimeEntryWithUser; changes: Record<string, unknown>; initiatorId: number };
'time-entry.deleted': { entryId: number; taskId: number; goalId: number; userId: number; initiatorId: number };
'sprint.created': { sprint: SprintsSchemaTypeForSelect; initiatorId: number };
'sprint.updated': { sprint: SprintsSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
'sprint.activated': { sprintId: number; goalId: number; initiatorId: number | null };
'sprint.reviewStarted': { sprintId: number; goalId: number; initiatorId: number };
'sprint.completed': { sprintId: number; goalId: number; initiatorId: number };
'sprint.paused': { sprintId: number; goalId: number; initiatorId: number };
'sprint.resumed': { sprintId: number; goalId: number; initiatorId: number };
'sprint.deleted': { sprintId: number; goalId: number; initiatorId: number };
'task.assignedToSprint': {
taskId: number;
sprintId: number | null;
prevSprintId: number | null;
goalId: number;
initiatorId: number;
};
'recurrence.created': { rule: RecurrenceRulesSchemaTypeForSelect; initiatorId: number };
'recurrence.updated': { rule: RecurrenceRulesSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
'recurrence.paused': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.resumed': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.ended': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.deleted': { ruleId: number; goalId: number; initiatorId: number };
'recurrence.instanceSkipped': { ruleId: number; goalId: number; date: string; initiatorId: number };
}
type EventName = keyof AppEvents;
+5
View File
@@ -45,6 +45,11 @@ export class FetchTasksQueryBuilder {
args.push(this.data.filters.priority);
}
if (this.data.filters.sprintId !== undefined) {
query += ` AND t.sprint_id = $${args.length + 1}`;
args.push(this.data.filters.sprintId);
}
if (this.limit !== null) {
if (this.data.showCompleted === 0) {
query += ` AND t.complete = $${args.length + 1}`;
+15 -4
View File
@@ -9,7 +9,7 @@ import {
PermissionsSchema,
} from 'taskview-db-schemas';
import { Database } from '../modules/db';
import type { FetchGoalIdsWithAnyPermissionParams, GoalPermissionItemsFromDb } from '../types/auth.types';
import type { FetchGoalIdsWithAnyPermissionParams, FetchPermissionsForGoalByUserParams, GoalPermissionItemsFromDb } from '../types/auth.types';
import type { GoalItemInDb } from '../types/goal.type';
import type { ListItemInDb } from '../types/lists.types';
import type { TaskItemInDb } from '../types/tasks.types';
@@ -33,7 +33,18 @@ export class GoalPermissionsRepository {
}
async fetchPermissionsForGoal(goalId: number, user: AppUser): Promise<GoalPermissionItemsFromDb> {
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [goalId]);
const userData = user.getUserData();
if (!userData) return [];
return this.fetchPermissionsForGoalByUser({ goalId, userId: userData.id, email: userData.email });
}
/**
* Permission set of an arbitrary user for a goal, without an AppUser/request
* context — used by background workers (e.g. deadline notifications) that
* must gate content per recipient.
*/
async fetchPermissionsForGoalByUser(params: FetchPermissionsForGoalByUserParams): Promise<GoalPermissionItemsFromDb> {
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [params.goalId]);
if (goalInfo.rows.length === 0) {
return [];
@@ -42,7 +53,7 @@ export class GoalPermissionsRepository {
let query = '';
let args: any = [];
if (goalInfo.rows[0].owner === user.getUserData()?.id) {
if (goalInfo.rows[0].owner === params.userId) {
query = `select name as "permissionName", id as "permissionId" from tv_auth.permissions;`;
} else {
query = `select p.name as "permissionName", p.id as "permissionId"
@@ -54,7 +65,7 @@ export class GoalPermissionsRepository {
left join collaboration.permissions_to_role ptr on rol.id = ptr.role_id
left join tv_auth.permissions p on ptr.permission_id = p.id
where email = $1 and tg.id = $2 and p.name is not null and p.id is not null;`;
args = [user.getUserData()?.email, goalId];
args = [params.email, params.goalId];
}
const result = await this.db.query<GoalPermissionItemsFromDb[number]>(query, args);
+4
View File
@@ -4,12 +4,16 @@ import { NotificationDispatcher } from '../tv-modules/notifications/Notification
import { RealtimeDispatcher } from '../tv-modules/realtime/RealtimeDispatcher';
import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher';
import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTrackingDispatcher';
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
new RealtimeDispatcher(),
new WebhooksDispatcher(),
new TimeTrackingDispatcher(),
new SprintsDispatcher(),
new RecurrenceDispatcher(),
];
export function registerAllEventHandlers() {
+68
View File
@@ -572,5 +572,73 @@
"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)."
]
}
}
@@ -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';
+4
View File
@@ -19,6 +19,8 @@ import ScimRoutes from '../tv-modules/scim/ScimRoutes';
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -44,6 +46,8 @@ const routes: Record<string, RoutableConstructor> = {
'/module/analytics': AnalyticsRoutes,
'/module/time-tracking': TimeTrackingRoutes,
'/module/ui-preferences': UiPreferencesRoutes,
'/module/sprints': SprintsRoutes,
'/module/recurrence': RecurrenceRoutes,
'/scim/v2': ScimRoutes,
};
@@ -28,7 +28,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
if (!range) return res.status(400).send('invalid range')
const sectionIds = out.sections
@@ -41,6 +41,7 @@ export class AnalyticsController {
organizationId: out.organizationId,
period: out.period,
range,
timezone: out.timezone,
sectionIds,
})
return res.tvJson(data)
@@ -76,7 +77,7 @@ export class AnalyticsController {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
if (!range) return res.status(400).send('invalid range')
const meta = parseDrillDownMeta(out.meta)
@@ -10,6 +10,7 @@ import type { AppUser } from '../../core/AppUser'
import { $logger } from '../../modules/logget'
import { GoalPermissions } from '../../types/auth.types'
import { AnalyticsRepository } from './AnalyticsRepository'
import { formatDateInZone } from './helpers'
import { SectionRegistry } from './sections/SectionRegistry'
import type {
AnalyticsArgBuildSections,
@@ -45,7 +46,7 @@ export class AnalyticsManager {
}
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
const { scope, organizationId, period, range, sectionIds } = params
const { scope, organizationId, period, range, timezone, sectionIds } = params
const allAccessible = await this.getAccessibleGoalIds(organizationId)
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
@@ -86,7 +87,7 @@ export class AnalyticsManager {
return {
scope,
period,
range: { from: range.from.toISOString(), to: range.to.toISOString() },
range: { from: formatDateInZone(range.from, timezone), to: formatDateInZone(range.to, timezone) },
sections,
availableGoals,
failedSectionIds,
+43 -23
View File
@@ -1,7 +1,8 @@
import { type } from 'arktype'
import { sql, type SQL } from 'drizzle-orm'
import { DateTime } from 'luxon'
import type { AnalyticsPeriod } from 'taskview-api'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta } from './types'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta, type ResolveRangeArgs } from './types'
const MAX_INT32 = 2147483647
@@ -23,32 +24,51 @@ export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
}
}
export function resolveRange(
period: AnalyticsPeriod,
from?: string,
to?: string,
): AnalyticsRange | null {
const now = new Date()
const DAYS_BY_PERIOD: Record<Exclude<AnalyticsPeriod, 'custom' | 'month'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
}
/**
* Resolve a period into a concrete UTC instant range, with all calendar
* boundaries ("this month", a custom day) computed in the *viewer's* IANA
* timezone — not the server's. The returned Dates are real instants used for
* SQL filtering; the user-facing 'YYYY-MM-DD' labels come from
* {@link formatDateInZone} with the same timezone.
*/
export function resolveRange(args: ResolveRangeArgs): AnalyticsRange | null {
const { period, from, to } = args
const zone = args.timezone && DateTime.now().setZone(args.timezone).isValid ? args.timezone : 'utc'
const now = DateTime.now().setZone(zone)
if (period === 'custom') {
if (!from || !to) return null
const fromDate = new Date(from)
const toDate = new Date(to)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
if (fromDate > toDate) return null
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
return { from: fromDate, to: toDate }
const fromDt = DateTime.fromISO(from.slice(0, 10), { zone }).startOf('day')
const toDt = DateTime.fromISO(to.slice(0, 10), { zone }).endOf('day')
if (!fromDt.isValid || !toDt.isValid) return null
if (fromDt > toDt) return null
const maxRangeMs = 366 * 24 * 60 * 60 * 1000
if (toDt.toMillis() - fromDt.toMillis() > maxRangeMs) return null
return { from: fromDt.toJSDate(), to: toDt.toJSDate() }
}
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
if (period === 'month') {
return { from: now.startOf('month').toJSDate(), to: now.toJSDate() }
}
const days = daysByPeriod[period]
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return { from: fromDate, to: now }
return { from: now.minus({ days: DAYS_BY_PERIOD[period] }).toJSDate(), to: now.toJSDate() }
}
/**
* Format a range boundary as a 'YYYY-MM-DD' calendar day in the viewer's
* timezone. Slicing a UTC ISO string instead would shift the day across the
* date line for offset timezones (e.g. a "this month" range rendering from the
* 31st of the prior month).
*/
export function formatDateInZone(date: Date, timezone?: string): string {
const dt = DateTime.fromJSDate(date).setZone(timezone && DateTime.fromJSDate(date).setZone(timezone).isValid ? timezone : 'utc')
return dt.toISODate() as string
}
+14 -2
View File
@@ -20,8 +20,9 @@ const nonNegativeIntFromQuery = type('string | number')
export const AnalyticsFetchSectionsArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'sections?': 'string',
@@ -30,8 +31,9 @@ export const AnalyticsFetchSectionsArkType = type({
export const AnalyticsDrillDownArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'timezone?': 'string',
'from?': 'string',
'to?': 'string',
'bucket?': 'string',
@@ -53,6 +55,14 @@ export type AnalyticsRange = {
to: Date
}
export type ResolveRangeArgs = {
period: AnalyticsPeriod
/** IANA timezone the period boundaries are resolved in; defaults to UTC. */
timezone?: string
from?: string
to?: string
}
export type BuilderContext = {
appUser: AppUser
scope: AnalyticsScope
@@ -97,6 +107,8 @@ export type AnalyticsArgBuildSections = {
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
/** IANA timezone of the viewer; used to format the response range. */
timezone?: string
sectionIds?: string[]
}
@@ -156,6 +156,7 @@ export class GoalsRepository {
archive: GoalsSchema.archive,
backlogVersion: GoalsSchema.backlogVersion,
organizationId: GoalsSchema.organizationId,
estimateUnit: GoalsSchema.estimateUnit,
})
.from(GoalsSchema)
.leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId))
+1
View File
@@ -16,6 +16,7 @@ export const GoalsArkTypeUpdate = type({
'name?': 'string | null',
'description?': 'string | null',
'color?': 'string | null',
"estimateUnit?": "'hours' | 'points'",
});
export type GoalsArgUpdate = typeof GoalsArkTypeUpdate.infer;
+11 -1
View File
@@ -3,7 +3,7 @@ import { Database } from '../../modules/db';
import type { GoalItemInDb } from '../../types/goal.type';
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import type { KanbanStatusItemInDb } from './types';
import type { KanbanStatusItemInDb, StatusBelongsToGoalArgs } from './types';
import { callWithCatch } from '../../utils/helpers';
import { and, asc, eq, gt, gte, isNull, lt, lte, sql } from 'drizzle-orm';
import type { TaskItemInDb } from '../../types/tasks.types';
@@ -218,4 +218,14 @@ export class KanbanRepository {
return result?.[0]?.columnVersion ?? null;
}
/** Validates payload references to a kanban column from other modules (recurrence templates, etc.). */
async statusBelongsToGoal(args: StatusBelongsToGoalArgs): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle
.select({ id: TasksStatusesSchema.id })
.from(TasksStatusesSchema)
.where(and(eq(TasksStatusesSchema.id, args.statusId), eq(TasksStatusesSchema.goalId, args.goalId)))
.limit(1));
return !!result?.[0];
}
}
+8
View File
@@ -33,6 +33,8 @@ export type KanbanArgUpdateStatus = typeof KanbanArkTypeStatusUpdate.infer;
export type KanbanArgFetchAllStatuses = typeof KanbanArkTypeFetchAllStatuses.infer;
export type StatusBelongsToGoalArgs = { statusId: number; goalId: number };
export type KanbanStatusInDb = {
id: number;
goal_id: number;
@@ -69,9 +71,15 @@ const NumberArrayFromCommaSeparatedString = type('string|undefined').pipe((v) =>
return v.split(',').map(Number).filter((n) => !isNaN(n));
});
const SprintFilterFromString = type('string|number').pipe((v) => {
const n = Number(v);
return isNaN(n) ? undefined : n;
});
export const KanbanArkTypeFilters = type({
'listIds?': NumberArrayFromCommaSeparatedString,
'assigneeIds?': NumberArrayFromCommaSeparatedString,
'sprintId?': SprintFilterFromString,
});
export type KanbanArgFilters = typeof KanbanArkTypeFilters.infer;
@@ -1,4 +1,4 @@
import { desc, eq } from 'drizzle-orm';
import { and, desc, eq } from 'drizzle-orm';
import type { GoalsListSchemaTypeForSelect } from 'taskview-db-schemas';
import { GoalsListSchema } from 'taskview-db-schemas';
import type { AppUser } from '../../core/AppUser';
@@ -7,7 +7,7 @@ import type { GoalListInDb } from '../../types/goal-list.types';
import { logError } from '../../utils/api';
import { updateQuery } from '../../utils/db-helper';
import { callWithCatch } from '../../utils/helpers';
import type { GoalListArgAdd, GoalListArgDelete, GoalListArgFetch, GoalListArgUpdate } from './list.types';
import type { GoalListArgAdd, GoalListArgDelete, GoalListArgFetch, GoalListArgUpdate, ListBelongsToGoalArgs } from './list.types';
export class GoalListsRepository {
private readonly db: Database;
@@ -145,4 +145,16 @@ export class GoalListsRepository {
return !!(result.rowCount && result.rowCount > 0);
}
/** Validates payload references to a list from other modules (recurrence templates, etc.). */
async listBelongsToGoal(args: ListBelongsToGoalArgs): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: GoalsListSchema.id })
.from(GoalsListSchema)
.where(and(eq(GoalsListSchema.id, args.listId), eq(GoalsListSchema.goalId, args.goalId)))
.limit(1)
);
return !!result?.[0];
}
}
+2
View File
@@ -27,3 +27,5 @@ export const GoalListArkTypeFetch = type({
});
export type GoalListArgFetch = typeof GoalListArkTypeFetch.infer;
export type ListBelongsToGoalArgs = { listId: number; goalId: number };
@@ -10,6 +10,7 @@ import { NotificationMessages } from './NotificationMessages';
import { NotificationsRepository } from './repositories/NotificationsRepository';
import { DeviceTokensRepository } from './repositories/DeviceTokensRepository';
import { DeadlineScheduler } from './schedulers/DeadlineScheduler';
import { RecurrenceRepository } from '../recurrence/RecurrenceRepository';
import { NotificationType } from './types';
import { parseUtcTime } from './utils';
import type { Dispatcher } from '../../core/Dispatcher';
@@ -27,6 +28,8 @@ export class NotificationDispatcher implements Dispatcher {
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
eventBus.on('recurrence.created', (data) => this.onRecurrenceScheduled(data));
eventBus.on('recurrence.updated', (data) => this.onRecurrenceScheduled(data));
}
async registerWorkers(): Promise<void> {
@@ -44,9 +47,14 @@ export class NotificationDispatcher implements Dispatcher {
}
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
if (data.task.endDate) {
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
if (!data.task.endDate) return;
// Recurring instances remind about their deadline only when the series
// opted in — otherwise a daily series becomes a daily notification.
if (data.task.recurrenceRuleId) {
const rule = await new RecurrenceRepository().getById(data.task.recurrenceRuleId);
if (rule && !rule.notifyOnOccurrence) return;
}
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
}
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
@@ -73,6 +81,19 @@ export class NotificationDispatcher implements Dispatcher {
}
}
private async onRecurrenceScheduled(data: AppEvents['recurrence.created'] | AppEvents['recurrence.updated']): Promise<void> {
const instance = await new RecurrenceRepository().findOpenInstance(data.rule.id);
if (!instance) return;
this.notificationsRepo.deleteByTaskAndType(instance.id, NotificationType.DEADLINE);
await this.deadlineScheduler.cancel(instance.id);
if (data.rule.notifyOnOccurrence && instance.endDate) {
$logger.info(`[NotificationDispatcher] Scheduling occurrence deadline for task=${instance.id}, rule=${data.rule.id}`);
await this.deadlineScheduler.schedule(instance, data.initiatorId);
}
}
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
if (data.userIds.length === 0) return;
@@ -3,7 +3,8 @@ import { parseUtcTime } from './utils';
export class NotificationMessages {
static deadline(description: string | null, endDate: string, endTime: string | null, timezone: string): NotificationMessage {
const title = `Task: ${description || 'Task'}`;
// description is null for recipients without COMPONENT_CAN_WATCH_CONTENT
const title = description ? `Task: ${description}` : 'Task deadline';
if (endTime) {
const deadline = parseUtcTime(endDate, endTime);
@@ -1,13 +1,16 @@
import { eq, and, or, isNull } from 'drizzle-orm';
import { eq, and, or, isNull, inArray } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { TasksSchema, TasksAssigneeSchema, GoalsSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas';
import { getJobQueue, cancelJobBySingletonKey } from '../../../core/JobQueue';
import { GoalPermissionsChecker } from '../../../core/GoalPermissionsChecker';
import { GoalPermissionsRepository } from '../../../core/GoalPermissionsRepository';
import { Database } from '../../../modules/db';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { getNotificationService } from '../NotificationService';
import { NotificationMessages } from '../NotificationMessages';
import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository';
import { NotificationType, type DeadlineJobData, type TaskWithDeadline } from '../types';
import { NotificationType, type DeadlineJobData, type DeadlineRecipient, type TaskWithDeadline } from '../types';
import { parseUtcTime, localHourToUtc } from '../utils';
const DEADLINE_JOB = 'deadline-notification';
@@ -86,31 +89,36 @@ export class DeadlineScheduler {
return;
}
const recipientIds = await this.resolveRecipients(db, taskId, goalId, task[0].owner);
if (!recipientIds || recipientIds.length === 0) {
let recipients = await this.resolveRecipients(db, taskId, goalId, task[0].owner);
if (!recipients || recipients.length === 0) {
$logger.info(`[DeadlineScheduler] Task ${taskId}: no recipients`);
return;
}
if (immediate && initiatorId) {
const idx = recipientIds.indexOf(initiatorId);
if (idx !== -1) recipientIds.splice(idx, 1);
recipients = recipients.filter((r) => r.userId !== initiatorId);
}
if (recipientIds.length === 0) return;
if (recipients.length === 0) return;
$logger.info(`[DeadlineScheduler] Task ${taskId}: sending to [${recipientIds.join(',')}]`);
// Description rides in the notification title, but it is gated by
// COMPONENT_CAN_WATCH_CONTENT — split recipients so those without
// the permission get a generic title (no leak over push).
const { canWatch, cannotWatch } = await this.splitByContentPermission(goalId, recipients);
$logger.info(`[DeadlineScheduler] Task ${taskId}: sending to content=[${canWatch.join(',')}] generic=[${cannotWatch.join(',')}]`);
const tz = task[0].owner ? await this.deviceTokensRepo.getTimezoneByUserId(task[0].owner) : 'UTC';
const message = NotificationMessages.deadline(description, endDate, endTime, tz);
const meta = { goalId, goalListId, organizationId: organizationId ?? null };
await getNotificationService().notifyMany(
recipientIds,
NotificationType.DEADLINE,
message,
{ goalId, goalListId, organizationId: organizationId ?? null },
taskId,
);
if (canWatch.length > 0) {
const message = NotificationMessages.deadline(description, endDate, endTime, tz);
await getNotificationService().notifyMany(canWatch, NotificationType.DEADLINE, message, meta, taskId);
}
if (cannotWatch.length > 0) {
const message = NotificationMessages.deadline(null, endDate, endTime, tz);
await getNotificationService().notifyMany(cannotWatch, NotificationType.DEADLINE, message, meta, taskId);
}
});
}
@@ -127,7 +135,7 @@ export class DeadlineScheduler {
return result[0]?.organizationId ?? null;
}
private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise<number[] | null> {
private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise<DeadlineRecipient[] | null> {
const authUsers = alias(UsersSchema, 'auth_users');
try {
@@ -146,13 +154,46 @@ export class DeadlineScheduler {
const ids = new Set<number>();
if (taskOwner) ids.add(taskOwner);
assignees.forEach((r) => ids.add(r.userId));
for (const r of assignees) ids.add(r.userId);
if (goal[0]) ids.add(goal[0].owner);
if (ids.size === 0) return [];
return [...ids];
// Emails are needed to resolve per-recipient goal permissions (role join keys on email).
return await db.dbDrizzle
.select({ userId: UsersSchema.id, email: UsersSchema.email })
.from(UsersSchema)
.where(inArray(UsersSchema.id, [...ids]));
} catch (err) {
$logger.error(err, '[DeadlineScheduler] Failed to resolve recipients');
return null;
}
}
/** Partition recipients into those allowed to see task content and those who are not. */
private async splitByContentPermission(goalId: number,recipients: DeadlineRecipient[]): Promise<{ canWatch: number[]; cannotWatch: number[] }> {
const permissionsRepo = new GoalPermissionsRepository();
const canWatch: number[] = [];
const cannotWatch: number[] = [];
await Promise.all(
recipients.map(async (recipient) => {
// Fail closed per-recipient: a permission-fetch error drops this
// recipient to the generic message, never aborts the whole job.
const permissions = await permissionsRepo
.fetchPermissionsForGoalByUser({ goalId, userId: recipient.userId, email: recipient.email })
.catch((err) => {
$logger.error(err, `[DeadlineScheduler] permission check failed for user=${recipient.userId}`);
return [];
});
const checker = new GoalPermissionsChecker(permissions);
if (checker.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT)) {
canWatch.push(recipient.userId);
} else {
cannotWatch.push(recipient.userId);
}
}),
);
return { canWatch, cannotWatch };
}
}
@@ -121,4 +121,9 @@ export interface TaskWithDeadline {
owner: number | null;
endDate: string | null;
endTime: string | null;
}
export interface DeadlineRecipient {
userId: number;
email: string;
}
@@ -0,0 +1,72 @@
import { type } from 'arktype';
import type { Request, Response } from 'express';
import {
RecurrenceArkTypeCreate,
RecurrenceArkTypeRuleIdParam,
RecurrenceArkTypeTaskIdParam,
RecurrenceArkTypeUpdate,
} from './types';
import type { RecurrenceErrorCode, RecurrenceResult } from './types';
const codeToStatus: Record<RecurrenceErrorCode, number> = {
not_found: 404,
conflict: 409,
invalid_state: 400,
invalid_rule: 422,
};
export default class RecurrenceController {
private sendResult<T>(res: Response, result: RecurrenceResult<T>) {
if (result.ok) return res.tvJson(result.data);
return res.status(codeToStatus[result.code]).send(result.message ?? result.code);
}
create = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeCreate(req.body);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.createRule(data));
};
getOne = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.getDetails(data.ruleId));
};
getForTask = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeTaskIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.getDetailsForTask(data.taskId));
};
update = async (req: Request, res: Response) => {
// Route params win over body: the ruleId authorized by the middleware must be the one operated on.
const data = RecurrenceArkTypeUpdate({ ...req.body, ruleId: Number(req.params.ruleId) });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.updateRule(data));
};
pause = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.pauseRule(data.ruleId));
};
resume = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.resumeRule(data.ruleId));
};
skip = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.skipCurrent(data.ruleId));
};
remove = async (req: Request, res: Response) => {
const data = RecurrenceArkTypeRuleIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.recurrenceManager.deleteRule(data.ruleId));
};
}
@@ -0,0 +1,139 @@
import { eq } from 'drizzle-orm';
import {
CollaborationUsersSchema,
CollaborationUsersToGoalsSchema,
GoalsSchema,
UsersSchema,
} from 'taskview-db-schemas';
import { getCentrifugoClient } from '../../core/CentrifugoClient';
import type { Dispatcher } from '../../core/Dispatcher';
import type { AppEvents } from '../../core/EventBus';
import { eventBus } from '../../core/EventBus';
import { getJobQueue } from '../../core/JobQueue';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import { RecurrenceGenerator } from './RecurrenceGenerator';
import { RecurrenceRepository } from './RecurrenceRepository';
export const RECURRENCE_RECONCILE_JOB = 'recurrence-reconcile';
const RECURRENCE_RT_EVENT = 'recurrence.instanceCreated';
export class RecurrenceDispatcher implements Dispatcher {
private readonly generator = new RecurrenceGenerator();
private readonly repository = new RecurrenceRepository();
register(): void {
// The heart of the lazy model: completing the open instance materializes the next one.
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
// Push the freshly materialized instance to goal members so the next card appears without a refresh.
eventBus.on('task.created', (data) => this.onTaskCreated(data));
// An ex-collaborator must not keep being auto-assigned to new instances:
// project removal deletes users_to_goals, not collaboration.users, so the
// FK CASCADE on the snapshot never fires — clean it up explicitly.
eventBus.on('collaboration.userRemoved', (data) => this.onCollaboratorRemoved(data));
}
async registerWorkers(): Promise<void> {
const boss = getJobQueue();
await boss.createQueue(RECURRENCE_RECONCILE_JOB);
// Safety net only: re-creates the open instance for series stalled by a
// crash between the complete commit and the event handler. Normally a no-op.
await boss.schedule(RECURRENCE_RECONCILE_JOB, '0 3 * * *');
await boss.work(RECURRENCE_RECONCILE_JOB, async () => {
const stalled = await this.repository.findStalledActiveRules();
for (const rule of stalled) {
await this.generator
.materializeNext({ ruleId: rule.id, initiatorId: rule.creatorId })
.catch((e) => $logger.error(e, `[RecurrenceDispatcher] reconcile rule=${rule.id}`));
}
if (stalled.length > 0) {
$logger.info(`[RecurrenceDispatcher] reconcile recovered ${stalled.length} stalled series`);
}
});
}
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
if (!data.task.recurrenceRuleId) return;
if (data.changes.complete === true) {
await this.generator.materializeNext({
ruleId: data.task.recurrenceRuleId,
initiatorId: data.initiatorId,
});
return;
}
await this.syncTemplateFromOpenInstance(data);
}
/**
* In the lazy model the open instance IS the series in the user's mind
* (Todoist mental model): renaming the card must rename future occurrences
* too, otherwise the next instance "reverts" to the stale snapshot.
* Completed instances are history and never touch the template.
*/
private async syncTemplateFromOpenInstance(data: AppEvents['task.updated']): Promise<void> {
if (data.task.complete) return;
const patch: Parameters<RecurrenceRepository['patch']>[0]['patch'] = {};
if (data.changes.description !== undefined && data.task.description !== null) {
patch.templateDescription = data.task.description;
}
if (data.changes.note !== undefined) patch.templateNote = data.task.note;
if (data.changes.priorityId !== undefined) patch.templatePriorityId = data.task.priorityId;
if (Object.keys(patch).length === 0) return;
await this.repository.patch({ ruleId: data.task.recurrenceRuleId as number, patch });
}
private async onTaskCreated(data: AppEvents['task.created']): Promise<void> {
if (!data.task.recurrenceRuleId) return;
try {
const memberIds = await this.resolveGoalMemberIds(data.task.goalId);
if (memberIds.length === 0) return;
const centrifugo = getCentrifugoClient();
// Task fields are gated per role (TaskFieldPermissionsForWatching),
// and recipients have different roles — so the broadcast carries ids
// only, never content (same thin-event convention as goals.changed).
// Each client fetches the task through REST, where fields are
// cleaned for that user (fail closed).
await Promise.all(
memberIds.map((userId) =>
centrifugo.publishToUser(userId, RECURRENCE_RT_EVENT, {
goalId: data.task.goalId,
ruleId: data.task.recurrenceRuleId,
taskId: data.task.id,
})
)
);
} catch (err) {
$logger.error(err, '[RecurrenceDispatcher] real-time publish failed');
}
}
private async onCollaboratorRemoved(data: AppEvents['collaboration.userRemoved']): Promise<void> {
await this.repository.removeTemplateAssigneeFromGoal({
goalId: data.goalId,
collabUserId: data.collaborationUserId,
});
}
private async resolveGoalMemberIds(goalId: number): Promise<number[]> {
const db = Database.getInstance();
const [ownerRows, collabRows] = await Promise.all([
db.dbDrizzle.select({ id: GoalsSchema.owner }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1),
db.dbDrizzle
.select({ id: UsersSchema.id })
.from(CollaborationUsersToGoalsSchema)
.innerJoin(
CollaborationUsersSchema,
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id)
)
.innerJoin(UsersSchema, eq(CollaborationUsersSchema.email, UsersSchema.email))
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId)),
]);
const ids = new Set<number>();
if (ownerRows[0]?.id) ids.add(ownerRows[0].id);
for (const row of collabRows) ids.add(row.id);
return [...ids];
}
}
@@ -0,0 +1,252 @@
import { and, asc, eq, ne, sql } from 'drizzle-orm';
import {
GoalsListSchema,
RecurrenceRulesSchema,
type RecurrenceRulesSchemaTypeForSelect,
RecurrenceSkipDatesSchema,
RecurrenceTemplateAssigneesSchema,
RecurrenceTemplateTagsSchema,
TasksAssigneeSchema,
TasksSchema,
type TasksSchemaTypeForSelect,
TasksStatusesSchema,
TasksToTagsSchema,
} from 'taskview-db-schemas';
import { eventBus } from '../../core/EventBus';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import { TasksRepository } from '../tasks/TasksRepository';
import { RecurrenceParser } from './RecurrenceParser';
import type { MaterializeNextArgs } from './types';
/**
* Materializes the next instance of a series. The lazy model invariant — at
* most one open instance per rule — is protected on two levels: the rule row
* is locked FOR UPDATE for the duration of the transaction (serializes the
* complete-trigger against the reconcile job and rule edits), and the partial
* unique index on (recurrence_rule_id, recurrence_instance_date) makes the
* insert idempotent even across processes.
*/
export class RecurrenceGenerator {
private readonly db: Database;
constructor() {
this.db = Database.getInstance();
}
async materializeNext(args: MaterializeNextArgs): Promise<TasksSchemaTypeForSelect | null> {
const db = this.db.dbDrizzle;
const outcome = await db
.transaction(async (tx) => {
const ruleRows = await tx
.select()
.from(RecurrenceRulesSchema)
.where(eq(RecurrenceRulesSchema.id, args.ruleId))
.for('update')
.limit(1);
const rule = ruleRows[0];
if (!rule || rule.state !== 'active') return null;
// Lazy-model invariant: at most one open instance per rule. Without
// this guard a duplicate trigger (repeated PATCH complete=true, or
// reconcile racing the event handler) would materialize a SECOND
// open instance on the next date — the unique index only catches
// same-date duplicates.
const openRows = await tx
.select({ id: TasksSchema.id })
.from(TasksSchema)
.where(
and(
eq(TasksSchema.recurrenceRuleId, rule.id),
ne(sql`COALESCE(${TasksSchema.complete}, false)`, sql`true`)
)
)
.limit(1);
if (openRows[0]) return null;
// "N materialized instances" cap for COUNT-series.
const count = RecurrenceParser.getCount(rule.rrule);
if (count !== null && rule.instancesCreated >= count) {
await tx
.update(RecurrenceRulesSchema)
.set({ state: 'ended', editedAt: new Date() })
.where(eq(RecurrenceRulesSchema.id, rule.id));
return { ended: rule } as const;
}
const skipRows = await tx
.select({ skipDate: RecurrenceSkipDatesSchema.skipDate })
.from(RecurrenceSkipDatesSchema)
.where(eq(RecurrenceSkipDatesSchema.ruleId, rule.id));
const skipDates = new Set(skipRows.map((r) => r.skipDate));
// Completed late → next from today, not a pile of overdue copies (Todoist behavior).
const today = RecurrenceParser.todayInTimezone(rule.timezone);
const afterDate = rule.lastInstanceDate > today ? rule.lastInstanceDate : today;
const nextDate = RecurrenceParser.nextOccurrenceDate({
rrule: rule.rrule,
dtstart: rule.dtstart,
afterDate,
skipDates,
});
if (!nextDate) {
await tx
.update(RecurrenceRulesSchema)
.set({ state: 'ended', editedAt: new Date() })
.where(eq(RecurrenceRulesSchema.id, rule.id));
return { ended: rule } as const;
}
const insertedRows = await tx
.insert(TasksSchema)
.values(await this.buildInstanceValues({ rule, instanceDate: nextDate, tx }))
.onConflictDoNothing()
.returning();
const instance = insertedRows[0];
// Conflict on (rule_id, instance_date): a concurrent run already materialized it.
if (!instance) return null;
const [assignees, tags] = await Promise.all([
tx
.select({ collabUserId: RecurrenceTemplateAssigneesSchema.collabUserId })
.from(RecurrenceTemplateAssigneesSchema)
.where(eq(RecurrenceTemplateAssigneesSchema.ruleId, rule.id)),
tx
.select({ tagId: RecurrenceTemplateTagsSchema.tagId })
.from(RecurrenceTemplateTagsSchema)
.where(eq(RecurrenceTemplateTagsSchema.ruleId, rule.id)),
]);
if (assignees.length > 0) {
await tx
.insert(TasksAssigneeSchema)
.values(assignees.map((a) => ({ taskId: instance.id, collabUserId: a.collabUserId })))
.onConflictDoNothing();
}
if (tags.length > 0) {
await tx
.insert(TasksToTagsSchema)
.values(tags.map((t) => ({ taskId: instance.id, tagId: t.tagId })))
.onConflictDoNothing();
}
await tx
.update(RecurrenceRulesSchema)
.set({
lastInstanceDate: nextDate,
instancesCreated: rule.instancesCreated + 1,
editedAt: new Date(),
})
.where(eq(RecurrenceRulesSchema.id, rule.id));
return { instance, rule } as const;
})
.catch((err) => {
$logger.error(err, `[RecurrenceGenerator] materializeNext failed rule=${args.ruleId}`);
return null;
});
if (!outcome) return null;
if ('ended' in outcome) {
eventBus.emit('recurrence.ended', {
ruleId: outcome.ended.id,
goalId: outcome.ended.goalId,
initiatorId: args.initiatorId,
});
return null;
}
// Standard event so DeadlineScheduler / webhooks / realtime treat the instance as a normal new task.
eventBus.emit('task.created', { task: outcome.instance, initiatorId: args.initiatorId });
return outcome.instance;
}
private async buildInstanceValues(args: {
rule: RecurrenceRulesSchemaTypeForSelect;
instanceDate: string;
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
}): Promise<typeof TasksSchema.$inferInsert> {
const { rule, instanceDate, tx } = args;
const statusId = await this.resolveStatusId({ rule, tx });
const goalListId = await this.resolveGoalListId({ rule, tx });
const kanbanOrder = await this.nextKanbanOrder({ goalId: rule.goalId, tx });
// Tasks store UTC instants; the series is anchored to wall-clock time in
// its timezone, so the instant is recomputed per occurrence (DST-aware).
const window = RecurrenceParser.instanceWindowUtc({
occurrenceDate: instanceDate,
dtstart: rule.dtstart,
hasTime: rule.hasTime,
timezone: rule.timezone,
durationMinutes: rule.templateDurationMinutes,
});
return {
goalId: rule.goalId,
description: rule.templateDescription ?? '',
note: rule.templateNote,
complete: false,
priorityId: rule.templatePriorityId,
statusId,
goalListId,
creatorId: rule.creatorId,
kanbanOrder,
startDate: window.startDate,
startTime: window.startTime,
endDate: window.endDate,
endTime: window.endTime,
recurrenceRuleId: rule.id,
recurrenceInstanceDate: instanceDate,
};
}
/** Snapshot kanban column if it still exists, otherwise the first column of the goal. */
private async resolveStatusId(args: {
rule: RecurrenceRulesSchemaTypeForSelect;
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
}): Promise<number | null> {
if (args.rule.templateStatusId !== null) {
const rows = await args.tx
.select({ id: TasksStatusesSchema.id })
.from(TasksStatusesSchema)
.where(and(eq(TasksStatusesSchema.id, args.rule.templateStatusId), eq(TasksStatusesSchema.goalId, args.rule.goalId)))
.limit(1);
if (rows[0]) return rows[0].id;
}
const fallback = await args.tx
.select({ id: TasksStatusesSchema.id })
.from(TasksStatusesSchema)
.where(eq(TasksStatusesSchema.goalId, args.rule.goalId))
.orderBy(asc(TasksStatusesSchema.id))
.limit(1);
return fallback[0]?.id ?? null;
}
/** Snapshot list if it still exists, otherwise no list. */
private async resolveGoalListId(args: {
rule: RecurrenceRulesSchemaTypeForSelect;
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
}): Promise<number | null> {
if (args.rule.templateGoalListId === null) return null;
const rows = await args.tx
.select({ id: GoalsListSchema.id })
.from(GoalsListSchema)
.where(and(eq(GoalsListSchema.id, args.rule.templateGoalListId), eq(GoalsListSchema.goalId, args.rule.goalId)))
.limit(1);
return rows[0]?.id ?? null;
}
/** Same top-of-board convention as manual creation (getNextKanbanOrder), but inside the transaction. */
private async nextKanbanOrder(args: {
goalId: number;
tx: Parameters<Parameters<Database['dbDrizzle']['transaction']>[0]>[0];
}): Promise<number> {
const rows = await args.tx
.select({ minKanbanOrder: sql<number | null>`MIN(${TasksSchema.kanbanOrder})` })
.from(TasksSchema)
.where(eq(TasksSchema.goalId, args.goalId));
return (rows[0]?.minKanbanOrder ?? 0) - TasksRepository.KANBAN_ORDER_GAP;
}
}
@@ -0,0 +1,331 @@
import type { RecurrenceRulesSchemaTypeForSelect } from 'taskview-db-schemas';
import type { AppUser } from '../../core/AppUser';
import { eventBus } from '../../core/EventBus';
import { KanbanRepository } from '../kanban/KanbanRepository';
import { GoalListsRepository } from '../lists/GoalListsRepository';
import { TaskFieldPermissionsForWatching } from '../tasks/tasks.server.types';
import { TasksRepository } from '../tasks/TasksRepository';
import { RecurrenceGenerator } from './RecurrenceGenerator';
import { RecurrenceParser } from './RecurrenceParser';
import { RecurrenceRepository } from './RecurrenceRepository';
import type {
RecurrenceCreateArgs,
RecurrenceErrorCode,
RecurrenceResult,
RecurrenceRuleDetails,
RecurrenceUpdateArgs,
} from './types';
const ok = <T>(data: T): RecurrenceResult<T> => ({ ok: true, data });
const fail = (code: RecurrenceErrorCode, message?: string): RecurrenceResult<never> => ({ ok: false, code, message });
// Template fields mirror task fields, so the task-field permission map stays
// the single source of truth for which permission gates which field.
const RuleTemplateFieldPermissionsForWatching = {
templateDescription: TaskFieldPermissionsForWatching.description,
templateNote: TaskFieldPermissionsForWatching.note,
templatePriorityId: TaskFieldPermissionsForWatching.priorityId,
templateStatusId: TaskFieldPermissionsForWatching.statusId,
templateGoalListId: TaskFieldPermissionsForWatching.goalListId,
} as const;
export class RecurrenceManager {
private readonly user: AppUser;
public readonly repository: RecurrenceRepository;
private readonly generator: RecurrenceGenerator;
private readonly tasksRepository: TasksRepository;
private readonly kanbanRepository: KanbanRepository;
private readonly goalListsRepository: GoalListsRepository;
constructor(user: AppUser) {
this.user = user;
this.repository = new RecurrenceRepository();
this.generator = new RecurrenceGenerator();
this.tasksRepository = new TasksRepository();
this.kanbanRepository = new KanbanRepository();
this.goalListsRepository = new GoalListsRepository();
}
private get initiatorId(): number {
return this.user.getUserData()?.id as number;
}
async createRule(args: RecurrenceCreateArgs): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
const task = await this.repository.getTaskById(args.taskId);
if (!task) return fail('not_found', 'task not found');
if (task.recurrenceRuleId) return fail('conflict', 'task is already part of a series');
if (task.parentId) return fail('invalid_state', 'subtasks can not be recurring');
if (task.complete) return fail('invalid_state', 'completed tasks can not start a series');
if (!RecurrenceParser.isValidTimezone(args.timezone)) {
return fail('invalid_rule', 'timezone must be a valid IANA name');
}
let dtstart: Date;
let hasTime: boolean;
try {
RecurrenceParser.validateRuleString(args.rrule);
({ date: dtstart, hasTime } = RecurrenceParser.parseDtstart(args.dtstart));
} catch (err) {
return fail('invalid_rule', (err as Error).message);
}
const originInstanceDate = RecurrenceParser.firstOccurrenceDate({ rrule: args.rrule, dtstart });
if (!originInstanceDate) return fail('invalid_rule', 'rule produces no occurrences');
const templateDurationMinutes = this.durationFromTask({
startDate: task.startDate ?? originInstanceDate,
startTime: task.startTime,
endDate: task.endDate,
endTime: task.endTime,
});
// One transaction: rule + origin attachment + snapshot. The origin task
// becomes the first (and only open) instance of the series, its window
// normalized into the same UTC frame future instances will use —
// otherwise a series created through a non-browser client (MCP, raw API)
// could leave the origin and its successors in different time frames.
const outcome = await this.repository.createWithOriginTask({
rule: {
goalId: task.goalId,
templateTaskId: task.id,
templateDescription: task.description ?? '',
templateNote: task.note,
templatePriorityId: task.priorityId,
templateStatusId: task.statusId,
templateGoalListId: task.goalListId,
templateDurationMinutes,
rrule: args.rrule,
dtstart,
hasTime,
timezone: args.timezone,
lastInstanceDate: originInstanceDate,
notifyOnOccurrence: args.notifyOnOccurrence ?? false,
creatorId: this.initiatorId,
},
originTaskId: task.id,
originInstanceDate,
window: RecurrenceParser.instanceWindowUtc({
occurrenceDate: originInstanceDate,
dtstart,
hasTime,
timezone: args.timezone,
durationMinutes: templateDurationMinutes,
}),
});
if ('error' in outcome) {
if (outcome.error === 'not_found') return fail('not_found', 'task not found');
if (outcome.error === 'conflict') return fail('conflict', 'task is already part of a series');
return fail('invalid_state', 'could not create rule');
}
eventBus.emit('recurrence.created', { rule: outcome.rule, initiatorId: this.initiatorId });
return ok(await this.cleanRuleFieldsRegardPermissions(outcome.rule));
}
/**
* Task fields are permission-gated for reading (TaskFieldPermissionsForWatching,
* applied by cleanTaskFieldsRegardPermissions in the task API) — no series
* endpoint may become a side door to them, so every response carrying a rule
* strips the template fields the caller is not allowed to watch.
*/
private async cleanRuleFieldsRegardPermissions(rule: RecurrenceRulesSchemaTypeForSelect): Promise<RecurrenceRulesSchemaTypeForSelect> {
const checker = await this.user.permissionsFetcher.getCheckerForGoal(rule.goalId).catch(() => null);
const cleaned = { ...rule };
(Object.keys(RuleTemplateFieldPermissionsForWatching) as (keyof typeof RuleTemplateFieldPermissionsForWatching)[]).forEach((field) => {
if (!checker?.hasPermissions(RuleTemplateFieldPermissionsForWatching[field])) {
cleaned[field] = null;
}
});
return cleaned;
}
async getDetails(ruleId: number): Promise<RecurrenceResult<RecurrenceRuleDetails>> {
const rule = await this.repository.getById(ruleId);
if (!rule) return fail('not_found');
const [skipDates, openInstance] = await Promise.all([
this.repository.getSkipDates(ruleId),
this.repository.findOpenInstance(ruleId),
]);
// The open instance is an ordinary task — gate its fields exactly like
// the task API does. Fail closed: better no instance than a leak.
const cleanedInstance = openInstance
? await this.user.tasksManager.cleanTaskFieldsRegardPermissions(openInstance).catch(() => null)
: null;
return ok({
rule: await this.cleanRuleFieldsRegardPermissions(rule),
skipDates,
openInstance: cleanedInstance,
});
}
async getDetailsForTask(taskId: number): Promise<RecurrenceResult<RecurrenceRuleDetails>> {
const rule = await this.repository.getByTaskId(taskId);
if (!rule) return fail('not_found');
return this.getDetails(rule.id);
}
async updateRule(args: RecurrenceUpdateArgs): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
const rule = await this.repository.getById(args.ruleId);
if (!rule) return fail('not_found');
if (rule.state === 'ended') return fail('invalid_state', 'ended series are read-only');
const patch: Parameters<RecurrenceRepository['patch']>[0]['patch'] = {};
if (args.rrule !== undefined) {
try {
RecurrenceParser.validateRuleString(args.rrule);
} catch (err) {
return fail('invalid_rule', (err as Error).message);
}
patch.rrule = args.rrule;
}
if (args.dtstart !== undefined) {
try {
const parsed = RecurrenceParser.parseDtstart(args.dtstart);
patch.dtstart = parsed.date;
patch.hasTime = parsed.hasTime;
} catch (err) {
return fail('invalid_rule', (err as Error).message);
}
}
if (args.timezone !== undefined) {
if (!RecurrenceParser.isValidTimezone(args.timezone)) {
return fail('invalid_rule', 'timezone must be a valid IANA name');
}
patch.timezone = args.timezone;
}
if (patch.rrule !== undefined || patch.dtstart !== undefined) {
const nextDate = RecurrenceParser.nextOccurrenceDate({
rrule: patch.rrule ?? rule.rrule,
dtstart: patch.dtstart ?? rule.dtstart,
afterDate: RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone),
skipDates: new Set<string>(),
});
if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences');
}
if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence;
if (args.templateOverrides) {
const o = args.templateOverrides;
// Foreign/dead ids must fail here, not get stored and silently
// fall back at materialization time (null clears the override).
if (o.statusId !== undefined && o.statusId !== null) {
const belongs = await this.kanbanRepository.statusBelongsToGoal({ statusId: o.statusId, goalId: rule.goalId });
if (!belongs) return fail('invalid_rule', 'status does not belong to the goal');
}
if (o.goalListId !== undefined && o.goalListId !== null) {
const belongs = await this.goalListsRepository.listBelongsToGoal({ listId: o.goalListId, goalId: rule.goalId });
if (!belongs) return fail('invalid_rule', 'list does not belong to the goal');
}
if (o.description !== undefined) patch.templateDescription = o.description;
if (o.note !== undefined) patch.templateNote = o.note;
if (o.priorityId !== undefined) patch.templatePriorityId = o.priorityId;
if (o.statusId !== undefined) patch.templateStatusId = o.statusId;
if (o.goalListId !== undefined) patch.templateGoalListId = o.goalListId;
if (o.durationMinutes !== undefined) patch.templateDurationMinutes = o.durationMinutes;
}
if (Object.keys(patch).length === 0) return ok(await this.cleanRuleFieldsRegardPermissions(rule));
const updated = await this.repository.patch({ ruleId: args.ruleId, patch });
if (!updated) return fail('invalid_state');
if (patch.dtstart !== undefined || patch.timezone !== undefined || patch.rrule !== undefined) {
const openInstance = await this.repository.findOpenInstance(args.ruleId);
if (openInstance?.recurrenceInstanceDate) {
await this.repository.updateInstanceWindow({
taskId: openInstance.id,
window: RecurrenceParser.instanceWindowUtc({
occurrenceDate: openInstance.recurrenceInstanceDate,
dtstart: updated.dtstart,
hasTime: updated.hasTime,
timezone: updated.timezone,
durationMinutes: updated.templateDurationMinutes,
}),
});
}
}
eventBus.emit('recurrence.updated', { rule: updated, changes: patch, initiatorId: this.initiatorId });
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
}
async pauseRule(ruleId: number): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
const rule = await this.repository.getById(ruleId);
if (!rule) return fail('not_found');
if (rule.state !== 'active') return fail('invalid_state', `can not pause a ${rule.state} series`);
const updated = await this.repository.patch({ ruleId, patch: { state: 'paused' } });
if (!updated) return fail('invalid_state');
eventBus.emit('recurrence.paused', { ruleId, goalId: rule.goalId, initiatorId: this.initiatorId });
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
}
async resumeRule(ruleId: number): Promise<RecurrenceResult<RecurrenceRulesSchemaTypeForSelect>> {
const rule = await this.repository.getById(ruleId);
if (!rule) return fail('not_found');
if (rule.state !== 'paused') return fail('invalid_state', `can not resume a ${rule.state} series`);
const updated = await this.repository.patch({ ruleId, patch: { state: 'active' } });
if (!updated) return fail('invalid_state');
// Occurrences missed while paused are not backfilled; if the open
// instance was completed during the pause, restart the chain from today.
const openInstance = await this.repository.findOpenInstance(ruleId);
if (!openInstance) {
await this.generator.materializeNext({ ruleId, initiatorId: this.initiatorId });
}
eventBus.emit('recurrence.resumed', { ruleId, goalId: rule.goalId, initiatorId: this.initiatorId });
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
}
/** Skip the current occurrence: the open instance is removed and the card "jumps" to the next date. */
async skipCurrent(ruleId: number): Promise<RecurrenceResult<RecurrenceRuleDetails>> {
const rule = await this.repository.getById(ruleId);
if (!rule) return fail('not_found');
if (rule.state !== 'active') return fail('invalid_state', `can not skip in a ${rule.state} series`);
const openInstance = await this.repository.findOpenInstance(ruleId);
if (!openInstance || !openInstance.recurrenceInstanceDate) return fail('invalid_state', 'series has no open instance');
await this.repository.addSkipDate({ ruleId, skipDate: openInstance.recurrenceInstanceDate });
await this.tasksRepository.deleteTaskNew({ taskId: openInstance.id });
eventBus.emit('task.deleted', { taskId: openInstance.id, goalId: rule.goalId, initiatorId: this.initiatorId });
eventBus.emit('recurrence.instanceSkipped', {
ruleId,
goalId: rule.goalId,
date: openInstance.recurrenceInstanceDate,
initiatorId: this.initiatorId,
});
await this.generator.materializeNext({ ruleId, initiatorId: this.initiatorId });
return this.getDetails(ruleId);
}
/** Delete the series; existing instances stay as ordinary tasks (FK SET NULL). */
async deleteRule(ruleId: number): Promise<RecurrenceResult<{ deleted: true }>> {
const rule = await this.repository.getById(ruleId);
if (!rule) return fail('not_found');
const deleted = await this.repository.deleteById(ruleId);
if (!deleted) return fail('invalid_state');
eventBus.emit('recurrence.deleted', { ruleId, goalId: rule.goalId, initiatorId: this.initiatorId });
return ok({ deleted: true });
}
/** Wall-clock difference between the task's start and end, treating missing times as midnight. */
private durationFromTask(args: {
startDate: string;
startTime: string | null;
endDate: string | null;
endTime: string | null;
}): number | null {
if (!args.endDate) return null;
const start = new Date(`${args.startDate}T${args.startTime ?? '00:00:00'}Z`).getTime();
const end = new Date(`${args.endDate}T${args.endTime ?? '00:00:00'}Z`).getTime();
if (Number.isNaN(start) || Number.isNaN(end)) return null;
const minutes = Math.round((end - start) / 60_000);
return minutes > 0 ? minutes : null;
}
}
@@ -0,0 +1,164 @@
import { DateTime } from 'luxon';
import { RRule } from 'rrule';
import type { InstanceWindow, InstanceWindowArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
const ALLOWED_FREQUENCIES = new Set<number>([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]);
const MAX_COUNT = 10000;
/**
* All recurrence math happens in a single floating wall-clock frame:
* `dtstart` is a Date whose UTC components equal the wall-clock components of
* the series (the API runs with TZ=UTC, so naive DB timestamps read back this
* way). Occurrences returned by `rrule` carry the same convention and are
* written component-wise into tasks.start_date / start_time. The IANA timezone
* of the rule is only used to resolve "today" for the user.
*/
export class RecurrenceParser {
/** Throws a human-readable Error if the RRULE string is unsupported. */
static validateRuleString(rruleString: string): void {
let options: ReturnType<typeof RRule.parseString>;
try {
options = RRule.parseString(rruleString);
} catch {
throw new Error('Invalid RRULE string');
}
if (options.freq === undefined || !ALLOWED_FREQUENCIES.has(options.freq)) {
throw new Error('FREQ must be one of DAILY, WEEKLY, MONTHLY, YEARLY');
}
if (options.interval !== undefined && (!Number.isInteger(options.interval) || options.interval < 1)) {
throw new Error('INTERVAL must be a positive integer');
}
if (options.count !== undefined && options.count !== null && (options.count < 1 || options.count > MAX_COUNT)) {
throw new Error(`COUNT must be between 1 and ${MAX_COUNT}`);
}
if (options.count && options.until) {
throw new Error('COUNT and UNTIL are mutually exclusive (RFC 5545)');
}
}
/** COUNT encoded in the RRULE string, if any. */
static getCount(rruleString: string): number | null {
return RRule.parseString(rruleString).count ?? null;
}
/**
* First occurrence date strictly after `afterDate`, skipping explicit skip
* dates. COUNT is intentionally stripped: the cap is "N materialized
* instances" enforced by the caller via instances_created, not "N calendar
* positions" (a series completed late must not silently lose remaining runs).
* Returns 'YYYY-MM-DD' or null when the series is over (UNTIL passed / no
* more occurrences within the search horizon).
*/
static nextOccurrenceDate(args: NextOccurrenceArgs): string | null {
const options = RRule.parseString(args.rrule);
delete options.count;
const rule = new RRule({ ...options, dtstart: args.dtstart });
// End of the boundary day in the floating frame: "strictly after that day".
let searchFrom = new Date(`${args.afterDate}T23:59:59.999Z`);
// Skip dates form a finite set; each loop pass moves searchFrom forward, so this terminates.
for (;;) {
const occurrence = rule.after(searchFrom, false);
if (!occurrence) return null;
const isoDate = RecurrenceParser.toIsoDate(occurrence);
if (!args.skipDates.has(isoDate)) return isoDate;
searchFrom = new Date(`${isoDate}T23:59:59.999Z`);
}
}
/** First occurrence on or after the dtstart day — the instance date of the origin task. */
static firstOccurrenceDate(args: ParseRuleArgs): string | null {
const options = RRule.parseString(args.rrule);
delete options.count;
const rule = new RRule({ ...options, dtstart: args.dtstart });
const occurrence = rule.after(new Date(args.dtstart.getTime() - 1), true);
return occurrence ? RecurrenceParser.toIsoDate(occurrence) : null;
}
/** Today's date in the rule's IANA timezone. */
static todayInTimezone(timezone: string): string {
const today = DateTime.now().setZone(timezone);
return today.isValid ? (today.toISODate() as string) : (DateTime.utc().toISODate() as string);
}
/**
* 'HH:mm:ss' wall-clock time of day of the series, or null for a date-only
* series. The distinction is carried explicitly by `hasTime` (rule column)
* — never inferred from a midnight dtstart, otherwise an explicit 00:00
* series would be indistinguishable from "no time".
*/
static timeOfDay(args: { dtstart: Date; hasTime: boolean }): string | null {
if (!args.hasTime) return null;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(args.dtstart.getUTCHours())}:${pad(args.dtstart.getUTCMinutes())}:00`;
}
static isValidTimezone(timezone: string): boolean {
return DateTime.now().setZone(timezone).isValid;
}
/**
* UTC start/end window of a single occurrence. The series is anchored to
* wall-clock time in its IANA timezone ("every day at 9:00 in Berlin"),
* while tasks store UTC instants — so the instant is recomputed for every
* occurrence date with the offset valid on that day (DST-aware): a summer
* occurrence lands on 07:00 UTC, a winter one on 08:00 UTC, and the user
* always sees 9:00 on the wall. Non-existent wall times on spring-forward
* days are pushed forward by luxon to the nearest valid time.
*/
static instanceWindowUtc(args: InstanceWindowArgs): InstanceWindow {
const wallTime = RecurrenceParser.timeOfDay({ dtstart: args.dtstart, hasTime: args.hasTime });
// Date-only series: calendar dates pass through untouched (no instant semantics).
if (!wallTime) {
const days = args.durationMinutes ? Math.floor(args.durationMinutes / (24 * 60)) : 0;
// An occurrence is due on its own date — a series without an explicit
// end is not a "no deadline" task, the deadline IS the occurrence date.
const endDate =
days > 0
? RecurrenceParser.toIsoDate(new Date(new Date(`${args.occurrenceDate}T00:00:00Z`).getTime() + days * 24 * 60 * 60_000))
: args.occurrenceDate;
return { startDate: args.occurrenceDate, startTime: null, endDate, endTime: null };
}
const [year, month, day] = args.occurrenceDate.split('-').map(Number);
let start = DateTime.fromObject(
{ year, month, day, hour: args.dtstart.getUTCHours(), minute: args.dtstart.getUTCMinutes() },
{ zone: args.timezone }
);
if (!start.isValid) {
start = DateTime.fromISO(`${args.occurrenceDate}T${wallTime}`, { zone: 'utc' });
}
const startUtc = start.toUTC();
const window: InstanceWindow = {
startDate: startUtc.toISODate() as string,
startTime: startUtc.toFormat('HH:mm:ss'),
// No explicit duration → due at the occurrence moment itself.
endDate: startUtc.toISODate() as string,
endTime: startUtc.toFormat('HH:mm:ss'),
};
if (args.durationMinutes !== null && args.durationMinutes > 0) {
const endUtc = startUtc.plus({ minutes: args.durationMinutes });
window.endDate = endUtc.toISODate() as string;
window.endTime = endUtc.toFormat('HH:mm:ss');
}
return window;
}
/**
* dtstart in RFC 5545 DATE or DATE-TIME shape → Date (+ whether a time was
* given). `YYYY-MM-DD` is date-only (`hasTime: false`); `YYYY-MM-DDTHH:mm:ss`
* carries a wall-clock time (`hasTime: true`), including an explicit
* `T00:00:00`. Either way the Date holds floating wall-clock UTC components.
*/
static parseDtstart(dtstart: string): { date: Date; hasTime: boolean } {
const hasTime = dtstart.includes('T');
const date = new Date(`${hasTime ? dtstart : `${dtstart}T00:00:00`}Z`);
if (Number.isNaN(date.getTime())) throw new Error('Invalid dtstart');
return { date, hasTime };
}
static toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
}
@@ -0,0 +1,233 @@
import { and, eq, inArray, isNotNull, ne, notExists, sql } from 'drizzle-orm';
import {
RecurrenceRulesSchema,
type RecurrenceRulesSchemaTypeForSelect,
RecurrenceSkipDatesSchema,
RecurrenceTemplateAssigneesSchema,
RecurrenceTemplateTagsSchema,
TasksAssigneeSchema,
TasksSchema,
type TasksSchemaTypeForSelect,
TasksToTagsSchema,
} from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import { callWithCatch } from '../../utils/helpers';
import type {
AddSkipDateArgs,
CreateRuleWithOriginArgs,
CreateRuleWithOriginResult,
RecurrenceRulePatchArgs,
RemoveTemplateAssigneeFromGoalArgs,
UpdateInstanceWindowArgs,
} from './types';
const PG_UNIQUE_VIOLATION = '23505';
export class RecurrenceRepository {
private readonly db: Database;
constructor() {
this.db = Database.getInstance();
}
async getById(ruleId: number): Promise<RecurrenceRulesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(RecurrenceRulesSchema).where(eq(RecurrenceRulesSchema.id, ruleId)).limit(1)
);
return result?.[0] ?? null;
}
async getByTaskId(taskId: number): Promise<RecurrenceRulesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ rule: RecurrenceRulesSchema })
.from(TasksSchema)
.innerJoin(RecurrenceRulesSchema, eq(TasksSchema.recurrenceRuleId, RecurrenceRulesSchema.id))
.where(eq(TasksSchema.id, taskId))
.limit(1)
);
return result?.[0]?.rule ?? null;
}
async patch(args: RecurrenceRulePatchArgs): Promise<RecurrenceRulesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(RecurrenceRulesSchema)
.set({ ...args.patch, editedAt: new Date() })
.where(eq(RecurrenceRulesSchema.id, args.ruleId))
.returning()
);
return result?.[0] ?? null;
}
async deleteById(ruleId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(RecurrenceRulesSchema).where(eq(RecurrenceRulesSchema.id, ruleId))
);
return !!result?.rowCount;
}
async getSkipDates(ruleId: number): Promise<string[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ skipDate: RecurrenceSkipDatesSchema.skipDate })
.from(RecurrenceSkipDatesSchema)
.where(eq(RecurrenceSkipDatesSchema.ruleId, ruleId))
);
return result?.map((r) => r.skipDate) ?? [];
}
async addSkipDate(args: AddSkipDateArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle
.insert(RecurrenceSkipDatesSchema)
.values({ ruleId: args.ruleId, skipDate: args.skipDate })
.onConflictDoNothing()
);
}
/** Drops an ex-collaborator from the assignee snapshot of every rule in the goal. */
async removeTemplateAssigneeFromGoal(args: RemoveTemplateAssigneeFromGoalArgs): Promise<void> {
const goalRules = this.db.dbDrizzle
.select({ id: RecurrenceRulesSchema.id })
.from(RecurrenceRulesSchema)
.where(eq(RecurrenceRulesSchema.goalId, args.goalId));
await callWithCatch(() =>
this.db.dbDrizzle
.delete(RecurrenceTemplateAssigneesSchema)
.where(
and(
eq(RecurrenceTemplateAssigneesSchema.collabUserId, args.collabUserId),
inArray(RecurrenceTemplateAssigneesSchema.ruleId, goalRules)
)
)
);
}
async getTaskById(taskId: number): Promise<TasksSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, taskId)).limit(1)
);
return result?.[0] ?? null;
}
/**
* Atomic series creation. The origin task row is locked FOR UPDATE, so
* concurrent creates on the same task serialize: the loser waits on the
* lock, then sees recurrenceRuleId already set and reports a conflict.
* The partial unique index uniq_recurrence_rules_template_task backs this
* up on the DB level. Everything — rule insert, origin attachment (with
* the window normalized to the series frame) and the assignee/tag
* snapshot — commits or rolls back together.
*/
async createWithOriginTask(args: CreateRuleWithOriginArgs): Promise<CreateRuleWithOriginResult> {
try {
return await this.db.dbDrizzle.transaction(async (tx) => {
const taskRows = await tx
.select({ recurrenceRuleId: TasksSchema.recurrenceRuleId })
.from(TasksSchema)
.where(eq(TasksSchema.id, args.originTaskId))
.for('update')
.limit(1);
const task = taskRows[0];
if (!task) return { error: 'not_found' as const };
if (task.recurrenceRuleId) return { error: 'conflict' as const };
const ruleRows = await tx.insert(RecurrenceRulesSchema).values(args.rule).returning();
const rule = ruleRows[0];
await tx
.update(TasksSchema)
.set({
recurrenceRuleId: rule.id,
recurrenceInstanceDate: args.originInstanceDate,
startDate: args.window.startDate,
startTime: args.window.startTime,
endDate: args.window.endDate,
endTime: args.window.endTime,
})
.where(eq(TasksSchema.id, args.originTaskId));
const [assignees, tags] = await Promise.all([
tx
.select({ collabUserId: TasksAssigneeSchema.collabUserId })
.from(TasksAssigneeSchema)
.where(eq(TasksAssigneeSchema.taskId, args.originTaskId)),
tx
.select({ tagId: TasksToTagsSchema.tagId })
.from(TasksToTagsSchema)
.where(eq(TasksToTagsSchema.taskId, args.originTaskId)),
]);
if (assignees.length > 0) {
await tx
.insert(RecurrenceTemplateAssigneesSchema)
.values(assignees.map((a) => ({ ruleId: rule.id, collabUserId: a.collabUserId })))
.onConflictDoNothing();
}
if (tags.length > 0) {
await tx
.insert(RecurrenceTemplateTagsSchema)
.values(tags.map((t) => ({ ruleId: rule.id, tagId: t.tagId })))
.onConflictDoNothing();
}
return { rule };
});
} catch (error) {
if ((error as { code?: string } | null)?.code === PG_UNIQUE_VIOLATION) {
return { error: 'conflict' };
}
$logger.error(error, '[RecurrenceRepository] createWithOriginTask failed');
return { error: 'failed' };
}
}
/** The single not-completed instance of the rule (the lazy model keeps at most one). */
async findOpenInstance(ruleId: number): Promise<TasksSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(TasksSchema)
.where(and(eq(TasksSchema.recurrenceRuleId, ruleId), ne(sql`COALESCE(${TasksSchema.complete}, false)`, sql`true`)))
.limit(1)
);
return result?.[0] ?? null;
}
/** Re-stamp an open instance's deadline window after the series time/timezone changed. */
async updateInstanceWindow(args: UpdateInstanceWindowArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle
.update(TasksSchema)
.set({
startDate: args.window.startDate,
startTime: args.window.startTime,
endDate: args.window.endDate,
endTime: args.window.endTime,
})
.where(eq(TasksSchema.id, args.taskId))
);
}
/** Active rules with no open instance — the reconcile sweep input. Empty in normal operation. */
async findStalledActiveRules(): Promise<RecurrenceRulesSchemaTypeForSelect[]> {
const openInstance = this.db.dbDrizzle
.select({ one: sql`1` })
.from(TasksSchema)
.where(
and(
eq(TasksSchema.recurrenceRuleId, RecurrenceRulesSchema.id),
isNotNull(TasksSchema.recurrenceRuleId),
ne(sql`COALESCE(${TasksSchema.complete}, false)`, sql`true`)
)
);
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(RecurrenceRulesSchema)
.where(and(eq(RecurrenceRulesSchema.state, 'active'), notExists(openInstance)))
);
return result ?? [];
}
}
@@ -0,0 +1,44 @@
import { Router } from 'express';
import { GoalPermissions } from '../../types/auth.types';
import type { Routable } from '../../types/routable.type';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import RecurrenceController from './RecurrenceController';
import {
goalIdFromRuleParam,
goalIdFromTaskBody,
goalIdFromTaskParam,
requireRecurrencePermission,
} from './middlewares/require-recurrence-permission';
export default class RecurrenceRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly controller: RecurrenceController;
constructor() {
this.router = Router();
this.controller = new RecurrenceController();
this.initRoutes();
}
getRouter() {
return this.router;
}
initRoutes() {
// Recurrence is a schedule attribute of a task, so editing reuses the existing deadline permission.
this.router.post('', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromTaskBody)], this.controller.create);
// Read access mirrors reading the task itself.
this.router.get('/task/:taskId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT, goalIdFromTaskParam)], this.controller.getForTask);
this.router.get('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT, goalIdFromRuleParam)], this.controller.getOne);
this.router.patch('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.update);
this.router.post('/:ruleId/pause', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.pause);
this.router.post('/:ruleId/resume', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.resume);
// Skip is the one schedule operation that physically deletes the open
// instance (with its subtasks and tracked time) — so on top of the
// schedule permission it requires the same right as DELETE /tasks.
this.router.post('/:ruleId/skip', [IsLoggedIn, requireRecurrencePermission([GoalPermissions.TASKS_CAN_EDIT_DEADLINE, GoalPermissions.TASKS_CAN_DELETE], goalIdFromRuleParam)], this.controller.skip);
this.router.delete('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.remove);
}
}
@@ -0,0 +1,53 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import type { GoalPermissionType } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
import { RecurrenceRepository } from '../RecurrenceRepository';
type GoalIdResolver = (req: Request) => Promise<number | null> | number | null;
/** goalId for POST /module/recurrence — resolved through the task being made recurring. */
export const goalIdFromTaskBody: GoalIdResolver = async (req) => {
const taskId = Number(req.body?.taskId);
if (!taskId) return null;
const task = await new RecurrenceRepository().getTaskById(taskId);
return task?.goalId ?? null;
};
/** goalId for /module/recurrence/:ruleId routes — resolved through the rule. */
export const goalIdFromRuleParam: GoalIdResolver = async (req) => {
const ruleId = Number(req.params?.ruleId);
if (!ruleId) return null;
const rule = await new RecurrenceRepository().getById(ruleId);
return rule?.goalId ?? null;
};
/** goalId for GET /module/recurrence/task/:taskId. */
export const goalIdFromTaskParam: GoalIdResolver = async (req) => {
const taskId = Number(req.params?.taskId);
if (!taskId) return null;
const task = await new RecurrenceRepository().getTaskById(taskId);
return task?.goalId ?? null;
};
/** A single permission or a list — the caller must hold ALL of them. */
export function requireRecurrencePermission(permission: GoalPermissionType | GoalPermissionType[], resolveGoalId: GoalIdResolver) {
const required = Array.isArray(permission) ? permission : [permission];
return async (req: Request, res: Response, next: NextFunction) => {
const goalId = await resolveGoalId(req);
if (!goalId) return res.status(404).end();
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not resolve recurrence permissions');
return res.status(500).end();
}
if (required.every((p) => permissions.hasPermissions(p))) return next();
return res.status(403).end();
};
}
+138
View File
@@ -0,0 +1,138 @@
import { type } from 'arktype';
import type {
RecurrenceRulesSchemaTypeForInsert,
RecurrenceRulesSchemaTypeForSelect,
TasksSchemaTypeForSelect,
} from 'taskview-db-schemas';
/** Request validators (ArkType) */
export const RecurrenceArkTypeCreate = type({
taskId: 'number',
rrule: 'string > 0',
dtstart: 'string', // 'YYYY-MM-DDTHH:mm:ss' floating wall-clock, no TZ suffix
timezone: 'string > 0', // IANA name, e.g. 'Europe/Moscow'
'notifyOnOccurrence?': 'boolean',
});
export const RecurrenceArkTypeUpdate = type({
ruleId: 'number',
'rrule?': 'string > 0',
'dtstart?': 'string',
'timezone?': 'string > 0',
'notifyOnOccurrence?': 'boolean',
'templateOverrides?': type({
'description?': 'string',
'note?': 'string | null',
'priorityId?': '1 | 2 | 3 | null',
'statusId?': 'number | null',
'goalListId?': 'number | null',
'durationMinutes?': 'number | null',
}),
});
export const RecurrenceArkTypeRuleIdParam = type({
ruleId: type('string | number').pipe((v) => Number(v)),
});
export const RecurrenceArkTypeTaskIdParam = type({
taskId: type('string | number').pipe((v) => Number(v)),
});
/** Inferred argument types (args-as-object) */
export type RecurrenceCreateArgs = typeof RecurrenceArkTypeCreate.infer;
export type RecurrenceUpdateArgs = typeof RecurrenceArkTypeUpdate.infer;
export type RecurrenceTemplateOverrides = NonNullable<RecurrenceUpdateArgs['templateOverrides']>;
/** Generator args */
export type MaterializeNextArgs = {
ruleId: number;
/** Who triggered materialization (instance completer, resume initiator or rule creator for the reconcile job). */
initiatorId: number;
};
/** Parser args */
export type ParseRuleArgs = { rrule: string; dtstart: Date };
export type NextOccurrenceArgs = {
rrule: string;
dtstart: Date;
/** 'YYYY-MM-DD' — next occurrence is searched strictly after this day. */
afterDate: string;
skipDates: Set<string>;
};
export type InstanceWindowArgs = {
/** 'YYYY-MM-DD' wall-clock occurrence date in the rule's timezone. */
occurrenceDate: string;
dtstart: Date;
/** False → date-only occurrence (no start/end time). */
hasTime: boolean;
timezone: string;
durationMinutes: number | null;
};
export type InstanceWindow = {
startDate: string;
startTime: string | null;
endDate: string | null;
endTime: string | null;
};
/** Repository args */
export type UpdateInstanceWindowArgs = {
taskId: number;
window: InstanceWindow;
};
export type RecurrenceRulePatchArgs = {
ruleId: number;
patch: Partial<{
rrule: string;
dtstart: Date;
hasTime: boolean;
timezone: string;
state: 'active' | 'paused' | 'ended';
lastInstanceDate: string;
instancesCreated: number;
notifyOnOccurrence: boolean;
templateDescription: string;
templateNote: string | null;
templatePriorityId: 1 | 2 | 3 | null;
templateStatusId: number | null;
templateGoalListId: number | null;
templateDurationMinutes: number | null;
templateTaskId: number | null;
}>;
};
export type AddSkipDateArgs = { ruleId: number; skipDate: string };
export type RemoveTemplateAssigneeFromGoalArgs = { goalId: number; collabUserId: number };
/**
* Atomic series creation: rule insert + origin task attachment (with its
* window normalized to the series frame) + assignee/tag snapshot — one
* transaction with the origin task row locked FOR UPDATE, so concurrent
* creates on the same task serialize instead of producing two rules.
*/
export type CreateRuleWithOriginArgs = {
rule: RecurrenceRulesSchemaTypeForInsert;
originTaskId: number;
originInstanceDate: string;
window: InstanceWindow;
};
export type CreateRuleWithOriginResult =
| { rule: RecurrenceRulesSchemaTypeForSelect }
| { error: 'not_found' | 'conflict' | 'failed' };
/** Detail shape returned by GET endpoints */
export type RecurrenceRuleDetails = {
rule: RecurrenceRulesSchemaTypeForSelect;
skipDates: string[];
openInstance: TasksSchemaTypeForSelect | null;
};
export type RecurrenceErrorCode = 'not_found' | 'conflict' | 'invalid_state' | 'invalid_rule';
export type RecurrenceResult<T> = { ok: true; data: T } | { ok: false; code: RecurrenceErrorCode; message?: string };
@@ -0,0 +1,84 @@
import { getJobQueue } from '../../core/JobQueue';
import { $logger } from '../../modules/logget';
import { SprintsRepository } from './SprintsRepository';
export const SPRINT_CADENCE_JOB = 'sprint-cadence-generate';
export class SprintScheduler {
private readonly repo = new SprintsRepository();
/**
* Ensure the current + `lookahead` future sprints exist for a goal whose
* cadence is enabled. Windows run every `length_days` from `start_date`.
* Idempotent: skips windows whose start date already has a live (non-completed)
* sprint, so manual sprints and repeated runs never duplicate. All generated
* sprints are created as `planned` — activation is always manual.
* Returns the number of sprints created.
*/
async generateCadenceForGoal(goalId: number): Promise<number> {
const cadence = await this.repo.getCadence(goalId);
if (!cadence || !cadence.enabled) return 0;
const lengthDays = cadence.lengthDays > 0 ? cadence.lengthDays : 14;
const lookahead = cadence.lookahead >= 0 ? cadence.lookahead : 0;
const today = new Date().toISOString().slice(0, 10);
const offset = this.daysBetween(cadence.startDate, today);
const currentIdx = offset < 0 ? 0 : Math.floor(offset / lengthDays);
const lastIdx = currentIdx + lookahead;
let created = 0;
let maxStart = cadence.lastGeneratedDate ?? null;
for (let idx = currentIdx; idx <= lastIdx; idx++) {
const winStart = this.addDays(cadence.startDate, idx * lengthDays);
const winEnd = this.addDays(winStart, lengthDays - 1);
const existing = await this.repo.findByGoalAndStartDate({ goalId, startDate: winStart });
if (existing) {
if (!maxStart || winStart > maxStart) maxStart = winStart;
continue;
}
const name = cadence.nameTemplate.includes('{n}')
? cadence.nameTemplate.replace('{n}', String(idx + 1))
: `${cadence.nameTemplate} ${idx + 1}`;
const sprint = await this.repo.createCadenceSprint({ goalId, name, startDate: winStart, endDate: winEnd });
if (!sprint) continue;
created++;
if (!maxStart || winStart > maxStart) maxStart = winStart;
}
if (maxStart) await this.repo.setCadenceLastGenerated({ goalId, lastGeneratedDate: maxStart });
$logger.info(`[SprintScheduler] cadence goal=${goalId} created=${created} windows=[${currentIdx}..${lastIdx}]`);
return created;
}
async registerCadenceWorker(): Promise<void> {
const boss = getJobQueue();
await boss.createQueue(SPRINT_CADENCE_JOB);
await boss.schedule(SPRINT_CADENCE_JOB, '0 1 * * *');
await boss.work(SPRINT_CADENCE_JOB, async () => {
const cadences = await this.repo.getEnabledCadences();
for (const c of cadences) {
await this.generateCadenceForGoal(c.goalId).catch((e) =>
$logger.error(e, `[SprintScheduler] cadence sweep goal=${c.goalId}`)
);
}
});
}
private addDays(dateStr: string, days: number): string {
const d = new Date(`${dateStr}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
private daysBetween(from: string, to: string): number {
const a = new Date(`${from}T00:00:00Z`).getTime();
const b = new Date(`${to}T00:00:00Z`).getTime();
return Math.floor((b - a) / 86_400_000);
}
}
@@ -0,0 +1,154 @@
import { type } from 'arktype';
import type { Request, Response } from 'express';
import type { SprintStatus } from 'taskview-db-schemas';
import { logError } from '../../utils/api';
import {
SprintArkTypeClose,
SprintArkTypeCreate,
SprintArkTypeGoalIdParam,
SprintArkTypeListQuery,
SprintArkTypePlanningQuery,
SprintArkTypeSaveRetro,
SprintArkTypeSetCadence,
SprintArkTypeSetTask,
SprintArkTypeSprintIdParam,
SprintArkTypeUpdate,
SprintArkTypeVelocityQuery,
} from './types';
import type { SprintErrorCode, SprintResult } from './types';
const codeToStatus: Record<SprintErrorCode, number> = {
not_found: 404,
conflict: 409,
invalid_state: 400,
forbidden: 403,
};
const VALID_STATUSES: SprintStatus[] = ['draft', 'planned', 'active', 'review', 'completed'];
export default class SprintsController {
private sendResult<T>(res: Response, result: SprintResult<T>) {
if (result.ok) return res.tvJson(result.data);
return res.status(codeToStatus[result.code]).send(result.message ?? result.code);
}
listForGoal = async (req: Request, res: Response) => {
// Route params win over query: the goalId authorized by the middleware must
// be the one operated on (a query-supplied goalId must not override it).
const data = SprintArkTypeListQuery({ ...req.query, ...req.params });
if (data instanceof type.errors) return res.status(400).send(data.summary);
const statuses = data.status
? (data.status.split(',').map((s) => s.trim()).filter((s) => VALID_STATUSES.includes(s as SprintStatus)) as SprintStatus[])
: undefined;
return res.tvJson(await req.appUser.sprintsManager.listSprints({ goalId: data.goalId, statuses }).catch(logError));
};
getOne = async (req: Request, res: Response) => {
const data = SprintArkTypeSprintIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return res.tvJson(await req.appUser.sprintsManager.getSprint(data.sprintId).catch(logError));
};
create = async (req: Request, res: Response) => {
const data = SprintArkTypeCreate(req.body);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.createSprint(data));
};
update = async (req: Request, res: Response) => {
const data = SprintArkTypeUpdate({ ...req.body, sprintId: Number(req.params.sprintId) });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.updateSprint(data));
};
activate = async (req: Request, res: Response) => {
const data = SprintArkTypeSprintIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.activateSprint(data.sprintId));
};
review = async (req: Request, res: Response) => {
const data = SprintArkTypeSprintIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.startReview(data.sprintId));
};
close = async (req: Request, res: Response) => {
const data = SprintArkTypeClose({ ...req.body, sprintId: Number(req.params.sprintId) });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.closeSprint(data));
};
pause = async (req: Request, res: Response) => {
const data = SprintArkTypeSprintIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.pauseSprint(data.sprintId));
};
resume = async (req: Request, res: Response) => {
const data = SprintArkTypeSprintIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.resumeSprint(data.sprintId));
};
remove = async (req: Request, res: Response) => {
const data = SprintArkTypeSprintIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.deleteSprint(data.sprintId));
};
saveRetro = async (req: Request, res: Response) => {
const data = SprintArkTypeSaveRetro({ ...req.body, sprintId: Number(req.params.sprintId) });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.saveRetro(data));
};
setTaskSprint = async (req: Request, res: Response) => {
const data = SprintArkTypeSetTask({ taskId: Number(req.params.taskId), sprintId: req.body.sprintId });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.setTaskSprint(data));
};
burndown = async (req: Request, res: Response) => {
const data = SprintArkTypeSprintIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return res.tvJson(await req.appUser.sprintsManager.getBurndown(data.sprintId).catch(logError));
};
planning = async (req: Request, res: Response) => {
const data = SprintArkTypePlanningQuery({ ...req.query, ...req.params });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return res.tvJson(
await req.appUser.sprintsManager
.getPlanningTasks({
sprintId: data.sprintId,
scope: data.scope,
cursor: data.cursor ?? null,
limit: data.limit ?? 30,
})
.catch(logError)
);
};
velocity = async (req: Request, res: Response) => {
const data = SprintArkTypeVelocityQuery({ ...req.query, ...req.params });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return res.tvJson(
await req.appUser.sprintsManager.getVelocity({ goalId: data.goalId, lastN: data.lastN ?? 6 }).catch(logError)
);
};
getCadence = async (req: Request, res: Response) => {
const data = SprintArkTypeGoalIdParam(req.params);
if (data instanceof type.errors) return res.status(400).send(data.summary);
return res.tvJson(await req.appUser.sprintsManager.getCadence(data.goalId).catch(logError));
};
setCadence = async (req: Request, res: Response) => {
const data = SprintArkTypeSetCadence({ ...req.body, goalId: Number(req.params.goalId) });
if (data instanceof type.errors) return res.status(400).send(data.summary);
return this.sendResult(res, await req.appUser.sprintsManager.setCadence(data));
};
}
@@ -0,0 +1,71 @@
import { eq } from 'drizzle-orm';
import {
CollaborationUsersSchema,
CollaborationUsersToGoalsSchema,
GoalsSchema,
UsersSchema,
} from 'taskview-db-schemas';
import { getCentrifugoClient } from '../../core/CentrifugoClient';
import type { Dispatcher } from '../../core/Dispatcher';
import { eventBus } from '../../core/EventBus';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import { SprintScheduler } from './SprintScheduler';
const SPRINT_RT_EVENT = 'sprints.changed';
export class SprintsDispatcher implements Dispatcher {
private readonly scheduler = new SprintScheduler();
register(): void {
eventBus.on('sprint.created', (d) => this.notifyGoalMembers(d.sprint.goalId, { sprintId: d.sprint.id }));
eventBus.on('sprint.updated', (d) => this.notifyGoalMembers(d.sprint.goalId, { sprintId: d.sprint.id }));
eventBus.on('sprint.activated', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
eventBus.on('sprint.reviewStarted', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
eventBus.on('sprint.completed', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
eventBus.on('sprint.paused', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
eventBus.on('sprint.resumed', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
eventBus.on('sprint.deleted', (d) => this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId }));
eventBus.on('task.assignedToSprint', (d) =>
this.notifyGoalMembers(d.goalId, { sprintId: d.sprintId, prevSprintId: d.prevSprintId, taskId: d.taskId })
);
}
async registerWorkers(): Promise<void> {
await this.scheduler.registerCadenceWorker();
}
private async notifyGoalMembers(goalId: number, payload: Record<string, unknown>): Promise<void> {
try {
const memberIds = await this.resolveGoalMemberIds(goalId);
if (memberIds.length === 0) return;
const centrifugo = getCentrifugoClient();
await Promise.all(
memberIds.map((userId) => centrifugo.publishToUser(userId, SPRINT_RT_EVENT, { goalId, ...payload }))
);
} catch (err) {
$logger.error(err, '[SprintsDispatcher] real-time publish failed');
}
}
private async resolveGoalMemberIds(goalId: number): Promise<number[]> {
const db = Database.getInstance();
const [ownerRows, collabRows] = await Promise.all([
db.dbDrizzle.select({ id: GoalsSchema.owner }).from(GoalsSchema).where(eq(GoalsSchema.id, goalId)).limit(1),
db.dbDrizzle
.select({ id: UsersSchema.id })
.from(CollaborationUsersToGoalsSchema)
.innerJoin(
CollaborationUsersSchema,
eq(CollaborationUsersToGoalsSchema.userId, CollaborationUsersSchema.id)
)
.innerJoin(UsersSchema, eq(CollaborationUsersSchema.email, UsersSchema.email))
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId)),
]);
const ids = new Set<number>();
if (ownerRows[0]?.id) ids.add(ownerRows[0].id);
collabRows.forEach((r) => ids.add(r.id));
return [...ids];
}
}
@@ -0,0 +1,332 @@
import type {
SprintCadenceSchemaTypeForSelect,
SprintsSchemaTypeForInsert,
SprintsSchemaTypeForSelect,
SprintStatus,
TasksSchemaTypeForSelect,
} from 'taskview-db-schemas';
import type { AppUser } from '../../core/AppUser';
import { eventBus } from '../../core/EventBus';
import { $logger } from '../../modules/logget';
import { SprintScheduler } from './SprintScheduler';
import { SprintsRepository } from './SprintsRepository';
import type {
BurndownPoint,
SprintCloseArgs,
SprintCreateArgs,
SprintErrorCode,
SprintListFilter,
SprintPlanningManagerArgs,
SprintPlanningPage,
SprintResult,
SprintSaveRetroArgs,
SprintSetCadenceArgs,
SprintSetTaskArgs,
SprintUpdateArgs,
SprintVelocityArgs,
} from './types';
const ok = <T>(data: T): SprintResult<T> => ({ ok: true, data });
const fail = (code: SprintErrorCode, message?: string): SprintResult<never> => ({ ok: false, code, message });
export class SprintsManager {
private readonly user: AppUser;
public readonly repository: SprintsRepository;
private readonly scheduler: SprintScheduler;
constructor(user: AppUser) {
this.user = user;
this.repository = new SprintsRepository();
this.scheduler = new SprintScheduler();
}
private get initiatorId(): number {
return this.user.getUserData()?.id as number;
}
private today(): string {
return new Date().toISOString().slice(0, 10);
}
async listSprints(filter: SprintListFilter): Promise<SprintsSchemaTypeForSelect[]> {
return this.repository.listForGoal(filter);
}
async getSprint(sprintId: number) {
const sprint = await this.repository.getById(sprintId);
if (!sprint) return null;
const retro = await this.repository.getRetro(sprintId);
return {
...sprint,
retro: retro
? { wentWell: retro.wentWell, wentBad: retro.wentBad, actionItems: retro.actionItems }
: null,
};
}
async createSprint(args: SprintCreateArgs): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
if (args.endDate < args.startDate) return fail('invalid_state', 'endDate must be >= startDate');
const status: SprintStatus = args.startDate > this.today() ? 'planned' : 'draft';
const sprint = await this.repository.create({ ...args, creatorId: this.initiatorId }, status);
if (!sprint) return fail('invalid_state', 'could not create sprint');
eventBus.emit('sprint.created', { sprint, initiatorId: this.initiatorId });
return ok(sprint);
}
async updateSprint(args: SprintUpdateArgs): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
const sprint = await this.repository.getById(args.sprintId);
if (!sprint) return fail('not_found');
if (sprint.status === 'completed') return fail('invalid_state', 'completed sprints are read-only');
const patch: Partial<SprintsSchemaTypeForInsert> = {};
if (args.name !== undefined) patch.name = args.name;
if (args.startDate !== undefined) patch.startDate = args.startDate;
if (args.endDate !== undefined) patch.endDate = args.endDate;
if (args.goalText !== undefined) patch.goalText = args.goalText;
if (args.capacity !== undefined) {
patch.capacity = args.capacity != null ? String(args.capacity) : null;
}
const newStart = args.startDate ?? sprint.startDate;
const newEnd = args.endDate ?? sprint.endDate;
if (newEnd < newStart) return fail('invalid_state', 'endDate must be >= startDate');
const updated = await this.repository.patch({ sprintId: args.sprintId, patch });
if (!updated) return fail('invalid_state');
eventBus.emit('sprint.updated', { sprint: updated, changes: patch, initiatorId: this.initiatorId });
return ok(updated);
}
async activateSprint(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
const sprint = await this.repository.getById(sprintId);
if (!sprint) return fail('not_found');
if (sprint.status !== 'draft' && sprint.status !== 'planned') {
return fail('invalid_state', 'only draft or planned sprints can be activated');
}
const conflict = await this.repository.findActiveOrReview(sprint.goalId, sprint.id);
if (conflict) return fail('conflict', 'another sprint is already active or in review');
const updated = await this.repository.patch({ sprintId, patch: { status: 'active' } });
if (!updated) return fail('invalid_state');
eventBus.emit('sprint.activated', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
return ok(updated);
}
async startReview(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
const sprint = await this.repository.getById(sprintId);
if (!sprint) return fail('not_found');
if (sprint.status !== 'active') return fail('invalid_state', 'only an active sprint can enter review');
const updated = await this.repository.patch({
sprintId,
patch: { status: 'review', reviewStartedAt: new Date() },
});
if (!updated) return fail('invalid_state');
eventBus.emit('sprint.reviewStarted', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
return ok(updated);
}
async closeSprint(args: SprintCloseArgs): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
const sprint = await this.repository.getById(args.sprintId);
if (!sprint) return fail('not_found');
if (sprint.status !== 'review') return fail('invalid_state', 'sprint must be in review before closing');
for (const o of args.outcomes) {
if (o.outcome === 'carried-over' && o.carriedOverTo != null) {
if (o.carriedOverTo === sprint.id) return fail('invalid_state', 'cannot carry over to the same sprint');
const target = await this.repository.getById(o.carriedOverTo);
if (!target || target.goalId !== sprint.goalId) {
return fail('invalid_state', 'carry-over target sprint not found in this project');
}
}
}
const closed = await this.repository.applyClose({ ...args, initiatorId: this.initiatorId });
if (!closed) return fail('invalid_state');
eventBus.emit('sprint.completed', { sprintId: args.sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
return ok(closed);
}
async pauseSprint(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
const sprint = await this.repository.getById(sprintId);
if (!sprint) return fail('not_found');
if (sprint.status !== 'active') return fail('invalid_state', 'only an active sprint can be paused');
if (sprint.pausedAt) return fail('invalid_state', 'sprint is already paused');
const updated = await this.repository.patch({ sprintId, patch: { pausedAt: new Date() } });
if (!updated) return fail('invalid_state');
eventBus.emit('sprint.paused', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
return ok(updated);
}
async resumeSprint(sprintId: number): Promise<SprintResult<SprintsSchemaTypeForSelect>> {
const sprint = await this.repository.getById(sprintId);
if (!sprint) return fail('not_found');
if (sprint.status !== 'active' || !sprint.pausedAt) return fail('invalid_state', 'sprint is not paused');
const updated = await this.repository.patch({ sprintId, patch: { pausedAt: null } });
if (!updated) return fail('invalid_state');
eventBus.emit('sprint.resumed', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
return ok(updated);
}
async deleteSprint(sprintId: number): Promise<SprintResult<true>> {
const sprint = await this.repository.getById(sprintId);
if (!sprint) return fail('not_found');
// A sprint of any status can be deleted (including completed) — useful for
// cleaning up an accidentally closed sprint. The DB FK sets tasks.sprint_id
// to NULL (tasks return to the backlog) and cascades outcomes/retros/capacity.
const deleted = await this.repository.delete(sprintId);
if (!deleted) return fail('invalid_state');
eventBus.emit('sprint.deleted', { sprintId, goalId: sprint.goalId, initiatorId: this.initiatorId });
return ok(true);
}
async saveRetro(args: SprintSaveRetroArgs): Promise<SprintResult<SprintSaveRetroArgs>> {
const sprint = await this.repository.getById(args.sprintId);
if (!sprint) return fail('not_found');
const saved = await this.repository.saveRetro({ ...args, editedBy: this.initiatorId });
if (!saved) return fail('invalid_state');
return ok(args);
}
async setTaskSprint(args: SprintSetTaskArgs): Promise<SprintResult<{ taskId: number; sprintId: number | null }>> {
const taskMeta = await this.repository.getTaskMeta(args.taskId);
if (!taskMeta) return fail('not_found', 'task not found');
if (args.sprintId !== null) {
const sprint = await this.repository.getById(args.sprintId);
if (!sprint) return fail('not_found', 'sprint not found');
if (sprint.goalId !== taskMeta.goalId) {
return fail('forbidden', 'task and sprint belong to different projects');
}
if (sprint.status === 'completed') {
return fail('invalid_state', 'cannot move tasks into a completed sprint');
}
}
const prevSprintId = taskMeta.sprintId;
const done = await this.repository.setTaskSprint(args);
if (!done) return fail('invalid_state');
eventBus.emit('task.assignedToSprint', {
taskId: args.taskId,
sprintId: args.sprintId,
prevSprintId,
goalId: taskMeta.goalId,
initiatorId: this.initiatorId,
});
return ok({ taskId: args.taskId, sprintId: args.sprintId });
}
async getBurndown(sprintId: number): Promise<{ total: number; points: BurndownPoint[] } | null> {
const sprint = await this.repository.getById(sprintId);
if (!sprint) return null;
const tasks = await this.repository.getSprintTaskEstimates(sprintId);
const total = tasks.reduce((sum, t) => sum + (t.estimateValue ? Number(t.estimateValue) : 0), 0);
const days = this.enumerateDays(sprint.startDate, sprint.endDate);
const n = days.length;
const points: BurndownPoint[] = days.map((date, i) => {
const dayEnd = new Date(`${date}T23:59:59.999Z`);
let remaining = 0;
for (const t of tasks) {
const est = t.estimateValue ? Number(t.estimateValue) : 0;
const isRemaining = t.complete !== true || (t.dateComplete != null && t.dateComplete > dayEnd);
if (isRemaining) remaining += est;
}
const ideal = n > 1 ? total * (1 - i / (n - 1)) : 0;
return {
date,
remainingHours: Math.round(remaining * 100) / 100,
idealHours: Math.max(0, Math.round(ideal * 100) / 100),
};
});
return { total: Math.round(total * 100) / 100, points };
}
async getVelocity(args: SprintVelocityArgs) {
return this.repository.velocity({ goalId: args.goalId, lastN: args.lastN || 6 });
}
async getCadence(goalId: number): Promise<SprintCadenceSchemaTypeForSelect | null> {
return this.repository.getCadence(goalId);
}
async setCadence(args: SprintSetCadenceArgs): Promise<SprintResult<SprintCadenceSchemaTypeForSelect>> {
const existing = await this.repository.getCadence(args.goalId);
const lengthDays = args.lengthDays ?? existing?.lengthDays ?? 14;
const lookahead = args.lookahead ?? existing?.lookahead ?? 2;
if (lengthDays < 1 || lengthDays > 90) return fail('invalid_state', 'lengthDays must be 1..90');
if (lookahead < 0 || lookahead > 12) return fail('invalid_state', 'lookahead must be 0..12');
const startDate = args.startDate ?? existing?.startDate ?? this.today();
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) return fail('invalid_state', 'startDate must be YYYY-MM-DD');
const nameTemplate = args.nameTemplate?.trim() || existing?.nameTemplate || 'Sprint {n}';
const saved = await this.repository.upsertCadence({
goalId: args.goalId,
enabled: args.enabled,
lengthDays,
startDate,
lookahead,
nameTemplate,
});
if (!saved) return fail('invalid_state', 'could not save cadence');
if (saved.enabled) {
await this.scheduler
.generateCadenceForGoal(args.goalId)
.catch((e) => $logger.error(e, '[Sprints] cadence generate'));
}
return ok(saved);
}
async getPlanningTasks(args: SprintPlanningManagerArgs): Promise<SprintPlanningPage | null> {
const sprint = await this.repository.getById(args.sprintId);
if (!sprint) return null;
if (args.scope === 'sprint') {
const rows = await this.repository.getSprintTasksForPlanning({
sprintId: args.sprintId,
cursor: args.cursor,
limit: args.limit,
});
const { tasks, nextCursor } = this.paginate(rows, args.limit);
const totalPoints = await this.repository.sumEstimateForSprint(args.sprintId);
return { tasks, nextCursor, totalPoints };
}
const rows = await this.repository.getBacklogTasksForPlanning({
goalId: sprint.goalId,
sprintId: args.sprintId,
cursor: args.cursor,
limit: args.limit,
});
return this.paginate(rows, args.limit);
}
private paginate(
rows: TasksSchemaTypeForSelect[],
limit: number
): { tasks: TasksSchemaTypeForSelect[]; nextCursor: number | null } {
const hasMore = rows.length > limit;
const tasks = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? tasks[tasks.length - 1].id : null;
return { tasks, nextCursor };
}
private enumerateDays(start: string, end: string): string[] {
const days: string[] = [];
const cur = new Date(`${start}T00:00:00Z`);
const last = new Date(`${end}T00:00:00Z`);
// hard cap to avoid pathological ranges
let guard = 0;
while (cur <= last && guard < 400) {
days.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
guard++;
}
return days;
}
}
@@ -0,0 +1,473 @@
import { and, asc, desc, eq, gt, inArray, isNull, ne, or, sql } from 'drizzle-orm';
import {
SprintCadenceSchema,
SprintsSchema,
SprintTaskOutcomesSchema,
SprintUserCapacitySchema,
SprintRetrosSchema,
TasksSchema,
type SprintCadenceSchemaTypeForSelect,
type SprintStatus,
type SprintsSchemaTypeForInsert,
type SprintsSchemaTypeForSelect,
type TasksSchemaTypeForSelect,
} from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
import type {
SprintCadenceFindByStartArgs,
SprintCadenceSprintCreateArgs,
SprintCadenceTouchArgs,
SprintCadenceUpsertRepoArgs,
SprintCloseManagerArgs,
SprintCreateRepoArgs,
SprintListFilter,
SprintPlanningPageArgs,
SprintSaveRetroManagerArgs,
VelocityPoint,
} from './types';
export class SprintsRepository {
private readonly db: Database;
constructor() {
this.db = Database.getInstance();
}
async create(args: SprintCreateRepoArgs, status: SprintStatus): Promise<SprintsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(SprintsSchema)
.values({
goalId: args.goalId,
name: args.name,
startDate: args.startDate,
endDate: args.endDate,
goalText: args.goalText ?? null,
capacity: args.capacity != null ? String(args.capacity) : null,
status,
creatorId: args.creatorId,
})
.returning()
);
return result?.[0] ?? null;
}
async getById(sprintId: number): Promise<SprintsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).limit(1)
);
return result?.[0] ?? null;
}
/** Cadence: per-project auto-generation config (Linear-style). */
async getCadence(goalId: number): Promise<SprintCadenceSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SprintCadenceSchema).where(eq(SprintCadenceSchema.goalId, goalId)).limit(1)
);
return result?.[0] ?? null;
}
async upsertCadence(args: SprintCadenceUpsertRepoArgs): Promise<SprintCadenceSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(SprintCadenceSchema)
.values({
goalId: args.goalId,
enabled: args.enabled,
lengthDays: args.lengthDays,
startDate: args.startDate,
lookahead: args.lookahead,
nameTemplate: args.nameTemplate,
})
.onConflictDoUpdate({
target: SprintCadenceSchema.goalId,
set: {
enabled: args.enabled,
lengthDays: args.lengthDays,
startDate: args.startDate,
lookahead: args.lookahead,
nameTemplate: args.nameTemplate,
editedAt: new Date(),
},
})
.returning()
);
return result?.[0] ?? null;
}
async getEnabledCadences(): Promise<SprintCadenceSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SprintCadenceSchema).where(eq(SprintCadenceSchema.enabled, true))
);
return result ?? [];
}
async findByGoalAndStartDate(args: SprintCadenceFindByStartArgs): Promise<SprintsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SprintsSchema)
.where(
and(
eq(SprintsSchema.goalId, args.goalId),
eq(SprintsSchema.startDate, args.startDate),
ne(SprintsSchema.status, 'completed')
)
)
.limit(1)
);
return result?.[0] ?? null;
}
async setCadenceLastGenerated(args: SprintCadenceTouchArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle
.update(SprintCadenceSchema)
.set({ lastGeneratedDate: args.lastGeneratedDate, editedAt: new Date() })
.where(eq(SprintCadenceSchema.goalId, args.goalId))
);
}
/** Create an auto-generated (cadence) sprint — no creator, always starts planned. */
async createCadenceSprint(args: SprintCadenceSprintCreateArgs): Promise<SprintsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(SprintsSchema)
.values({
goalId: args.goalId,
name: args.name,
startDate: args.startDate,
endDate: args.endDate,
status: 'planned',
creatorId: null,
})
.returning()
);
return result?.[0] ?? null;
}
async listForGoal(filter: SprintListFilter): Promise<SprintsSchemaTypeForSelect[]> {
const conditions = [eq(SprintsSchema.goalId, filter.goalId)];
if (filter.statuses && filter.statuses.length > 0) {
conditions.push(inArray(SprintsSchema.status, filter.statuses));
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SprintsSchema)
.where(and(...conditions))
.orderBy(desc(SprintsSchema.startDate))
);
return result ?? [];
}
/** The single active OR in-review sprint of a goal, if any. */
async findActiveOrReview(goalId: number, excludeSprintId?: number): Promise<SprintsSchemaTypeForSelect | null> {
const conditions = [
eq(SprintsSchema.goalId, goalId),
inArray(SprintsSchema.status, ['active', 'review'] as SprintStatus[]),
];
if (excludeSprintId) {
conditions.push(ne(SprintsSchema.id, excludeSprintId));
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SprintsSchema)
.where(and(...conditions))
.limit(1)
);
return result?.[0] ?? null;
}
async patch(args: {
sprintId: number;
patch: Partial<SprintsSchemaTypeForInsert>;
}): Promise<SprintsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(SprintsSchema)
.set({ ...args.patch, editedAt: new Date() })
.where(eq(SprintsSchema.id, args.sprintId))
.returning()
);
return result?.[0] ?? null;
}
async delete(sprintId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(SprintsSchema).where(eq(SprintsSchema.id, sprintId)).returning()
);
return !!result?.length;
}
async getTaskMeta(taskId: number): Promise<{ goalId: number; sprintId: number | null } | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ goalId: TasksSchema.goalId, sprintId: TasksSchema.sprintId })
.from(TasksSchema)
.where(eq(TasksSchema.id, taskId))
.limit(1)
);
return result?.[0] ?? null;
}
async setTaskSprint(args: { taskId: number; sprintId: number | null }): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(TasksSchema)
.set({ sprintId: args.sprintId })
.where(eq(TasksSchema.id, args.taskId))
.returning()
);
return !!result?.length;
}
/** Estimate rows of all tasks currently in the sprint — input for burndown. */
async getSprintTaskEstimates(
sprintId: number
): Promise<{ estimateValue: string | null; complete: boolean | null; dateComplete: Date | null }[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
estimateValue: TasksSchema.estimateValue,
complete: TasksSchema.complete,
// trigger-maintained completion moment (tasks.update_date_complete)
dateComplete: TasksSchema.dateComplete,
})
.from(TasksSchema)
.where(eq(TasksSchema.sprintId, sprintId))
);
return result ?? [];
}
async getOutcomes(sprintId: number) {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SprintTaskOutcomesSchema)
.where(eq(SprintTaskOutcomesSchema.sprintId, sprintId))
);
return result ?? [];
}
async getUserCapacities(sprintId: number) {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SprintUserCapacitySchema)
.where(eq(SprintUserCapacitySchema.sprintId, sprintId))
);
return result ?? [];
}
async getRetro(sprintId: number) {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SprintRetrosSchema)
.where(eq(SprintRetrosSchema.sprintId, sprintId))
.limit(1)
);
return result?.[0] ?? null;
}
async saveRetro(args: SprintSaveRetroManagerArgs) {
const set = {
wentWell: args.wentWell ?? null,
wentBad: args.wentBad ?? null,
actionItems: args.actionItems ?? null,
editedBy: args.editedBy,
editedAt: new Date(),
};
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(SprintRetrosSchema)
.values({ sprintId: args.sprintId, ...set })
.onConflictDoUpdate({ target: SprintRetrosSchema.sprintId, set })
.returning()
);
return result?.[0] ?? null;
}
/**
* Close transaction: set sprint completed, record per-task outcomes
* (untouched tasks -> 'incomplete'), apply task sprint moves.
*/
async applyClose(args: SprintCloseManagerArgs): Promise<SprintsSchemaTypeForSelect | null> {
return this.db.dbDrizzle.transaction(async (tx) => {
const updated = await tx
.update(SprintsSchema)
.set({
status: 'completed',
completedAt: new Date(),
goalAchieved: args.goalAchieved,
editedAt: new Date(),
})
.where(eq(SprintsSchema.id, args.sprintId))
.returning();
const tasks = await tx
.select({ id: TasksSchema.id, complete: TasksSchema.complete, estimateValue: TasksSchema.estimateValue })
.from(TasksSchema)
.where(eq(TasksSchema.sprintId, args.sprintId));
const explicit = new Map(args.outcomes.map((o) => [o.taskId, o]));
// Resolve a final outcome per task, ENFORCING the invariant:
// a completed task is always 'accepted' (and stays in the sprint);
// an unfinished task can only be 'carried-over' or 'dropped' (anything
// else falls back to 'incomplete'). This keeps velocity consistent
// regardless of what the client sent — you can't carry over / drop
// already-done work, nor accept unfinished work.
const resolved = tasks.map((t) => {
// Snapshot the task's estimate at close — frozen, independent of later edits.
const estimateValue = t.estimateValue;
if (t.complete) {
return { taskId: t.id, outcome: 'accepted' as const, carriedOverTo: null as number | null, estimateValue };
}
const decided = explicit.get(t.id);
if (decided?.outcome === 'carried-over') {
return { taskId: t.id, outcome: 'carried-over' as const, carriedOverTo: decided.carriedOverTo ?? null, estimateValue };
}
if (decided?.outcome === 'dropped') {
return { taskId: t.id, outcome: 'dropped' as const, carriedOverTo: null as number | null, estimateValue };
}
return { taskId: t.id, outcome: 'incomplete' as const, carriedOverTo: null as number | null, estimateValue };
});
if (resolved.length > 0) {
await tx
.insert(SprintTaskOutcomesSchema)
.values(
resolved.map((r) => ({
sprintId: args.sprintId,
taskId: r.taskId,
outcome: r.outcome,
carriedOverTo: r.carriedOverTo,
decidedBy: args.initiatorId,
estimateValue: r.estimateValue,
}))
)
.onConflictDoNothing();
}
// Move only unfinished tasks out of the sprint; completed ('accepted')
// and untouched ('incomplete') tasks stay in the closed sprint.
for (const r of resolved) {
if (r.outcome === 'carried-over') {
await tx
.update(TasksSchema)
.set({ sprintId: r.carriedOverTo })
.where(eq(TasksSchema.id, r.taskId));
} else if (r.outcome === 'dropped') {
await tx.update(TasksSchema).set({ sprintId: null }).where(eq(TasksSchema.id, r.taskId));
}
}
return updated[0] ?? null;
});
}
async velocity(args: { goalId: number; lastN: number }): Promise<VelocityPoint[]> {
const sprints = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: SprintsSchema.id, name: SprintsSchema.name })
.from(SprintsSchema)
.where(and(eq(SprintsSchema.goalId, args.goalId), eq(SprintsSchema.status, 'completed' as SprintStatus)))
.orderBy(desc(SprintsSchema.completedAt))
.limit(args.lastN)
);
if (!sprints || sprints.length === 0) return [];
const points: VelocityPoint[] = [];
for (const s of sprints) {
const rows = await callWithCatch(() =>
this.db.dbDrizzle
// Read the FROZEN snapshot, not the live task estimate.
.select({ outcome: SprintTaskOutcomesSchema.outcome, estimate: SprintTaskOutcomesSchema.estimateValue })
.from(SprintTaskOutcomesSchema)
.where(eq(SprintTaskOutcomesSchema.sprintId, s.id))
);
let accepted = 0;
let planned = 0;
(rows ?? []).forEach((r) => {
const est = r.estimate ? Number(r.estimate) : 0;
planned += est;
if (r.outcome === 'accepted') accepted += est;
});
points.push({ sprintId: s.id, name: s.name, acceptedHours: accepted, plannedHours: planned });
}
return points.reverse();
}
/**
* Backlog tasks for sprint planning: top-level, incomplete, not yet in any sprint.
* Cursor-paginated by ascending task id; returns up to `limit + 1` rows so the caller
* can detect a next page.
*/
async getBacklogTasksForPlanning(args: SprintPlanningPageArgs & { goalId: number }): Promise<TasksSchemaTypeForSelect[]> {
const conditions = [
eq(TasksSchema.goalId, args.goalId),
isNull(TasksSchema.sprintId),
or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete)),
isNull(TasksSchema.parentId),
];
if (args.cursor != null) {
conditions.push(gt(TasksSchema.id, args.cursor));
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(TasksSchema)
.where(and(...conditions))
.orderBy(asc(TasksSchema.id))
.limit(args.limit + 1)
);
return result ?? [];
}
/**
* Tasks currently in the sprint for planning: top-level tasks of the sprint
* (completed included — they belong to the sprint). Cursor-paginated by ascending id.
*/
async getSprintTasksForPlanning(args: SprintPlanningPageArgs): Promise<TasksSchemaTypeForSelect[]> {
const conditions = [eq(TasksSchema.sprintId, args.sprintId), isNull(TasksSchema.parentId)];
if (args.cursor != null) {
conditions.push(gt(TasksSchema.id, args.cursor));
}
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(TasksSchema)
.where(and(...conditions))
.orderBy(asc(TasksSchema.id))
.limit(args.limit + 1)
);
return result ?? [];
}
/** SUM(estimate_value) over ALL top-level tasks in the sprint — for the capacity counter. */
async sumEstimateForSprint(sprintId: number): Promise<number> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ total: sql<string | null>`COALESCE(SUM(${TasksSchema.estimateValue}), 0)` })
.from(TasksSchema)
.where(and(eq(TasksSchema.sprintId, sprintId), isNull(TasksSchema.parentId)))
);
return result?.[0]?.total ? Number(result[0].total) : 0;
}
/** Active sprint id of a goal — for the kanban `sprint=current` filter. */
async getActiveSprintId(goalId: number): Promise<number | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: SprintsSchema.id })
.from(SprintsSchema)
.where(and(eq(SprintsSchema.goalId, goalId), eq(SprintsSchema.status, 'active' as SprintStatus)))
.limit(1)
);
return result?.[0]?.id ?? null;
}
}
@@ -0,0 +1,56 @@
import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { GoalPermissions } from '../../types/auth.types';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import SprintsController from './SprintsController';
import { canAssignSprintTasks } from './middlewares/can-assign-sprint-tasks';
import { requireSprintPermission } from './middlewares/require-sprint-permission';
import { goalIdFromBody, goalIdFromParam, goalIdFromSprint } from './middlewares/goal-id-resolvers';
export default class SprintsRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly controller: SprintsController;
constructor() {
this.router = Router();
this.controller = new SprintsController();
this.initRoutes();
}
getRouter() {
return this.router;
}
initRoutes() {
// Create — goal comes from the request body (the creation target).
this.router.post('', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromBody)], this.controller.create);
// Analytics
this.router.get('/sprint/:sprintId/burndown', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW_ANALYTICS, goalIdFromSprint)], this.controller.burndown);
this.router.get('/goal/:goalId/velocity', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW_ANALYTICS, goalIdFromParam)], this.controller.velocity);
// Cadence: per-project auto-generation config (Linear-style) — goal from the URL param.
this.router.get('/goal/:goalId/cadence', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromParam)], this.controller.getCadence);
this.router.put('/goal/:goalId/cadence', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromParam)], this.controller.setCadence);
// Cursor-paginated planning task lists (?scope=backlog|sprint&cursor=&limit=)
this.router.get('/sprint/:sprintId/planning', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromSprint)], this.controller.planning);
// Single sprint detail + lifecycle — goal is always derived from the sprint.
this.router.get('/sprint/:sprintId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromSprint)], this.controller.getOne);
this.router.patch('/sprint/:sprintId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.update);
this.router.post('/sprint/:sprintId/activate', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.activate);
this.router.post('/sprint/:sprintId/review', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.review);
this.router.post('/sprint/:sprintId/close', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.close);
this.router.post('/sprint/:sprintId/pause', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.pause);
this.router.post('/sprint/:sprintId/resume', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.resume);
this.router.delete('/sprint/:sprintId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.remove);
this.router.put('/sprint/:sprintId/retro', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_MANAGE, goalIdFromSprint)], this.controller.saveRetro);
// Assign a task to / out of a sprint — authorized against the task's goal.
this.router.patch('/task/:taskId/sprint', [IsLoggedIn, canAssignSprintTasks], this.controller.setTaskSprint);
// List sprints of a project (supports ?status=active,planned) — goal from the URL param.
this.router.get('/:goalId', [IsLoggedIn, requireSprintPermission(GoalPermissions.SPRINT_CAN_VIEW, goalIdFromParam)], this.controller.listForGoal);
}
}
@@ -0,0 +1,22 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const canAssignSprintTasks = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.params.taskId ?? req.body?.taskId;
if (!taskId) return res.status(400).end();
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for canAssignSprintTasks middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.SPRINT_CAN_ASSIGN_TASKS)) return next();
return res.status(403).end();
};
@@ -0,0 +1,17 @@
import type { Request } from 'express';
import { SprintsRepository } from '../SprintsRepository';
export async function goalIdFromSprint(req: Request): Promise<number | null> {
const sprintId = req.params.sprintId;
if (sprintId == null) return null;
const sprint = await new SprintsRepository().getById(Number(sprintId));
return sprint?.goalId ?? null;
}
export function goalIdFromParam(req: Request): number | null {
return req.params.goalId != null ? Number(req.params.goalId) : null;
}
export function goalIdFromBody(req: Request): number | null {
return req.body?.goalId != null ? Number(req.body.goalId) : null;
}
@@ -0,0 +1,26 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import type { GoalPermissionType } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
type GoalIdResolver = (req: Request) => Promise<number | null> | number | null;
export function requireSprintPermission(permission: GoalPermissionType, resolveGoalId: GoalIdResolver) {
return async (req: Request, res: Response, next: NextFunction) => {
const goalId = await resolveGoalId(req);
if (!goalId) return res.status(400).end();
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not resolve sprint permissions');
return res.status(500).end();
}
if (permissions.hasPermissions(permission)) return next();
return res.status(403).end();
};
}
+174
View File
@@ -0,0 +1,174 @@
import { type } from 'arktype';
import type { SprintStatus, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
/** Request validators (ArkType) */
export const SprintArkTypeCreate = type({
goalId: 'number',
name: 'string > 0',
startDate: 'string', // 'YYYY-MM-DD'
endDate: 'string',
'goalText?': 'string | null',
'capacity?': 'number | null',
});
export const SprintArkTypeUpdate = type({
sprintId: 'number',
'name?': 'string > 0',
'startDate?': 'string',
'endDate?': 'string',
'goalText?': 'string | null',
'capacity?': 'number | null',
});
export const SprintArkTypeSprintIdParam = type({
sprintId: type('string | number').pipe((v) => Number(v)),
});
export const SprintArkTypeGoalIdParam = type({
goalId: type('string | number').pipe((v) => Number(v)),
});
export const SprintArkTypeListQuery = type({
goalId: type('string | number').pipe((v) => Number(v)),
'status?': 'string', // comma-separated subset of statuses
});
export const SprintArkTypeClose = type({
sprintId: 'number',
outcomes: type({
taskId: 'number',
outcome: "'accepted' | 'carried-over' | 'dropped'",
'carriedOverTo?': 'number | null',
}).array(),
goalAchieved: 'boolean',
});
export const SprintArkTypeSaveRetro = type({
sprintId: 'number',
'wentWell?': 'string | null',
'wentBad?': 'string | null',
'actionItems?': 'string | null',
});
export const SprintArkTypeSetTask = type({
taskId: 'number',
sprintId: 'number | null',
});
export const SprintArkTypeVelocityQuery = type({
goalId: type('string | number').pipe((v) => Number(v)),
'lastN?': type('string | number').pipe((v) => Number(v)),
});
export const SprintArkTypeSetCadence = type({
goalId: 'number',
enabled: 'boolean',
'lengthDays?': 'number',
'startDate?': 'string', // 'YYYY-MM-DD', anchor of the first window
'lookahead?': 'number', // how many future sprints to keep created beyond the current one
'nameTemplate?': 'string', // '{n}' is replaced with the 1-based window number
});
const PLANNING_DEFAULT_LIMIT = 30;
const PLANNING_MAX_LIMIT = 100;
export const SprintArkTypePlanningQuery = type({
sprintId: type('string | number').pipe((v) => Number(v)),
scope: "'backlog' | 'sprint'",
'cursor?': type('string | number').pipe((v) => Number(v)),
'limit?': type('string | number').pipe((v) => {
const n = Number(v);
if (isNaN(n) || n <= 0) return PLANNING_DEFAULT_LIMIT;
return Math.min(n, PLANNING_MAX_LIMIT);
}),
});
/** Inferred argument types (args-as-object) */
export type SprintCreateArgs = typeof SprintArkTypeCreate.infer;
export type SprintUpdateArgs = typeof SprintArkTypeUpdate.infer;
export type SprintCloseArgs = typeof SprintArkTypeClose.infer;
export type SprintSaveRetroArgs = typeof SprintArkTypeSaveRetro.infer;
export type SprintSetTaskArgs = typeof SprintArkTypeSetTask.infer;
export type SprintPlanningQueryArgs = typeof SprintArkTypePlanningQuery.infer;
export type SprintSetCadenceArgs = typeof SprintArkTypeSetCadence.infer;
/** Planning scope: backlog (unassigned, incomplete) vs tasks already in the sprint. */
export type SprintPlanningScope = 'backlog' | 'sprint';
/** Manager-level args for the planning page fetch. */
export type SprintPlanningManagerArgs = {
sprintId: number;
scope: SprintPlanningScope;
cursor: number | null;
limit: number;
};
/** Repository-level args for a cursor-paginated planning page. */
export type SprintPlanningPageArgs = {
sprintId: number;
cursor: number | null;
limit: number;
};
export type SprintTaskOutcomeInput = SprintCloseArgs['outcomes'][number];
/** Internal manager/repository arg types */
export type SprintCreateRepoArgs = SprintCreateArgs & { creatorId: number };
export type SprintActivateArgs = { sprintId: number; initiatorId: number | null };
export type SprintReviewArgs = { sprintId: number; initiatorId: number };
export type SprintPauseArgs = { sprintId: number; initiatorId: number };
export type SprintResumeArgs = { sprintId: number; initiatorId: number };
export type SprintVelocityArgs = { goalId: number; lastN: number };
export type SprintCloseManagerArgs = SprintCloseArgs & { initiatorId: number };
export type SprintSaveRetroManagerArgs = SprintSaveRetroArgs & { editedBy: number };
export type SprintSetTaskManagerArgs = SprintSetTaskArgs & { initiatorId: number };
/** Cadence repository arg types (args-as-object) */
export type SprintCadenceUpsertRepoArgs = {
goalId: number;
enabled: boolean;
lengthDays: number;
startDate: string;
lookahead: number;
nameTemplate: string;
};
export type SprintCadenceFindByStartArgs = { goalId: number; startDate: string };
export type SprintCadenceTouchArgs = { goalId: number; lastGeneratedDate: string };
export type SprintCadenceSprintCreateArgs = {
goalId: number;
name: string;
startDate: string;
endDate: string;
};
export type SprintListFilter = { goalId: number; statuses?: SprintStatus[] };
export type BurndownPoint = { date: string; remainingHours: number; idealHours: number };
export type VelocityPoint = { sprintId: number; name: string; acceptedHours: number; plannedHours: number };
/** Sprint with related data for the detail endpoint */
export type SprintRetro = {
wentWell: string | null;
wentBad: string | null;
actionItems: string | null;
};
/** Planning page response shapes. Backlog has no capacity counter; sprint scope carries totalPoints. */
export type SprintPlanningBacklogPage = {
tasks: TasksSchemaTypeForSelect[];
nextCursor: number | null;
};
export type SprintPlanningSprintPage = {
tasks: TasksSchemaTypeForSelect[];
nextCursor: number | null;
totalPoints: number;
};
export type SprintPlanningPage = SprintPlanningBacklogPage | SprintPlanningSprintPage;
export type SprintErrorCode = 'not_found' | 'conflict' | 'invalid_state' | 'forbidden';
export type SprintResult<T> = { ok: true; data: T } | { ok: false; code: SprintErrorCode; message?: string };
@@ -36,6 +36,7 @@ export class TaskItemForClient {
amount: number | null;
transactionType: 1 | 0 | null;
nodeGraphPosition: Record<string, unknown> | null;
recurrenceRuleId: number | null;
constructor(task: TaskItemInDb, tags: number[] = [], historyId: number | null = null, assignees: number[] = []) {
this.id = task.id;
@@ -69,5 +70,6 @@ export class TaskItemForClient {
this.amount = task.amount;
this.transactionType = task.transaction_type;
this.nodeGraphPosition = task.node_graph_position;
this.recurrenceRuleId = task.recurrence_rule_id;
}
}
+3 -3
View File
@@ -34,14 +34,13 @@ import {
type TaskArgFetchTasksNew,
type TaskArgRestoreTaskHistory,
type TaskArgUpdate,
type TaskFieldPermissionsForEditOrCreation,
TaskFieldPermissionsForWatching,
type TaskForClientNew,
type TasksArgToggleTaskUsers,
} from './tasks.server.types';
import type { KanbanArgFetchTasksForColumn, KanbanArgFilters } from '../kanban/types';
type TaskFieldPermissionKey = keyof typeof TaskFieldPermissionsForEditOrCreation & keyof TasksSchemaTypeForSelect;
type TaskFieldPermissionKey = keyof typeof TaskFieldPermissionsForWatching & keyof TasksSchemaTypeForSelect;
export class TasksManager {
public readonly repository: TasksRepository;
@@ -308,7 +307,8 @@ export class TasksManager {
return extendedTask[0] ?? null;
}
private async cleanTaskFieldsRegardPermissions(task: TasksSchemaTypeForSelect): Promise<TasksSchemaTypeForSelect> {
/** Public: other modules returning task rows (e.g. recurrence) gate fields through the same cleaner. */
async cleanTaskFieldsRegardPermissions(task: TasksSchemaTypeForSelect): Promise<TasksSchemaTypeForSelect> {
const permissions = await this.user.permissionsFetcher.getCheckerForGoal(task.goalId);
(Object.keys(TaskFieldPermissionsForWatching) as TaskFieldPermissionKey[]).forEach((key) => {
if (!permissions.hasPermissions(TaskFieldPermissionsForWatching[key])) {
@@ -209,6 +209,7 @@ export class TasksRepository {
async updateTaskComplete(taskId: number, complete: boolean): Promise<TaskItemInDb | false> {
const queryData = updateQuery({
table: 'tasks.tasks',
// date_complete is maintained by the tasks.update_date_complete DB trigger.
data: { complete: Number(complete) },
where: { id: taskId },
});
@@ -555,6 +556,11 @@ export class TasksRepository {
conditions.push(eq(TasksSchema.priorityId, data.filters.priority as 1 | 2 | 3));
}
// sprint
if (data.filters?.sprintId !== undefined) {
conditions.push(eq(TasksSchema.sprintId, data.filters.sprintId));
}
// search
if (data.searchText) {
conditions.push(ilike(TasksSchema.description, `%${data.searchText}%`));
@@ -663,6 +669,9 @@ export class TasksRepository {
if (cursor !== null) {
conditions.push(gt(TasksSchema.kanbanOrder, cursor));
}
if (filters?.sprintId !== undefined) {
conditions.push(eq(TasksSchema.sprintId, filters.sprintId));
}
if (filters?.listIds && filters.listIds.length > 0) {
conditions.push(inArray(TasksSchema.goalListId, filters.listIds));
}
@@ -26,6 +26,7 @@ export const TaskArkTypeUpdate = type({
'amount?': 'string|null',
'transactionType?': '1|0|null',
'nodeGraphPosition?': 'object|null',
'estimateValue?': 'number|null',
});
export const TaskArkTypeNumberFromString = type('string | number').pipe((v) => Number(v));
@@ -42,6 +43,7 @@ export const TaskArkTypeFetchTasksNewFilters = type({
'selectedUser?': TaskArkTypeNumberFromString,
'priority?': TaskArkTypePriorityToNumber,
'selectedTags?': TaskArkTypeSelectedTagsToNumber,
'sprintId?': TaskArkTypeNumberFromString,
});
export const TaskArkTypeFetchTasksNew = type({
@@ -97,6 +99,7 @@ export const TaskArkTypeAdd = type({
amount: type('number | null').optional(),
transactionType: type('0 | 1 | null').optional(),
nodeGraphPosition: type('object | null').optional(),
estimateValue: type('number | null').optional(),
});
export type TaskArgAdd = typeof TaskArkTypeAdd.infer;
@@ -118,8 +121,9 @@ export const TaskFieldPermissionsForEditOrCreation = {
taskOrder: GoalPermissions.TASKS_CAN_DELETE,
kanbanOrder: GoalPermissions.TASKS_CAN_DELETE,
amount: GoalPermissions.TASKS_CAN_DELETE,
transactionType: GoalPermissions.TASKS_CAN_DELETE,
transactionType: GoalPermissions.TASKS_CAN_DELETE,
nodeGraphPosition: GoalPermissions.TASKS_CAN_DELETE,
estimateValue: GoalPermissions.TASKS_CAN_EDIT_PRIORITY,
};
export const TaskFieldPermissionsForWatching = {
+23 -1
View File
@@ -36,8 +36,22 @@ const UiPreferencesSectionArkType = UiPreferencesItemArkType.array().narrow((arr
return true
})
export const UI_SETTINGS_KEY = '__settings__'
const firstDayOfWeekArkType = type('number.integer').narrow((v, ctx) =>
v >= 0 && v <= 6 ? true : ctx.mustBe('an integer 0..6 (0=Sunday)'),
)
export const UiSettingsArkType = type({
'firstDayOfWeek?': firstDayOfWeekArkType,
})
export type UiSettings = typeof UiSettingsArkType.infer
const SectionOrSettingsArkType = UiPreferencesSectionArkType.or(UiSettingsArkType)
export const UiPreferencesArkType = type({
'[string]': UiPreferencesSectionArkType,
'[string]': SectionOrSettingsArkType,
}).narrow((obj, ctx) => {
const keys = Object.keys(obj)
if (keys.length > MAX_SECTIONS) {
@@ -50,6 +64,14 @@ export const UiPreferencesArkType = type({
if (!ID_PATTERN.test(key)) {
return ctx.mustBe(`section key matching [a-zA-Z0-9_.\\-:]+ (got "${key}")`)
}
const value = (obj as Record<string, unknown>)[key]
if (key === UI_SETTINGS_KEY) {
if (Array.isArray(value)) {
return ctx.mustBe(`"${UI_SETTINGS_KEY}" to be a settings object, not an array`)
}
} else if (!Array.isArray(value)) {
return ctx.mustBe(`section "${key}" to be an array of items`)
}
}
return true
})
@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm';
import { TasksSchema } from 'taskview-db-schemas';
import { SprintsSchema, TasksSchema } from 'taskview-db-schemas';
import { eventBus, type AppEvents } from '../../core/EventBus';
import { getJobQueue } from '../../core/JobQueue';
import { decrypt } from '../../utils/crypto';
@@ -28,6 +28,15 @@ export class WebhooksDispatcher implements Dispatcher {
eventBus.on('time-entry.created', (data) => this.dispatch('time-entry.created', data.entry.goalId, data));
eventBus.on('time-entry.updated', (data) => this.dispatch('time-entry.updated', data.entry.goalId, data));
eventBus.on('time-entry.deleted', (data) => this.dispatch('time-entry.deleted', data.goalId, data));
eventBus.on('sprint.created', (data) => this.dispatch('sprint.created', data.sprint.goalId, data));
eventBus.on('sprint.updated', (data) => this.dispatch('sprint.updated', data.sprint.goalId, data));
eventBus.on('sprint.activated', (data) => this.dispatchSprintLifecycle('sprint.activated', data));
eventBus.on('sprint.reviewStarted', (data) => this.dispatchSprintLifecycle('sprint.reviewStarted', data));
eventBus.on('sprint.completed', (data) => this.dispatchSprintLifecycle('sprint.completed', data));
eventBus.on('sprint.paused', (data) => this.dispatchSprintLifecycle('sprint.paused', data));
eventBus.on('sprint.resumed', (data) => this.dispatchSprintLifecycle('sprint.resumed', data));
eventBus.on('sprint.deleted', (data) => this.dispatch('sprint.deleted', data.goalId, data));
eventBus.on('task.assignedToSprint', (data) => this.dispatch('task.assignedToSprint', data.goalId, data));
}
async registerWorkers(): Promise<void> {
@@ -56,6 +65,15 @@ export class WebhooksDispatcher implements Dispatcher {
await this.dispatch('task.assigneesChanged', task[0].goalId, data);
}
private async dispatchSprintLifecycle(
event: string,
data: { sprintId: number; goalId: number; initiatorId: number | null },
): Promise<void> {
const db = Database.getInstance();
const sprint = await db.dbDrizzle.select().from(SprintsSchema).where(eq(SprintsSchema.id, data.sprintId)).limit(1);
await this.dispatch(event, data.goalId, { ...data, sprint: sprint[0] ?? null });
}
private async enqueueDelivery(webhookId: number, url: string, secretEncrypted: string, event: string, payload: object): Promise<void> {
const delivery = await this.repository.createDelivery({ webhookId, event, payload });
if (!delivery) return;
+9
View File
@@ -57,6 +57,15 @@ export const WEBHOOK_EVENTS = [
'time-entry.created',
'time-entry.updated',
'time-entry.deleted',
'sprint.created',
'sprint.updated',
'sprint.activated',
'sprint.reviewStarted',
'sprint.completed',
'sprint.paused',
'sprint.resumed',
'sprint.deleted',
'task.assignedToSprint',
] as const;
export type WebhookEvent = typeof WEBHOOK_EVENTS[number];
+7 -7
View File
@@ -14,13 +14,13 @@ export const AppEnvSchema = z.object({
ACCESS_LIFE_TIME: z.string(),
REFRESH_LIFE_TIME: z.string(),
SMTP_HOST: z.string(),
SMTP_PORT: z.string(),
SMTP_USERNAME: z.string(),
SMTP_PASSWORD: z.string(),
SMTP_ENCRYPTION: z.string(),
SMTP_FROM_NAME: z.string(),
SMTP_FROM_EMAIL: z.string(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USERNAME: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_ENCRYPTION: z.string().optional(),
SMTP_FROM_NAME: z.string().optional(),
SMTP_FROM_EMAIL: z.string().optional(),
APP_URL: z.string(),
});
+11
View File
@@ -132,6 +132,11 @@ export const GoalPermissions = {
TIMETRACKING_CAN_VIEW: 'timetracking_can_view',
TIMETRACKING_CAN_LOG: 'timetracking_can_log',
TIMETRACKING_CAN_MANAGE_ALL: 'timetracking_can_manage_all',
SPRINT_CAN_VIEW: 'sprint_can_view',
SPRINT_CAN_MANAGE: 'sprint_can_manage',
SPRINT_CAN_ASSIGN_TASKS: 'sprint_can_assign_tasks',
SPRINT_CAN_VIEW_ANALYTICS: 'sprint_can_view_analytics',
} as const;
export type PermissionsEntityType =
@@ -145,3 +150,9 @@ export type FetchGoalIdsWithAnyPermissionParams = {
organizationId: number;
permissionNames: string[];
};
export type FetchPermissionsForGoalByUserParams = {
goalId: number;
userId: number;
email: string;
};
+2
View File
@@ -26,6 +26,7 @@ export type TaskItemInDb = {
amount: number | null;
transaction_type: 1 | 0 | null;
node_graph_position: Record<string, unknown> | null;
recurrence_rule_id: number | null;
// history_id?: number | null;
};
@@ -45,6 +46,7 @@ export const TasksFiltersSchema = z.object({
selectedUser: z.number().optional(),
priority: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
selectedTags: NumberTrueSchema.optional(),
sprintId: z.number().optional(),
});
const zeroOrOneSchema = z
Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

+39 -1
View File
@@ -15,6 +15,20 @@ Webhooks let you receive HTTP POST requests when events happen in your projects.
| `task.updated` | A task is updated (description, status, priority, deadline, etc.) |
| `task.deleted` | A task is deleted |
| `task.assigneesChanged` | Task assignees are added or removed |
| `time-entry.started` | A timer is started on a task |
| `time-entry.stopped` | A running timer is stopped |
| `time-entry.created` | A time entry is created manually |
| `time-entry.updated` | A time entry is updated |
| `time-entry.deleted` | A time entry is deleted |
| `sprint.created` | A new sprint is created (draft or planned) |
| `sprint.updated` | Sprint fields are edited (name, dates, capacity, goal) |
| `sprint.activated` | A sprint transitions to the active state |
| `sprint.reviewStarted` | An active sprint enters review |
| `sprint.completed` | A sprint is closed and marked completed |
| `sprint.paused` | An active sprint is paused |
| `sprint.resumed` | A paused sprint is resumed |
| `sprint.deleted` | A sprint is deleted (any status, including completed) |
| `task.assignedToSprint` | A task is added to, moved between, or removed from a sprint |
## Setup
@@ -52,7 +66,31 @@ Every webhook delivery is an HTTP POST with `Content-Type: application/json`:
}
```
The `changes` field is only present on `task.updated` events and contains only the fields that changed.
The `changes` field is only present on `task.updated` and `sprint.updated` events and contains only the fields that changed.
Sprint events carry the sprint payload. Lifecycle events (`sprint.activated`, `sprint.reviewStarted`, `sprint.completed`, `sprint.paused`, `sprint.resumed`) include the full sprint object reflecting its state at the time the event fired, plus `sprintId`, `goalId`, and `initiatorId`:
```json
{
"event": "sprint.completed",
"timestamp": "2026-03-22T12:00:00.000Z",
"sprintId": 42,
"goalId": 774,
"initiatorId": 1,
"sprint": {
"id": 42,
"goalId": 774,
"name": "Sprint 7",
"status": "completed",
"startDate": "2026-03-08",
"endDate": "2026-03-22",
"goalAchieved": true,
"completedAt": "2026-03-22T12:00:00.000Z"
}
}
```
`task.assignedToSprint` carries `taskId`, `sprintId` (the new sprint, or `null` when removed), `prevSprintId`, `goalId`, and `initiatorId`. `sprint.deleted` carries `sprintId`, `goalId`, and `initiatorId` only, since the sprint no longer exists. `initiatorId` may be `null` for sprints generated automatically by a cadence.
## Signature verification
+45
View File
@@ -0,0 +1,45 @@
---
title: MCP Server
description: Connect AI assistants like Claude Code and Claude Desktop to TaskView via the Model Context Protocol (MCP). Manage projects and tasks with a scoped API token.
navigation:
icon: i-lucide-bot
---
TaskView ships an MCP (Model Context Protocol) server that lets AI assistants such as Claude Code and Claude Desktop work with your projects and tasks through the TaskView API.
```
AI client ──stdio──▶ taskview-mcp ──HTTPS──▶ TaskView API
```
## Prerequisites
- Node.js >= 24
- A TaskView API token (`tvk_...`) — create one in your account settings. See [API tokens](/docs/features/api-tokens).
## Configuration
No installation is required — the server runs via `npx`. Add it to your MCP client config:
```json
{
"mcpServers": {
"taskview": {
"command": "npx",
"args": ["-y", "taskview-mcp"],
"env": {
"TASKVIEW_URL": "https://api.taskview.tech",
"TASKVIEW_TOKEN": "tvk_your_token_here"
}
}
}
}
```
- **Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global).
- **Claude Desktop** — add to `claude_desktop_config.json`.
Set `TASKVIEW_URL` to your own instance when self-hosting.
## Permissions
The assistant can only do what the API token allows. Token permissions are scoped to selected projects and intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.44.0",
"version": "1.48.4",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -27,7 +27,7 @@
},
"pnpm": {
"overrides": {
"pg": "^8.20.0"
"pg": "8.20.0"
}
},
"engines": {
@@ -44,4 +44,4 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0"
}
}
}
+33 -9
View File
@@ -5,7 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
pg: ^8.20.0
pg: 8.20.0
importers:
@@ -108,6 +108,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.3
luxon:
specifier: ^3.7.2
version: 3.7.2
openid-client:
specifier: ^6.8.2
version: 6.8.2
@@ -124,7 +127,7 @@ importers:
specifier: ^2.0.0
version: 2.0.0
pg:
specifier: ^8.20.0
specifier: 8.20.0
version: 8.20.0
pg-boss:
specifier: ^12.14.0
@@ -135,6 +138,9 @@ importers:
rotating-file-stream:
specifier: ^3.2.5
version: 3.2.7
rrule:
specifier: ^2.8.1
version: 2.8.1
semver:
specifier: ^7.6.3
version: 7.7.3
@@ -181,6 +187,9 @@ importers:
'@types/jsonwebtoken':
specifier: ^9.0.7
version: 9.0.10
'@types/luxon':
specifier: ^3.7.1
version: 3.7.1
'@types/node':
specifier: ^22.10.3
version: 22.19.7
@@ -234,7 +243,7 @@ importers:
specifier: ^1.3.0
version: 1.3.6
pg:
specifier: ^8.20.0
specifier: 8.20.0
version: 8.20.0
testcontainers:
specifier: ^11.7.1
@@ -436,6 +445,9 @@ importers:
qs:
specifier: ^6.14.1
version: 6.14.1
rrule:
specifier: ^2.8.1
version: 2.8.1
scule:
specifier: ^1.3.0
version: 1.3.0
@@ -3302,6 +3314,9 @@ packages:
'@types/long@4.0.2':
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/mapbox__point-geometry@0.1.4':
resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==}
@@ -4947,7 +4962,7 @@ packages:
knex: '*'
kysely: '*'
mysql2: '>=2'
pg: ^8.20.0
pg: 8.20.0
postgres: '>=3'
prisma: '*'
sql.js: '>=1'
@@ -6933,7 +6948,7 @@ packages:
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: ^8.20.0
pg: 8.20.0
pg-protocol@1.11.0:
resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==}
@@ -7426,6 +7441,9 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
rrule@2.8.1:
resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -9592,7 +9610,7 @@ snapshots:
rimraf: 4.4.1
semver: 7.7.3
tar: 6.2.1
tslib: 2.6.2
tslib: 2.8.1
xml2js: 0.5.0
transitivePeerDependencies:
- supports-color
@@ -10541,7 +10559,7 @@ snapshots:
'@ionic/utils-array@2.1.6':
dependencies:
debug: 4.4.3
tslib: 2.6.2
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -10550,7 +10568,7 @@ snapshots:
'@types/fs-extra': 8.1.5
debug: 4.4.3
fs-extra: 9.1.0
tslib: 2.6.2
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -12054,6 +12072,8 @@ snapshots:
'@types/long@4.0.2':
optional: true
'@types/luxon@3.7.1': {}
'@types/mapbox__point-geometry@0.1.4': {}
'@types/mapbox__vector-tile@1.3.4':
@@ -12648,7 +12668,7 @@ snapshots:
'@vue/shared': 3.5.27
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.6
postcss: 8.5.8
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.27':
@@ -16717,6 +16737,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
rrule@2.8.1:
dependencies:
tslib: 2.8.1
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "taskview-api",
"private": false,
"version": "1.44.0",
"version": "1.48.3",
"type": "module",
"repository": {
"type": "git",

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