mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10a0d01719 | |||
| eb0aba02e9 | |||
| 9beb2ff9e5 | |||
| 76b38b8ac1 | |||
| b7b91e16d3 | |||
| d14398faae | |||
| 02b9f8cd30 | |||
| 6f8e01b446 | |||
| 673cc868f8 | |||
| eb7565debb | |||
| e09b6e2c0f | |||
| 1dd779555a |
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.44.1",
|
||||
"version": "1.45.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^22.10.3",
|
||||
"@types/passport-apple": "^2.0.3",
|
||||
"@types/pg": "^8.15.5",
|
||||
@@ -61,6 +62,7 @@
|
||||
"firebase-admin": "^12.7.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"luxon": "^3.7.2",
|
||||
"openid-client": "^6.8.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
@@ -70,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:^",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
@@ -16,6 +16,28 @@ export interface AppEvents {
|
||||
'time-entry.created': { entry: TimeEntryWithUser; initiatorId: number };
|
||||
'time-entry.updated': { entry: TimeEntryWithUser; changes: Record<string, unknown>; initiatorId: number };
|
||||
'time-entry.deleted': { entryId: number; taskId: number; goalId: number; userId: number; initiatorId: number };
|
||||
'sprint.created': { sprint: SprintsSchemaTypeForSelect; initiatorId: number };
|
||||
'sprint.updated': { sprint: SprintsSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'sprint.activated': { sprintId: number; goalId: number; initiatorId: number | null };
|
||||
'sprint.reviewStarted': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.completed': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.paused': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.resumed': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'sprint.deleted': { sprintId: number; goalId: number; initiatorId: number };
|
||||
'task.assignedToSprint': {
|
||||
taskId: number;
|
||||
sprintId: number | null;
|
||||
prevSprintId: number | null;
|
||||
goalId: number;
|
||||
initiatorId: number;
|
||||
};
|
||||
'recurrence.created': { rule: RecurrenceRulesSchemaTypeForSelect; initiatorId: number };
|
||||
'recurrence.updated': { rule: RecurrenceRulesSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'recurrence.paused': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.resumed': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.ended': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.deleted': { ruleId: number; goalId: number; initiatorId: number };
|
||||
'recurrence.instanceSkipped': { ruleId: number; goalId: number; date: string; initiatorId: number };
|
||||
}
|
||||
|
||||
type EventName = keyof AppEvents;
|
||||
|
||||
@@ -45,6 +45,11 @@ export class FetchTasksQueryBuilder {
|
||||
args.push(this.data.filters.priority);
|
||||
}
|
||||
|
||||
if (this.data.filters.sprintId !== undefined) {
|
||||
query += ` AND t.sprint_id = $${args.length + 1}`;
|
||||
args.push(this.data.filters.sprintId);
|
||||
}
|
||||
|
||||
if (this.limit !== null) {
|
||||
if (this.data.showCompleted === 0) {
|
||||
query += ` AND t.complete = $${args.length + 1}`;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
PermissionsSchema,
|
||||
} from 'taskview-db-schemas';
|
||||
import { Database } from '../modules/db';
|
||||
import type { FetchGoalIdsWithAnyPermissionParams, GoalPermissionItemsFromDb } from '../types/auth.types';
|
||||
import type { FetchGoalIdsWithAnyPermissionParams, FetchPermissionsForGoalByUserParams, GoalPermissionItemsFromDb } from '../types/auth.types';
|
||||
import type { GoalItemInDb } from '../types/goal.type';
|
||||
import type { ListItemInDb } from '../types/lists.types';
|
||||
import type { TaskItemInDb } from '../types/tasks.types';
|
||||
@@ -33,7 +33,18 @@ export class GoalPermissionsRepository {
|
||||
}
|
||||
|
||||
async fetchPermissionsForGoal(goalId: number, user: AppUser): Promise<GoalPermissionItemsFromDb> {
|
||||
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [goalId]);
|
||||
const userData = user.getUserData();
|
||||
if (!userData) return [];
|
||||
return this.fetchPermissionsForGoalByUser({ goalId, userId: userData.id, email: userData.email });
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission set of an arbitrary user for a goal, without an AppUser/request
|
||||
* context — used by background workers (e.g. deadline notifications) that
|
||||
* must gate content per recipient.
|
||||
*/
|
||||
async fetchPermissionsForGoalByUser(params: FetchPermissionsForGoalByUserParams): Promise<GoalPermissionItemsFromDb> {
|
||||
const goalInfo = await this.db.query<GoalItemInDb>('select * from tasks.goals where id = $1', [params.goalId]);
|
||||
|
||||
if (goalInfo.rows.length === 0) {
|
||||
return [];
|
||||
@@ -42,7 +53,7 @@ export class GoalPermissionsRepository {
|
||||
let query = '';
|
||||
let args: any = [];
|
||||
|
||||
if (goalInfo.rows[0].owner === user.getUserData()?.id) {
|
||||
if (goalInfo.rows[0].owner === params.userId) {
|
||||
query = `select name as "permissionName", id as "permissionId" from tv_auth.permissions;`;
|
||||
} else {
|
||||
query = `select p.name as "permissionName", p.id as "permissionId"
|
||||
@@ -54,7 +65,7 @@ export class GoalPermissionsRepository {
|
||||
left join collaboration.permissions_to_role ptr on rol.id = ptr.role_id
|
||||
left join tv_auth.permissions p on ptr.permission_id = p.id
|
||||
where email = $1 and tg.id = $2 and p.name is not null and p.id is not null;`;
|
||||
args = [user.getUserData()?.email, goalId];
|
||||
args = [params.email, params.goalId];
|
||||
}
|
||||
|
||||
const result = await this.db.query<GoalPermissionItemsFromDb[number]>(query, args);
|
||||
|
||||
@@ -4,12 +4,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() {
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Database } from '../../modules/db';
|
||||
import type { GoalItemInDb } from '../../types/goal.type';
|
||||
import { logError } from '../../utils/api';
|
||||
import { updateQuery } from '../../utils/db-helper';
|
||||
import type { KanbanStatusItemInDb } from './types';
|
||||
import type { KanbanStatusItemInDb, StatusBelongsToGoalArgs } from './types';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import { and, asc, eq, gt, gte, isNull, lt, lte, sql } from 'drizzle-orm';
|
||||
import type { TaskItemInDb } from '../../types/tasks.types';
|
||||
@@ -218,4 +218,14 @@ export class KanbanRepository {
|
||||
|
||||
return result?.[0]?.columnVersion ?? null;
|
||||
}
|
||||
|
||||
/** Validates payload references to a kanban column from other modules (recurrence templates, etc.). */
|
||||
async statusBelongsToGoal(args: StatusBelongsToGoalArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() => this.db.dbDrizzle
|
||||
.select({ id: TasksStatusesSchema.id })
|
||||
.from(TasksStatusesSchema)
|
||||
.where(and(eq(TasksStatusesSchema.id, args.statusId), eq(TasksStatusesSchema.goalId, args.goalId)))
|
||||
.limit(1));
|
||||
return !!result?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export type KanbanArgUpdateStatus = typeof KanbanArkTypeStatusUpdate.infer;
|
||||
|
||||
export type KanbanArgFetchAllStatuses = typeof KanbanArkTypeFetchAllStatuses.infer;
|
||||
|
||||
export type StatusBelongsToGoalArgs = { statusId: number; goalId: number };
|
||||
|
||||
export type KanbanStatusInDb = {
|
||||
id: number;
|
||||
goal_id: number;
|
||||
@@ -69,9 +71,15 @@ const NumberArrayFromCommaSeparatedString = type('string|undefined').pipe((v) =>
|
||||
return v.split(',').map(Number).filter((n) => !isNaN(n));
|
||||
});
|
||||
|
||||
const SprintFilterFromString = type('string|number').pipe((v) => {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? undefined : n;
|
||||
});
|
||||
|
||||
export const KanbanArkTypeFilters = type({
|
||||
'listIds?': NumberArrayFromCommaSeparatedString,
|
||||
'assigneeIds?': NumberArrayFromCommaSeparatedString,
|
||||
'sprintId?': SprintFilterFromString,
|
||||
});
|
||||
|
||||
export type KanbanArgFilters = typeof KanbanArkTypeFilters.infer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { GoalsListSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { GoalsListSchema } from 'taskview-db-schemas';
|
||||
import type { AppUser } from '../../core/AppUser';
|
||||
@@ -7,7 +7,7 @@ import type { GoalListInDb } from '../../types/goal-list.types';
|
||||
import { logError } from '../../utils/api';
|
||||
import { updateQuery } from '../../utils/db-helper';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { GoalListArgAdd, GoalListArgDelete, GoalListArgFetch, GoalListArgUpdate } from './list.types';
|
||||
import type { GoalListArgAdd, GoalListArgDelete, GoalListArgFetch, GoalListArgUpdate, ListBelongsToGoalArgs } from './list.types';
|
||||
|
||||
export class GoalListsRepository {
|
||||
private readonly db: Database;
|
||||
@@ -145,4 +145,16 @@ export class GoalListsRepository {
|
||||
|
||||
return !!(result.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
/** Validates payload references to a list from other modules (recurrence templates, etc.). */
|
||||
async listBelongsToGoal(args: ListBelongsToGoalArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ id: GoalsListSchema.id })
|
||||
.from(GoalsListSchema)
|
||||
.where(and(eq(GoalsListSchema.id, args.listId), eq(GoalsListSchema.goalId, args.goalId)))
|
||||
.limit(1)
|
||||
);
|
||||
return !!result?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,5 @@ export const GoalListArkTypeFetch = type({
|
||||
});
|
||||
|
||||
export type GoalListArgFetch = typeof GoalListArkTypeFetch.infer;
|
||||
|
||||
export type ListBelongsToGoalArgs = { listId: number; goalId: number };
|
||||
|
||||
@@ -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';
|
||||
@@ -44,9 +45,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> {
|
||||
|
||||
@@ -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,315 @@
|
||||
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');
|
||||
|
||||
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,217 @@
|
||||
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,
|
||||
} 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;
|
||||
}
|
||||
|
||||
/** Active rules with no open instance — the reconcile sweep input. Empty in normal operation. */
|
||||
async findStalledActiveRules(): Promise<RecurrenceRulesSchemaTypeForSelect[]> {
|
||||
const openInstance = this.db.dbDrizzle
|
||||
.select({ one: sql`1` })
|
||||
.from(TasksSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(TasksSchema.recurrenceRuleId, RecurrenceRulesSchema.id),
|
||||
isNotNull(TasksSchema.recurrenceRuleId),
|
||||
ne(sql`COALESCE(${TasksSchema.complete}, false)`, sql`true`)
|
||||
)
|
||||
);
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(RecurrenceRulesSchema)
|
||||
.where(and(eq(RecurrenceRulesSchema.state, 'active'), notExists(openInstance)))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import type { Routable } from '../../types/routable.type';
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
|
||||
import RecurrenceController from './RecurrenceController';
|
||||
import {
|
||||
goalIdFromRuleParam,
|
||||
goalIdFromTaskBody,
|
||||
goalIdFromTaskParam,
|
||||
requireRecurrencePermission,
|
||||
} from './middlewares/require-recurrence-permission';
|
||||
|
||||
export default class RecurrenceRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>;
|
||||
private readonly controller: RecurrenceController;
|
||||
|
||||
constructor() {
|
||||
this.router = Router();
|
||||
this.controller = new RecurrenceController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
// Recurrence is a schedule attribute of a task, so editing reuses the existing deadline permission.
|
||||
this.router.post('', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromTaskBody)], this.controller.create);
|
||||
|
||||
// Read access mirrors reading the task itself.
|
||||
this.router.get('/task/:taskId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT, goalIdFromTaskParam)], this.controller.getForTask);
|
||||
this.router.get('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT, goalIdFromRuleParam)], this.controller.getOne);
|
||||
|
||||
this.router.patch('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.update);
|
||||
this.router.post('/:ruleId/pause', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.pause);
|
||||
this.router.post('/:ruleId/resume', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.resume);
|
||||
// Skip is the one schedule operation that physically deletes the open
|
||||
// instance (with its subtasks and tracked time) — so on top of the
|
||||
// schedule permission it requires the same right as DELETE /tasks.
|
||||
this.router.post('/:ruleId/skip', [IsLoggedIn, requireRecurrencePermission([GoalPermissions.TASKS_CAN_EDIT_DEADLINE, GoalPermissions.TASKS_CAN_DELETE], goalIdFromRuleParam)], this.controller.skip);
|
||||
this.router.delete('/:ruleId', [IsLoggedIn, requireRecurrencePermission(GoalPermissions.TASKS_CAN_EDIT_DEADLINE, goalIdFromRuleParam)], this.controller.remove);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
|
||||
import { $logger } from '../../../modules/logget';
|
||||
import type { GoalPermissionType } from '../../../types/auth.types';
|
||||
import { logError } from '../../../utils/api';
|
||||
import { RecurrenceRepository } from '../RecurrenceRepository';
|
||||
|
||||
type GoalIdResolver = (req: Request) => Promise<number | null> | number | null;
|
||||
|
||||
/** goalId for POST /module/recurrence — resolved through the task being made recurring. */
|
||||
export const goalIdFromTaskBody: GoalIdResolver = async (req) => {
|
||||
const taskId = Number(req.body?.taskId);
|
||||
if (!taskId) return null;
|
||||
const task = await new RecurrenceRepository().getTaskById(taskId);
|
||||
return task?.goalId ?? null;
|
||||
};
|
||||
|
||||
/** goalId for /module/recurrence/:ruleId routes — resolved through the rule. */
|
||||
export const goalIdFromRuleParam: GoalIdResolver = async (req) => {
|
||||
const ruleId = Number(req.params?.ruleId);
|
||||
if (!ruleId) return null;
|
||||
const rule = await new RecurrenceRepository().getById(ruleId);
|
||||
return rule?.goalId ?? null;
|
||||
};
|
||||
|
||||
/** goalId for GET /module/recurrence/task/:taskId. */
|
||||
export const goalIdFromTaskParam: GoalIdResolver = async (req) => {
|
||||
const taskId = Number(req.params?.taskId);
|
||||
if (!taskId) return null;
|
||||
const task = await new RecurrenceRepository().getTaskById(taskId);
|
||||
return task?.goalId ?? null;
|
||||
};
|
||||
|
||||
/** A single permission or a list — the caller must hold ALL of them. */
|
||||
export function requireRecurrencePermission(permission: GoalPermissionType | GoalPermissionType[], resolveGoalId: GoalIdResolver) {
|
||||
const required = Array.isArray(permission) ? permission : [permission];
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const goalId = await resolveGoalId(req);
|
||||
if (!goalId) return res.status(404).end();
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not resolve recurrence permissions');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (required.every((p) => permissions.hasPermissions(p))) return next();
|
||||
return res.status(403).end();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 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();
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -45,6 +45,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.44.1",
|
||||
"version": "1.45.0",
|
||||
"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": {
|
||||
|
||||
Generated
+33
-9
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "taskview-api",
|
||||
"private": false,
|
||||
"version": "1.44.0",
|
||||
"version": "1.45.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const API_URL = 'http://localhost:11401';
|
||||
export const API_URL = process.env.TASKVIEW_TEST_URL || 'http://localhost:11401';
|
||||
export const DEFAULT_USER = 'user';
|
||||
export const DEFAULT_USER_2 = 'user2';
|
||||
export const DEFAULT_PASSWORD = 'user1!#Q';
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { initApi, API_URL, DEFAULT_USER, DEFAULT_USER_2, DEFAULT_PASSWORD } from './init-api'
|
||||
import { ymd } from './test-helpers'
|
||||
import { TvPermissions } from '@/api/permissions'
|
||||
import type { RecurrenceRuleDetails } from '@/api/recurrence.types'
|
||||
|
||||
/**
|
||||
* Integration tests for recurring tasks (lazy materialization).
|
||||
*
|
||||
* The model keeps exactly ONE open instance per series: completing it
|
||||
* materializes the next occurrence through an async event handler, so
|
||||
* assertions about "the next card" poll via waitFor().
|
||||
*
|
||||
* All rules use Europe/Moscow (fixed UTC+3, no DST) with a 10:45 wall-clock
|
||||
* time, so UTC expectations are deterministic: stored start_time is 07:45:00.
|
||||
*/
|
||||
describe('Recurrence', () => {
|
||||
let $api: TvApi
|
||||
let $apiUser2: TvApi
|
||||
let raw: AxiosInstance
|
||||
let goalId: number
|
||||
|
||||
const MSK_TIME = 'T10:45:00'
|
||||
const UTC_TIME = '07:45:00'
|
||||
const USER2_EMAIL = 'user2@test.com'
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi, $tvApiForSecondUser } = await initApi()
|
||||
$api = $tvApi
|
||||
$apiUser2 = $tvApiForSecondUser
|
||||
|
||||
// Raw axios with validateStatus:true to assert error statuses directly.
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
raw = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const goal = await $api.goals.createGoal({ name: `Recurrence test project-${Date.now()}` })
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
goalId = goal.id!
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await $api.goals.deleteGoal(goalId).catch(() => {})
|
||||
})
|
||||
|
||||
async function createTask(description: string, startDate = ymd(3)) {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description,
|
||||
startDate,
|
||||
startTime: UTC_TIME, // stored frame is UTC; wall-clock 10:45 MSK
|
||||
endDate: startDate,
|
||||
endTime: '08:45:00',
|
||||
})
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
return task
|
||||
}
|
||||
|
||||
async function createRule(taskId: number, rrule: string, startDate = ymd(3)) {
|
||||
return await $api.recurrence.create({
|
||||
taskId,
|
||||
rrule,
|
||||
dtstart: `${startDate}${MSK_TIME}`,
|
||||
timezone: 'Europe/Moscow',
|
||||
})
|
||||
}
|
||||
|
||||
/** Last day of the month `monthsAhead` months from now ('YYYY-MM-DD', UTC). */
|
||||
function lastDayOfMonth(monthsAhead: number): string {
|
||||
const now = new Date()
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + monthsAhead + 1, 0)).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Polls the rule details until the predicate holds (materialization is async). */
|
||||
async function waitFor(
|
||||
ruleId: number,
|
||||
predicate: (details: RecurrenceRuleDetails) => boolean,
|
||||
timeoutMs = 8000,
|
||||
): Promise<RecurrenceRuleDetails> {
|
||||
const startedAt = Date.now()
|
||||
for (;;) {
|
||||
const details = await $api.recurrence.getById(ruleId).catch(() => null)
|
||||
if (details && predicate(details)) return details
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`waitFor timed out for rule ${ruleId}: ${JSON.stringify(details)?.slice(0, 300)}`)
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
}
|
||||
|
||||
describe('validation', () => {
|
||||
let taskId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
taskId = (await createTask('Validation target')).id
|
||||
})
|
||||
|
||||
const base = { dtstart: `${ymd(3)}${MSK_TIME}`, timezone: 'Europe/Moscow' }
|
||||
|
||||
it('rejects sub-daily frequencies', async () => {
|
||||
const res = await raw.post('/module/recurrence', { taskId, rrule: 'FREQ=HOURLY', ...base })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects an invalid IANA timezone', async () => {
|
||||
const res = await raw.post('/module/recurrence', { taskId, rrule: 'FREQ=DAILY', ...base, timezone: 'Mars/Olympus' })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects a malformed rrule string', async () => {
|
||||
const res = await raw.post('/module/recurrence', { taskId, rrule: 'garbage', ...base })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects COUNT and UNTIL together (RFC 5545)', async () => {
|
||||
const res = await raw.post('/module/recurrence', { taskId, rrule: 'FREQ=DAILY;COUNT=5;UNTIL=20270101T000000Z', ...base })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects an oversized COUNT', async () => {
|
||||
const res = await raw.post('/module/recurrence', { taskId, rrule: 'FREQ=DAILY;COUNT=100000', ...base })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 404 for a missing task', async () => {
|
||||
const res = await raw.post('/module/recurrence', { taskId: 99999999, rrule: 'FREQ=DAILY', ...base })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects a completed task as series origin', async () => {
|
||||
const done = await createTask('Already done')
|
||||
await $api.tasks.updateTask({ id: done.id, complete: true })
|
||||
const res = await raw.post('/module/recurrence', { taskId: done.id, rrule: 'FREQ=DAILY', ...base })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a subtask as series origin', async () => {
|
||||
const parent = await createTask('Parent')
|
||||
const sub = await $api.tasks.createTask({ goalId, parentId: parent.id, description: 'Subtask' })
|
||||
const res = await raw.post('/module/recurrence', { taskId: sub!.id, rrule: 'FREQ=DAILY', ...base })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a second rule on the same task', async () => {
|
||||
await createRule(taskId, 'FREQ=DAILY')
|
||||
const res = await raw.post('/module/recurrence', { taskId, rrule: 'FREQ=WEEKLY', ...base })
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('origin task becomes the open instance with a UTC-normalized window', async () => {
|
||||
const task = await createTask('Daily standup')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
expect(rule.state).toBe('active')
|
||||
expect(rule.instancesCreated).toBe(1)
|
||||
expect(rule.timezone).toBe('Europe/Moscow')
|
||||
|
||||
const details = await $api.recurrence.getForTask(task.id)
|
||||
expect(details?.rule.id).toBe(rule.id)
|
||||
expect(details?.openInstance?.id).toBe(task.id)
|
||||
expect(details?.openInstance?.recurrenceInstanceDate).toBe(ymd(3))
|
||||
// 10:45 wall-clock Moscow stored as the 07:45 UTC instant
|
||||
expect(details?.openInstance?.startTime).toBe(UTC_TIME)
|
||||
})
|
||||
|
||||
it('completing the open instance materializes exactly one next occurrence', async () => {
|
||||
const task = await createTask('Daily report')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
expect(details.openInstance?.startTime).toBe(UTC_TIME)
|
||||
expect(details.openInstance?.complete).toBe(false)
|
||||
expect(details.openInstance?.description).toBe('Daily report')
|
||||
expect(details.rule.instancesCreated).toBe(2)
|
||||
|
||||
// A repeated complete=true PATCH must not spawn another instance.
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
const after = await $api.recurrence.getById(rule.id)
|
||||
expect(after?.rule.instancesCreated).toBe(2)
|
||||
})
|
||||
|
||||
it('weekly rule materializes on the next scheduled weekday', async () => {
|
||||
// First Monday at least 3 days out, so "today in MSK" never overtakes it.
|
||||
let offset = 3
|
||||
while (new Date(`${ymd(offset)}T00:00:00Z`).getUTCDay() !== 1) offset++
|
||||
|
||||
const task = await createTask('Weekly sync', ymd(offset))
|
||||
const rule = await createRule(task.id, 'FREQ=WEEKLY;BYDAY=MO', ymd(offset))
|
||||
|
||||
const details = await $api.recurrence.getForTask(task.id)
|
||||
expect(details?.openInstance?.recurrenceInstanceDate).toBe(ymd(offset))
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const next = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(next.openInstance?.recurrenceInstanceDate).toBe(ymd(offset + 7))
|
||||
})
|
||||
|
||||
it('skip jumps the card to the next date and records the skipped occurrence', async () => {
|
||||
const task = await createTask('Skippable daily')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
const details = await $api.recurrence.skip(rule.id)
|
||||
expect(details.skipDates).toContain(ymd(3))
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
// the skipped origin task is deleted, so its rule lookup is gone too
|
||||
const forTask = await raw.get(`/module/recurrence/task/${task.id}`)
|
||||
expect(forTask.status).toBe(404)
|
||||
})
|
||||
|
||||
it('a COUNT-limited series ends after the last materialized instance is completed', async () => {
|
||||
const task = await createTask('Twice and done')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY;COUNT=2')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const second = await waitFor(rule.id, (d) => d.rule.instancesCreated === 2)
|
||||
expect(second.openInstance).toBeTruthy()
|
||||
|
||||
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
|
||||
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
|
||||
expect(ended.openInstance).toBeNull()
|
||||
expect(ended.rule.instancesCreated).toBe(2)
|
||||
})
|
||||
|
||||
it('pause blocks materialization; resume restores the open instance', async () => {
|
||||
const task = await createTask('Pausable daily')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
const paused = await $api.recurrence.pause(rule.id)
|
||||
expect(paused.state).toBe('paused')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
const whilePaused = await $api.recurrence.getById(rule.id)
|
||||
expect(whilePaused?.openInstance).toBeNull()
|
||||
expect(whilePaused?.rule.instancesCreated).toBe(1)
|
||||
|
||||
const resumed = await $api.recurrence.resume(rule.id)
|
||||
expect(resumed.state).toBe('active')
|
||||
const restored = await waitFor(rule.id, (d) => !!d.openInstance)
|
||||
expect(restored.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
})
|
||||
|
||||
it('renaming the open instance renames future occurrences (template auto-sync)', async () => {
|
||||
const task = await createTask('Original name')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
// user edits the visible card: description, note, priority
|
||||
await $api.tasks.updateTask({ id: task.id, description: 'Renamed card', note: 'fresh note', priorityId: 2 })
|
||||
await new Promise((r) => setTimeout(r, 800)) // template sync is event-driven
|
||||
|
||||
const synced = await $api.recurrence.getById(rule.id)
|
||||
expect(synced?.rule.templateDescription).toBe('Renamed card')
|
||||
expect(synced?.rule.templateNote).toBe('fresh note')
|
||||
expect(synced?.rule.templatePriorityId).toBe(2)
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.description).toBe('Renamed card')
|
||||
expect(details.openInstance?.note).toBe('fresh note')
|
||||
expect(details.openInstance?.priorityId).toBe(2)
|
||||
|
||||
// editing a COMPLETED (historical) instance must NOT touch the template
|
||||
await $api.tasks.updateTask({ id: task.id, description: 'History edit' })
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
const after = await $api.recurrence.getById(rule.id)
|
||||
expect(after?.rule.templateDescription).toBe('Renamed card')
|
||||
})
|
||||
|
||||
it('template overrides apply to the next materialized instance', async () => {
|
||||
const task = await createTask('Old name')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
const updated = await $api.recurrence.update({
|
||||
ruleId: rule.id,
|
||||
templateOverrides: { description: 'New name', priorityId: 3 },
|
||||
})
|
||||
expect(updated.templateDescription).toBe('New name')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(details.openInstance?.description).toBe('New name')
|
||||
expect(details.openInstance?.priorityId).toBe(3)
|
||||
})
|
||||
|
||||
it('updating rrule and timezone re-anchors the series', async () => {
|
||||
const task = await createTask('Movable')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
const updated = await $api.recurrence.update({
|
||||
ruleId: rule.id,
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=TH',
|
||||
timezone: 'Asia/Vladivostok',
|
||||
notifyOnOccurrence: true,
|
||||
})
|
||||
expect(updated.rrule).toContain('BYDAY=TH')
|
||||
expect(updated.timezone).toBe('Asia/Vladivostok')
|
||||
expect(updated.notifyOnOccurrence).toBe(true)
|
||||
})
|
||||
|
||||
it('deleting the series keeps existing instances as ordinary tasks', async () => {
|
||||
const task = await createTask('Survivor')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
const removed = await $api.recurrence.remove(rule.id)
|
||||
expect(removed.deleted).toBe(true)
|
||||
|
||||
const ruleGone = await raw.get(`/module/recurrence/${rule.id}`)
|
||||
expect(ruleGone.status).toBe(404)
|
||||
|
||||
const survivor = await $api.tasks.fetchTaskById(task.id)
|
||||
expect(survivor).toBeTruthy()
|
||||
expect(survivor!.recurrenceRuleId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('advanced schedules & series content', () => {
|
||||
it('a skipped occurrence counts toward COUNT (RFC 5545)', async () => {
|
||||
const task = await createTask('Skip eats count')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY;COUNT=2')
|
||||
|
||||
// skip removes the origin and materializes occurrence #2 of 2
|
||||
const details = await $api.recurrence.skip(rule.id)
|
||||
expect(details.rule.instancesCreated).toBe(2)
|
||||
expect(details.skipDates).toContain(ymd(3))
|
||||
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
|
||||
// completing the last allowed occurrence ends the series
|
||||
await $api.tasks.updateTask({ id: details.openInstance!.id, complete: true })
|
||||
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
|
||||
expect(ended.openInstance).toBeNull()
|
||||
})
|
||||
|
||||
it('an UNTIL-bounded series ends once the boundary is passed', async () => {
|
||||
const task = await createTask('Until series')
|
||||
const until = `${ymd(4).replace(/-/g, '')}T235959Z` // floating wall-clock boundary
|
||||
const rule = await createRule(task.id, `FREQ=DAILY;UNTIL=${until}`)
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const second = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(second.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
|
||||
|
||||
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
|
||||
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
|
||||
expect(ended.openInstance).toBeNull()
|
||||
})
|
||||
|
||||
it('last-day-of-month rule lands on actual month ends', async () => {
|
||||
const firstEnd = lastDayOfMonth(1)
|
||||
const secondEnd = lastDayOfMonth(2)
|
||||
|
||||
const task = await createTask('Close the books', firstEnd)
|
||||
const rule = await createRule(task.id, 'FREQ=MONTHLY;BYMONTHDAY=-1', firstEnd)
|
||||
|
||||
const details = await $api.recurrence.getForTask(task.id)
|
||||
expect(details?.openInstance?.recurrenceInstanceDate).toBe(firstEnd)
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const next = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
expect(next.openInstance?.recurrenceInstanceDate).toBe(secondEnd)
|
||||
})
|
||||
|
||||
it('a date-only series (date-only dtstart) gets deadline = occurrence date (shows up in Today/Upcoming)', async () => {
|
||||
const task = await $api.tasks.createTask({ goalId, description: 'No-end daily', startDate: ymd(3) })
|
||||
const rule = await $api.recurrence.create({
|
||||
taskId: task!.id,
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: ymd(3), // date-only: no wall-clock time
|
||||
timezone: 'Europe/Moscow',
|
||||
})
|
||||
expect(rule!.hasTime).toBe(false)
|
||||
|
||||
// the origin window is normalized the same way
|
||||
const origin = await $api.tasks.fetchTaskById(task!.id)
|
||||
expect(origin?.endDate).toBe(ymd(3))
|
||||
expect(origin?.endTime).toBeNull()
|
||||
|
||||
await $api.tasks.updateTask({ id: task!.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task!.id)
|
||||
expect(details.openInstance?.startDate).toBe(ymd(4))
|
||||
expect(details.openInstance?.endDate).toBe(ymd(4))
|
||||
expect(details.openInstance?.startTime).toBeNull()
|
||||
expect(details.openInstance?.endTime).toBeNull()
|
||||
})
|
||||
|
||||
it('an explicit midnight series (T00:00:00 dtstart) is timed, not date-only', async () => {
|
||||
const task = await $api.tasks.createTask({ goalId, description: 'Midnight daily', startDate: ymd(3) })
|
||||
const rule = await $api.recurrence.create({
|
||||
taskId: task!.id,
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: `${ymd(3)}T00:00:00`, // explicit midnight wall-clock in MSK
|
||||
timezone: 'Europe/Moscow',
|
||||
})
|
||||
expect(rule!.hasTime).toBe(true)
|
||||
|
||||
await $api.tasks.updateTask({ id: task!.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task!.id)
|
||||
// 00:00 MSK = 21:00 UTC the previous calendar day — a real time, not null.
|
||||
expect(details.openInstance?.startTime).toBe('21:00:00')
|
||||
expect(details.openInstance?.startDate).toBe(ymd(3))
|
||||
})
|
||||
|
||||
it('a timed series without duration is due at the occurrence moment', async () => {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: 'Timed no-end',
|
||||
startDate: ymd(3),
|
||||
startTime: UTC_TIME,
|
||||
})
|
||||
const rule = await createRule(task!.id, 'FREQ=DAILY')
|
||||
|
||||
await $api.tasks.updateTask({ id: task!.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task!.id)
|
||||
expect(details.openInstance?.endDate).toBe(ymd(4))
|
||||
expect(details.openInstance?.endTime).toBe(UTC_TIME) // due exactly at 10:45 MSK
|
||||
})
|
||||
|
||||
it('materialized instances inherit assignees and tags from the snapshot', async () => {
|
||||
const task = await createTask('Assigned standup')
|
||||
|
||||
// collaborator + tag must exist on the origin BEFORE the rule snapshots them
|
||||
await $api.collaboration.inviteUserToGoal({ goalId, email: 'user2@test.com' })
|
||||
const users = await $api.collaboration.fetchUsersForGoal(goalId)
|
||||
const collabId = users?.find((u) => u.email === 'user2@test.com')?.id
|
||||
expect(collabId).toBeTruthy()
|
||||
await $api.tasks.toggleTasksAssignee({ taskId: task.id, userIds: [collabId!] })
|
||||
|
||||
const tag = await $api.tags.createTag({ goalId, name: `recur-tag-${Date.now()}`, color: '#00ff00' })
|
||||
expect(tag?.id).toBeTruthy()
|
||||
await $api.tags.toggleTag({ tagId: tag!.id, taskId: task.id })
|
||||
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
|
||||
const instance = await $api.tasks.fetchTaskById(details.openInstance!.id)
|
||||
expect(instance?.assignedUsers).toContain(collabId)
|
||||
expect(instance?.tags).toContain(tag!.id)
|
||||
})
|
||||
|
||||
it('a collaborator removed from the project stops being auto-assigned', async () => {
|
||||
const task = await createTask('Ex-employee standup')
|
||||
|
||||
await $api.collaboration.inviteUserToGoal({ goalId, email: 'user2@test.com' })
|
||||
const users = await $api.collaboration.fetchUsersForGoal(goalId)
|
||||
const collabId = users?.find((u) => u.email === 'user2@test.com')?.id
|
||||
expect(collabId).toBeTruthy()
|
||||
await $api.tasks.toggleTasksAssignee({ taskId: task.id, userIds: [collabId!] })
|
||||
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
|
||||
// remove the collaborator — the assignee snapshot must be cleaned up
|
||||
await $api.collaboration.deleteUserFromGoal({ goalId, id: collabId! })
|
||||
await new Promise((r) => setTimeout(r, 1000)) // cleanup is event-driven
|
||||
|
||||
await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
|
||||
|
||||
const instance = await $api.tasks.fetchTaskById(details.openInstance!.id)
|
||||
expect(instance?.assignedUsers ?? []).not.toContain(collabId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-tenant access (IDOR)', () => {
|
||||
let attackerRaw: AxiosInstance
|
||||
let victimTaskId: number
|
||||
let victimRuleId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER_2,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
attackerRaw = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const task = await createTask('Victim recurring')
|
||||
victimTaskId = task.id
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY')
|
||||
victimRuleId = rule.id
|
||||
})
|
||||
|
||||
it('denies reading a foreign rule', async () => {
|
||||
const res = await attackerRaw.get(`/module/recurrence/${victimRuleId}`)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('denies reading a foreign rule through the task route', async () => {
|
||||
const res = await attackerRaw.get(`/module/recurrence/task/${victimTaskId}`)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('denies creating a rule on a foreign task', async () => {
|
||||
const res = await attackerRaw.post('/module/recurrence', {
|
||||
taskId: victimTaskId,
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: `${ymd(3)}${MSK_TIME}`,
|
||||
timezone: 'Europe/Moscow',
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('denies updating a foreign rule (including body.ruleId injection)', async () => {
|
||||
const res = await attackerRaw.patch(`/module/recurrence/${victimRuleId}`, {
|
||||
ruleId: victimRuleId, // injected — params must win anyway
|
||||
rrule: 'FREQ=YEARLY',
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
const intact = await $api.recurrence.getById(victimRuleId)
|
||||
expect(intact?.rule.rrule).toContain('DAILY')
|
||||
})
|
||||
|
||||
it('denies skip / pause / delete on a foreign rule', async () => {
|
||||
const skip = await attackerRaw.post(`/module/recurrence/${victimRuleId}/skip`, {})
|
||||
expect(skip.status).toBe(403)
|
||||
const pause = await attackerRaw.post(`/module/recurrence/${victimRuleId}/pause`, {})
|
||||
expect(pause.status).toBe(403)
|
||||
const del = await attackerRaw.delete(`/module/recurrence/${victimRuleId}`)
|
||||
expect(del.status).toBe(403)
|
||||
|
||||
const intact = await $api.recurrence.getById(victimRuleId)
|
||||
expect(intact?.rule.state).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('has_time flag', () => {
|
||||
it('a rule created from a timed dtstart carries has_time=true', async () => {
|
||||
const task = await createTask('Timed origin')
|
||||
const rule = await createRule(task.id, 'FREQ=DAILY') // dtstart includes MSK_TIME
|
||||
expect(rule.hasTime).toBe(true)
|
||||
})
|
||||
|
||||
it('updating dtstart toggles has_time both ways', async () => {
|
||||
const task = await $api.tasks.createTask({ goalId, description: 'Toggle time', startDate: ymd(3) })
|
||||
const rule = await $api.recurrence.create({
|
||||
taskId: task!.id, rrule: 'FREQ=DAILY', dtstart: ymd(3), timezone: 'Europe/Moscow',
|
||||
})
|
||||
expect(rule.hasTime).toBe(false)
|
||||
|
||||
const timed = await $api.recurrence.update({ ruleId: rule.id, dtstart: `${ymd(3)}T09:00:00` })
|
||||
expect(timed.hasTime).toBe(true)
|
||||
|
||||
const back = await $api.recurrence.update({ ruleId: rule.id, dtstart: ymd(3) })
|
||||
expect(back.hasTime).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rule edit validation', () => {
|
||||
let ruleId: number
|
||||
beforeAll(async () => {
|
||||
const task = await createTask('Edit validation target')
|
||||
ruleId = (await createRule(task.id, 'FREQ=DAILY')).id
|
||||
})
|
||||
|
||||
it('rejects a templateOverrides.statusId not belonging to the goal', async () => {
|
||||
const res = await raw.patch(`/module/recurrence/${ruleId}`, { templateOverrides: { statusId: 99999999 } })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects a templateOverrides.goalListId not belonging to the goal', async () => {
|
||||
const res = await raw.patch(`/module/recurrence/${ruleId}`, { templateOverrides: { goalListId: 99999999 } })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('allows clearing overrides with null', async () => {
|
||||
const res = await raw.patch(`/module/recurrence/${ruleId}`, { templateOverrides: { statusId: null, goalListId: null } })
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects an impossible rule on update (no occurrences — UNTIL in the past)', async () => {
|
||||
const res = await raw.patch(`/module/recurrence/${ruleId}`, { rrule: 'FREQ=DAILY;UNTIL=20000101T000000Z' })
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent creation (race)', () => {
|
||||
it('two parallel creates on the same task yield exactly one rule', async () => {
|
||||
const task = await createTask('Race target')
|
||||
const body = { taskId: task.id, rrule: 'FREQ=DAILY', dtstart: `${ymd(3)}${MSK_TIME}`, timezone: 'Europe/Moscow' }
|
||||
const [a, b] = await Promise.all([
|
||||
raw.post('/module/recurrence', body),
|
||||
raw.post('/module/recurrence', body),
|
||||
])
|
||||
// exactly one wins (200), the other is rejected as a conflict (409) —
|
||||
// guaranteed by the FOR UPDATE transaction + partial unique index.
|
||||
expect([a.status, b.status].sort()).toEqual([200, 409])
|
||||
})
|
||||
})
|
||||
|
||||
describe('permission gating', () => {
|
||||
let gateGoalId: number
|
||||
let user2Raw: AxiosInstance
|
||||
let collabId: number
|
||||
let roleSeq = 0
|
||||
|
||||
beforeAll(async () => {
|
||||
const goal = await $api.goals.createGoal({ name: `Gating project-${Date.now()}` })
|
||||
gateGoalId = goal!.id!
|
||||
await $api.collaboration.inviteUserToGoal({ goalId: gateGoalId, email: USER2_EMAIL })
|
||||
const users = await $api.collaboration.fetchUsersForGoal(gateGoalId)
|
||||
collabId = users!.find((u) => u.email === USER2_EMAIL)!.id
|
||||
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, { login: DEFAULT_USER_2, password: DEFAULT_PASSWORD })
|
||||
user2Raw = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await $api.goals.deleteGoal(gateGoalId).catch(() => {})
|
||||
})
|
||||
|
||||
/** Replace user2's role set with a single fresh role holding exactly `permissionNames`. */
|
||||
async function grantUser2(permissionNames: string[]) {
|
||||
const role = await $api.collaboration.createRoleForGoal({ goalId: gateGoalId, roleName: `r-${Date.now()}-${roleSeq++}` })
|
||||
const allPerms = await $api.collaboration.fetchAllPermissions()
|
||||
for (const name of permissionNames) {
|
||||
const perm = allPerms!.find((p) => p.name === name)!
|
||||
await $api.collaboration.toggleRolePermission({ roleId: role!.id, permissionId: perm.id })
|
||||
}
|
||||
await $api.collaboration.toggleUserRoles({ userId: collabId, goalId: gateGoalId, roles: [role!.id] })
|
||||
}
|
||||
|
||||
async function createGateRule(description: string, note: string) {
|
||||
const task = await $api.tasks.createTask({ goalId: gateGoalId, description, note, startDate: ymd(3), startTime: UTC_TIME })
|
||||
const rule = await $api.recurrence.create({ taskId: task!.id, rrule: 'FREQ=DAILY', dtstart: `${ymd(3)}${MSK_TIME}`, timezone: 'Europe/Moscow' })
|
||||
return { taskId: task!.id, rule }
|
||||
}
|
||||
|
||||
it('owner sees templateNote; a member without note permission gets it stripped', async () => {
|
||||
const { rule } = await createGateRule('Secret standup', 'confidential note')
|
||||
|
||||
const ownerView = await $api.recurrence.getById(rule.id)
|
||||
expect(ownerView?.rule.templateNote).toBe('confidential note')
|
||||
|
||||
// content-watch + deadline-edit, but NOT note-watch
|
||||
await grantUser2([TvPermissions.COMPONENT_CAN_WATCH_CONTENT, TvPermissions.TASK_CAN_EDIT_DEADLINE])
|
||||
const memberView = await $apiUser2.recurrence.getById(rule.id)
|
||||
expect(memberView?.rule.templateNote).toBeNull()
|
||||
if (memberView?.openInstance) expect(memberView.openInstance.note).toBeNull()
|
||||
})
|
||||
|
||||
it('the note is stripped from mutation responses too (pause)', async () => {
|
||||
const { rule } = await createGateRule('Secret pausable', 'hidden note')
|
||||
await grantUser2([TvPermissions.COMPONENT_CAN_WATCH_CONTENT, TvPermissions.TASK_CAN_EDIT_DEADLINE])
|
||||
const paused = await $apiUser2.recurrence.pause(rule.id)
|
||||
expect(paused.templateNote).toBeNull()
|
||||
})
|
||||
|
||||
it('skip requires task-delete permission on top of deadline-edit', async () => {
|
||||
const { rule } = await createGateRule('Skippable gated', 'note')
|
||||
|
||||
// deadline-edit only: pause is allowed, but skip (which deletes the instance) is forbidden
|
||||
await grantUser2([TvPermissions.COMPONENT_CAN_WATCH_CONTENT, TvPermissions.TASK_CAN_EDIT_DEADLINE])
|
||||
const skipForbidden = await user2Raw.post(`/module/recurrence/${rule.id}/skip`, {})
|
||||
expect(skipForbidden.status).toBe(403)
|
||||
const pauseOk = await user2Raw.post(`/module/recurrence/${rule.id}/pause`, {})
|
||||
expect(pauseOk.status).toBe(200)
|
||||
await user2Raw.post(`/module/recurrence/${rule.id}/resume`, {}) // restore active state
|
||||
|
||||
// grant delete: skip now allowed
|
||||
await grantUser2([TvPermissions.COMPONENT_CAN_WATCH_CONTENT, TvPermissions.TASK_CAN_EDIT_DEADLINE, TvPermissions.TASK_CAN_DELETE])
|
||||
const skipOk = await user2Raw.post(`/module/recurrence/${rule.id}/skip`, {})
|
||||
expect(skipOk.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { initApi, API_URL, DEFAULT_USER_2, DEFAULT_PASSWORD } from './init-api'
|
||||
import { ymd } from './test-helpers'
|
||||
|
||||
/**
|
||||
* Cross-tenant (IDOR) tests for the sprints module.
|
||||
*
|
||||
* `owner` (user1) owns a project the `attacker` (user2) is NOT a member of.
|
||||
* The attacker only manages their OWN project. Each test tries to read or
|
||||
* mutate the victim's sprint data through an injection vector (body.goalId,
|
||||
* body.sprintId, query goalId override) and asserts it is refused / has no
|
||||
* effect. These assertions describe the SECURE behaviour — they fail against a
|
||||
* server that still trusts a client-supplied goalId, and pass once the
|
||||
* goal-id-resolver + controller param-precedence fixes are deployed.
|
||||
*/
|
||||
describe('Sprints — cross-tenant access (IDOR)', () => {
|
||||
let owner: TvApi
|
||||
let attacker: TvApi
|
||||
let attackerRaw: AxiosInstance
|
||||
|
||||
let victimGoalId: number
|
||||
let victimSprintId: number
|
||||
let victimDeletableSprintId: number
|
||||
let victimTaskId: number
|
||||
let attackerGoalId: number
|
||||
let attackerSprintId: number
|
||||
let attackerTaskId: number
|
||||
|
||||
const VICTIM_SPRINT_NAME = 'Victim sprint'
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi, $tvApiForSecondUser } = await initApi()
|
||||
owner = $tvApi
|
||||
attacker = $tvApiForSecondUser
|
||||
|
||||
// Raw axios as the attacker — lets us send crafted payloads the typed client
|
||||
// would never produce. validateStatus:true so we can assert on status codes.
|
||||
const auth = await axios.post(`${API_URL}/module/auth/login`, {
|
||||
login: DEFAULT_USER_2,
|
||||
password: DEFAULT_PASSWORD,
|
||||
})
|
||||
attackerRaw = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${auth.data.access}` },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
// Victim project + sprint (attacker is not a member).
|
||||
const vGoal = await owner.goals.createGoal({ name: `Victim project-${Date.now()}` })
|
||||
if (!vGoal) throw new Error('Failed to create victim goal')
|
||||
victimGoalId = vGoal.id!
|
||||
const vSprint = await owner.sprints.create({
|
||||
goalId: victimGoalId, name: VICTIM_SPRINT_NAME, startDate: ymd(7), endDate: ymd(21),
|
||||
})
|
||||
if (!vSprint) throw new Error('Failed to create victim sprint')
|
||||
victimSprintId = vSprint.id
|
||||
|
||||
// A task inside the victim sprint — used to detect planning-list leaks.
|
||||
const vTask = await owner.tasks.createTask({ goalId: victimGoalId, description: `Victim task-${Date.now()}` })
|
||||
if (!vTask) throw new Error('Failed to create victim task')
|
||||
victimTaskId = vTask.id
|
||||
await owner.sprints.setTaskSprint({ taskId: victimTaskId, sprintId: victimSprintId })
|
||||
|
||||
// A separate throwaway victim sprint for the destructive delete test, so a
|
||||
// successful exploit there can't wipe the shared victim other tests rely on.
|
||||
const vDeletable = await owner.sprints.create({
|
||||
goalId: victimGoalId, name: 'Victim deletable', startDate: ymd(7), endDate: ymd(21),
|
||||
})
|
||||
if (!vDeletable) throw new Error('Failed to create deletable victim sprint')
|
||||
victimDeletableSprintId = vDeletable.id
|
||||
|
||||
// Attacker's own project + sprint (attacker manages it).
|
||||
const aGoal = await attacker.goals.createGoal({ name: `Attacker project-${Date.now()}` })
|
||||
if (!aGoal) throw new Error('Failed to create attacker goal')
|
||||
attackerGoalId = aGoal.id!
|
||||
const aSprint = await attacker.sprints.create({
|
||||
goalId: attackerGoalId, name: 'Attacker sprint', startDate: ymd(7), endDate: ymd(21),
|
||||
})
|
||||
if (!aSprint) throw new Error('Failed to create attacker sprint')
|
||||
attackerSprintId = aSprint.id
|
||||
|
||||
// A task the attacker owns — used to probe the setTaskSprint goal boundary.
|
||||
const aTask = await attacker.tasks.createTask({ goalId: attackerGoalId, description: `Attacker task-${Date.now()}` })
|
||||
if (!aTask) throw new Error('Failed to create attacker task')
|
||||
attackerTaskId = aTask.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await owner.goals.deleteGoal(victimGoalId).catch(() => {})
|
||||
await attacker.goals.deleteGoal(attackerGoalId).catch(() => {})
|
||||
})
|
||||
|
||||
it('denies reading another project\'s sprint directly', async () => {
|
||||
const res = await attackerRaw.get(`/module/sprints/sprint/${victimSprintId}`)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('denies updating another project\'s sprint via injected body.goalId', async () => {
|
||||
const res = await attackerRaw.patch(`/module/sprints/sprint/${victimSprintId}`, {
|
||||
goalId: attackerGoalId, // attacker's own goal — a vulnerable server authorizes against this
|
||||
name: 'HACKED',
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
const victim = await owner.sprints.getById(victimSprintId)
|
||||
expect(victim?.name).toBe(VICTIM_SPRINT_NAME)
|
||||
})
|
||||
|
||||
it('denies deleting another project\'s sprint via injected body.goalId', async () => {
|
||||
const res = await attackerRaw.delete(`/module/sprints/sprint/${victimDeletableSprintId}`, {
|
||||
data: { goalId: attackerGoalId },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
const victim = await owner.sprints.getById(victimDeletableSprintId)
|
||||
expect(victim).not.toBeNull()
|
||||
})
|
||||
|
||||
it('denies activating another project\'s sprint via injected body.goalId', async () => {
|
||||
const res = await attackerRaw.post(`/module/sprints/sprint/${victimSprintId}/activate`, {
|
||||
goalId: attackerGoalId,
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
const victim = await owner.sprints.getById(victimSprintId)
|
||||
expect(victim?.status).toBe('planned')
|
||||
})
|
||||
|
||||
it('does not let body.sprintId redirect an update onto another project\'s sprint', async () => {
|
||||
// Attacker legitimately updates their OWN sprint, but injects the victim id.
|
||||
await attackerRaw.patch(`/module/sprints/sprint/${attackerSprintId}`, {
|
||||
sprintId: victimSprintId, // a vulnerable controller lets the body override the URL id
|
||||
name: 'REDIRECTED',
|
||||
})
|
||||
|
||||
const victim = await owner.sprints.getById(victimSprintId)
|
||||
expect(victim?.name).toBe(VICTIM_SPRINT_NAME)
|
||||
})
|
||||
|
||||
it('does not leak another project\'s sprints via a query goalId override', async () => {
|
||||
const res = await attackerRaw.get(`/module/sprints/${attackerGoalId}`, {
|
||||
params: { goalId: victimGoalId }, // try to redirect the listing to the victim project
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const sprints = (res.data?.response ?? []) as Array<{ id: number }>
|
||||
const ids = sprints.map((s) => s.id)
|
||||
expect(ids).not.toContain(victimSprintId)
|
||||
expect(ids).toContain(attackerSprintId)
|
||||
})
|
||||
|
||||
it('does not leak another project\'s planning tasks via a query sprintId override', async () => {
|
||||
const res = await attackerRaw.get(`/module/sprints/sprint/${attackerSprintId}/planning`, {
|
||||
params: { sprintId: victimSprintId, scope: 'sprint' }, // try to redirect to the victim sprint
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Must be scoped to the attacker's own (empty) sprint — never expose the victim's task.
|
||||
const tasks = (res.data?.response?.tasks ?? []) as Array<{ id: number }>
|
||||
expect(tasks.map((t) => t.id)).not.toContain(victimTaskId)
|
||||
})
|
||||
|
||||
it('does not let setCadence write to another project via body.goalId', async () => {
|
||||
// Attacker legitimately configures cadence on their OWN goal but injects the
|
||||
// victim goal in the body. The URL param must win: cadence lands on the
|
||||
// attacker's goal, the victim's stays untouched.
|
||||
const res = await attackerRaw.put(`/module/sprints/goal/${attackerGoalId}/cadence`, {
|
||||
goalId: victimGoalId,
|
||||
enabled: true,
|
||||
lengthDays: 7,
|
||||
startDate: ymd(0),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const victimCadence = await owner.sprints.getCadence(victimGoalId)
|
||||
expect(victimCadence).toBeNull()
|
||||
|
||||
const attackerCadence = await attacker.sprints.getCadence(attackerGoalId)
|
||||
expect(attackerCadence?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('denies closing another project\'s sprint via injected body.goalId', async () => {
|
||||
const res = await attackerRaw.post(`/module/sprints/sprint/${victimSprintId}/close`, {
|
||||
goalId: attackerGoalId,
|
||||
outcomes: [],
|
||||
goalAchieved: false,
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
const victim = await owner.sprints.getById(victimSprintId)
|
||||
expect(victim?.status).toBe('planned')
|
||||
})
|
||||
|
||||
it('denies overwriting another project\'s sprint retro via injected body.goalId', async () => {
|
||||
const res = await attackerRaw.put(`/module/sprints/sprint/${victimSprintId}/retro`, {
|
||||
goalId: attackerGoalId,
|
||||
wentWell: 'HACKED',
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
const victim = await owner.sprints.getById(victimSprintId)
|
||||
expect(victim?.retro?.wentWell ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it('denies review/pause/resume on another project\'s sprint', async () => {
|
||||
for (const action of ['review', 'pause', 'resume']) {
|
||||
const res = await attackerRaw.post(`/module/sprints/sprint/${victimSprintId}/${action}`, {
|
||||
goalId: attackerGoalId,
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
}
|
||||
const victim = await owner.sprints.getById(victimSprintId)
|
||||
expect(victim?.status).toBe('planned')
|
||||
})
|
||||
|
||||
it('denies reading another project\'s burndown, velocity and cadence', async () => {
|
||||
const burndown = await attackerRaw.get(`/module/sprints/sprint/${victimSprintId}/burndown`)
|
||||
expect(burndown.status).toBe(403)
|
||||
|
||||
const velocity = await attackerRaw.get(`/module/sprints/goal/${victimGoalId}/velocity`)
|
||||
expect(velocity.status).toBe(403)
|
||||
|
||||
const cadence = await attackerRaw.get(`/module/sprints/goal/${victimGoalId}/cadence`)
|
||||
expect(cadence.status).toBe(403)
|
||||
})
|
||||
|
||||
describe('setTaskSprint goal boundary', () => {
|
||||
it('denies pulling another project\'s task into the attacker\'s sprint', async () => {
|
||||
const res = await attackerRaw.patch(`/module/sprints/task/${victimTaskId}/sprint`, {
|
||||
sprintId: attackerSprintId,
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
// The victim task stays in the victim sprint.
|
||||
const page = await owner.sprints.getPlanningTasks({ sprintId: victimSprintId, scope: 'sprint' })
|
||||
expect(page?.tasks.map((t) => t.id)).toContain(victimTaskId)
|
||||
})
|
||||
|
||||
it('denies pushing the attacker\'s task into another project\'s sprint (manager goal-match)', async () => {
|
||||
// canAssignSprintTasks authorizes on the task's (attacker's) goal, so the
|
||||
// middleware passes — only the manager's task.goalId === sprint.goalId check
|
||||
// can stop this. It must reject.
|
||||
const res = await attackerRaw.patch(`/module/sprints/task/${attackerTaskId}/sprint`, {
|
||||
sprintId: victimSprintId,
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
|
||||
// The attacker's task must NOT appear in the victim sprint.
|
||||
const page = await owner.sprints.getPlanningTasks({ sprintId: victimSprintId, scope: 'sprint' })
|
||||
expect(page?.tasks.map((t) => t.id)).not.toContain(attackerTaskId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('positive control — the owner is not over-blocked', () => {
|
||||
// Proves the fixes deny attackers without breaking legitimate access: if a
|
||||
// future over-broad fix 403'd everyone, the IDOR tests above would still pass
|
||||
// but these would fail.
|
||||
let scratchSprintId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const s = await attacker.sprints.create({
|
||||
goalId: attackerGoalId, name: 'Owner scratch', startDate: ymd(7), endDate: ymd(21),
|
||||
})
|
||||
if (!s) throw new Error('Failed to create scratch sprint')
|
||||
scratchSprintId = s.id
|
||||
})
|
||||
|
||||
it('can read and update its own sprint', async () => {
|
||||
const fetched = await attacker.sprints.getById(scratchSprintId)
|
||||
expect(fetched?.id).toBe(scratchSprintId)
|
||||
|
||||
const updated = await attacker.sprints.update({ sprintId: scratchSprintId, name: 'Renamed by owner' })
|
||||
expect(updated?.name).toBe('Renamed by owner')
|
||||
})
|
||||
|
||||
it('can run its own sprint through the lifecycle', async () => {
|
||||
const activated = await attacker.sprints.activate(scratchSprintId)
|
||||
expect(activated?.status).toBe('active')
|
||||
|
||||
const paused = await attacker.sprints.pause(scratchSprintId)
|
||||
expect(paused?.pausedAt).toBeTruthy()
|
||||
|
||||
const resumed = await attacker.sprints.resume(scratchSprintId)
|
||||
expect(resumed?.pausedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('can read its own analytics', async () => {
|
||||
const burndown = await attacker.sprints.getBurndown(scratchSprintId)
|
||||
expect(burndown).toBeDefined()
|
||||
|
||||
const velocity = await attacker.sprints.getVelocity({ goalId: attackerGoalId })
|
||||
expect(Array.isArray(velocity)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,578 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
} from 'vitest'
|
||||
import { initApi } from './init-api'
|
||||
import { ymd } from './test-helpers'
|
||||
import type { SprintPlanningSprintPage } from '@/api/sprints.types'
|
||||
|
||||
describe('Sprints', () => {
|
||||
let $api: TvApi
|
||||
let goalId: number
|
||||
const created: number[] = []
|
||||
|
||||
/** Track a sprint so afterEach can drive it to a terminal/deletable state. */
|
||||
const track = (id: number) => { created.push(id); return id }
|
||||
|
||||
/** Force a sprint out of any active/review state so it never blocks later activations. */
|
||||
async function releaseSprint(id: number) {
|
||||
const s = await $api.sprints.getById(id).catch(() => null)
|
||||
if (!s) return
|
||||
if (s.status === 'draft' || s.status === 'planned') {
|
||||
await $api.sprints.remove(id).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (s.status === 'active') {
|
||||
await $api.sprints.startReview(id).catch(() => {})
|
||||
}
|
||||
const cur = await $api.sprints.getById(id).catch(() => null)
|
||||
if (cur?.status === 'review') {
|
||||
await $api.sprints.close({ sprintId: id, outcomes: [], goalAchieved: false }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi } = await initApi()
|
||||
$api = $tvApi
|
||||
|
||||
const goal = await $api.goals.createGoal({ name: `Sprint test project-${Date.now()}` }).catch(console.error)
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
goalId = goal.id!
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of created) {
|
||||
await releaseSprint(id)
|
||||
}
|
||||
created.length = 0
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await $api.goals.deleteGoal(goalId).catch(() => {})
|
||||
})
|
||||
|
||||
async function createPlanned(name = `Planned-${Date.now()}`) {
|
||||
const sprint = await $api.sprints.create({
|
||||
goalId,
|
||||
name,
|
||||
startDate: ymd(7),
|
||||
endDate: ymd(21),
|
||||
}).catch(console.error)
|
||||
if (!sprint) throw new Error('Failed to create sprint')
|
||||
return track(sprint.id)
|
||||
}
|
||||
|
||||
describe('CRUD', () => {
|
||||
it('creates a planned sprint when startDate is in the future', async () => {
|
||||
const sprint = await $api.sprints.create({
|
||||
goalId,
|
||||
name: 'Future sprint',
|
||||
startDate: ymd(7),
|
||||
endDate: ymd(21),
|
||||
goalText: 'Ship the thing',
|
||||
capacity: 40,
|
||||
}).catch(console.error)
|
||||
|
||||
if (!sprint) throw new Error('Failed to create sprint')
|
||||
track(sprint.id)
|
||||
|
||||
expect(sprint.id).toBeGreaterThan(0)
|
||||
expect(sprint.goalId).toBe(goalId)
|
||||
expect(sprint.name).toBe('Future sprint')
|
||||
expect(sprint.status).toBe('planned')
|
||||
expect(sprint.startDate).toBe(ymd(7))
|
||||
expect(sprint.endDate).toBe(ymd(21))
|
||||
expect(sprint.goalText).toBe('Ship the thing')
|
||||
expect(Number(sprint.capacity)).toBe(40)
|
||||
})
|
||||
|
||||
it('creates a draft sprint when startDate is today or earlier', async () => {
|
||||
const sprint = await $api.sprints.create({
|
||||
goalId,
|
||||
name: 'Draft sprint',
|
||||
startDate: ymd(0),
|
||||
endDate: ymd(14),
|
||||
}).catch(console.error)
|
||||
|
||||
if (!sprint) throw new Error('Failed to create sprint')
|
||||
track(sprint.id)
|
||||
|
||||
expect(sprint.status).toBe('draft')
|
||||
})
|
||||
|
||||
it('rejects creation when endDate is before startDate', async () => {
|
||||
const sprint = await $api.sprints.create({
|
||||
goalId,
|
||||
name: 'Bad dates',
|
||||
startDate: ymd(10),
|
||||
endDate: ymd(5),
|
||||
}).catch(() => null)
|
||||
|
||||
expect(sprint).toBeFalsy()
|
||||
})
|
||||
|
||||
it('lists sprints for a goal', async () => {
|
||||
const id = await createPlanned('Listed sprint')
|
||||
|
||||
const list = await $api.sprints.listForGoal({ goalId }).catch(console.error)
|
||||
if (!list) throw new Error('Failed to list sprints')
|
||||
|
||||
expect(Array.isArray(list)).toBe(true)
|
||||
expect(list.find((s) => s.id === id)).toBeDefined()
|
||||
})
|
||||
|
||||
it('filters sprints by status', async () => {
|
||||
const plannedId = await createPlanned('Planned for filter')
|
||||
const draft = await $api.sprints.create({
|
||||
goalId, name: 'Draft for filter', startDate: ymd(0), endDate: ymd(14),
|
||||
}).catch(console.error)
|
||||
if (!draft) throw new Error('Failed to create draft')
|
||||
track(draft.id)
|
||||
|
||||
const onlyPlanned = await $api.sprints.listForGoal({ goalId, status: 'planned' }).catch(console.error)
|
||||
if (!onlyPlanned) throw new Error('Failed to filter sprints')
|
||||
|
||||
expect(onlyPlanned.every((s) => s.status === 'planned')).toBe(true)
|
||||
expect(onlyPlanned.find((s) => s.id === plannedId)).toBeDefined()
|
||||
expect(onlyPlanned.find((s) => s.id === draft.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gets a sprint by id with a null retro', async () => {
|
||||
const id = await createPlanned('Fetched sprint')
|
||||
|
||||
const sprint = await $api.sprints.getById(id).catch(console.error)
|
||||
if (!sprint) throw new Error('Failed to get sprint')
|
||||
|
||||
expect(sprint.id).toBe(id)
|
||||
expect(sprint.retro).toBeNull()
|
||||
})
|
||||
|
||||
it('updates name, dates, goalText and capacity', async () => {
|
||||
const id = await createPlanned('Before update')
|
||||
|
||||
const updated = await $api.sprints.update({
|
||||
sprintId: id,
|
||||
name: 'After update',
|
||||
startDate: ymd(8),
|
||||
endDate: ymd(22),
|
||||
goalText: 'Revised goal',
|
||||
capacity: 55,
|
||||
}).catch(console.error)
|
||||
if (!updated) throw new Error('Failed to update sprint')
|
||||
|
||||
expect(updated.name).toBe('After update')
|
||||
expect(updated.startDate).toBe(ymd(8))
|
||||
expect(updated.endDate).toBe(ymd(22))
|
||||
expect(updated.goalText).toBe('Revised goal')
|
||||
expect(Number(updated.capacity)).toBe(55)
|
||||
})
|
||||
|
||||
it('deletes a draft/planned sprint', async () => {
|
||||
const sprint = await $api.sprints.create({
|
||||
goalId, name: 'To delete', startDate: ymd(5), endDate: ymd(19),
|
||||
}).catch(console.error)
|
||||
if (!sprint) throw new Error('Failed to create sprint')
|
||||
|
||||
const result = await $api.sprints.remove(sprint.id).catch(console.error)
|
||||
expect(result).toBe(true)
|
||||
|
||||
const gone = await $api.sprints.getById(sprint.id).catch(() => null)
|
||||
expect(gone).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('activates a planned sprint', async () => {
|
||||
const id = await createPlanned()
|
||||
|
||||
const activated = await $api.sprints.activate(id).catch(console.error)
|
||||
if (!activated) throw new Error('Failed to activate')
|
||||
|
||||
expect(activated.status).toBe('active')
|
||||
})
|
||||
|
||||
it('rejects activating a second sprint while one is active', async () => {
|
||||
const first = await createPlanned('First active')
|
||||
await $api.sprints.activate(first)
|
||||
|
||||
const second = await createPlanned('Second sprint')
|
||||
const conflict = await $api.sprints.activate(second).catch(() => null)
|
||||
|
||||
expect(conflict).toBeFalsy()
|
||||
})
|
||||
|
||||
it('deletes a sprint of any status, including active', async () => {
|
||||
const id = await createPlanned()
|
||||
await $api.sprints.activate(id)
|
||||
|
||||
const result = await $api.sprints.remove(id).catch(console.error)
|
||||
expect(result).toBe(true)
|
||||
|
||||
const gone = await $api.sprints.getById(id).catch(() => null)
|
||||
expect(gone).toBeNull()
|
||||
})
|
||||
|
||||
it('pauses and resumes an active sprint', async () => {
|
||||
const id = await createPlanned()
|
||||
await $api.sprints.activate(id)
|
||||
|
||||
const paused = await $api.sprints.pause(id).catch(console.error)
|
||||
if (!paused) throw new Error('Failed to pause')
|
||||
expect(paused.status).toBe('active')
|
||||
expect(paused.pausedAt).toBeTruthy()
|
||||
|
||||
const resumed = await $api.sprints.resume(id).catch(console.error)
|
||||
if (!resumed) throw new Error('Failed to resume')
|
||||
expect(resumed.status).toBe('active')
|
||||
expect(resumed.pausedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects pausing a sprint that is not active', async () => {
|
||||
const id = await createPlanned()
|
||||
const result = await $api.sprints.pause(id).catch(() => null)
|
||||
expect(result).toBeFalsy()
|
||||
})
|
||||
|
||||
it('moves an active sprint into review', async () => {
|
||||
const id = await createPlanned()
|
||||
await $api.sprints.activate(id)
|
||||
|
||||
const review = await $api.sprints.startReview(id).catch(console.error)
|
||||
if (!review) throw new Error('Failed to start review')
|
||||
|
||||
expect(review.status).toBe('review')
|
||||
expect(review.reviewStartedAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects starting review on a non-active sprint', async () => {
|
||||
const id = await createPlanned()
|
||||
const result = await $api.sprints.startReview(id).catch(() => null)
|
||||
expect(result).toBeFalsy()
|
||||
})
|
||||
|
||||
it('closes a sprint in review and marks it completed', async () => {
|
||||
const id = await createPlanned()
|
||||
await $api.sprints.activate(id)
|
||||
await $api.sprints.startReview(id)
|
||||
|
||||
const closed = await $api.sprints.close({
|
||||
sprintId: id,
|
||||
outcomes: [],
|
||||
goalAchieved: true,
|
||||
}).catch(console.error)
|
||||
if (!closed) throw new Error('Failed to close')
|
||||
|
||||
expect(closed.status).toBe('completed')
|
||||
expect(closed.goalAchieved).toBe(true)
|
||||
expect(closed.completedAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects closing a sprint that is not in review', async () => {
|
||||
const id = await createPlanned()
|
||||
await $api.sprints.activate(id)
|
||||
|
||||
const result = await $api.sprints.close({ sprintId: id, outcomes: [], goalAchieved: true }).catch(() => null)
|
||||
expect(result).toBeFalsy()
|
||||
})
|
||||
|
||||
it('treats a completed sprint as read-only but still deletable', async () => {
|
||||
const id = await createPlanned()
|
||||
await $api.sprints.activate(id)
|
||||
await $api.sprints.startReview(id)
|
||||
await $api.sprints.close({ sprintId: id, outcomes: [], goalAchieved: false })
|
||||
|
||||
const update = await $api.sprints.update({ sprintId: id, name: 'nope' }).catch(() => null)
|
||||
expect(update).toBeFalsy()
|
||||
|
||||
// A completed sprint can be deleted (e.g. to undo an accidental close).
|
||||
const remove = await $api.sprints.remove(id).catch(console.error)
|
||||
expect(remove).toBe(true)
|
||||
|
||||
const gone = await $api.sprints.getById(id).catch(() => null)
|
||||
expect(gone).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Close outcomes', () => {
|
||||
async function reviewSprintWithTask(opts: { complete?: boolean } = {}) {
|
||||
const id = await createPlanned()
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Outcome task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
if (opts.complete) await $api.tasks.updateTask({ id: task.id, complete: true })
|
||||
await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: id })
|
||||
await $api.sprints.activate(id)
|
||||
await $api.sprints.startReview(id)
|
||||
return { id, taskId: task.id }
|
||||
}
|
||||
|
||||
it('carries an unfinished task over to the target sprint', async () => {
|
||||
const target = await createPlanned('Carry-over target')
|
||||
const { id, taskId } = await reviewSprintWithTask()
|
||||
|
||||
const closed = await $api.sprints.close({
|
||||
sprintId: id,
|
||||
outcomes: [{ taskId, outcome: 'carried-over', carriedOverTo: target }],
|
||||
goalAchieved: false,
|
||||
}).catch(console.error)
|
||||
expect(closed).toBeTruthy()
|
||||
|
||||
const moved = await $api.tasks.fetchTaskById(taskId).catch(console.error)
|
||||
expect(moved?.sprintId).toBe(target)
|
||||
|
||||
await $api.tasks.deleteTask(taskId).catch(() => {})
|
||||
})
|
||||
|
||||
it('drops an unfinished task out of the sprint', async () => {
|
||||
const { id, taskId } = await reviewSprintWithTask()
|
||||
|
||||
const closed = await $api.sprints.close({
|
||||
sprintId: id,
|
||||
outcomes: [{ taskId, outcome: 'dropped' }],
|
||||
goalAchieved: false,
|
||||
}).catch(console.error)
|
||||
expect(closed).toBeTruthy()
|
||||
|
||||
const dropped = await $api.tasks.fetchTaskById(taskId).catch(console.error)
|
||||
expect(dropped?.sprintId).toBeNull()
|
||||
|
||||
await $api.tasks.deleteTask(taskId).catch(() => {})
|
||||
})
|
||||
|
||||
it('forces a completed task to "accepted" even when the client requests "dropped"', async () => {
|
||||
const { id, taskId } = await reviewSprintWithTask({ complete: true })
|
||||
|
||||
await $api.sprints.close({
|
||||
sprintId: id,
|
||||
outcomes: [{ taskId, outcome: 'dropped' }],
|
||||
goalAchieved: true,
|
||||
})
|
||||
|
||||
// A done task can't be dropped — it is accepted and stays in the closed sprint.
|
||||
const kept = await $api.tasks.fetchTaskById(taskId).catch(console.error)
|
||||
expect(kept?.sprintId).toBe(id)
|
||||
|
||||
await $api.tasks.deleteTask(taskId).catch(() => {})
|
||||
})
|
||||
|
||||
it('rejects carrying a task over to the same sprint', async () => {
|
||||
const { id, taskId } = await reviewSprintWithTask()
|
||||
|
||||
const result = await $api.sprints.close({
|
||||
sprintId: id,
|
||||
outcomes: [{ taskId, outcome: 'carried-over', carriedOverTo: id }],
|
||||
goalAchieved: false,
|
||||
}).catch(() => null)
|
||||
expect(result).toBeFalsy()
|
||||
|
||||
const stillReview = await $api.sprints.getById(id).catch(console.error)
|
||||
expect(stillReview?.status).toBe('review')
|
||||
|
||||
await $api.tasks.deleteTask(taskId).catch(() => {})
|
||||
})
|
||||
|
||||
it('rejects carrying a task over to a sprint in a different project', async () => {
|
||||
const otherGoal = await $api.goals.createGoal({ name: `Other project-${Date.now()}` }).catch(console.error)
|
||||
if (!otherGoal) throw new Error('Failed to create other goal')
|
||||
const otherSprint = await $api.sprints.create({
|
||||
goalId: otherGoal.id!, name: 'Other sprint', startDate: ymd(7), endDate: ymd(21),
|
||||
}).catch(console.error)
|
||||
if (!otherSprint) throw new Error('Failed to create other sprint')
|
||||
|
||||
const { id, taskId } = await reviewSprintWithTask()
|
||||
|
||||
const result = await $api.sprints.close({
|
||||
sprintId: id,
|
||||
outcomes: [{ taskId, outcome: 'carried-over', carriedOverTo: otherSprint.id }],
|
||||
goalAchieved: false,
|
||||
}).catch(() => null)
|
||||
expect(result).toBeFalsy()
|
||||
|
||||
await $api.tasks.deleteTask(taskId).catch(() => {})
|
||||
await $api.goals.deleteGoal(otherGoal.id!).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tasks in sprint', () => {
|
||||
it('assigns a task to a sprint and removes it', async () => {
|
||||
const id = await createPlanned()
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Sprint task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
|
||||
const assigned = await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: id }).catch(console.error)
|
||||
expect(assigned).toBeDefined()
|
||||
expect(assigned!.sprintId).toBe(id)
|
||||
|
||||
const inSprint = await $api.sprints.getPlanningTasks({ sprintId: id, scope: 'sprint' }).catch(console.error)
|
||||
expect(inSprint?.tasks.find((t) => t.id === task.id)).toBeDefined()
|
||||
|
||||
const removed = await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: null }).catch(console.error)
|
||||
expect(removed).toBeDefined()
|
||||
expect(removed!.sprintId).toBeNull()
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
})
|
||||
|
||||
it('rejects assigning a task into a completed sprint', async () => {
|
||||
const id = await createPlanned()
|
||||
await $api.sprints.activate(id)
|
||||
await $api.sprints.startReview(id)
|
||||
await $api.sprints.close({ sprintId: id, outcomes: [], goalAchieved: false })
|
||||
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Late task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
|
||||
const result = await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: id }).catch(() => null)
|
||||
expect(result).toBeFalsy()
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
})
|
||||
|
||||
it('rejects assigning a task to a sprint in a different project', async () => {
|
||||
const otherGoal = await $api.goals.createGoal({ name: `Foreign project-${Date.now()}` }).catch(console.error)
|
||||
if (!otherGoal) throw new Error('Failed to create other goal')
|
||||
const otherSprint = await $api.sprints.create({
|
||||
goalId: otherGoal.id!, name: 'Foreign sprint', startDate: ymd(7), endDate: ymd(21),
|
||||
}).catch(console.error)
|
||||
if (!otherSprint) throw new Error('Failed to create other sprint')
|
||||
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Foreign assign-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
|
||||
const result = await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: otherSprint.id }).catch(() => null)
|
||||
expect(result).toBeFalsy()
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
await $api.goals.deleteGoal(otherGoal.id!).catch(() => {})
|
||||
})
|
||||
|
||||
it('separates backlog and sprint planning scopes with capacity totals', async () => {
|
||||
const id = await createPlanned()
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Backlog task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
await $api.tasks.updateTask({ id: task.id, estimateValue: 8 })
|
||||
|
||||
const backlog = await $api.sprints.getPlanningTasks({ sprintId: id, scope: 'backlog' }).catch(console.error)
|
||||
expect(backlog?.tasks.find((t) => t.id === task.id)).toBeDefined()
|
||||
|
||||
await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: id })
|
||||
|
||||
const sprintScope = await $api.sprints.getPlanningTasks({ sprintId: id, scope: 'sprint' }).catch(console.error) as SprintPlanningSprintPage | undefined
|
||||
expect(sprintScope?.tasks.find((t) => t.id === task.id)).toBeDefined()
|
||||
expect(sprintScope?.totalPoints).toBe(8)
|
||||
|
||||
const backlogAfter = await $api.sprints.getPlanningTasks({ sprintId: id, scope: 'backlog' }).catch(console.error)
|
||||
expect(backlogAfter?.tasks.find((t) => t.id === task.id)).toBeUndefined()
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retro', () => {
|
||||
it('saves and reads back a retrospective', async () => {
|
||||
const id = await createPlanned()
|
||||
|
||||
const saved = await $api.sprints.saveRetro({
|
||||
sprintId: id,
|
||||
wentWell: 'Shipped on time',
|
||||
wentBad: 'Too many meetings',
|
||||
actionItems: 'Fewer meetings',
|
||||
}).catch(console.error)
|
||||
expect(saved).toBeDefined()
|
||||
|
||||
const sprint = await $api.sprints.getById(id).catch(console.error)
|
||||
expect(sprint?.retro).toBeDefined()
|
||||
expect(sprint?.retro?.wentWell).toBe('Shipped on time')
|
||||
expect(sprint?.retro?.wentBad).toBe('Too many meetings')
|
||||
expect(sprint?.retro?.actionItems).toBe('Fewer meetings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Burndown & Velocity', () => {
|
||||
it('returns a burndown curve with the total estimate', async () => {
|
||||
const id = await createPlanned()
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Burndown task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
await $api.tasks.updateTask({ id: task.id, estimateValue: 6 })
|
||||
await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: id })
|
||||
|
||||
const burndown = await $api.sprints.getBurndown(id).catch(console.error)
|
||||
if (!burndown) throw new Error('Failed to get burndown')
|
||||
|
||||
expect(burndown.total).toBeGreaterThanOrEqual(6)
|
||||
expect(Array.isArray(burndown.points)).toBe(true)
|
||||
expect(burndown.points.length).toBeGreaterThan(0)
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
})
|
||||
|
||||
it('reports velocity for completed sprints', async () => {
|
||||
const id = await createPlanned()
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Velocity task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
await $api.tasks.updateTask({ id: task.id, estimateValue: 5, complete: true })
|
||||
await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: id })
|
||||
|
||||
await $api.sprints.activate(id)
|
||||
await $api.sprints.startReview(id)
|
||||
await $api.sprints.close({ sprintId: id, outcomes: [], goalAchieved: true })
|
||||
|
||||
const velocity = await $api.sprints.getVelocity({ goalId }).catch(console.error)
|
||||
if (!velocity) throw new Error('Failed to get velocity')
|
||||
|
||||
const point = velocity.find((p) => p.sprintId === id)
|
||||
expect(point).toBeDefined()
|
||||
expect(point!.acceptedHours).toBe(5)
|
||||
expect(point!.plannedHours).toBe(5)
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cadence', () => {
|
||||
it('returns null cadence before configuration', async () => {
|
||||
const fresh = await $api.goals.createGoal({ name: `Cadence empty-${Date.now()}` }).catch(console.error)
|
||||
if (!fresh) throw new Error('Failed to create goal')
|
||||
|
||||
const cadence = await $api.sprints.getCadence(fresh.id!).catch(console.error)
|
||||
expect(cadence).toBeNull()
|
||||
|
||||
await $api.goals.deleteGoal(fresh.id!).catch(() => {})
|
||||
})
|
||||
|
||||
it('configures cadence and auto-generates planned sprints', async () => {
|
||||
const fresh = await $api.goals.createGoal({ name: `Cadence project-${Date.now()}` }).catch(console.error)
|
||||
if (!fresh) throw new Error('Failed to create goal')
|
||||
const cadenceGoalId = fresh.id!
|
||||
|
||||
const saved = await $api.sprints.setCadence({
|
||||
goalId: cadenceGoalId,
|
||||
enabled: true,
|
||||
lengthDays: 7,
|
||||
startDate: ymd(0),
|
||||
lookahead: 2,
|
||||
nameTemplate: 'Iteration {n}',
|
||||
}).catch(console.error)
|
||||
if (!saved) throw new Error('Failed to set cadence')
|
||||
|
||||
expect(saved.enabled).toBe(true)
|
||||
expect(saved.lengthDays).toBe(7)
|
||||
expect(saved.lookahead).toBe(2)
|
||||
expect(saved.nameTemplate).toBe('Iteration {n}')
|
||||
|
||||
const cadence = await $api.sprints.getCadence(cadenceGoalId).catch(console.error)
|
||||
expect(cadence?.enabled).toBe(true)
|
||||
expect(cadence?.lengthDays).toBe(7)
|
||||
|
||||
const generated = await $api.sprints.listForGoal({ goalId: cadenceGoalId }).catch(console.error)
|
||||
expect(generated && generated.length).toBeGreaterThanOrEqual(1)
|
||||
expect(generated!.every((s) => s.status === 'planned')).toBe(true)
|
||||
|
||||
await $api.goals.deleteGoal(cadenceGoalId).catch(() => {})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Returns a YYYY-MM-DD date string offset by `offsetDays` from today (UTC). */
|
||||
export function ymd(offsetDays = 0): string {
|
||||
const d = new Date()
|
||||
d.setUTCDate(d.getUTCDate() + offsetDays)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import { networkInterfaces } from 'os'
|
||||
|
||||
function getLocalIp(): string {
|
||||
const nets = networkInterfaces()
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]!) {
|
||||
if (net.family === 'IPv4' && !net.internal) {
|
||||
return net.address
|
||||
}
|
||||
}
|
||||
}
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
export type ReceivedWebhook = {
|
||||
body: any
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny HTTP server the API container can POST webhook deliveries to.
|
||||
* Binds on 0.0.0.0 and advertises the host LAN IP so the Dockerized API
|
||||
* (which cannot reach `localhost`) can deliver to it.
|
||||
*/
|
||||
export function createWebhookReceiver() {
|
||||
const received: ReceivedWebhook[] = []
|
||||
const waiters: Array<(value: any) => void> = []
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => { body += chunk })
|
||||
req.on('end', () => {
|
||||
let parsed: any = null
|
||||
try { parsed = JSON.parse(body) } catch { parsed = body }
|
||||
received.push({ body: parsed, headers: req.headers })
|
||||
if (waiters.length > 0) {
|
||||
waiters.shift()!(parsed)
|
||||
}
|
||||
res.writeHead(200)
|
||||
res.end('ok')
|
||||
})
|
||||
})
|
||||
|
||||
const ip = getLocalIp()
|
||||
|
||||
return {
|
||||
server,
|
||||
received,
|
||||
getUrl: () => `http://${ip}:${(server.address() as any).port}/webhook`,
|
||||
start: () => new Promise<void>((resolve) => {
|
||||
server.listen(0, '0.0.0.0', () => resolve())
|
||||
}),
|
||||
stop: () => new Promise<void>((resolve) => {
|
||||
server.close(() => resolve())
|
||||
}),
|
||||
// Reset BOTH buffers: a lingering waiter (e.g. one whose promise already
|
||||
// timed out) would otherwise consume a future delivery slot and starve the
|
||||
// next test's waitForDelivery, so the queue must be emptied alongside `received`.
|
||||
clear: () => { received.length = 0; waiters.length = 0 },
|
||||
/** Resolve with the payload of the first delivery whose `event` matches (already-received or future). */
|
||||
waitForDelivery: (event?: string, timeoutMs = 8000) => new Promise<any>((resolve, reject) => {
|
||||
const idx = event
|
||||
? received.findIndex(r => r.body?.event === event)
|
||||
: received.length > 0 ? 0 : -1
|
||||
if (idx >= 0) {
|
||||
resolve(received[idx].body)
|
||||
return
|
||||
}
|
||||
|
||||
const check = (payload: any) => {
|
||||
if (!event || payload?.event === event) {
|
||||
clearTimeout(timer)
|
||||
resolve(payload)
|
||||
} else {
|
||||
waiters.push(check)
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// Drop our own waiter so a late delivery doesn't fire a dead resolver.
|
||||
const i = waiters.indexOf(check)
|
||||
if (i >= 0) waiters.splice(i, 1)
|
||||
reject(new Error(`No webhook delivery${event ? ` for ${event}` : ''} received within ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
waiters.push(check)
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { TvApi } from '@/tv'
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest'
|
||||
import { initApi } from './init-api'
|
||||
import { createWebhookReceiver } from './webhook-receiver'
|
||||
import { ymd } from './test-helpers'
|
||||
|
||||
const SPRINT_EVENTS = [
|
||||
'sprint.created',
|
||||
'sprint.updated',
|
||||
'sprint.activated',
|
||||
'sprint.reviewStarted',
|
||||
'sprint.completed',
|
||||
'sprint.paused',
|
||||
'sprint.resumed',
|
||||
'sprint.deleted',
|
||||
'task.assignedToSprint',
|
||||
]
|
||||
|
||||
describe('Webhooks: sprint events', () => {
|
||||
let $api: TvApi
|
||||
let goalId: number
|
||||
let webhookId: number
|
||||
let webhookUrl: string
|
||||
const receiver = createWebhookReceiver()
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi } = await initApi()
|
||||
$api = $tvApi
|
||||
|
||||
await receiver.start()
|
||||
webhookUrl = receiver.getUrl()
|
||||
|
||||
const goal = await $api.goals.createGoal({ name: `Sprint webhook project-${Date.now()}` }).catch(console.error)
|
||||
if (!goal) throw new Error('Failed to create goal')
|
||||
goalId = goal.id!
|
||||
|
||||
const created = await $api.webhooks.create({
|
||||
goalId,
|
||||
url: webhookUrl,
|
||||
events: SPRINT_EVENTS,
|
||||
}).catch(console.error)
|
||||
if (!created) throw new Error('Failed to create webhook')
|
||||
webhookId = created.webhook.id
|
||||
|
||||
expect(created.webhook.events).toEqual(SPRINT_EVENTS)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await $api.webhooks.delete({ id: webhookId }).catch(() => {})
|
||||
await $api.goals.deleteGoal(goalId).catch(() => {})
|
||||
await receiver.stop()
|
||||
})
|
||||
|
||||
async function createPlanned(name = `Webhook sprint-${Date.now()}`) {
|
||||
const sprint = await $api.sprints.create({
|
||||
goalId, name, startDate: ymd(7), endDate: ymd(21),
|
||||
}).catch(console.error)
|
||||
if (!sprint) throw new Error('Failed to create sprint')
|
||||
return sprint
|
||||
}
|
||||
|
||||
it('delivers an enriched payload through the whole lifecycle', async () => {
|
||||
receiver.clear()
|
||||
|
||||
const sprint = await createPlanned('Lifecycle sprint')
|
||||
|
||||
const createdPayload = await receiver.waitForDelivery('sprint.created')
|
||||
expect(createdPayload.event).toBe('sprint.created')
|
||||
expect(createdPayload.timestamp).toBeTruthy()
|
||||
expect(createdPayload.sprint).toBeDefined()
|
||||
expect(createdPayload.sprint.id).toBe(sprint.id)
|
||||
expect(createdPayload.sprint.goalId).toBe(goalId)
|
||||
// The event is attributed to the acting user — the same id the sprint records as its creator.
|
||||
expect(createdPayload.initiatorId).toBe(createdPayload.sprint.creatorId)
|
||||
|
||||
await $api.sprints.update({
|
||||
sprintId: sprint.id,
|
||||
name: 'Lifecycle renamed',
|
||||
goalText: 'Revised',
|
||||
capacity: 30,
|
||||
})
|
||||
const updatedPayload = await receiver.waitForDelivery('sprint.updated')
|
||||
expect(updatedPayload.sprint.id).toBe(sprint.id)
|
||||
expect(updatedPayload.changes).toBeDefined()
|
||||
expect(updatedPayload.changes.name).toBe('Lifecycle renamed')
|
||||
expect(updatedPayload.changes.goalText).toBe('Revised')
|
||||
// capacity is serialized to a numeric string in the change set.
|
||||
expect(Number(updatedPayload.changes.capacity)).toBe(30)
|
||||
|
||||
await $api.sprints.activate(sprint.id)
|
||||
const activatedPayload = await receiver.waitForDelivery('sprint.activated')
|
||||
expect(activatedPayload.sprintId).toBe(sprint.id)
|
||||
expect(activatedPayload.goalId).toBe(goalId)
|
||||
expect(activatedPayload.sprint?.status).toBe('active')
|
||||
|
||||
await $api.sprints.pause(sprint.id)
|
||||
const pausedPayload = await receiver.waitForDelivery('sprint.paused')
|
||||
expect(pausedPayload.sprintId).toBe(sprint.id)
|
||||
expect(pausedPayload.sprint?.pausedAt).toBeTruthy()
|
||||
|
||||
await $api.sprints.resume(sprint.id)
|
||||
const resumedPayload = await receiver.waitForDelivery('sprint.resumed')
|
||||
expect(resumedPayload.sprintId).toBe(sprint.id)
|
||||
expect(resumedPayload.sprint?.pausedAt).toBeNull()
|
||||
|
||||
await $api.sprints.startReview(sprint.id)
|
||||
const reviewPayload = await receiver.waitForDelivery('sprint.reviewStarted')
|
||||
expect(reviewPayload.sprintId).toBe(sprint.id)
|
||||
expect(reviewPayload.sprint?.status).toBe('review')
|
||||
|
||||
await $api.sprints.close({ sprintId: sprint.id, outcomes: [], goalAchieved: true })
|
||||
const completedPayload = await receiver.waitForDelivery('sprint.completed')
|
||||
expect(completedPayload.sprintId).toBe(sprint.id)
|
||||
expect(completedPayload.goalId).toBe(goalId)
|
||||
expect(completedPayload.sprint?.status).toBe('completed')
|
||||
expect(completedPayload.sprint?.goalAchieved).toBe(true)
|
||||
}, 40000)
|
||||
|
||||
it('signs sprint deliveries with an HMAC header', async () => {
|
||||
receiver.clear()
|
||||
const sprint = await createPlanned('Signed sprint')
|
||||
|
||||
await receiver.waitForDelivery('sprint.created')
|
||||
const delivery = receiver.received.find((r) => r.body?.event === 'sprint.created')
|
||||
expect(delivery).toBeDefined()
|
||||
const signature = delivery!.headers['x-webhook-signature']
|
||||
expect(typeof signature).toBe('string')
|
||||
expect(String(signature)).toMatch(/^sha256=/)
|
||||
|
||||
await $api.sprints.remove(sprint.id).catch(() => {})
|
||||
}, 15000)
|
||||
|
||||
it('delivers sprint.deleted with ids only (no sprint body)', async () => {
|
||||
receiver.clear()
|
||||
const sprint = await createPlanned('Deletable sprint')
|
||||
await receiver.waitForDelivery('sprint.created')
|
||||
|
||||
receiver.clear()
|
||||
await $api.sprints.remove(sprint.id)
|
||||
|
||||
const deletedPayload = await receiver.waitForDelivery('sprint.deleted')
|
||||
expect(deletedPayload.sprintId).toBe(sprint.id)
|
||||
expect(deletedPayload.goalId).toBe(goalId)
|
||||
expect(typeof deletedPayload.initiatorId).toBe('number')
|
||||
expect(deletedPayload.sprint).toBeUndefined()
|
||||
}, 15000)
|
||||
|
||||
it('delivers task.assignedToSprint on assignment and removal', async () => {
|
||||
receiver.clear()
|
||||
const sprint = await createPlanned('Assignment sprint')
|
||||
await receiver.waitForDelivery('sprint.created')
|
||||
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Assigned task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
|
||||
receiver.clear()
|
||||
await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: sprint.id })
|
||||
|
||||
const assignedPayload = await receiver.waitForDelivery('task.assignedToSprint')
|
||||
expect(assignedPayload.taskId).toBe(task.id)
|
||||
expect(assignedPayload.sprintId).toBe(sprint.id)
|
||||
expect(assignedPayload.prevSprintId).toBeNull()
|
||||
expect(assignedPayload.goalId).toBe(goalId)
|
||||
|
||||
receiver.clear()
|
||||
await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: null })
|
||||
|
||||
const removedPayload = await receiver.waitForDelivery('task.assignedToSprint')
|
||||
expect(removedPayload.taskId).toBe(task.id)
|
||||
expect(removedPayload.sprintId).toBeNull()
|
||||
expect(removedPayload.prevSprintId).toBe(sprint.id)
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
await $api.sprints.remove(sprint.id).catch(() => {})
|
||||
}, 20000)
|
||||
|
||||
it('does not emit task.assignedToSprint when a close carries a task over', async () => {
|
||||
// Closing a sprint moves carried-over tasks directly; unlike setTaskSprint it
|
||||
// does NOT emit task.assignedToSprint. Only sprint.completed should fire.
|
||||
receiver.clear()
|
||||
const target = await createPlanned('Carry target (wh)')
|
||||
const source = await createPlanned('Carry source (wh)')
|
||||
const task = await $api.tasks.createTask({ goalId, description: `Carry task-${Date.now()}` }).catch(console.error)
|
||||
if (!task) throw new Error('Failed to create task')
|
||||
|
||||
await $api.sprints.setTaskSprint({ taskId: task.id, sprintId: source.id })
|
||||
// Drain the legit assignment event before clearing, so its async delivery
|
||||
// can't bleed into the post-close window and be mistaken for a close emit.
|
||||
await receiver.waitForDelivery('task.assignedToSprint')
|
||||
await $api.sprints.activate(source.id)
|
||||
await $api.sprints.startReview(source.id)
|
||||
|
||||
receiver.clear()
|
||||
await $api.sprints.close({
|
||||
sprintId: source.id,
|
||||
outcomes: [{ taskId: task.id, outcome: 'carried-over', carriedOverTo: target.id }],
|
||||
goalAchieved: false,
|
||||
})
|
||||
|
||||
await receiver.waitForDelivery('sprint.completed')
|
||||
// Give any (erroneous) task.assignedToSprint delivery time to land, then assert none did.
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
const assignmentEvents = receiver.received.filter((r) => r.body?.event === 'task.assignedToSprint')
|
||||
expect(assignmentEvents.length).toBe(0)
|
||||
|
||||
// The move still happened in the data, just without an assignment event.
|
||||
const moved = await $api.tasks.fetchTaskById(task.id).catch(console.error)
|
||||
expect(moved?.sprintId).toBe(target.id)
|
||||
|
||||
await $api.tasks.deleteTask(task.id).catch(() => {})
|
||||
await $api.sprints.remove(target.id).catch(() => {})
|
||||
}, 25000)
|
||||
|
||||
it('delivers only subscribed events to a narrowly-scoped webhook', async () => {
|
||||
// A dedicated receiver + a webhook subscribed ONLY to sprint.completed: it must
|
||||
// receive nothing for sprint.created, but must receive sprint.completed.
|
||||
const narrowReceiver = createWebhookReceiver()
|
||||
await narrowReceiver.start()
|
||||
const narrow = await $api.webhooks.create({
|
||||
goalId,
|
||||
url: narrowReceiver.getUrl(),
|
||||
events: ['sprint.completed'],
|
||||
}).catch(console.error)
|
||||
if (!narrow) throw new Error('Failed to create narrow webhook')
|
||||
|
||||
try {
|
||||
const sprint = await createPlanned('Narrow-scope sprint')
|
||||
// sprint.created fired; let any delivery settle, then confirm the narrow webhook got nothing.
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
expect(narrowReceiver.received.length).toBe(0)
|
||||
|
||||
await $api.sprints.activate(sprint.id)
|
||||
await $api.sprints.startReview(sprint.id)
|
||||
await $api.sprints.close({ sprintId: sprint.id, outcomes: [], goalAchieved: true })
|
||||
|
||||
const completed = await narrowReceiver.waitForDelivery('sprint.completed')
|
||||
expect(completed.sprintId).toBe(sprint.id)
|
||||
// It only ever saw the one subscribed event.
|
||||
expect(narrowReceiver.received.every((r) => r.body?.event === 'sprint.completed')).toBe(true)
|
||||
} finally {
|
||||
await $api.webhooks.delete({ id: narrow.webhook.id }).catch(() => {})
|
||||
await narrowReceiver.stop()
|
||||
}
|
||||
}, 20000)
|
||||
})
|
||||
@@ -7,79 +7,7 @@ import {
|
||||
afterAll,
|
||||
} from 'vitest'
|
||||
import { initApi } from './init-api'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import { networkInterfaces } from 'os'
|
||||
|
||||
function getLocalIp(): string {
|
||||
const nets = networkInterfaces()
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]!) {
|
||||
if (net.family === 'IPv4' && !net.internal) {
|
||||
return net.address
|
||||
}
|
||||
}
|
||||
}
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
function createWebhookReceiver() {
|
||||
const received: Array<{ body: any; headers: Record<string, string | string[] | undefined> }> = []
|
||||
const waiters: Array<(value: any) => void> = []
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => { body += chunk })
|
||||
req.on('end', () => {
|
||||
let parsed: any = null
|
||||
try { parsed = JSON.parse(body) } catch { parsed = body }
|
||||
received.push({ body: parsed, headers: req.headers })
|
||||
if (waiters.length > 0) {
|
||||
waiters.shift()!(parsed)
|
||||
}
|
||||
res.writeHead(200)
|
||||
res.end('ok')
|
||||
})
|
||||
})
|
||||
|
||||
const ip = getLocalIp()
|
||||
|
||||
return {
|
||||
server,
|
||||
received,
|
||||
getUrl: () => `http://${ip}:${(server.address() as any).port}/webhook`,
|
||||
start: () => new Promise<void>((resolve) => {
|
||||
server.listen(0, '0.0.0.0', () => resolve())
|
||||
}),
|
||||
stop: () => new Promise<void>((resolve) => {
|
||||
server.close(() => resolve())
|
||||
}),
|
||||
clear: () => { received.length = 0 },
|
||||
waitForDelivery: (event?: string, timeoutMs = 5000) => new Promise<any>((resolve, reject) => {
|
||||
// check already received
|
||||
const idx = event
|
||||
? received.findIndex(r => r.body?.event === event)
|
||||
: received.length > 0 ? 0 : -1
|
||||
if (idx >= 0) {
|
||||
resolve(received[idx].body)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`No webhook delivery${event ? ` for ${event}` : ''} received within ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
const check = (payload: any) => {
|
||||
if (!event || payload?.event === event) {
|
||||
clearTimeout(timer)
|
||||
resolve(payload)
|
||||
} else {
|
||||
waiters.push(check)
|
||||
}
|
||||
}
|
||||
waiters.push(check)
|
||||
}),
|
||||
}
|
||||
}
|
||||
import { createWebhookReceiver } from './webhook-receiver'
|
||||
|
||||
describe('Webhooks', () => {
|
||||
let $api: TvApi
|
||||
|
||||
@@ -49,7 +49,7 @@ export type CollaborationArgDeleteRoleFromGoal = {
|
||||
export type CollaborationResponseDeleteRoleFromGoal = boolean;
|
||||
|
||||
//FIXME describe each group differently
|
||||
export type PermissionGroupsIds = 1 | 2 | 3 | 4;
|
||||
export type PermissionGroupsIds = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export type CollaborationPermission = {
|
||||
id: number;
|
||||
|
||||
@@ -43,13 +43,14 @@ export type GoalItem = {
|
||||
archive: 1 | 0;
|
||||
dateCreation: string | null;
|
||||
organizationId: number | null;
|
||||
estimateUnit: 'hours' | 'points';
|
||||
};
|
||||
|
||||
export type GoalArgItemAdd = Pick<GoalItem, 'name'> & Partial<Pick<GoalItem, 'description' | 'color' | 'organizationId'>>;
|
||||
|
||||
export type GoalResponseAdd = GoalItem | null;
|
||||
|
||||
export type GoalArgItemUpdate = Pick<GoalItem, 'id'> & Partial<Pick<GoalItem, 'name' | 'description' | 'color' | 'archive'>>;
|
||||
export type GoalArgItemUpdate = Pick<GoalItem, 'id'> & Partial<Pick<GoalItem, 'name' | 'description' | 'color' | 'archive' | 'estimateUnit'>>;
|
||||
|
||||
export type GoalResponseUpdate = GoalItem | null;
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ export default class TvKanban extends TvApiBase {
|
||||
if (filters.assigneeIds && filters.assigneeIds.length > 0) {
|
||||
params.set('assigneeIds', filters.assigneeIds.join(','));
|
||||
}
|
||||
if (filters.sprintId !== undefined && filters.sprintId !== null) {
|
||||
params.set('sprintId', String(filters.sprintId));
|
||||
}
|
||||
}
|
||||
const query = params.toString();
|
||||
const url = `${this.moduleUrl}/tasks/${goalId}/${columnId}/${cursor}${query ? `?${query}` : ''}`;
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Task } from "./tasks.api.types";
|
||||
export type KanbanFilters = {
|
||||
listIds?: number[];
|
||||
assigneeIds?: number[];
|
||||
/** Filter to a sprint by id. */
|
||||
sprintId?: number;
|
||||
};
|
||||
|
||||
export type KanbanColumnItem = {
|
||||
|
||||
@@ -147,6 +147,23 @@ export const TvPermissions: Record<Uppercase<keyof GoalPermissions>, keyof GoalP
|
||||
* Implies both view and log permissions.
|
||||
*/
|
||||
TIMETRACKING_CAN_MANAGE_ALL: 'timetracking_can_manage_all',
|
||||
|
||||
/**
|
||||
* Can view the project's sprints
|
||||
*/
|
||||
SPRINT_CAN_VIEW: 'sprint_can_view',
|
||||
/**
|
||||
* Can create/edit/activate/close sprints and manage sprint cadence
|
||||
*/
|
||||
SPRINT_CAN_MANAGE: 'sprint_can_manage',
|
||||
/**
|
||||
* Can move tasks in and out of sprints
|
||||
*/
|
||||
SPRINT_CAN_ASSIGN_TASKS: 'sprint_can_assign_tasks',
|
||||
/**
|
||||
* Can view sprint analytics (burndown, velocity)
|
||||
*/
|
||||
SPRINT_CAN_VIEW_ANALYTICS: 'sprint_can_view_analytics',
|
||||
} as const;
|
||||
|
||||
export type GoalPermissions = {
|
||||
@@ -195,4 +212,9 @@ export type GoalPermissions = {
|
||||
timetracking_can_view?: true;
|
||||
timetracking_can_log?: true;
|
||||
timetracking_can_manage_all?: true;
|
||||
|
||||
sprint_can_view?: true;
|
||||
sprint_can_manage?: true;
|
||||
sprint_can_assign_tasks?: true;
|
||||
sprint_can_view_analytics?: true;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import TvApiBase from './base';
|
||||
import type { AppResponse } from './base.types';
|
||||
import type {
|
||||
RecurrenceCreateArgs,
|
||||
RecurrenceRule,
|
||||
RecurrenceRuleDetails,
|
||||
RecurrenceUpdateArgs,
|
||||
} from './recurrence.types';
|
||||
|
||||
export default class TvRecurrenceApi extends TvApiBase {
|
||||
protected moduleUrl = '/module/recurrence';
|
||||
|
||||
public async create(args: RecurrenceCreateArgs) {
|
||||
return this.request(this.$axios.post<AppResponse<RecurrenceRule>>(`${this.moduleUrl}`, args));
|
||||
}
|
||||
|
||||
public async getById(ruleId: number) {
|
||||
return this.request(this.$axios.get<AppResponse<RecurrenceRuleDetails | null>>(`${this.moduleUrl}/${ruleId}`));
|
||||
}
|
||||
|
||||
public async getForTask(taskId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<RecurrenceRuleDetails | null>>(`${this.moduleUrl}/task/${taskId}`)
|
||||
);
|
||||
}
|
||||
|
||||
public async update(args: RecurrenceUpdateArgs) {
|
||||
const { ruleId, ...body } = args;
|
||||
return this.request(this.$axios.patch<AppResponse<RecurrenceRule>>(`${this.moduleUrl}/${ruleId}`, body));
|
||||
}
|
||||
|
||||
public async pause(ruleId: number) {
|
||||
return this.request(this.$axios.post<AppResponse<RecurrenceRule>>(`${this.moduleUrl}/${ruleId}/pause`, {}));
|
||||
}
|
||||
|
||||
public async resume(ruleId: number) {
|
||||
return this.request(this.$axios.post<AppResponse<RecurrenceRule>>(`${this.moduleUrl}/${ruleId}/resume`, {}));
|
||||
}
|
||||
|
||||
public async skip(ruleId: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<RecurrenceRuleDetails>>(`${this.moduleUrl}/${ruleId}/skip`, {})
|
||||
);
|
||||
}
|
||||
|
||||
public async remove(ruleId: number) {
|
||||
return this.request(this.$axios.delete<AppResponse<{ deleted: true }>>(`${this.moduleUrl}/${ruleId}`));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Task } from './tasks.api.types';
|
||||
|
||||
export type RecurrenceState = 'active' | 'paused' | 'ended';
|
||||
|
||||
export type RecurrenceRule = {
|
||||
id: number;
|
||||
goalId: number;
|
||||
templateTaskId: number | null;
|
||||
templateDescription: string | null;
|
||||
templateNote: string | null;
|
||||
templatePriorityId: 1 | 2 | 3 | null;
|
||||
templateStatusId: number | null;
|
||||
templateGoalListId: number | null;
|
||||
templateDurationMinutes: number | null;
|
||||
/** RFC 5545 RRULE string; COUNT/UNTIL live inside it. */
|
||||
rrule: string;
|
||||
/** Floating wall-clock 'YYYY-MM-DDTHH:mm:ss' anchor of the series. */
|
||||
dtstart: string;
|
||||
/** Whether the series is anchored to a wall-clock time (incl. 00:00) or is date-only. */
|
||||
hasTime: boolean;
|
||||
/** IANA timezone name, e.g. 'Europe/Moscow'. */
|
||||
timezone: string;
|
||||
state: RecurrenceState;
|
||||
lastInstanceDate: string;
|
||||
instancesCreated: number;
|
||||
notifyOnOccurrence: boolean;
|
||||
creatorId: number;
|
||||
createdAt: string;
|
||||
editedAt: string;
|
||||
};
|
||||
|
||||
export type RecurrenceRuleDetails = {
|
||||
rule: RecurrenceRule;
|
||||
skipDates: string[];
|
||||
openInstance: Task | null;
|
||||
};
|
||||
|
||||
export type RecurrenceCreateArgs = {
|
||||
taskId: number;
|
||||
rrule: string;
|
||||
/** 'YYYY-MM-DD' for a date-only series, 'YYYY-MM-DDTHH:mm:ss' for a timed one (incl. 00:00). */
|
||||
dtstart: string;
|
||||
timezone: string;
|
||||
notifyOnOccurrence?: boolean;
|
||||
};
|
||||
|
||||
export type RecurrenceTemplateOverrides = Partial<{
|
||||
description: string;
|
||||
note: string | null;
|
||||
priorityId: 1 | 2 | 3 | null;
|
||||
statusId: number | null;
|
||||
goalListId: number | null;
|
||||
durationMinutes: number | null;
|
||||
}>;
|
||||
|
||||
export type RecurrenceUpdateArgs = {
|
||||
ruleId: number;
|
||||
rrule?: string;
|
||||
dtstart?: string;
|
||||
timezone?: string;
|
||||
notifyOnOccurrence?: boolean;
|
||||
templateOverrides?: RecurrenceTemplateOverrides;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import TvApiBase from './base';
|
||||
import type { AppResponse } from './base.types';
|
||||
import type {
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
SprintCadence,
|
||||
SprintCloseArgs,
|
||||
SprintCreateArgs,
|
||||
SprintListFilterArgs,
|
||||
SprintPlanningArgs,
|
||||
SprintPlanningPage,
|
||||
SprintSaveRetroArgs,
|
||||
SprintSetCadenceArgs,
|
||||
SprintSetTaskArgs,
|
||||
SprintUpdateArgs,
|
||||
SprintVelocityArgs,
|
||||
SprintVelocityPoint,
|
||||
SprintWithRetro,
|
||||
} from './sprints.types';
|
||||
|
||||
export default class TvSprintApi extends TvApiBase {
|
||||
protected moduleUrl = '/module/sprints';
|
||||
|
||||
public async listForGoal(args: SprintListFilterArgs) {
|
||||
const query = args.status ? `?status=${encodeURIComponent(args.status)}` : '';
|
||||
return this.request(this.$axios.get<AppResponse<Sprint[]>>(`${this.moduleUrl}/${args.goalId}${query}`));
|
||||
}
|
||||
|
||||
public async getById(sprintId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SprintWithRetro | null>>(`${this.moduleUrl}/sprint/${sprintId}`)
|
||||
);
|
||||
}
|
||||
|
||||
public async getBurndown(sprintId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SprintBurndown | null>>(`${this.moduleUrl}/sprint/${sprintId}/burndown`)
|
||||
);
|
||||
}
|
||||
|
||||
public async getPlanningTasks(args: SprintPlanningArgs) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('scope', args.scope);
|
||||
if (args.cursor != null) params.set('cursor', String(args.cursor));
|
||||
if (args.limit != null) params.set('limit', String(args.limit));
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SprintPlanningPage>>(
|
||||
`${this.moduleUrl}/sprint/${args.sprintId}/planning?${params.toString()}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async getVelocity(args: SprintVelocityArgs) {
|
||||
const query = args.lastN ? `?lastN=${args.lastN}` : '';
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SprintVelocityPoint[]>>(`${this.moduleUrl}/goal/${args.goalId}/velocity${query}`)
|
||||
);
|
||||
}
|
||||
|
||||
public async create(args: SprintCreateArgs) {
|
||||
return this.request(this.$axios.post<AppResponse<Sprint>>(`${this.moduleUrl}`, args));
|
||||
}
|
||||
|
||||
public async update(args: SprintUpdateArgs) {
|
||||
const { sprintId, ...body } = args;
|
||||
return this.request(this.$axios.patch<AppResponse<Sprint>>(`${this.moduleUrl}/sprint/${sprintId}`, body));
|
||||
}
|
||||
|
||||
public async activate(sprintId: number) {
|
||||
return this.request(this.$axios.post<AppResponse<Sprint>>(`${this.moduleUrl}/sprint/${sprintId}/activate`, {}));
|
||||
}
|
||||
|
||||
public async startReview(sprintId: number) {
|
||||
return this.request(this.$axios.post<AppResponse<Sprint>>(`${this.moduleUrl}/sprint/${sprintId}/review`, {}));
|
||||
}
|
||||
|
||||
public async close(args: SprintCloseArgs) {
|
||||
const { sprintId, ...body } = args;
|
||||
return this.request(this.$axios.post<AppResponse<Sprint>>(`${this.moduleUrl}/sprint/${sprintId}/close`, body));
|
||||
}
|
||||
|
||||
public async pause(sprintId: number) {
|
||||
return this.request(this.$axios.post<AppResponse<Sprint>>(`${this.moduleUrl}/sprint/${sprintId}/pause`, {}));
|
||||
}
|
||||
|
||||
public async resume(sprintId: number) {
|
||||
return this.request(this.$axios.post<AppResponse<Sprint>>(`${this.moduleUrl}/sprint/${sprintId}/resume`, {}));
|
||||
}
|
||||
|
||||
public async remove(sprintId: number) {
|
||||
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/sprint/${sprintId}`));
|
||||
}
|
||||
|
||||
public async saveRetro(args: SprintSaveRetroArgs) {
|
||||
const { sprintId, ...body } = args;
|
||||
return this.request(
|
||||
this.$axios.put<AppResponse<SprintSaveRetroArgs>>(`${this.moduleUrl}/sprint/${sprintId}/retro`, body)
|
||||
);
|
||||
}
|
||||
|
||||
public async setTaskSprint(args: SprintSetTaskArgs) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<{ taskId: number; sprintId: number | null }>>(
|
||||
`${this.moduleUrl}/task/${args.taskId}/sprint`,
|
||||
{ sprintId: args.sprintId }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async getCadence(goalId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SprintCadence | null>>(`${this.moduleUrl}/goal/${goalId}/cadence`)
|
||||
);
|
||||
}
|
||||
|
||||
public async setCadence(args: SprintSetCadenceArgs) {
|
||||
const { goalId, ...body } = args;
|
||||
return this.request(
|
||||
this.$axios.put<AppResponse<SprintCadence>>(`${this.moduleUrl}/goal/${goalId}/cadence`, body)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Task } from './tasks.api.types';
|
||||
|
||||
export type SprintStatus = 'draft' | 'planned' | 'active' | 'review' | 'completed';
|
||||
|
||||
export type Sprint = {
|
||||
id: number;
|
||||
goalId: number;
|
||||
name: string;
|
||||
goalText: string | null;
|
||||
goalAchieved: boolean | null;
|
||||
status: SprintStatus;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
capacity: string | null;
|
||||
pausedAt: string | null;
|
||||
creatorId: number | null;
|
||||
createdAt: string;
|
||||
editedAt: string;
|
||||
reviewStartedAt: string | null;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
export type SprintRetro = {
|
||||
wentWell: string | null;
|
||||
wentBad: string | null;
|
||||
actionItems: string | null;
|
||||
};
|
||||
|
||||
export type SprintWithRetro = Sprint & { retro: SprintRetro | null };
|
||||
|
||||
export type SprintCreateArgs = {
|
||||
goalId: number;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
goalText?: string | null;
|
||||
capacity?: number | null;
|
||||
};
|
||||
|
||||
export type SprintUpdateArgs = {
|
||||
sprintId: number;
|
||||
name?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
goalText?: string | null;
|
||||
capacity?: number | null;
|
||||
};
|
||||
|
||||
export type SprintOutcome = 'accepted' | 'carried-over' | 'dropped';
|
||||
|
||||
export type SprintTaskOutcomeInput = {
|
||||
taskId: number;
|
||||
outcome: SprintOutcome;
|
||||
carriedOverTo?: number | null;
|
||||
};
|
||||
|
||||
export type SprintCloseArgs = {
|
||||
sprintId: number;
|
||||
outcomes: SprintTaskOutcomeInput[];
|
||||
goalAchieved: boolean;
|
||||
};
|
||||
|
||||
export type SprintSaveRetroArgs = {
|
||||
sprintId: number;
|
||||
wentWell?: string | null;
|
||||
wentBad?: string | null;
|
||||
actionItems?: string | null;
|
||||
};
|
||||
|
||||
export type SprintSetTaskArgs = {
|
||||
taskId: number;
|
||||
sprintId: number | null;
|
||||
};
|
||||
|
||||
export type SprintListFilterArgs = {
|
||||
goalId: number;
|
||||
/** comma-separated subset, e.g. 'active,planned' */
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type SprintVelocityArgs = {
|
||||
goalId: number;
|
||||
lastN?: number;
|
||||
};
|
||||
|
||||
export type SprintPlanningScope = 'backlog' | 'sprint';
|
||||
|
||||
export type SprintPlanningArgs = {
|
||||
sprintId: number;
|
||||
scope: SprintPlanningScope;
|
||||
cursor?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/** Backlog scope: tasks + cursor only. */
|
||||
export type SprintPlanningBacklogPage = {
|
||||
tasks: Task[];
|
||||
nextCursor: number | null;
|
||||
};
|
||||
|
||||
/** Sprint scope: tasks + cursor + capacity counter (sum of all in-sprint estimates). */
|
||||
export type SprintPlanningSprintPage = {
|
||||
tasks: Task[];
|
||||
nextCursor: number | null;
|
||||
totalPoints: number;
|
||||
};
|
||||
|
||||
export type SprintPlanningPage = SprintPlanningBacklogPage | SprintPlanningSprintPage;
|
||||
|
||||
export type SprintBurndownPoint = { date: string; remainingHours: number; idealHours: number };
|
||||
export type SprintBurndown = { total: number; points: SprintBurndownPoint[] };
|
||||
export type SprintVelocityPoint = { sprintId: number; name: string; acceptedHours: number; plannedHours: number };
|
||||
|
||||
export type SprintCadence = {
|
||||
goalId: number;
|
||||
enabled: boolean;
|
||||
lengthDays: number;
|
||||
startDate: string;
|
||||
lookahead: number;
|
||||
nameTemplate: string;
|
||||
lastGeneratedDate: string | null;
|
||||
createdAt: string;
|
||||
editedAt: string;
|
||||
};
|
||||
|
||||
export type SprintSetCadenceArgs = {
|
||||
goalId: number;
|
||||
enabled: boolean;
|
||||
lengthDays?: number;
|
||||
startDate?: string;
|
||||
lookahead?: number;
|
||||
nameTemplate?: string;
|
||||
};
|
||||
@@ -31,6 +31,12 @@ export interface TaskBase {
|
||||
nodeGraphPosition: Record<'x' | 'y', number> | null;
|
||||
creatorId: number | null;
|
||||
sourceUrl: string | null;
|
||||
|
||||
sprintId: number | null;
|
||||
estimateValue: number | string | null;
|
||||
|
||||
recurrenceRuleId: number | null;
|
||||
recurrenceInstanceDate: string | null;
|
||||
}
|
||||
|
||||
export interface Task extends TaskBase {
|
||||
@@ -40,7 +46,9 @@ export interface Task extends TaskBase {
|
||||
};
|
||||
|
||||
// we can not update defined fields
|
||||
type NotAllowedToUpdate = 'id' | 'goalId' | 'owner' | 'dateCreation' | 'dateComplete' | 'historyId';
|
||||
// sprintId is managed via the sprints module; dateComplete is trigger-maintained.
|
||||
// recurrence fields are managed via the recurrence module.
|
||||
type NotAllowedToUpdate = 'id' | 'goalId' | 'owner' | 'dateCreation' | 'dateComplete' | 'historyId' | 'sprintId' | 'recurrenceRuleId' | 'recurrenceInstanceDate';
|
||||
|
||||
export type TaskArgUpdate = Pick<Task, 'id'> & Partial<Omit<Task, NotAllowedToUpdate>>;
|
||||
export type TaskResponseUpdate = (Task & { syncFailed?: boolean }) | null;
|
||||
|
||||
@@ -5,4 +5,12 @@ export type UiPreferencesItem = {
|
||||
width?: 'narrow' | 'wide'
|
||||
}
|
||||
|
||||
export type UiPreferences = Record<string, UiPreferencesItem[]>
|
||||
export type FirstDayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
|
||||
export type UiSettings = {
|
||||
firstDayOfWeek?: FirstDayOfWeek
|
||||
}
|
||||
|
||||
export const UI_SETTINGS_KEY = '__settings__'
|
||||
|
||||
export type UiPreferences = Record<string, UiPreferencesItem[] | UiSettings>
|
||||
|
||||
@@ -52,6 +52,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];
|
||||
|
||||
@@ -18,4 +18,6 @@ export * from '@/api/organizations.types';
|
||||
export * from '@/api/sso.types';
|
||||
export * from '@/api/analytics.types';
|
||||
export * from '@/api/time-tracking.types';
|
||||
export * from '@/api/ui-preferences.types';
|
||||
export * from '@/api/ui-preferences.types';
|
||||
export * from '@/api/sprints.types';
|
||||
export * from '@/api/recurrence.types';
|
||||
@@ -16,6 +16,8 @@ import TvSsoApi from "./api/sso";
|
||||
import TvAnalyticsApi from "./api/analytics";
|
||||
import TvTimeTrackingApi from "./api/time-tracking";
|
||||
import TvUiPreferencesApi from "./api/ui-preferences";
|
||||
import TvSprintApi from "./api/sprints";
|
||||
import TvRecurrenceApi from "./api/recurrence";
|
||||
|
||||
export class TvApi {
|
||||
|
||||
@@ -55,6 +57,10 @@ export class TvApi {
|
||||
|
||||
public uiPreferences: TvUiPreferencesApi;
|
||||
|
||||
public sprints: TvSprintApi;
|
||||
|
||||
public recurrence: TvRecurrenceApi;
|
||||
|
||||
constructor($axios: AxiosInstance) {
|
||||
this.$axios = $axios;
|
||||
|
||||
@@ -91,6 +97,10 @@ export class TvApi {
|
||||
this.timeTracking = new TvTimeTrackingApi(this.$axios);
|
||||
|
||||
this.uiPreferences = new TvUiPreferencesApi(this.$axios);
|
||||
|
||||
this.sprints = new TvSprintApi(this.$axios);
|
||||
|
||||
this.recurrence = new TvRecurrenceApi(this.$axios);
|
||||
}
|
||||
|
||||
public setBaseUrl(baseUrl: string) {
|
||||
|
||||
@@ -20,3 +20,12 @@ export * from './schemas/sso.schema';
|
||||
export * from './schemas/time-entries.schema';
|
||||
export * from './schemas/time-entries-history.schema';
|
||||
export * from './schemas/ui-preferences.schema';
|
||||
export * from './schemas/sprints.schema';
|
||||
export * from './schemas/sprint-task-outcomes.schema';
|
||||
export * from './schemas/sprint-user-capacity.schema';
|
||||
export * from './schemas/sprint-retros.schema';
|
||||
export * from './schemas/sprint-cadence.schema';
|
||||
export * from './schemas/recurrence-rules.schema';
|
||||
export * from './schemas/recurrence-skip-dates.schema';
|
||||
export * from './schemas/recurrence-template-assignees.schema';
|
||||
export * from './schemas/recurrence-template-tags.schema';
|
||||
|
||||
@@ -15,6 +15,7 @@ export const GoalsSchema = pgSchema('tasks').table('goals', {
|
||||
archive: integer().notNull().default(0),
|
||||
backlogVersion: integer('backlog_version').default(1),
|
||||
organizationId: integer('organization_id').references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
|
||||
estimateUnit: varchar('estimate_unit', { length: 10 }).$type<'hours' | 'points'>().notNull().default('points'),
|
||||
});
|
||||
|
||||
export type GoalsSchemaTypeForSelect = typeof GoalsSchema.$inferSelect;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { integer, pgSchema, varchar, boolean, timestamp } from "drizzle-orm/pg-core";
|
||||
import { TasksSchema } from "./tasks.schema";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
import { SprintsSchema } from "./sprints.schema";
|
||||
|
||||
export const NotificationsSchema = pgSchema('tasks').table('notifications', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
@@ -11,6 +12,7 @@ export const NotificationsSchema = pgSchema('tasks').table('notifications', {
|
||||
body: varchar({ length: 1000 }),
|
||||
read: boolean().notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
sprintId: integer('sprint_id').references(() => SprintsSchema.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export type NotificationsSchemaTypeForSelect = typeof NotificationsSchema.$inferSelect;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { boolean, date, integer, pgSchema, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
import { GoalsSchema } from "./goals.schema";
|
||||
import { TasksSchema } from "./tasks.schema";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
|
||||
export type RecurrenceState = 'active' | 'paused' | 'ended';
|
||||
|
||||
export const RecurrenceRulesSchema = pgSchema('tasks').table('recurrence_rules', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
goalId: integer('goal_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }),
|
||||
templateTaskId: integer('template_task_id').references(() => TasksSchema.id, { onDelete: 'set null' }),
|
||||
templateDescription: varchar('template_description', { length: 2000 }),
|
||||
templateNote: varchar('template_note', { length: 2000 }),
|
||||
templatePriorityId: integer('template_priority_id').$type<1 | 2 | 3 | null>(),
|
||||
templateStatusId: integer('template_status_id'),
|
||||
templateGoalListId: integer('template_goal_list_id'),
|
||||
templateDurationMinutes: integer('template_duration_minutes'),
|
||||
rrule: text().notNull(),
|
||||
dtstart: timestamp().notNull(),
|
||||
hasTime: boolean('has_time').notNull().default(false),
|
||||
timezone: varchar({ length: 50 }).notNull(),
|
||||
state: varchar({ length: 20 }).$type<RecurrenceState>().notNull().default('active'),
|
||||
lastInstanceDate: date('last_instance_date').notNull(),
|
||||
instancesCreated: integer('instances_created').notNull().default(1),
|
||||
notifyOnOccurrence: boolean('notify_on_occurrence').notNull().default(false),
|
||||
creatorId: integer('creator_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
editedAt: timestamp('edited_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type RecurrenceRulesSchemaTypeForSelect = typeof RecurrenceRulesSchema.$inferSelect;
|
||||
export type RecurrenceRulesSchemaTypeForInsert = typeof RecurrenceRulesSchema.$inferInsert;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { date, integer, pgSchema, primaryKey, timestamp } from "drizzle-orm/pg-core";
|
||||
import { RecurrenceRulesSchema } from "./recurrence-rules.schema";
|
||||
|
||||
export const RecurrenceSkipDatesSchema = pgSchema('tasks').table('recurrence_skip_dates', {
|
||||
ruleId: integer('rule_id').notNull().references(() => RecurrenceRulesSchema.id, { onDelete: 'cascade' }),
|
||||
skipDate: date('skip_date').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.ruleId, table.skipDate] }),
|
||||
]);
|
||||
|
||||
export type RecurrenceSkipDatesSchemaTypeForSelect = typeof RecurrenceSkipDatesSchema.$inferSelect;
|
||||
export type RecurrenceSkipDatesSchemaTypeForInsert = typeof RecurrenceSkipDatesSchema.$inferInsert;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { integer, pgSchema, primaryKey } from "drizzle-orm/pg-core";
|
||||
import { CollaborationUsersSchema } from "./collaboration-users.schema";
|
||||
import { RecurrenceRulesSchema } from "./recurrence-rules.schema";
|
||||
|
||||
export const RecurrenceTemplateAssigneesSchema = pgSchema('tasks').table('recurrence_template_assignees', {
|
||||
ruleId: integer('rule_id').notNull().references(() => RecurrenceRulesSchema.id, { onDelete: 'cascade' }),
|
||||
collabUserId: integer('collab_user_id').notNull().references(() => CollaborationUsersSchema.id, { onDelete: 'cascade' }),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.ruleId, table.collabUserId] }),
|
||||
]);
|
||||
|
||||
export type RecurrenceTemplateAssigneesSchemaTypeForSelect = typeof RecurrenceTemplateAssigneesSchema.$inferSelect;
|
||||
export type RecurrenceTemplateAssigneesSchemaTypeForInsert = typeof RecurrenceTemplateAssigneesSchema.$inferInsert;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { integer, pgSchema, primaryKey } from "drizzle-orm/pg-core";
|
||||
import { RecurrenceRulesSchema } from "./recurrence-rules.schema";
|
||||
import { TagsSchema } from "./tags.schema";
|
||||
|
||||
export const RecurrenceTemplateTagsSchema = pgSchema('tasks').table('recurrence_template_tags', {
|
||||
ruleId: integer('rule_id').notNull().references(() => RecurrenceRulesSchema.id, { onDelete: 'cascade' }),
|
||||
tagId: integer('tag_id').notNull().references(() => TagsSchema.id, { onDelete: 'cascade' }),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.ruleId, table.tagId] }),
|
||||
]);
|
||||
|
||||
export type RecurrenceTemplateTagsSchemaTypeForSelect = typeof RecurrenceTemplateTagsSchema.$inferSelect;
|
||||
export type RecurrenceTemplateTagsSchemaTypeForInsert = typeof RecurrenceTemplateTagsSchema.$inferInsert;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { boolean, date, integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
import { GoalsSchema } from "./goals.schema";
|
||||
|
||||
export const SprintCadenceSchema = pgSchema('tasks').table('sprint_cadence', {
|
||||
goalId: integer('goal_id').primaryKey().references(() => GoalsSchema.id, { onDelete: 'cascade' }),
|
||||
enabled: boolean().notNull().default(false),
|
||||
lengthDays: integer('length_days').notNull().default(14),
|
||||
startDate: date('start_date').notNull(),
|
||||
lookahead: integer().notNull().default(2),
|
||||
nameTemplate: varchar('name_template', { length: 100 }).notNull().default('Sprint {n}'),
|
||||
lastGeneratedDate: date('last_generated_date'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
editedAt: timestamp('edited_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type SprintCadenceSchemaTypeForSelect = typeof SprintCadenceSchema.$inferSelect;
|
||||
export type SprintCadenceSchemaTypeForInsert = typeof SprintCadenceSchema.$inferInsert;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { integer, pgSchema, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { SprintsSchema } from "./sprints.schema";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
|
||||
export const SprintRetrosSchema = pgSchema('tasks').table('sprint_retros', {
|
||||
sprintId: integer('sprint_id').primaryKey().references(() => SprintsSchema.id, { onDelete: 'cascade' }),
|
||||
wentWell: text('went_well'),
|
||||
wentBad: text('went_bad'),
|
||||
actionItems: text('action_items'),
|
||||
editedAt: timestamp('edited_at').notNull().defaultNow(),
|
||||
editedBy: integer('edited_by').references(() => UsersSchema.id),
|
||||
});
|
||||
|
||||
export type SprintRetrosSchemaTypeForSelect = typeof SprintRetrosSchema.$inferSelect;
|
||||
export type SprintRetrosSchemaTypeForInsert = typeof SprintRetrosSchema.$inferInsert;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { integer, numeric, pgSchema, primaryKey, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
import { SprintsSchema } from "./sprints.schema";
|
||||
import { TasksSchema } from "./tasks.schema";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
|
||||
export type SprintOutcome = 'accepted' | 'carried-over' | 'dropped' | 'incomplete';
|
||||
|
||||
export const SprintTaskOutcomesSchema = pgSchema('tasks').table('sprint_task_outcomes', {
|
||||
sprintId: integer('sprint_id').notNull().references(() => SprintsSchema.id, { onDelete: 'cascade' }),
|
||||
taskId: integer('task_id').notNull().references(() => TasksSchema.id, { onDelete: 'cascade' }),
|
||||
outcome: varchar({ length: 20 }).$type<SprintOutcome>().notNull(),
|
||||
carriedOverTo: integer('carried_over_to').references(() => SprintsSchema.id, { onDelete: 'set null' }),
|
||||
decidedBy: integer('decided_by').references(() => UsersSchema.id),
|
||||
decidedAt: timestamp('decided_at').notNull().defaultNow(),
|
||||
// Snapshot of the task's estimate captured at sprint close — frozen history.
|
||||
estimateValue: numeric('estimate_value', { precision: 10, scale: 2 }),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.sprintId, table.taskId] }),
|
||||
]);
|
||||
|
||||
export type SprintTaskOutcomesSchemaTypeForSelect = typeof SprintTaskOutcomesSchema.$inferSelect;
|
||||
export type SprintTaskOutcomesSchemaTypeForInsert = typeof SprintTaskOutcomesSchema.$inferInsert;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { integer, numeric, pgSchema, primaryKey } from "drizzle-orm/pg-core";
|
||||
import { SprintsSchema } from "./sprints.schema";
|
||||
import { UsersSchema } from "./users.schema";
|
||||
|
||||
export const SprintUserCapacitySchema = pgSchema('tasks').table('sprint_user_capacity', {
|
||||
sprintId: integer('sprint_id').notNull().references(() => SprintsSchema.id, { onDelete: 'cascade' }),
|
||||
userId: integer('user_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }),
|
||||
hours: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.sprintId, table.userId] }),
|
||||
]);
|
||||
|
||||
export type SprintUserCapacitySchemaTypeForSelect = typeof SprintUserCapacitySchema.$inferSelect;
|
||||
export type SprintUserCapacitySchemaTypeForInsert = typeof SprintUserCapacitySchema.$inferInsert;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user