From c9ab955a2ab340ab215fcfc62af266f23259b8d4 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Thu, 12 Nov 2026 12:06:24 +0100 Subject: [PATCH 1/8] feat(processes): start processes on domain events in the same transaction --- docs/architecture.md | 8 +- packages/api/drizzle/0038_processes.sql | 4 +- packages/api/drizzle/meta/0038_snapshot.json | 12 +- packages/api/importRules.json | 2 + packages/api/src/core/db/beforeCommitBus.ts | 30 ++ packages/api/src/core/db/index.ts | 4 +- .../api/src/core/db/transaction.int.test.ts | 144 +++++++- packages/api/src/core/db/transaction.ts | 52 ++- packages/api/src/core/events/eventBus.ts | 3 + packages/api/src/core/events/index.ts | 9 +- packages/api/src/core/moduleManifest.ts | 4 +- .../core/records/app/recordHistoryService.ts | 4 +- .../src/core/records/domain/recordHistory.ts | 3 +- packages/api/src/core/server.ts | 8 +- .../employees/app/employeeDirectory.ts | 12 +- .../modules/processes/app/dueRunService.ts | 41 +-- .../modules/processes/app/eventRunService.ts | 191 ++++++++++ .../modules/processes/app/processService.ts | 38 +- .../src/modules/processes/app/runReader.ts | 1 + .../src/modules/processes/app/runStarter.ts | 341 +++++++++--------- .../app/startRunsOnEvents.int.test.ts | 340 +++++++++++++++++ .../modules/processes/app/workingCalendars.ts | 76 ++++ .../api/src/modules/processes/db/index.ts | 7 +- .../modules/processes/db/processRepository.ts | 122 +++++-- .../modules/processes/db/processRunsTable.ts | 9 +- .../processes/domain/eventTrigger.test.ts | 107 ++++++ .../modules/processes/domain/eventTrigger.ts | 73 ++++ .../api/src/modules/processes/domain/index.ts | 11 + .../domain/processDefinition.test.ts | 80 +++- .../processes/domain/processDefinition.ts | 73 +++- .../modules/processes/domain/processRun.ts | 9 +- packages/api/src/modules/processes/index.ts | 3 + packages/api/src/modules/processes/module.md | 5 +- .../modules/tasks/app/createTask.int.test.ts | 85 ++++- .../api/src/modules/tasks/app/createTasks.ts | 95 +++++ packages/api/src/modules/tasks/app/index.ts | 3 +- .../modules/tasks/app/markDone.int.test.ts | 27 +- .../src/modules/tasks/app/registrations.ts | 23 +- .../api/src/modules/tasks/app/taskService.ts | 86 +---- .../src/modules/tasks/db/taskRepository.ts | 10 +- packages/api/test/support/domainEvents.ts | 20 + packages/api/test/support/index.ts | 3 +- packages/api/test/support/processService.ts | 30 +- packages/api/test/support/seedProcess.ts | 28 ++ packages/api/test/support/seedTask.ts | 37 +- packages/api/test/support/services.ts | 4 +- packages/api/test/support/taskService.ts | 11 + packages/api/test/support/testSeed.ts | 4 + 48 files changed, 1903 insertions(+), 389 deletions(-) create mode 100644 packages/api/src/core/db/beforeCommitBus.ts create mode 100644 packages/api/src/modules/processes/app/eventRunService.ts create mode 100644 packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts create mode 100644 packages/api/src/modules/processes/app/workingCalendars.ts create mode 100644 packages/api/src/modules/processes/domain/eventTrigger.test.ts create mode 100644 packages/api/src/modules/processes/domain/eventTrigger.ts create mode 100644 packages/api/src/modules/tasks/app/createTasks.ts create mode 100644 packages/api/test/support/domainEvents.ts create mode 100644 packages/api/test/support/seedProcess.ts create mode 100644 packages/api/test/support/taskService.ts diff --git a/docs/architecture.md b/docs/architecture.md index 9f60e2baf..1a630ca33 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,10 +79,10 @@ For example, a domain rule decides whether an import source may retry. The appli - **Domain changes notify through events**, never injected callbacks or callback ports ([RULE72](code-quality.md#layers-and-place)). The publisher owns the fact and each receiving module owns its reaction, so adding a reaction does not add dependencies to the publisher or its callers. Work that must commit together uses direct public operations in one transaction. - **Postgres is the durable work store.** pg-boss runs background jobs in the API process, using the instance's database. Enqueuing joins the business transaction, so rollback drops the work. Handlers run as the system, are idempotent and reject stale work; retries and terminal failures belong to the job lifecycle. - **Scheduled jobs** are pg-boss schedules a module declares beside its job handler (a cron and a time zone); a missed occurrence runs once on the next start. -- **Events currently run in process after commit.** Rollback drops them. Handlers run sequentially and are awaited; handler failures are logged without undoing the committed operation. A crash between commit and dispatch can lose an event, an accepted limitation for now. -- **Record changes publish `records.changed`**, one event per history line with the record, the operation, the changed field keys and the cause, never values. Processes will be triggered by it; running many changes as one batch belongs to the future process runner, not the event. -- **The durability decision is a transactional outbox**, written with the business change and audit record and drained by pg-boss. Delivery is ordered per entity and consumers remain idempotent. This avoids an external queue or a second workflow engine; the outbox is not implemented yet. -- **Workflows** such as approvals and onboarding are domain state machines, with durable jobs executing their steps. A process starts from a trigger on a record, never from a feature calling it: a record's date starts it by a daily job today, and record changes will through the outbox. A step's task is finished through the process, which changes the task and the run in one transaction. +- **Events reach handlers at two moments.** Before the commit, a module's before-commit handler receives all of a transaction's new events at once, inside it, round after round while handlers publish more (at most ten rounds); its failure rolls the transaction back, so its reaction commits with the change or not at all. After the commit, in-process handlers run sequentially and are awaited; their failures are logged without undoing the committed operation, and a crash between commit and dispatch can lose an event, an accepted limitation for them. Rollback drops both. +- **Record changes publish `records.changed`**, one event per history line with the record, the operation, the changed field keys and the cause, never values. It notifies; processes trigger on domain events instead. +- **Durable delivery after the commit is a transactional outbox**, a pg-boss job queued with the business change, for reactions that are slow or call outside systems. It is not implemented yet; this avoids an external queue or a second workflow engine. +- **Workflows** such as approvals and onboarding are domain state machines, with durable jobs executing their steps. A process starts from a trigger, never from a feature calling it: a record's date starts it by a daily job, and a domain event a module lists as triggering (a person added, a task created or its status changed) starts it in the transaction that published the event, one run per event and record, batched per process like a bulk save. A run that cannot start fails that change. A step's task is finished through the process, which changes the task and the run in one transaction. - **A process acts as the system on its own and as the person when they act.** A run's own writes, such as creating its tasks or cancelling itself, run as the system with the run as their history cause (`PROCESS_RUN`); an assignee marking their step done, or an admin starting or cancelling a run, acts as themselves under their own permissions. Who acts for a step that calls another module is not decided yet. - **A run notices removals when it next runs.** Nothing reacts to a person's removal: a step still waiting on a task whose assignee was removed, or a removed subject, cancels the run the next time a command or the daily job touches it, and the cancellation commits instead of being thrown. diff --git a/packages/api/drizzle/0038_processes.sql b/packages/api/drizzle/0038_processes.sql index dc9e41a65..2fc43a446 100644 --- a/packages/api/drizzle/0038_processes.sql +++ b/packages/api/drizzle/0038_processes.sql @@ -55,6 +55,7 @@ CREATE TABLE "process_runs" ( "started_by_employee_id" uuid, "trigger_date" date, "start_key" text, + "trigger_event" jsonb, "start_day" date NOT NULL, "status" text NOT NULL, "finished_at" timestamp with time zone, @@ -65,7 +66,8 @@ CREATE TABLE "process_runs" ( "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "process_runs_status_known" CHECK ("process_runs"."status" in ('in_progress', 'finished', 'cancelled')), - CONSTRAINT "process_runs_start_cause_known" CHECK ("process_runs"."start_cause" in ('started_on_record_date', 'started_by_admin')), + CONSTRAINT "process_runs_start_cause_known" CHECK ("process_runs"."start_cause" in ('started_on_record_date', 'started_by_admin', 'started_on_event')), + CONSTRAINT "process_runs_trigger_event_follows_start_cause" CHECK (("process_runs"."start_cause" = 'started_on_event') = ("process_runs"."trigger_event" is not null)), CONSTRAINT "process_runs_cancel_cause_known" CHECK ("process_runs"."cancel_cause" in ('cancelled_by_admin', 'cancelled_because_step_assignee_was_removed', 'cancelled_because_subject_was_removed')), CONSTRAINT "process_runs_columns_follow_status" CHECK (("process_runs"."status" = 'finished') = ("process_runs"."finished_at" is not null) and ("process_runs"."status" = 'cancelled') = ("process_runs"."cancelled_at" is not null) diff --git a/packages/api/drizzle/meta/0038_snapshot.json b/packages/api/drizzle/meta/0038_snapshot.json index 3c2c9d6ab..3de22da07 100644 --- a/packages/api/drizzle/meta/0038_snapshot.json +++ b/packages/api/drizzle/meta/0038_snapshot.json @@ -3595,6 +3595,12 @@ "primaryKey": false, "notNull": false }, + "trigger_event": { + "name": "trigger_event", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "start_day": { "name": "start_day", "type": "date", @@ -3797,7 +3803,11 @@ }, "process_runs_start_cause_known": { "name": "process_runs_start_cause_known", - "value": "\"process_runs\".\"start_cause\" in ('started_on_record_date', 'started_by_admin')" + "value": "\"process_runs\".\"start_cause\" in ('started_on_record_date', 'started_by_admin', 'started_on_event')" + }, + "process_runs_trigger_event_follows_start_cause": { + "name": "process_runs_trigger_event_follows_start_cause", + "value": "(\"process_runs\".\"start_cause\" = 'started_on_event') = (\"process_runs\".\"trigger_event\" is not null)" }, "process_runs_cancel_cause_known": { "name": "process_runs_cancel_cause_known", diff --git a/packages/api/importRules.json b/packages/api/importRules.json index b3573373d..9447198b0 100644 --- a/packages/api/importRules.json +++ b/packages/api/importRules.json @@ -173,6 +173,8 @@ "src/modules/employees/db", "src/modules/imports/db", "src/modules/imports/domain", + "src/modules/processes/db", + "src/modules/processes/domain", "src/modules/slack/db", "src/modules/tasks/db", "src/modules/tasks/domain", diff --git a/packages/api/src/core/db/beforeCommitBus.ts b/packages/api/src/core/db/beforeCommitBus.ts new file mode 100644 index 000000000..5a71567d1 --- /dev/null +++ b/packages/api/src/core/db/beforeCommitBus.ts @@ -0,0 +1,30 @@ +import type { PublishedEvent } from "../events"; +import type { Tx } from "./transaction"; + +/** Reacts to a transaction's events inside it, before it commits; an error rolls it back. */ +export type BeforeCommitHandler = (tx: Tx, events: readonly PublishedEvent[]) => Promise; + +/** + * Hands each transaction's events to the modules that must react in the same transaction, such as + * processes starting their runs, so the reaction commits with the change or not at all. + */ +export interface BeforeCommitBus { + subscribe(handler: BeforeCommitHandler): void; + /** Runs each subscribed handler in turn on the events; the first failure is thrown. */ + deliver(tx: Tx, events: readonly PublishedEvent[]): Promise; +} + +export function createBeforeCommitBus(): BeforeCommitBus { + const handlers: BeforeCommitHandler[] = []; + return { + subscribe(handler) { + handlers.push(handler); + }, + + async deliver(tx, events) { + for (const handler of handlers) { + await handler(tx, events); + } + }, + }; +} diff --git a/packages/api/src/core/db/index.ts b/packages/api/src/core/db/index.ts index 7005d15c4..b1cc9140a 100644 --- a/packages/api/src/core/db/index.ts +++ b/packages/api/src/core/db/index.ts @@ -5,10 +5,12 @@ export { MIGRATION_LOCK_KEY } from "./migrationRun"; export { anyOf } from "./anyOf"; export { jsonb } from "./jsonb"; export { afterKeyset, keysetOrder, nullsLast, orderByKeyset } from "./keyset"; -export { ormOf, transactor } from "./transaction"; +export { createBeforeCommitBus } from "./beforeCommitBus"; +export { MAX_BEFORE_COMMIT_ROUNDS, ormOf, transactor } from "./transaction"; export { isForeignKeyViolation, isUniqueViolation } from "./uniqueViolation"; // Public types. +export type { BeforeCommitBus, BeforeCommitHandler } from "./beforeCommitBus"; export type { Database, Orm, OrmTransaction } from "./database"; export type { KeysetOrder } from "./keyset"; export type { Transact, Tx } from "./transaction"; diff --git a/packages/api/src/core/db/transaction.int.test.ts b/packages/api/src/core/db/transaction.int.test.ts index eadd58936..ecbed6bd2 100644 --- a/packages/api/src/core/db/transaction.int.test.ts +++ b/packages/api/src/core/db/transaction.int.test.ts @@ -1,8 +1,8 @@ import { EXAMPLE_PAYLOAD, expect, SYSTEM, test, instances, setupSteps } from "#test/support"; -import { createEventBus } from "../events"; +import { createEventBus, type PublishedEvent } from "../events"; import { createLogger } from "../logger"; -import { transactor } from "."; +import { createBeforeCommitBus, MAX_BEFORE_COMMIT_ROUNDS, transactor } from "."; test("dispatches queued events after the write commits", async ({ orm, seed }) => { await seed.owner(); @@ -90,3 +90,143 @@ test("refuses the publisher's transaction inside an after-commit handler", async expect(rejection).toEqual(new Error("The transaction has ended")); expect((await instances.readInstance(orm)).step).toBe("company"); }); + +test("hands identified events to handlers before committing their writes with the work", async ({ + orm, + seed, +}) => { + await seed.owner(); + await seed.assignment(); + const beforeCommit = createBeforeCommitBus(); + const transact = transactor(orm, createEventBus(createLogger("silent")), beforeCommit); + const received: PublishedEvent[] = []; + beforeCommit.subscribe(async (tx, events) => { + received.push(...events); + expect((await instances.readInstance(orm)).step).toBe("company"); + expect((await instances.readInstance(tx)).step).toBe("import"); + await setupSteps.approveImportStep(SYSTEM, tx); + }); + + await transact(async (tx) => { + await setupSteps.completeCompanyStep(SYSTEM, tx); + tx.publish("example.happened", EXAMPLE_PAYLOAD); + tx.publish("example.happened", { id: "example-2" }); + }); + + expect(received).toEqual([ + { id: expect.any(String) as unknown, type: "example.happened", payload: EXAMPLE_PAYLOAD }, + { id: expect.any(String) as unknown, type: "example.happened", payload: { id: "example-2" } }, + ]); + expect(received[0]!.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/); + expect(received[1]!.id).not.toBe(received[0]!.id); + expect((await instances.readInstance(orm)).step).toBe("ready"); +}); + +test("delivers handler-published events in the next round and after the commit", async ({ + orm, + seed, +}) => { + await seed.owner(); + const eventBus = createEventBus(createLogger("silent")); + const beforeCommit = createBeforeCommitBus(); + const transact = transactor(orm, eventBus, beforeCommit); + const rounds: (readonly PublishedEvent[])[] = []; + const received: unknown[] = []; + beforeCommit.subscribe((tx, events) => { + rounds.push(events); + expect(received).toEqual([]); + if ( + events.some( + (event) => event.type === "example.happened" && event.payload.id === EXAMPLE_PAYLOAD.id, + ) + ) { + tx.publish("example.happened", { id: "from-handler" }); + } + return Promise.resolve(); + }); + eventBus.subscribe({ + "example.happened": async (payload) => { + expect((await instances.readInstance(orm)).step).toBe("import"); + received.push(payload); + }, + }); + + await transact(async (tx) => { + await setupSteps.completeCompanyStep(SYSTEM, tx); + tx.publish("example.happened", EXAMPLE_PAYLOAD); + }); + + expect(rounds).toMatchObject([ + [{ type: "example.happened", payload: EXAMPLE_PAYLOAD }], + [{ type: "example.happened", payload: { id: "from-handler" } }], + ]); + expect(received).toEqual([EXAMPLE_PAYLOAD, { id: "from-handler" }]); +}); + +test("rolls back the work and delivers no events when a before-commit handler fails", async ({ + orm, + seed, +}) => { + await seed.owner(); + const eventBus = createEventBus(createLogger("silent")); + const beforeCommit = createBeforeCommitBus(); + const transact = transactor(orm, eventBus, beforeCommit); + const received: unknown[] = []; + const failure = new Error("Cannot start the work"); + eventBus.subscribe({ + "example.happened": (payload) => { + received.push(payload); + return Promise.resolve(); + }, + }); + beforeCommit.subscribe(async (tx) => { + await setupSteps.approveImportStep(SYSTEM, tx); + tx.publish("example.happened", { id: "from-handler" }); + throw failure; + }); + + await expect( + transact(async (tx) => { + await setupSteps.completeCompanyStep(SYSTEM, tx); + tx.publish("example.happened", EXAMPLE_PAYLOAD); + }), + ).rejects.toBe(failure); + + expect((await instances.readInstance(orm)).step).toBe("company"); + expect(received).toEqual([]); +}); + +test("rolls back events that keep publishing after ten before-commit rounds", async ({ + orm, + seed, +}) => { + await seed.owner(); + const eventBus = createEventBus(createLogger("silent")); + const beforeCommit = createBeforeCommitBus(); + const transact = transactor(orm, eventBus, beforeCommit); + const received: unknown[] = []; + const rounds: (readonly PublishedEvent[])[] = []; + eventBus.subscribe({ + "example.happened": (payload) => { + received.push(payload); + return Promise.resolve(); + }, + }); + beforeCommit.subscribe((tx, events) => { + rounds.push(events); + tx.publish("example.happened", EXAMPLE_PAYLOAD); + return Promise.resolve(); + }); + + await expect( + transact(async (tx) => { + await setupSteps.completeCompanyStep(SYSTEM, tx); + tx.publish("example.happened", EXAMPLE_PAYLOAD); + }), + ).rejects.toThrow("Events kept publishing more events before the commit after 10 rounds."); + + expect(rounds).toHaveLength(MAX_BEFORE_COMMIT_ROUNDS); + expect(rounds).toHaveLength(10); + expect((await instances.readInstance(orm)).step).toBe("company"); + expect(received).toEqual([]); +}); diff --git a/packages/api/src/core/db/transaction.ts b/packages/api/src/core/db/transaction.ts index abdf1176a..9654d7073 100644 --- a/packages/api/src/core/db/transaction.ts +++ b/packages/api/src/core/db/transaction.ts @@ -1,5 +1,8 @@ +import { randomUUID } from "node:crypto"; + import { invalidInput } from "../applicationError"; -import type { DomainEvents, EventBus, EventType, DomainEvent } from "../events"; +import type { DomainEvents, EventBus, EventType, PublishedEvent } from "../events"; +import { type BeforeCommitBus, createBeforeCommitBus } from "./beforeCommitBus"; import type { Orm, OrmTransaction } from "./database"; import { isUnstorableText } from "./uniqueViolation"; @@ -8,12 +11,12 @@ import { isUnstorableText } from "./uniqueViolation"; * it. Only database adapters reach the ORM behind it, through `ormOf`. */ export interface Tx { - /** Queues an event; it is dispatched once the transaction commits. */ + /** Queues an event: handed to before-commit handlers inside the transaction, dispatched after it commits. */ publish(type: Type, payload: DomainEvents[Type]): void; } class Transaction implements Tx { - readonly events: DomainEvent[] = []; + readonly events: PublishedEvent[] = []; #orm: OrmTransaction | undefined; constructor(orm: OrmTransaction) { @@ -31,7 +34,7 @@ class Transaction implements Tx { // Reading `orm` checks the transaction is still open. void this.orm; // TypeScript cannot tie a generic `type` to its payload in the union, hence the cast. - this.events.push({ type, payload } as DomainEvent); + this.events.push({ id: randomUUID(), type, payload } as PublishedEvent); } end(): void { @@ -42,31 +45,41 @@ class Transaction implements Tx { /** Runs work in one transaction, as `runInTransaction` describes. */ export type Transact = (work: (tx: Tx) => Promise) => Promise; -/** Binds `runInTransaction` to the database and the event bus, once at startup. */ -export function transactor(orm: Orm, eventBus: EventBus): Transact { - return (work) => runInTransaction(orm, eventBus, work); +/** Binds `runInTransaction` to the database and the event buses, once at startup. */ +export function transactor( + orm: Orm, + eventBus: EventBus, + beforeCommit: BeforeCommitBus = createBeforeCommitBus(), +): Transact { + return (work) => runInTransaction(orm, { eventBus, beforeCommit }, work); } +/** How many rounds of events before a commit may publish more: a loop of reactions beyond it is a bug. */ +export const MAX_BEFORE_COMMIT_ROUNDS = 10; + /** The ORM to query: the pool, or the open transaction behind `db`. */ export function ormOf(db: Orm | Tx): Orm | OrmTransaction { return db instanceof Transaction ? db.orm : (db as Orm); } /** - * Runs `work` in one transaction. After the commit, dispatches the events published - * on it; a rollback drops them. Inner operations join `tx`; there is no nesting. + * Runs `work` in one transaction. Before the commit, hands the events published on it to the + * before-commit handlers, round after round while they publish more; after the commit, dispatches + * them all. A rollback drops them. Inner operations join `tx`; there is no nesting. */ async function runInTransaction( orm: Orm, - eventBus: EventBus, + { eventBus, beforeCommit }: { eventBus: EventBus; beforeCommit: BeforeCommitBus }, work: (tx: Tx) => Promise, ): Promise { let transaction: Transaction | undefined; let result: T; try { - result = await orm.transaction((ormTransaction) => { + result = await orm.transaction(async (ormTransaction) => { transaction = new Transaction(ormTransaction); - return work(transaction); + const value = await work(transaction); + await handBeforeCommit(transaction, beforeCommit); + return value; }); } catch (error) { // Text Postgres cannot store, such as a NUL in a name, is input the caller must change. @@ -78,3 +91,18 @@ async function runInTransaction( await eventBus.dispatch(transaction!.events); return result; } + +/** Hands each round's new events to the before-commit handlers until a round publishes none. */ +async function handBeforeCommit(transaction: Transaction, beforeCommit: BeforeCommitBus) { + let handed = 0; + for (let round = 0; handed < transaction.events.length; round += 1) { + if (round === MAX_BEFORE_COMMIT_ROUNDS) { + throw new Error( + `Events kept publishing more events before the commit after ${MAX_BEFORE_COMMIT_ROUNDS} rounds.`, + ); + } + const events = transaction.events.slice(handed); + handed = transaction.events.length; + await beforeCommit.deliver(transaction, events); + } +} diff --git a/packages/api/src/core/events/eventBus.ts b/packages/api/src/core/events/eventBus.ts index f46d78745..581d84a09 100644 --- a/packages/api/src/core/events/eventBus.ts +++ b/packages/api/src/core/events/eventBus.ts @@ -13,6 +13,9 @@ export type DomainEvent = { [Type in EventType]: { type: Type; payload: DomainEvents[Type] }; }[EventType]; +/** An event with the id it got when it was published, which stays with it wherever it goes. */ +export type PublishedEvent = DomainEvent & { id: string }; + /** What a module does when an event happens, by event type. */ export type EventHandlers = { [Type in EventType]?: (payload: DomainEvents[Type]) => Promise; diff --git a/packages/api/src/core/events/index.ts b/packages/api/src/core/events/index.ts index ff4c93dbb..a23c4c253 100644 --- a/packages/api/src/core/events/index.ts +++ b/packages/api/src/core/events/index.ts @@ -2,4 +2,11 @@ export { createEventBus } from "./eventBus"; // Public types. -export type { DomainEvents, EventBus, EventHandlers, EventType, DomainEvent } from "./eventBus"; +export type { + DomainEvents, + EventBus, + EventHandlers, + EventType, + DomainEvent, + PublishedEvent, +} from "./eventBus"; diff --git a/packages/api/src/core/moduleManifest.ts b/packages/api/src/core/moduleManifest.ts index 4561cbc54..e2f3f8656 100644 --- a/packages/api/src/core/moduleManifest.ts +++ b/packages/api/src/core/moduleManifest.ts @@ -3,7 +3,7 @@ import type express from "express"; import type { AgentSecurityResolver } from "./agentAccess"; -import type { Orm, Transact } from "./db"; +import type { BeforeCommitHandler, Orm, Transact } from "./db"; import type { AddApprovedPeople, PasswordResetEmailService, SetupDemo } from "./instance"; import type { EventHandlers } from "./events"; import type { JobHandlers, JobQueue } from "./jobs"; @@ -39,6 +39,8 @@ export interface ModuleManifest { resolvers: Resolvers; /** What the module does when another module's event happens. */ events?: EventHandlers; + /** What the module does with a transaction's events inside it, committing with them or not at all. */ + eventsBeforeCommit?: BeforeCommitHandler; /** The record types the module owns that keep history: who may read it and how to load them. */ recordTypes?: RecordTypeRegistry; /** The module's background jobs, run as the system. */ diff --git a/packages/api/src/core/records/app/recordHistoryService.ts b/packages/api/src/core/records/app/recordHistoryService.ts index ee9674af7..87bf1c22b 100644 --- a/packages/api/src/core/records/app/recordHistoryService.ts +++ b/packages/api/src/core/records/app/recordHistoryService.ts @@ -57,12 +57,12 @@ export function createRecordHistoryService(): RecordHistoryService { async append(ctx, tx, { recordType, operation, cause, reason = null, records }) { const author = authorOf(ctx); const lines = await recordHistoryRepository(tx).insert( - records.map(({ recordId, changes }) => ({ + records.map(({ recordId, changes, cause: own = cause }) => ({ recordType, recordId, operation, author, - cause: { kind: cause.kind, ref: cause.ref ?? null }, + cause: { kind: own.kind, ref: own.ref ?? null }, reason, changes, })), diff --git a/packages/api/src/core/records/domain/recordHistory.ts b/packages/api/src/core/records/domain/recordHistory.ts index 2f12a4d66..a5ea8c06d 100644 --- a/packages/api/src/core/records/domain/recordHistory.ts +++ b/packages/api/src/core/records/domain/recordHistory.ts @@ -68,7 +68,8 @@ export interface HistoryAppend { operation: RecordOperation; cause: { kind: CauseKind; ref?: string | null }; reason?: string | null; - records: { recordId: string; changes: FieldChange[] }[]; + /** A record's own `cause` replaces the append's, such as tasks of different runs in one batch. */ + records: { recordId: string; changes: FieldChange[]; cause?: HistoryAppend["cause"] }[]; } /** The author of a change made with `ctx`. */ diff --git a/packages/api/src/core/server.ts b/packages/api/src/core/server.ts index ef73fdf97..a97102d5b 100644 --- a/packages/api/src/core/server.ts +++ b/packages/api/src/core/server.ts @@ -14,7 +14,7 @@ import { createAgentConnectionService, } from "./agentAccess"; import { authHandler, type DeploymentServer } from "./auth"; -import { connectDatabase, type Database, transactor } from "./db"; +import { connectDatabase, createBeforeCommitBus, type Database, transactor } from "./db"; import type { Env } from "./env"; import { createEventBus } from "./events"; import { type GraphqlApi, startGraphqlApi } from "./graphql"; @@ -58,7 +58,8 @@ export async function startServer( const secretBox = createSecretBox(await ensureSecret(env.dataDir, "encryption-key")); const database = connectDatabase(env.databaseUrl, logger); const eventBus = createEventBus(logger); - const transact = transactor(database.orm, eventBus); + const beforeCommit = createBeforeCommitBus(); + const transact = transactor(database.orm, eventBus, beforeCommit); const jobRunner = createJobRunner(env.databaseUrl, logger); const readiness = new Readiness(() => database.checkConnection()); const app = express(); @@ -132,6 +133,9 @@ export async function startServer( if (module.events) { eventBus.subscribe(module.events); } + if (module.eventsBeforeCommit) { + beforeCommit.subscribe(module.eventsBeforeCommit); + } if (module.router) { app.use( "/api", diff --git a/packages/api/src/modules/employees/app/employeeDirectory.ts b/packages/api/src/modules/employees/app/employeeDirectory.ts index 580a8fcb1..446334898 100644 --- a/packages/api/src/modules/employees/app/employeeDirectory.ts +++ b/packages/api/src/modules/employees/app/employeeDirectory.ts @@ -1,4 +1,4 @@ -import type { Orm } from "../../../core/db"; +import type { Orm, Tx } from "../../../core/db"; import { createOwnerService } from "../../../core/instance"; import type { EmployeeDirectory } from "../../../core/security"; import { todayInUtc } from "../../../utils"; @@ -6,10 +6,10 @@ import { employeeRepository, employmentRepository } from "../db"; const owners = createOwnerService(); -/** What permission checks read about people: who a user is, and who is above whom today. */ -export function employeeDirectory(orm: Orm): EmployeeDirectory { - const employees = employeeRepository(orm); - const employments = employmentRepository(orm); +/** What permission checks read about people: who a user is, and who is above whom today; in `db`. */ +export function employeeDirectory(db: Orm | Tx): EmployeeDirectory { + const employees = employeeRepository(db); + const employments = employmentRepository(db); const employeeIdOfUser = async (userId: string) => (await employees.findByUserId(userId))?.id ?? null; return { @@ -18,7 +18,7 @@ export function employeeDirectory(orm: Orm): EmployeeDirectory { managersAbove: (employeeIds) => employments.findManagersAbove(employeeIds, todayInUtc()), reportsBelow: (employeeId) => employments.findReportsBelow(employeeId, todayInUtc()), ownersEmployeeId: async () => { - const owner = await owners.readOwner(orm); + const owner = await owners.readOwner(db); return owner ? employeeIdOfUser(owner.userId) : null; }, }; diff --git a/packages/api/src/modules/processes/app/dueRunService.ts b/packages/api/src/modules/processes/app/dueRunService.ts index 3acdc2f4f..98c49d546 100644 --- a/packages/api/src/modules/processes/app/dueRunService.ts +++ b/packages/api/src/modules/processes/app/dueRunService.ts @@ -39,12 +39,13 @@ export function createDueRunService({ for (const process of await processRepository(orm).listProcesses()) { if (!process.turnedOnAt) continue; const { activeVersion } = process; - const definition = parseProcessDefinition( + const { trigger } = parseProcessDefinition( activeVersion.schemaVersion, activeVersion.definition, ); + if (trigger.kind !== "on_record_date") continue; const turnedOnDay = dateInLondon(process.turnedOnAt); - const due = await transact((tx) => starter.findDue(tx, definition, { today, turnedOnDay })); + const due = await transact((tx) => starter.findDue(tx, trigger, { today, turnedOnDay })); const withRuns = await processRepository(orm).findRecordsWithRuns( process.id, "employee", @@ -84,14 +85,8 @@ async function startOne( today: CalendarDate, ): Promise { try { - const workspaceOwnerEmployeeId = await starter.readWorkspaceOwner(); const run = await transact((tx) => - startIfStillDue( - starter, - tx, - { processId: process.id, employeeId, today }, - workspaceOwnerEmployeeId, - ), + startIfStillDue(starter, tx, { processId: process.id, employeeId, today }), ); if (run) { logger.info( @@ -117,7 +112,6 @@ async function startIfStillDue( starter: RunStarter, tx: Tx, { processId, employeeId, today }: { processId: string; employeeId: string; today: CalendarDate }, - workspaceOwnerEmployeeId: string | null, ): Promise { const processes = processRepository(tx); await processes.lockProcess(processId); @@ -126,6 +120,7 @@ async function startIfStillDue( if (!process?.turnedOnAt || !subject?.startDate) return null; const { activeVersion } = process; const definition = parseProcessDefinition(activeVersion.schemaVersion, activeVersion.definition); + if (definition.trigger.kind !== "on_record_date") return null; const triggerDate = dueTriggerDate(definition.trigger, subject.startDate, { today, turnedOnDay: dateInLondon(process.turnedOnAt), @@ -133,17 +128,21 @@ async function startIfStillDue( const hasRun = (await processes.findRecordsWithRuns(processId, "employee", [employeeId])).size > 0; if (!triggerDate || hasRun) return null; - return starter.start(tx, { - process, - definition, - subject, - startCause: "started_on_record_date", - startedByEmployeeId: null, - triggerDate, - startKey: `on_record_date:employee:${employeeId}:${triggerDate}`, - startDay: today, - workspaceOwnerEmployeeId, - }); + const [run] = await starter.startMany(tx, [ + { + process, + definition, + record: { recordType: "employee", recordId: employeeId }, + subject, + startCause: "started_on_record_date", + startedByEmployeeId: null, + triggerDate, + triggerEvent: null, + startKey: `on_record_date:employee:${employeeId}:${triggerDate}`, + startDay: today, + }, + ]); + return run!; } function errorKind(error: unknown): string | undefined { diff --git a/packages/api/src/modules/processes/app/eventRunService.ts b/packages/api/src/modules/processes/app/eventRunService.ts new file mode 100644 index 000000000..48d4a6548 --- /dev/null +++ b/packages/api/src/modules/processes/app/eventRunService.ts @@ -0,0 +1,191 @@ +import type { Tx } from "../../../core/db"; +import type { PublishedEvent } from "../../../core/events"; +import type { Logger } from "../../../core/logger"; +import type { SecurityContext } from "../../../core/security"; +import type { CalendarDate } from "../../../utils"; +import type { TaskService } from "../../tasks"; +import { processRepository, type StoredProcess } from "../db"; +import { + type EventStart, + isTriggeringEvent, + matchEventStarts, + parseProcessDefinition, + type ProcessDefinition, +} from "../domain"; +import type { RunStarter, RunSubject } from "./runStarter"; + +/** Starts the runs that a transaction's events trigger, inside that transaction. */ +export interface EventRunService { + /** + * Trusted, as the system, before the commit: one run per turned-on process and record each event + * triggers, skipping a record whose run of the process is in progress or that is gone. An error + * rolls the transaction back. + */ + startRunsOnEvents(ctx: SecurityContext, tx: Tx, events: readonly PublishedEvent[]): Promise; +} + +export function createEventRunService(dependencies: EventRunDependencies): EventRunService { + return { + async startRunsOnEvents(ctx, tx, events) { + ctx.requireSystem(); + const triggering = events.filter(({ type }) => isTriggeringEvent(type)); + if (triggering.length === 0) return; + const processes = await processRepository(tx).lockProcessesStartedBy([ + ...new Set(triggering.map(({ type }) => type)), + ]); + const definitions = new Map( + processes.map(({ id, activeVersion }) => [ + id, + parseProcessDefinition(activeVersion.schemaVersion, activeVersion.definition), + ]), + ); + const starts = matchEventStarts( + processes.map(({ id }) => ({ processId: id, trigger: definitions.get(id)!.trigger })), + triggering, + ); + for (const process of processes) { + const own = starts.filter(({ processId }) => processId === process.id); + if (own.length === 0) continue; + await startProcessRuns(dependencies, tx, { + process, + definition: definitions.get(process.id)!, + starts: own, + }); + } + }, + }; +} + +interface EventRunDependencies { + logger: Logger; + tasks: TaskService; + starter: RunStarter; + /** Today in London: the start day of the runs. */ + today: () => CalendarDate; +} + +type Start = EventStart; + +/** + * Starts one process's runs in waves, each record at most once a wave, so a second event for a + * record finds the first one's run when it is still in progress. + */ +async function startProcessRuns( + { logger, tasks, starter, today }: EventRunDependencies, + tx: Tx, + { + process, + definition, + starts, + }: { process: StoredProcess; definition: ProcessDefinition; starts: readonly Start[] }, +): Promise { + const { recordType } = definition.subject; + const subjects = await readRecordSubjects({ tasks, starter }, tx, recordType, starts); + const runs = processRepository(tx); + for (const wave of wavesOf(starts)) { + const inProgress = await runs.findRecordsWithRunInProgress( + process.id, + recordType, + wave.map(({ recordId }) => recordId), + ); + const ready = wave.filter((start) => { + const reason = skipReasonOf(start, { subjects, inProgress }); + if (reason) logSkip(logger, process, start, reason); + return reason === null; + }); + const started = await starter.startMany( + tx, + ready.map(({ event, recordId }) => ({ + process, + definition, + record: { recordType, recordId }, + subject: subjects.get(recordId) ?? null, + startCause: "started_on_event", + startedByEmployeeId: null, + triggerDate: null, + triggerEvent: event, + startKey: `on_event:${event.id}:${recordId}`, + startDay: today(), + })), + ); + if (started.length > 0) logStarted(logger, process, started, ready); + } +} + +/** Why a start is skipped: its record is gone or already has a run of the process in progress. */ +function skipReasonOf( + { recordId }: Start, + { + subjects, + inProgress, + }: { subjects: ReadonlyMap; inProgress: ReadonlySet }, +): "record_gone" | "run_in_progress" | null { + if (!subjects.has(recordId)) return "record_gone"; + return inProgress.has(recordId) ? "run_in_progress" : null; +} + +/** + * The subject of each record that still exists, by record id: the employee itself, or a task's + * subject, null for a task about nobody. + */ +async function readRecordSubjects( + { tasks, starter }: Pick, + tx: Tx, + recordType: ProcessDefinition["subject"]["recordType"], + starts: readonly Start[], +): Promise> { + const recordIds = [...new Set(starts.map(({ recordId }) => recordId))]; + if (recordType === "employee") return starter.readSubjects(tx, recordIds); + const found = await tasks.readTasksInTransaction(tx, recordIds); + const subjects = await starter.readSubjects( + tx, + found.flatMap(({ subjectEmployeeId }) => (subjectEmployeeId ? [subjectEmployeeId] : [])), + ); + return new Map( + found.map(({ id, subjectEmployeeId }) => [ + id, + subjectEmployeeId ? (subjects.get(subjectEmployeeId) ?? null) : null, + ]), + ); +} + +/** The starts split so each record appears at most once a wave, keeping their order. */ +function wavesOf(starts: readonly Start[]): Start[][] { + const waves: Start[][] = []; + for (const start of starts) { + const wave = waves.find((candidate) => + candidate.every(({ recordId }) => recordId !== start.recordId), + ); + if (wave) wave.push(start); + else waves.push([start]); + } + return waves; +} + +function logSkip( + logger: Logger, + process: StoredProcess, + { event, recordId }: Start, + reason: "record_gone" | "run_in_progress", +): void { + logger.info( + { processKey: process.key, eventId: event.id, eventType: event.type, recordId, reason }, + "Process run not started for an event", + ); +} + +function logStarted( + logger: Logger, + process: StoredProcess, + runs: readonly { id: string }[], + starts: readonly Start[], +): void { + logger.info( + { + processKey: process.key, + runIds: runs.map(({ id }) => id), + eventIds: [...new Set(starts.map(({ event }) => event.id))], + }, + "Process runs started with the change that triggered them", + ); +} diff --git a/packages/api/src/modules/processes/app/processService.ts b/packages/api/src/modules/processes/app/processService.ts index 9daa25511..3c28493aa 100644 --- a/packages/api/src/modules/processes/app/processService.ts +++ b/packages/api/src/modules/processes/app/processService.ts @@ -14,6 +14,7 @@ import type { TaskService } from "../../tasks"; import { processRepository, type StoredProcess } from "../db"; import { parseProcessDefinition } from "../domain"; import { createDueRunService } from "./dueRunService"; +import { createEventRunService, type EventRunService } from "./eventRunService"; import { processRecordType } from "./processRecordType"; import type { Process, @@ -74,6 +75,11 @@ export interface ProcessService { lockProcesses(ctx: SecurityContext, tx: Tx): Promise; /** Back to a fresh workspace: deletes every run with its tasks, turns every process off, forgets their history. */ resetProcesses(ctx: SecurityContext, tx: Tx): Promise; + /** + * Trusted, as the system, before a transaction commits: starts the runs its events trigger, so + * they commit with the change or not at all. + */ + startRunsOnEvents: EventRunService["startRunsOnEvents"]; } export function createProcessService({ @@ -94,6 +100,7 @@ export function createProcessService({ const reader = createRunReader({ transact, tasks, starter }); const lifecycle = createRunLifecycleService({ orm, transact, logger, tasks, starter, reader }); const dueRuns = createDueRunService({ orm, transact, logger, starter, lifecycle }); + const eventRuns = createEventRunService({ logger, tasks, starter, today }); const historyReader = createRecordHistoryReaderService(orm, { process: processRecordType(orm) }); return { @@ -132,10 +139,9 @@ export function createProcessService({ const subjectId = parseUuid(employeeId); if (!subjectId) throw employeeNotFound(); await ctx.requireInScope("process_run", "create", subjectId); - if (!definition.startableByAdmin) { + if (!definition.startableByAdmin || definition.subject.recordType !== "employee") { throw new ApplicationError("FORBIDDEN", "This process starts only by itself."); } - const workspaceOwnerEmployeeId = await starter.readWorkspaceOwner(); const run = await transact(async (tx) => { await processRepository(tx).lockProcess(process.id); const subject = await starter.readSubject(tx, subjectId); @@ -147,17 +153,21 @@ export function createProcessService({ ); } const startDay = laterOf(today(), subject.startDate); - return starter.start(tx, { - process, - definition, - subject, - startCause: "started_by_admin", - startedByEmployeeId: ctx.user?.employeeId ?? null, - triggerDate: null, - startKey: null, - startDay, - workspaceOwnerEmployeeId, - }); + const [started] = await starter.startMany(tx, [ + { + process, + definition, + record: { recordType: "employee", recordId: subjectId }, + subject, + startCause: "started_by_admin", + startedByEmployeeId: ctx.user?.employeeId ?? null, + triggerDate: null, + triggerEvent: null, + startKey: null, + startDay, + }, + ]); + return started!; }); logger.info({ runId: run.id, processKey, startCause: run.startCause }, "Process run started"); return reader.readDetails(run); @@ -191,6 +201,8 @@ export function createProcessService({ await processRepository(tx).lockAllProcesses(); }, + startRunsOnEvents: (ctx, tx, events) => eventRuns.startRunsOnEvents(ctx, tx, events), + async resetProcesses(ctx, tx) { ctx.requireSetupAccess("MANAGE_WORKSPACE"); await processRepository(tx).reset(); diff --git a/packages/api/src/modules/processes/app/runReader.ts b/packages/api/src/modules/processes/app/runReader.ts index f91c4a3d1..ba4dbf51f 100644 --- a/packages/api/src/modules/processes/app/runReader.ts +++ b/packages/api/src/modules/processes/app/runReader.ts @@ -74,6 +74,7 @@ function runView(run: StoredRun, process: StoredProcess): ProcessRun { subjectEmployeeId: run.subjectEmployeeId, startCause: run.startCause, startedByEmployeeId: run.startedByEmployeeId, + triggerEvent: run.triggerEvent, startDay: run.startDay, status: run.status, finishedAt: run.finishedAt, diff --git a/packages/api/src/modules/processes/app/runStarter.ts b/packages/api/src/modules/processes/app/runStarter.ts index 37680292e..3931266b3 100644 --- a/packages/api/src/modules/processes/app/runStarter.ts +++ b/packages/api/src/modules/processes/app/runStarter.ts @@ -1,6 +1,5 @@ -import { ApplicationError } from "../../../core/applicationError"; import type { Orm, Transact, Tx } from "../../../core/db"; -import { PrincipalSecurityContext, type SecurityContext } from "../../../core/security"; +import { PrincipalSecurityContext } from "../../../core/security"; import { addDays, type CalendarDate } from "../../../utils"; import type { BankHolidayService } from "../../bankHolidays"; import { @@ -8,26 +7,25 @@ import { createEmployeeWorkingPatternService, employeeDirectory, type EmployeeReadService, - type EmployeeWorkingPatternService, } from "../../employees"; -import type { TaskService } from "../../tasks"; -import { processRepository, type StoredProcess } from "../db"; +import type { NewTask, TaskService } from "../../tasks"; +import { type NewRunRow, processRepository, type StoredProcess } from "../db"; import { addDayOffset, type DayOffset, dueTriggerDate, - MAX_SEARCH_DAYS, type IsWorkingDay, parseProcessDefinition, type ProcessDefinition, type ProcessRun, + type RecordDateTrigger, resolveAssignee, + type ResolvedAssignee, type RunStep, - settleRun, type StartCause, - type TaskStep, - workingDaysOf, + type TriggerEvent, } from "../domain"; +import { createWorkingCalendars } from "./workingCalendars"; /** The person a run is about, as starting it needs them. */ export interface RunSubject { @@ -41,30 +39,34 @@ export interface RunSubject { export interface RunStart { process: StoredProcess; definition: ProcessDefinition; - subject: RunSubject; + /** The record the run is about. */ + record: { recordType: ProcessDefinition["subject"]["recordType"]; recordId: string }; + /** The person the run is about: the employee, or a task's subject; null for nobody. */ + subject: RunSubject | null; startCause: StartCause; startedByEmployeeId: string | null; triggerDate: CalendarDate | null; + triggerEvent: TriggerEvent | null; startKey: string | null; startDay: CalendarDate; - workspaceOwnerEmployeeId: string | null; } /** Starts runs: their steps' assignees, tasks and due dates. */ export interface RunStarter { /** The person with this id, or null when there is none. */ readSubject(tx: Tx, employeeId: string): Promise; - /** The owner's employee, read before any lock is taken: it needs its own connection. */ - readWorkspaceOwner(): Promise; + /** The people among these ids that exist, by id. */ + readSubjects(tx: Tx, employeeIds: readonly string[]): Promise>; /** - * Trusted: creates the run, its steps and their tasks in `tx`, as Broccoli, with the run as their - * cause; a run whose steps are all skipped finishes at once. + * Trusted: creates the runs, their steps and their tasks in `tx`, as Broccoli, with each run as + * its tasks' cause, in a fixed number of statements; a run whose steps are all skipped is + * finished at once. Returns the runs in the order of `starts`. */ - start(tx: Tx, start: RunStart): Promise; + startMany(tx: Tx, starts: readonly RunStart[]): Promise; /** The people whose start date triggers a run today, with the date it triggered on. */ findDue( tx: Tx, - definition: ProcessDefinition, + trigger: RecordDateTrigger, days: { today: CalendarDate; turnedOnDay: CalendarDate }, ): Promise<{ employeeId: string; triggerDate: CalendarDate }[]>; /** Each step's title as its task gets it, in the order the run's version lists the steps. */ @@ -83,50 +85,45 @@ export function createRunStarter({ bankHolidays: BankHolidayService; }): RunStarter { const employees = createEmployeeReadService({ orm }); - const calendars: WorkingCalendars = { + const calendars = createWorkingCalendars({ workingPatterns: createEmployeeWorkingPatternService({ transact }), bankHolidays, - }; - const directory = employeeDirectory(orm); - const system = PrincipalSecurityContext.system(); + }); + const readSubjects = (tx: Tx, ids: readonly string[]) => readSubjectsOf(employees, tx, ids); return { - readSubject: (tx, employeeId) => readSubject(employees, tx, employeeId), + readSubject: async (tx, employeeId) => + (await readSubjects(tx, [employeeId])).get(employeeId) ?? null, - readWorkspaceOwner: () => directory.ownersEmployeeId(), + readSubjects, - async start(tx, start) { - const { definition, subject, startDay } = start; - const runs = processRepository(tx); - const run = await runs.insertRun(newRun(start)); - const managers = await employees.readManagerIdsOn(tx, { - employeeIds: [subject.id], - date: startDay, + async startMany(tx, starts) { + if (starts.length === 0) return []; + const assignees = await resolveAssignees(employees, tx, starts); + const workingDays = await calendars.readWorkingDays(tx, { + employeeIds: subjectsCountingBusinessDays(starts), + days: starts.map(({ startDay }) => startDay), }); - const candidates = { - subject: subject.id, - subjects_manager: managers.get(subject.id) ?? null, - workspace_owner: start.workspaceOwnerEmployeeId, - }; - const needsWorkingDays = definition.steps.some(({ due }) => due.unit === "business_days"); - const isWorkingDay = needsWorkingDays - ? await workingDaysAround(calendars, system, tx, subject.id, startDay) - : null; - const steps: RunStep[] = []; - for (const step of definition.steps) { - const assignee = resolveAssignee(step.assignee, candidates); - steps.push( - assignee - ? await startStep(tasks, system, tx, { run, start, step, assignee, isWorkingDay }) - : skippedStep(run.id, step.key), - ); - } - await runs.insertSteps(steps); - const settled = settleRun(run, steps, new Date()); - return settled === run ? run : runs.saveRun(settled); + const now = new Date(); + const runs = await processRepository(tx).insertRuns( + starts.map((start, index) => newRun(start, assignees[index]!, now)), + ); + const plans = runs.map((run, index) => ({ + run, + start: starts[index]!, + assignees: assignees[index]!, + isWorkingDay: workingDays.get(starts[index]!.subject?.id ?? "") ?? null, + })); + const created = await tasks.createTasks( + PrincipalSecurityContext.system(), + tx, + plans.flatMap(newTasksOf), + ); + await processRepository(tx).insertSteps(stepsOf(plans, created, now)); + return runs; }, - async findDue(tx, { trigger }, days) { + async findDue(tx, trigger, days) { // A start date's trigger date is at most the offset's span away from it. const span = spanOf(trigger.offset) + trigger.maxDaysLate; const starts = await employees.readEmployeesStartingBetween(tx, { @@ -143,139 +140,145 @@ export function createRunStarter({ const version = (await processRepository(tx).findVersion(run.processVersionId))!; const { steps } = parseProcessDefinition(version.schemaVersion, version.definition); const subject = run.subjectEmployeeId - ? await readSubject(employees, tx, run.subjectEmployeeId) + ? ((await readSubjects(tx, [run.subjectEmployeeId])).get(run.subjectEmployeeId) ?? null) : null; return new Map(steps.map(({ key, title }) => [key, filled(title, subject)])); }, }; } -/** The person with this id, with their first employment's start, or null when there is none. */ -async function readSubject( +/** One run being started, with what its steps need. */ +interface RunPlan { + run: ProcessRun; + start: RunStart; + /** Each step's assignee, in the definition's order; null for a step skipped for want of one. */ + assignees: (ResolvedAssignee | null)[]; + isWorkingDay: IsWorkingDay | null; +} + +/** The people among these ids that exist, with their first employment's start, by id. */ +async function readSubjectsOf( employees: EmployeeReadService, tx: Tx, - employeeId: string, -): Promise { - const [employee] = await employees.readEmployeesByName(tx, [employeeId]); - if (!employee) return null; - const periods = (await employees.readEmploymentPeriods(tx, [employeeId])).get(employeeId); - return { - id: employee.id, - firstName: employee.firstName, - startDate: periods?.[0]?.startDate ?? null, - }; + employeeIds: readonly string[], +): Promise> { + if (employeeIds.length === 0) return new Map(); + const found = await employees.readEmployeesByName(tx, employeeIds); + const periods = await employees.readEmploymentPeriods( + tx, + found.map(({ id }) => id), + ); + return new Map( + found.map(({ id, firstName }) => [ + id, + { id, firstName, startDate: periods.get(id)?.[0]?.startDate ?? null }, + ]), + ); } -interface WorkingCalendars { - workingPatterns: EmployeeWorkingPatternService; - bankHolidays: BankHolidayService; +/** Each start's step assignees, with each subject's manager on its start day and the owner. */ +async function resolveAssignees( + employees: EmployeeReadService, + tx: Tx, + starts: readonly RunStart[], +): Promise<(ResolvedAssignee | null)[][]> { + const owner = await employeeDirectory(tx).ownersEmployeeId(); + const managersByDay = new Map>(); + for (const day of new Set(starts.map(({ startDay }) => startDay))) { + const employeeIds = starts.flatMap((start) => + start.startDay === day && start.subject ? [start.subject.id] : [], + ); + managersByDay.set(day, await employees.readManagerIdsOn(tx, { employeeIds, date: day })); + } + return starts.map(({ definition, subject, startDay }) => { + const candidates = { + subject: subject?.id ?? null, + subjects_manager: subject ? (managersByDay.get(startDay)!.get(subject.id) ?? null) : null, + workspace_owner: owner, + }; + return definition.steps.map((step) => resolveAssignee(step.assignee, candidates)); + }); +} + +/** The subjects of runs with a step due in business days, which count their own working days. */ +function subjectsCountingBusinessDays(starts: readonly RunStart[]): string[] { + const ids = starts.flatMap(({ definition, subject }) => + subject && definition.steps.some(({ due }) => due.unit === "business_days") ? [subject.id] : [], + ); + return [...new Set(ids)]; } -function newRun({ - process, - subject, - startCause, - startedByEmployeeId, - triggerDate, - startKey, - startDay, -}: RunStart) { +function newRun(start: RunStart, assignees: readonly (ResolvedAssignee | null)[], now: Date) { + const finished = assignees.every((assignee) => assignee === null); return { - processId: process.id, - processVersionId: process.activeVersion.id, - recordType: "employee", - recordId: subject.id, - subjectEmployeeId: subject.id, - startCause, - startedByEmployeeId, - triggerDate, - startKey, - startDay, - status: "in_progress" as const, - finishedAt: null, + processId: start.process.id, + processVersionId: start.process.activeVersion.id, + recordType: start.record.recordType, + recordId: start.record.recordId, + subjectEmployeeId: start.subject?.id ?? null, + startCause: start.startCause, + startedByEmployeeId: start.startedByEmployeeId, + triggerDate: start.triggerDate, + triggerEvent: start.triggerEvent, + startKey: start.startKey, + startDay: start.startDay, + status: finished ? "finished" : "in_progress", + finishedAt: finished ? now : null, cancelledAt: null, cancelledByEmployeeId: null, cancelCause: null, cancelReason: null, - }; + } satisfies NewRunRow; } -/** Gives the step's task to its assignee, due its offset after the start day. */ -async function startStep( - tasks: TaskService, - ctx: SecurityContext, - tx: Tx, - { - run, - start: { process, subject, startDay }, - step, - assignee, - isWorkingDay, - }: { - run: ProcessRun; - start: RunStart; - step: TaskStep; - assignee: { employeeId: string; note: RunStep["assigneeNote"] }; - isWorkingDay: IsWorkingDay | null; - }, -): Promise { - const task = await tasks.createTask(ctx, tx, { - type: "process_step_to_do", - title: filled(step.title, subject), - description: step.description === null ? null : filled(step.description, subject), - assigneeEmployeeId: assignee.employeeId, - processSource: { processKey: process.key, processRunId: run.id, stepKey: step.key }, - subjectEmployeeId: subject.id, - record: { recordType: run.recordType, recordId: run.recordId }, - dueDate: addDayOffset(startDay, step.due, isWorkingDay), - cause: { kind: "PROCESS_RUN", ref: run.id }, +/** The tasks of a run's assigned steps, due their offset after its start day. */ +function newTasksOf({ run, start, assignees, isWorkingDay }: RunPlan): NewTask[] { + return start.definition.steps.flatMap((step, index) => { + const assignee = assignees[index]; + if (!assignee) return []; + return [ + { + type: "process_step_to_do", + title: filled(step.title, start.subject), + description: step.description === null ? null : filled(step.description, start.subject), + assigneeEmployeeId: assignee.employeeId, + processSource: { processKey: start.process.key, processRunId: run.id, stepKey: step.key }, + subjectEmployeeId: start.subject?.id ?? null, + record: { recordType: run.recordType, recordId: run.recordId }, + dueDate: addDayOffset(start.startDay, step.due, isWorkingDay), + cause: { kind: "PROCESS_RUN", ref: run.id }, + }, + ]; }); - return { - runId: run.id, - stepKey: step.key, - status: "step_awaiting_task_completion", - taskId: task.id, - assigneeNote: assignee.note, - skipReason: null, - finishedAt: null, - }; } -/** Business days by the person's own working weeks and bank holidays, refusing a year with none known. */ -async function workingDaysAround( - { workingPatterns, bankHolidays }: WorkingCalendars, - ctx: SecurityContext, - tx: Tx, - employeeId: string, - day: CalendarDate, -): Promise { - const from = addDays(day, -MAX_SEARCH_DAYS); - const until = addDays(day, MAX_SEARCH_DAYS); - const weeks = - ( - await workingPatterns.readWorkingWeeksInTransaction(tx, { - employeeIds: [employeeId], - from, - until, - }) - ).get(employeeId) ?? []; - const calendarNames = [...new Set(weeks.flatMap(({ bankHolidayCalendar: c }) => (c ? [c] : [])))]; - const holidays = await bankHolidays.readBankHolidays(ctx, { - calendars: calendarNames, - from, - until, - }); - return workingDaysOf(weeks, (date, weekIndex) => { - const calendar = weeks[weekIndex]?.bankHolidayCalendar; - if (!calendar) return false; - const days = holidays.get(calendar) ?? []; - const year = date.slice(0, 4); - // Every UK calendar has bank holidays each year: none means the data ends before it. - if (!days.some((holiday) => holiday.date.startsWith(year))) { - throw new ApplicationError("CONFLICT", `No bank holidays are known for ${year}.`); - } - return days.some((holiday) => holiday.date === date); - }); +/** Every run's steps: awaiting the task created for it, in the same order, or skipped. */ +function stepsOf(plans: readonly RunPlan[], created: readonly { id: string }[], now: Date) { + let next = 0; + return plans.flatMap(({ run, start, assignees }) => + start.definition.steps.map((step, index): RunStep => { + const assignee = assignees[index]; + return assignee + ? { + runId: run.id, + stepKey: step.key, + status: "step_awaiting_task_completion", + taskId: created[next++]!.id, + assigneeNote: assignee.note, + skipReason: null, + finishedAt: null, + } + : { + runId: run.id, + stepKey: step.key, + status: "step_skipped", + taskId: null, + assigneeNote: null, + skipReason: "no_assignee_for_step", + finishedAt: now, + }; + }), + ); } /** How far a trigger date can be from its record's date: business days stretch by weekends and holidays. */ @@ -286,15 +289,3 @@ function spanOf({ amount, unit }: DayOffset): number { function filled(text: string, subject: Pick | null): string { return text.replaceAll("{firstName}", subject?.firstName ?? "them"); } - -function skippedStep(runId: string, stepKey: string): RunStep { - return { - runId, - stepKey, - status: "step_skipped", - taskId: null, - assigneeNote: null, - skipReason: "no_assignee_for_step", - finishedAt: new Date(), - }; -} diff --git a/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts b/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts new file mode 100644 index 000000000..9b825e95c --- /dev/null +++ b/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts @@ -0,0 +1,340 @@ +import { assert, onTestFinished, vi } from "vitest"; + +import { + ADMIN, + calendarDate, + employeeServices, + expect, + processService, + signedIn, + taskService, + test, + transactWithProcesses, +} from "#test/support"; + +import type { ProcessDefinition } from "../domain"; + +test("adding a person commits exactly one process run and a task for their manager", async ({ + orm, + seed, +}) => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-12-03T12:00:00Z") }); + onTestFinished(() => { + vi.useRealTimers(); + }); + const manager = await seed.employee(); + const definition = eventProcess("employees.added"); + definition.steps[0]!.assignee = { kind: "subjects_manager" }; + const { key } = await seed.process({ key: "welcome", definition }); + const transact = transactWithProcesses(orm, { today }); + + const employee = await transact(async (tx) => { + const employees = employeeServices(orm, { transact: (work) => work(tx) }); + const added = await employees.createEmployee(ADMIN, { + firstName: "Sam", + lastName: "New", + email: "sam@example.com", + }); + assert(added); + await employees.setManagers(ADMIN, { employeeIds: [added.id], managerId: manager.id }); + return added; + }); + + expect(await employeeServices(orm).readEmployee(ADMIN, employee.id)).toEqual(employee); + expect(await processService(orm).listRuns(ADMIN, key)).toMatchObject([ + { + startCause: "started_on_event", + startDay: "2026-12-03", + subjectEmployeeId: employee.id, + triggerEvent: { + type: "employees.added", + payload: { employeeIds: [employee.id] }, + }, + record: { recordType: "employee", recordId: employee.id }, + }, + ]); + const ctx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); + expect((await taskService(orm).listMyTasks(ctx)).needsYou).toMatchObject([ + { + subjectEmployeeId: employee.id, + processSource: { processKey: key }, + }, + ]); +}); + +test("a failed transaction leaves no event-started run", async ({ orm, seed }) => { + const employee = await seed.employee(); + const { key } = await seed.process({ + key: "welcome", + definition: eventProcess("employees.added"), + }); + const transact = transactWithProcesses(orm, { today }); + + await expect( + transact((tx) => { + tx.publish("employees.added", { employeeIds: [employee.id] }); + throw new Error("Save failed"); + }), + ).rejects.toThrow("Save failed"); + + expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + expect((await taskService(orm).listMyTasks(ctx)).needsYou).toEqual([]); +}); + +test("one event for three people starts three runs", async ({ orm, seed }) => { + const people = await seed.employees(3); + const ids = people.map(({ id }) => id); + const { key } = await seed.process({ + key: "welcome", + definition: eventProcess("employees.added"), + }); + const transact = transactWithProcesses(orm, { today }); + + await transact((tx) => { + tx.publish("employees.added", { employeeIds: ids }); + return Promise.resolve(); + }); + + const runs = await processService(orm).listRuns(ADMIN, key); + expect(runs.map(({ record }) => record.recordId).sort()).toEqual([...ids].sort()); + expect(new Set(runs.map(({ triggerEvent }) => triggerEvent!.id)).size).toBe(1); +}); + +test("three events in one transaction start three runs", async ({ orm, seed }) => { + const people = await seed.employees(3); + const ids = people.map(({ id }) => id); + const { key } = await seed.process({ + key: "welcome", + definition: eventProcess("employees.added"), + }); + const transact = transactWithProcesses(orm, { today }); + + await transact((tx) => { + for (const id of ids) tx.publish("employees.added", { employeeIds: [id] }); + return Promise.resolve(); + }); + + const runs = await processService(orm).listRuns(ADMIN, key); + expect(runs.map(({ record }) => record.recordId).sort()).toEqual([...ids].sort()); + expect(new Set(runs.map(({ triggerEvent }) => triggerEvent!.id)).size).toBe(3); +}); + +test("a run that cannot calculate its due date rolls back adding the person", async ({ + orm, + seed, +}) => { + const definition = eventProcess("employees.added"); + definition.steps[0]!.due = { amount: 1, unit: "business_days" }; + const { key } = await seed.process({ key: "welcome", definition }); + const transact = transactWithProcesses(orm, { today: () => calendarDate("2031-06-02") }); + const employees = employeeServices(orm, { transact }); + + await expect( + employees.createEmployee(ADMIN, { + firstName: "Sam", + lastName: "New", + email: "sam@example.com", + }), + ).rejects.toMatchObject({ code: "CONFLICT", message: "No bank holidays are known for 2031." }); + + expect((await employees.listEmployees(ADMIN, {})).edges).toEqual([]); + expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); +}); + +test("an off process starts nothing when its event happens", async ({ orm, seed }) => { + const { key } = await seed.process({ + key: "welcome", + definition: eventProcess("employees.added"), + turnedOn: false, + }); + const transact = transactWithProcesses(orm, { today }); + + await employeeServices(orm, { transact }).createEmployee(ADMIN, { + firstName: "Sam", + lastName: "New", + email: "sam@example.com", + }); + + expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); +}); + +test("an unmatched event condition starts nothing", async ({ orm, seed }) => { + const definition = eventProcess("employees.added", { field: "source", in: ["import"] }); + const { key } = await seed.process({ key: "welcome", definition }); + const transact = transactWithProcesses(orm, { today }); + + await employeeServices(orm, { transact }).createEmployee(ADMIN, { + firstName: "Sam", + lastName: "New", + email: "sam@example.com", + }); + + expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); +}); + +test("marking a task done starts a process carrying the person who acted", async ({ + orm, + seed, +}) => { + const assignee = await seed.employee(); + const subject = await seed.employee(); + const task = await seed.task({ + assigneeEmployeeId: assignee.id, + askedByEmployeeId: assignee.id, + subjectEmployeeId: subject.id, + }); + const definition = eventProcess("tasks.task_status_changed", { + field: "to", + in: ["done", "cancelled"], + }); + const { key } = await seed.process({ key: "follow_up", definition }); + const transact = transactWithProcesses(orm, { today }); + const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); + + await taskService(orm, { transact }).markDone(ctx, task.id); + + expect(await processService(orm).listRuns(ADMIN, key)).toMatchObject([ + { + startCause: "started_on_event", + subjectEmployeeId: subject.id, + record: { recordType: "task", recordId: task.id }, + triggerEvent: { + type: "tasks.task_status_changed", + payload: { + taskId: task.id, + from: "to_do", + to: "done", + byEmployeeId: assignee.id, + }, + }, + }, + ]); +}); + +test("marking a task waiting does not start a done-or-cancelled process", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: assignee.id }); + const definition = eventProcess("tasks.task_status_changed", { + field: "to", + in: ["done", "cancelled"], + }); + const { key } = await seed.process({ key: "follow_up", definition }); + const transact = transactWithProcesses(orm, { today }); + const tasks = taskService(orm, { transact }); + const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); + + await transact((tx) => tasks.markWaiting(ctx, tx, task.id)); + + expect((await tasks.readTask(ctx, task.id)).status).toBe("waiting"); + expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); +}); + +test("a run's tasks start another process in the same transaction", async ({ orm, seed }) => { + const first = await seed.process({ key: "welcome", definition: eventProcess("employees.added") }); + const definition = eventProcess("tasks.task_created", { field: "processKey", in: [first.key] }); + const second = await seed.process({ key: "follow_up", definition }); + const transact = transactWithProcesses(orm, { today }); + + const added = await employeeServices(orm, { transact }).createEmployee(ADMIN, { + firstName: "Sam", + lastName: "New", + email: "sam@example.com", + }); + + assert(added); + const processes = processService(orm); + const [run] = await processes.listRuns(ADMIN, first.key); + const details = await processes.readRun(ADMIN, run!.id); + const task = details.steps[0]!.task!; + expect(await processes.listRuns(ADMIN, second.key)).toMatchObject([ + { + subjectEmployeeId: added.id, + record: { recordType: "task", recordId: task.id }, + triggerEvent: { + type: "tasks.task_created", + payload: { taskId: task.id, processKey: first.key }, + }, + }, + ]); + const ctx = signedIn(orm, { employeeId: added.id, profiles: ["EMPLOYEE"] }); + expect((await taskService(orm).listMyTasks(ctx)).needsYou).toHaveLength(2); +}); + +test("two matching events start two runs when every step is skipped", async ({ orm, seed }) => { + const employee = await seed.employee(); + const definition = eventProcess("employees.added"); + definition.steps[0]!.assignee = { kind: "subjects_manager" }; + const { key } = await seed.process({ key: "welcome", definition }); + const transact = transactWithProcesses(orm, { today }); + + await transact((tx) => { + tx.publish("employees.added", { employeeIds: [employee.id] }); + tx.publish("employees.added", { employeeIds: [employee.id] }); + return Promise.resolve(); + }); + + const runs = await processService(orm).listRuns(ADMIN, key); + expect(runs).toMatchObject([{ status: "finished" }, { status: "finished" }]); + expect(runs[0]!.triggerEvent!.id).not.toBe(runs[1]!.triggerEvent!.id); +}); + +test("a matching event during a run in progress is skipped and the save commits", async ({ + orm, + seed, +}) => { + const employee = await seed.employee(); + const { key } = await seed.process({ + key: "welcome", + definition: eventProcess("employees.added"), + }); + const transact = transactWithProcesses(orm, { today }); + await transact((tx) => { + tx.publish("employees.added", { employeeIds: [employee.id] }); + return Promise.resolve(); + }); + const before = await processService(orm).listRuns(ADMIN, key); + + await transact(async (tx) => { + await employeeServices(orm, { transact: (work) => work(tx) }).updateEmployee(ADMIN, { + id: employee.id, + jobTitle: "Engineer", + }); + tx.publish("employees.added", { employeeIds: [employee.id] }); + }); + + expect(await processService(orm).listRuns(ADMIN, key)).toEqual(before); + expect(before).toMatchObject([{ status: "in_progress" }]); + expect(await employeeServices(orm).readEmployee(ADMIN, employee.id)).toMatchObject({ + jobTitle: "Engineer", + }); +}); + +const today = () => calendarDate("2026-12-03"); + +function eventProcess( + event: "employees.added" | "tasks.task_created" | "tasks.task_status_changed", + when?: { field: string; in: (string | null)[] }, +): ProcessDefinition { + return { + subject: { recordType: event === "employees.added" ? "employee" : "task" }, + trigger: { + kind: "on_event", + event, + run: "in_same_transaction", + faultMode: "rollback_transaction", + ...(when && { when }), + }, + startableByAdmin: false, + steps: [ + { + key: "follow_up", + kind: "task", + title: "Follow up", + description: null, + assignee: { kind: "subject" }, + due: { amount: 0, unit: "calendar_days" }, + }, + ], + }; +} diff --git a/packages/api/src/modules/processes/app/workingCalendars.ts b/packages/api/src/modules/processes/app/workingCalendars.ts new file mode 100644 index 000000000..9b48e3815 --- /dev/null +++ b/packages/api/src/modules/processes/app/workingCalendars.ts @@ -0,0 +1,76 @@ +import { ApplicationError } from "../../../core/applicationError"; +import type { Tx } from "../../../core/db"; +import { PrincipalSecurityContext } from "../../../core/security"; +import { addDays, type CalendarDate } from "../../../utils"; +import type { BankHolidayService } from "../../bankHolidays"; +import type { EmployeeWorkingPatternService } from "../../employees"; +import { type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "../domain"; + +/** People's working days, by their own working weeks and bank holidays, for counting business days. */ +export interface WorkingCalendars { + /** + * Each person's working days around these days, refusing a year with no bank holidays known; + * null for a person on no terms, whose offsets count calendar days. + */ + readWorkingDays( + tx: Tx, + input: { employeeIds: readonly string[]; days: readonly CalendarDate[] }, + ): Promise>; +} + +export function createWorkingCalendars({ + workingPatterns, + bankHolidays, +}: { + workingPatterns: EmployeeWorkingPatternService; + bankHolidays: BankHolidayService; +}): WorkingCalendars { + return { + async readWorkingDays(tx, { employeeIds, days }) { + if (employeeIds.length === 0) return new Map(); + const sorted = [...days].sort(); + const from = addDays(sorted[0]!, -MAX_SEARCH_DAYS); + const until = addDays(sorted.at(-1)!, MAX_SEARCH_DAYS); + const weeksByEmployee = await workingPatterns.readWorkingWeeksInTransaction(tx, { + employeeIds, + from, + until, + }); + const calendarNames = [ + ...new Set( + [...weeksByEmployee.values()].flatMap((weeks) => + weeks.flatMap(({ bankHolidayCalendar: c }) => (c ? [c] : [])), + ), + ), + ]; + const holidays = await bankHolidays.readBankHolidays(PrincipalSecurityContext.system(), { + calendars: calendarNames, + from, + until, + }); + return new Map( + employeeIds.map((employeeId) => { + const weeks = weeksByEmployee.get(employeeId) ?? []; + return [employeeId, workingDaysOf(weeks, isBankHolidayIn(weeks, holidays))]; + }), + ); + }, + }; +} + +function isBankHolidayIn( + weeks: readonly { bankHolidayCalendar: string | null }[], + holidays: ReadonlyMap, +) { + return (date: CalendarDate, weekIndex: number): boolean => { + const calendar = weeks[weekIndex]?.bankHolidayCalendar; + if (!calendar) return false; + const days = holidays.get(calendar) ?? []; + const year = date.slice(0, 4); + // Every UK calendar has bank holidays each year: none means the data ends before it. + if (!days.some((holiday) => holiday.date.startsWith(year))) { + throw new ApplicationError("CONFLICT", `No bank holidays are known for ${year}.`); + } + return days.some((holiday) => holiday.date === date); + }; +} diff --git a/packages/api/src/modules/processes/db/index.ts b/packages/api/src/modules/processes/db/index.ts index 1a43b56ec..51b2fa362 100644 --- a/packages/api/src/modules/processes/db/index.ts +++ b/packages/api/src/modules/processes/db/index.ts @@ -1 +1,6 @@ -export { processRepository, type StoredProcess } from "./processRepository"; +export { + type NewProcessRow, + type NewRunRow, + processRepository, + type StoredProcess, +} from "./processRepository"; diff --git a/packages/api/src/modules/processes/db/processRepository.ts b/packages/api/src/modules/processes/db/processRepository.ts index 38ce16aff..87ac9200d 100644 --- a/packages/api/src/modules/processes/db/processRepository.ts +++ b/packages/api/src/modules/processes/db/processRepository.ts @@ -1,8 +1,8 @@ -import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, type SQL, sql } from "drizzle-orm"; -import { ormOf, type Orm, type Tx } from "../../../core/db"; +import { ormOf, type Orm, type OrmTransaction, type Tx } from "../../../core/db"; import { inRecordScope, type RecordScope } from "../../../core/security"; -import type { ProcessRun, RunStep } from "../domain"; +import { PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessRun, type RunStep } from "../domain"; import { processes, processVersions } from "./processesTable"; import { processRuns, processRunSteps } from "./processRunsTable"; @@ -16,6 +16,15 @@ export interface StoredProcess { activeVersion: { id: string; version: number; schemaVersion: number; definition: unknown }; } +/** A process to insert, with the definition of its first version. */ +export interface NewProcessRow { + key: string; + name: string; + description: string; + definition: unknown; + turnedOnAt: Date | null; +} + /** A run as it is stored before it has an id and timestamps. */ export type NewRunRow = Omit; @@ -30,8 +39,17 @@ export interface ProcessRepository { lockProcess(id: string): Promise; /** Every process, locked until the transaction ends, in id order. */ lockAllProcesses(): Promise; + /** + * The turned-on processes whose active version starts on one of these events, share-locked until + * the transaction ends: starts from concurrent changes never wait for each other, while turning + * a process off or a reset waits for them. + */ + lockProcessesStartedBy(eventTypes: readonly string[]): Promise; + /** A new process with its first version, published and active; for tests and seeds. */ + insertProcess(process: NewProcessRow): Promise<{ id: string; key: string }>; setTurnedOnAt(id: string, turnedOnAt: Date | null): Promise; - insertRun(run: NewRunRow): Promise; + /** Inserts the runs in one statement; returns them in the same order. */ + insertRuns(runs: readonly NewRunRow[]): Promise; insertSteps(steps: readonly RunStep[]): Promise; findRun(id: string): Promise; /** The run, locked until the transaction ends, so step changes on it take turns. */ @@ -50,6 +68,12 @@ export interface ProcessRepository { recordType: string, recordIds: readonly string[], ): Promise>; + /** The records among these with a run of the process in progress. */ + findRecordsWithRunInProgress( + processId: string, + recordType: string, + recordIds: readonly string[], + ): Promise>; /** The record's run of the process in progress, if any. */ findRunInProgress( processId: string, @@ -112,6 +136,39 @@ export function processRepository(db: Orm | Tx): ProcessRepository { await orm.select({ id: processes.id }).from(processes).orderBy(processes.id).for("update"); }, + async lockProcessesStartedBy(eventTypes) { + const rows = await selectProcesses() + .where( + and( + isNotNull(processes.turnedOnAt), + sql`${processVersions.definition} -> 'trigger' ->> 'kind' = 'on_event'`, + sql`${processVersions.definition} -> 'trigger' ->> 'event' = any(${sql.param([...eventTypes])}::text[])`, + ), + ) + .orderBy(processes.id) + .for("share", { of: processes }); + return rows.map(toStoredProcess); + }, + + async insertProcess({ definition, ...process }) { + const [inserted] = await orm.insert(processes).values(process).returning(); + const [version] = await orm + .insert(processVersions) + .values({ + processId: inserted!.id, + version: 1, + status: "published", + definitionSchemaVersion: PROCESS_DEFINITION_SCHEMA_VERSION, + definition, + }) + .returning(); + await orm + .update(processes) + .set({ activeVersionId: version!.id }) + .where(eq(processes.id, inserted!.id)); + return { id: inserted!.id, key: inserted!.key }; + }, + async setTurnedOnAt(id, turnedOnAt) { await orm .update(processes) @@ -119,9 +176,12 @@ export function processRepository(db: Orm | Tx): ProcessRepository { .where(eq(processes.id, id)); }, - async insertRun(run) { - const [row] = await orm.insert(processRuns).values(run).returning(); - return row!; + async insertRuns(runs) { + if (runs.length === 0) return []; + return await orm + .insert(processRuns) + .values([...runs]) + .returning(); }, async insertSteps(steps) { @@ -181,20 +241,15 @@ export function processRepository(db: Orm | Tx): ProcessRepository { return await orm.select().from(processRuns).where(eq(processRuns.status, "in_progress")); }, - async findRecordsWithRuns(processId, recordType, recordIds) { - if (recordIds.length === 0) return new Set(); - const rows = await orm - .selectDistinct({ recordId: processRuns.recordId }) - .from(processRuns) - .where( - and( - eq(processRuns.processId, processId), - eq(processRuns.recordType, recordType), - sql`${processRuns.recordId} = any(${sql.param([...recordIds])}::text[])`, - ), - ); - return new Set(rows.map(({ recordId }) => recordId)); - }, + findRecordsWithRuns: (processId, recordType, recordIds) => + findRecordsWithRunsWhere(orm, { processId, recordType, recordIds }), + + findRecordsWithRunInProgress: (processId, recordType, recordIds) => + findRecordsWithRunsWhere( + orm, + { processId, recordType, recordIds }, + eq(processRuns.status, "in_progress"), + ), async findRunInProgress(processId, recordType, recordId) { const [row] = await orm @@ -218,6 +273,31 @@ export function processRepository(db: Orm | Tx): ProcessRepository { }; } +/** The records among these with a run of the process that also meets `condition`. */ +async function findRecordsWithRunsWhere( + orm: Orm | OrmTransaction, + { + processId, + recordType, + recordIds, + }: { processId: string; recordType: string; recordIds: readonly string[] }, + condition?: SQL, +): Promise> { + if (recordIds.length === 0) return new Set(); + const rows = await orm + .selectDistinct({ recordId: processRuns.recordId }) + .from(processRuns) + .where( + and( + eq(processRuns.processId, processId), + eq(processRuns.recordType, recordType), + sql`${processRuns.recordId} = any(${sql.param([...recordIds])}::text[])`, + condition, + ), + ); + return new Set(rows.map(({ recordId }) => recordId)); +} + type ProcessWithVersionRow = { process: typeof processes.$inferSelect; version: StoredProcess["activeVersion"]; diff --git a/packages/api/src/modules/processes/db/processRunsTable.ts b/packages/api/src/modules/processes/db/processRunsTable.ts index a6ad8dc68..d39e4a768 100644 --- a/packages/api/src/modules/processes/db/processRunsTable.ts +++ b/packages/api/src/modules/processes/db/processRunsTable.ts @@ -12,6 +12,7 @@ import { uuid, } from "drizzle-orm/pg-core"; +import { jsonb } from "../../../core/db"; import type { CalendarDate } from "../../../utils"; import { ASSIGNEE_NOTES, @@ -20,6 +21,7 @@ import { SKIP_REASONS, START_CAUSES, STEP_STATUSES, + type TriggerEvent, } from "../domain"; import { processes, processVersions } from "./processesTable"; @@ -43,6 +45,7 @@ export const processRuns = pgTable( startedByEmployeeId: uuid("started_by_employee_id"), triggerDate: date("trigger_date", { mode: "string" }).$type(), startKey: text("start_key"), + triggerEvent: jsonb("trigger_event").$type(), startDay: date("start_day", { mode: "string" }).$type().notNull(), status: text("status", { enum: RUN_STATUSES }).notNull(), finishedAt: timestamp("finished_at", { withTimezone: true }), @@ -80,7 +83,11 @@ export const processRuns = pgTable( ), check( "process_runs_start_cause_known", - sql`${table.startCause} in ('started_on_record_date', 'started_by_admin')`, + sql`${table.startCause} in ('started_on_record_date', 'started_by_admin', 'started_on_event')`, + ), + check( + "process_runs_trigger_event_follows_start_cause", + sql`(${table.startCause} = 'started_on_event') = (${table.triggerEvent} is not null)`, ), check( "process_runs_cancel_cause_known", diff --git a/packages/api/src/modules/processes/domain/eventTrigger.test.ts b/packages/api/src/modules/processes/domain/eventTrigger.test.ts new file mode 100644 index 000000000..7a9c57913 --- /dev/null +++ b/packages/api/src/modules/processes/domain/eventTrigger.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "vitest"; + +import { matchEventStarts } from "./eventTrigger"; +import type { ProcessDefinition } from "./processDefinition"; + +test("starts each added person in event, process and record order", () => { + const processes = [ + { processId: "welcome", trigger: onEvent("employees.added") }, + { processId: "induction", trigger: onEvent("employees.added") }, + ]; + const event = { + id: "event-1", + type: "employees.added", + payload: { employeeIds: ["sam", "alex", "jo"] }, + }; + + expect(matchEventStarts(processes, [event])).toEqual([ + { processId: "welcome", event, recordId: "sam" }, + { processId: "welcome", event, recordId: "alex" }, + { processId: "welcome", event, recordId: "jo" }, + { processId: "induction", event, recordId: "sam" }, + { processId: "induction", event, recordId: "alex" }, + { processId: "induction", event, recordId: "jo" }, + ]); +}); + +test.each(["tasks.task_created", "tasks.task_status_changed"] as const)( + "starts one run for the task in %s", + (type) => { + const event = { id: "event-1", type, payload: { taskId: "task-1" } }; + + expect(matchEventStarts([{ processId: "follow_up", trigger: onEvent(type) }], [event])).toEqual( + [{ processId: "follow_up", event, recordId: "task-1" }], + ); + }, +); + +test.each([ + { payload: { taskId: "task-1", to: "done" }, accepted: ["done", "cancelled"], matches: true }, + { + payload: { taskId: "task-1", to: "cancelled" }, + accepted: ["done", "cancelled"], + matches: true, + }, + { payload: { taskId: "task-1", to: "waiting" }, accepted: ["done", "cancelled"], matches: false }, + { payload: { taskId: "task-1", to: null }, accepted: ["done"], matches: false }, + { payload: { taskId: "task-1" }, accepted: ["done"], matches: false }, + { payload: { taskId: "task-1", to: null }, accepted: [null], matches: true }, + { payload: { taskId: "task-1" }, accepted: [null], matches: true }, +])("matches $payload against $accepted: $matches", ({ payload, accepted, matches }) => { + const event = { id: "event-1", type: "tasks.task_status_changed", payload }; + const trigger = onEvent("tasks.task_status_changed", { field: "to", in: accepted }); + + expect(matchEventStarts([{ processId: "follow_up", trigger }], [event])).toEqual( + matches ? [{ processId: "follow_up", event, recordId: "task-1" }] : [], + ); +}); + +test("does not start a date-triggered process from an event", () => { + const trigger: ProcessDefinition["trigger"] = { + kind: "on_record_date", + recordType: "employee", + dateField: "startDate", + offset: { amount: 0, unit: "calendar_days" }, + maxDaysLate: 7, + }; + const event = { id: "event-1", type: "employees.added", payload: { employeeIds: ["sam"] } }; + + expect(matchEventStarts([{ processId: "welcome", trigger }], [event])).toEqual([]); +}); + +test("keeps two matching events for the same record as separate starts", () => { + const first = { id: "event-1", type: "employees.added", payload: { employeeIds: ["sam"] } }; + const second = { ...first, id: "event-2" }; + const processes = [ + { processId: "welcome", trigger: onEvent("employees.added") }, + { processId: "induction", trigger: onEvent("employees.added") }, + ]; + + expect(matchEventStarts(processes, [first, second])).toEqual([ + { processId: "welcome", event: first, recordId: "sam" }, + { processId: "induction", event: first, recordId: "sam" }, + { processId: "welcome", event: second, recordId: "sam" }, + { processId: "induction", event: second, recordId: "sam" }, + ]); +}); + +test("does not start a process for an event of another type", () => { + const event = { id: "event-1", type: "tasks.task_created", payload: { taskId: "task-1" } }; + + expect( + matchEventStarts([{ processId: "welcome", trigger: onEvent("employees.added") }], [event]), + ).toEqual([]); +}); + +function onEvent( + event: "employees.added" | "tasks.task_created" | "tasks.task_status_changed", + when?: { field: string; in: (string | null)[] }, +): ProcessDefinition["trigger"] { + return { + kind: "on_event", + event, + run: "in_same_transaction", + faultMode: "rollback_transaction", + ...(when && { when }), + }; +} diff --git a/packages/api/src/modules/processes/domain/eventTrigger.ts b/packages/api/src/modules/processes/domain/eventTrigger.ts new file mode 100644 index 000000000..d8d1e6779 --- /dev/null +++ b/packages/api/src/modules/processes/domain/eventTrigger.ts @@ -0,0 +1,73 @@ +import type { DomainEvents } from "../../../core/events"; +import type { EventTrigger, ProcessDefinition } from "./processDefinition"; + +/** + * The events that may start a process, with the record type each is about and the ids of those + * records in its payload: one run per record. + */ +// shortcut: the list lives here; move it to the publishing modules' registrations when modules become dynamic. +export const TRIGGERING_EVENTS = { + "employees.added": { + recordType: "employee", + recordIds: ({ employeeIds }: DomainEvents["employees.added"]) => employeeIds, + }, + "tasks.task_created": { + recordType: "task", + recordIds: ({ taskId }: DomainEvents["tasks.task_created"]) => [taskId], + }, + "tasks.task_status_changed": { + recordType: "task", + recordIds: ({ taskId }: DomainEvents["tasks.task_status_changed"]) => [taskId], + }, +} as const; + +export type TriggeringEventType = keyof typeof TRIGGERING_EVENTS; + +/** An event as a trigger reads it. */ +export interface TriggerEvent { + id: string; + type: string; + payload: unknown; +} + +/** One run to start: a process, the event that triggered it and one record the event is about. */ +export interface EventStart { + processId: string; + event: Event; + recordId: string; +} + +/** + * The runs the events start, one per matching process and record, in the order of the events, + * then the processes, then the event's records. + */ +export function matchEventStarts( + processes: readonly { processId: string; trigger: ProcessDefinition["trigger"] }[], + events: readonly Event[], +): EventStart[] { + return events.flatMap((event) => + processes.flatMap(({ processId, trigger }) => + trigger.kind === "on_event" && isTriggeredBy(trigger, event) + ? recordIdsOf(event).map((recordId) => ({ processId, event, recordId })) + : [], + ), + ); +} + +export function isTriggeringEvent(type: string): type is TriggeringEventType { + return Object.hasOwn(TRIGGERING_EVENTS, type); +} + +function isTriggeredBy(trigger: EventTrigger, { type, payload }: TriggerEvent): boolean { + if (type !== trigger.event) return false; + if (!trigger.when) return true; + const value = (payload as Record)[trigger.when.field] ?? null; + return trigger.when.in.some((expected) => expected === value); +} + +function recordIdsOf({ type, payload }: TriggerEvent): readonly string[] { + if (!isTriggeringEvent(type)) return []; + // Each entry reads its own event's payload, which `type` names. + const { recordIds } = TRIGGERING_EVENTS[type] as { recordIds: (payload: unknown) => string[] }; + return recordIds(payload); +} diff --git a/packages/api/src/modules/processes/domain/index.ts b/packages/api/src/modules/processes/domain/index.ts index daa3dcf9d..64893cfd1 100644 --- a/packages/api/src/modules/processes/domain/index.ts +++ b/packages/api/src/modules/processes/domain/index.ts @@ -1,9 +1,19 @@ export { addDayOffset, type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "./dayOffset"; export { dueTriggerDate } from "./recordDateTrigger"; +export { + type EventStart, + isTriggeringEvent, + matchEventStarts, + TRIGGERING_EVENTS, + type TriggerEvent, +} from "./eventTrigger"; export { type DayOffset, + type EventTrigger, parseProcessDefinition, + PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessDefinition, + type RecordDateTrigger, type TaskStep, WELCOME_NEW_STARTER, } from "./processDefinition"; @@ -18,6 +28,7 @@ export { removalCancellation, reopenStep, resolveAssignee, + type ResolvedAssignee, RUN_STATUSES, type RunStatus, type RunStep, diff --git a/packages/api/src/modules/processes/domain/processDefinition.test.ts b/packages/api/src/modules/processes/domain/processDefinition.test.ts index 18879a115..3bcd050d6 100644 --- a/packages/api/src/modules/processes/domain/processDefinition.test.ts +++ b/packages/api/src/modules/processes/domain/processDefinition.test.ts @@ -16,21 +16,21 @@ test("refuses an unknown definition schema version", () => { ); }); -test("refuses a record-created trigger until durable events exist", () => { +test("refuses an unbuilt record-created trigger", () => { const definition = welcomeDefinition(); definition.trigger.kind = "on_record_created"; expect(() => parseProcessDefinition(1, definition)).toThrow( - "Trigger kind on_record_created needs durable record events, which are not built yet.", + "Trigger kind on_record_created is not built.", ); }); -test("refuses a record-changed trigger until durable events exist", () => { +test("refuses an unbuilt record-changed trigger", () => { const definition = welcomeDefinition(); definition.trigger.kind = "on_record_changed"; expect(() => parseProcessDefinition(1, definition)).toThrow( - "Trigger kind on_record_changed needs durable record events, which are not built yet.", + "Trigger kind on_record_changed is not built.", ); }); @@ -39,7 +39,7 @@ test("refuses a schedule trigger until it is supported", () => { definition.trigger.kind = "on_schedule"; expect(() => parseProcessDefinition(1, definition)).toThrow( - "Trigger kind on_schedule needs durable record events, which are not built yet.", + "Trigger kind on_schedule is not built.", ); }); @@ -118,6 +118,76 @@ test("refuses a trigger date other than the employee's start date", () => { expect(() => parseProcessDefinition(1, definition)).toThrow(); }); +test.each([ + ["employees.added", "employee"], + ["tasks.task_created", "task"], + ["tasks.task_status_changed", "task"], +])("accepts %s as an event trigger for %s records", (event, recordType) => { + const definition = eventDefinition(event, recordType); + + expect(parseProcessDefinition(1, definition)).toEqual(definition); +}); + +test("refuses an event that cannot trigger a process", () => { + expect(() => parseProcessDefinition(1, eventDefinition("example.happened"))).toThrow( + "Event example.happened cannot trigger a process.", + ); +}); + +test("refuses an event condition without any accepted values", () => { + const definition = eventDefinition("tasks.task_status_changed", "task"); + const trigger = { ...definition.trigger, when: { field: "to", in: [] } }; + + expect(() => parseProcessDefinition(1, { ...definition, trigger })).toThrow(); +}); + +test("refuses an event trigger that runs after commit", () => { + const definition = eventDefinition("employees.added"); + definition.trigger.run = "after_commit"; + + expect(() => parseProcessDefinition(1, definition)).toThrow(); +}); + +test("refuses an event trigger that silently ignores failures", () => { + const definition = eventDefinition("employees.added"); + definition.trigger.faultMode = "silent"; + + expect(() => parseProcessDefinition(1, definition)).toThrow(); +}); + +test("refuses an event trigger without a run mode", () => { + const definition = eventDefinition("employees.added"); + const { kind, event, faultMode } = definition.trigger; + + expect(() => + parseProcessDefinition(1, { + ...definition, + trigger: { kind, event, faultMode }, + }), + ).toThrow(); +}); + +test("refuses a subject differing from the triggering event's record type", () => { + const definition = eventDefinition("tasks.task_created", "employee"); + + expect(() => parseProcessDefinition(1, definition)).toThrow( + "The subject employee differs from the record type task of event tasks.task_created.", + ); +}); + +function eventDefinition(event: string, recordType = "employee") { + return { + ...welcomeDefinition(), + subject: { recordType }, + trigger: { + kind: "on_event", + event, + run: "in_same_transaction", + faultMode: "rollback_transaction", + }, + }; +} + function welcomeDefinition() { return { subject: { recordType: "employee" }, diff --git a/packages/api/src/modules/processes/domain/processDefinition.ts b/packages/api/src/modules/processes/domain/processDefinition.ts index f667cd77e..112e492ca 100644 --- a/packages/api/src/modules/processes/domain/processDefinition.ts +++ b/packages/api/src/modules/processes/domain/processDefinition.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { isTriggeringEvent, TRIGGERING_EVENTS, type TriggeringEventType } from "./eventTrigger"; + /** The shape of `process_versions.definition` this code reads; a migration moves older shapes on. */ export const PROCESS_DEFINITION_SCHEMA_VERSION = 1; @@ -40,10 +42,24 @@ export interface RecordDateTrigger { maxDaysLate: number; } +/** + * Starts a run for each record an event is about, inside the transaction that published it: the + * run commits with the change, and a run that fails fails the change. + */ +export interface EventTrigger { + kind: "on_event"; + event: TriggeringEventType; + /** Only events whose payload field holds one of these values. */ + when?: { field: string; in: (string | null)[] }; + run: "in_same_transaction"; + faultMode: "rollback_transaction"; +} + /** What one version of a process does; its steps run in parallel, listed in display order. */ export interface ProcessDefinition { - subject: { recordType: "employee" }; - trigger: RecordDateTrigger; + /** The record a run is about, the trigger's record type. */ + subject: { recordType: "employee" | "task" }; + trigger: RecordDateTrigger | EventTrigger; startableByAdmin: boolean; steps: TaskStep[]; } @@ -56,21 +72,38 @@ export function parseProcessDefinition( if (schemaVersion !== PROCESS_DEFINITION_SCHEMA_VERSION) { throw new Error(`Process definition schema version ${schemaVersion} is not known.`); } - refuseEventTrigger(definition); + refuseUnbuiltTrigger(definition); const parsed: ProcessDefinition = definitionSchema.parse(definition); + refuseSubjectOtherThanTrigger(parsed); refuseRepeatedStepKeys(parsed.steps); - for (const offset of [parsed.trigger.offset, ...parsed.steps.map(({ due }) => due)]) { + const triggerOffsets = parsed.trigger.kind === "on_record_date" ? [parsed.trigger.offset] : []; + for (const offset of [...triggerOffsets, ...parsed.steps.map(({ due }) => due)]) { refuseOffsetBeyondLimit(offset); } return parsed; } -const EVENT_TRIGGER_KINDS = new Set(["on_record_created", "on_record_changed", "on_schedule"]); +const UNBUILT_TRIGGER_KINDS = new Set(["on_record_created", "on_record_changed", "on_schedule"]); -function refuseEventTrigger(definition: unknown): void { - const kind = (definition as { trigger?: { kind?: unknown } } | null)?.trigger?.kind; - if (typeof kind === "string" && EVENT_TRIGGER_KINDS.has(kind)) { - throw new Error(`Trigger kind ${kind} needs durable record events, which are not built yet.`); +function refuseUnbuiltTrigger(definition: unknown): void { + const trigger = (definition as { trigger?: { kind?: unknown; event?: unknown } } | null)?.trigger; + if (typeof trigger?.kind === "string" && UNBUILT_TRIGGER_KINDS.has(trigger.kind)) { + throw new Error(`Trigger kind ${trigger.kind} is not built.`); + } + if (trigger?.kind === "on_event" && !isTriggeringEvent(String(trigger.event))) { + throw new Error(`Event ${String(trigger.event)} cannot trigger a process.`); + } +} + +function refuseSubjectOtherThanTrigger({ subject, trigger }: ProcessDefinition): void { + const [recordType, source] = + trigger.kind === "on_event" + ? [TRIGGERING_EVENTS[trigger.event].recordType, `event ${trigger.event}`] + : [trigger.recordType, `trigger ${trigger.kind}`]; + if (subject.recordType !== recordType) { + throw new Error( + `The subject ${subject.recordType} differs from the record type ${recordType} of ${source}.`, + ); } } @@ -102,15 +135,31 @@ const assigneeRuleSchema: z.ZodType = z.lazy(() => }), ); -const definitionSchema = z.object({ - subject: z.object({ recordType: z.literal("employee") }), - trigger: z.object({ +const triggerSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("on_record_date"), recordType: z.literal("employee"), dateField: z.literal("startDate"), offset: offsetSchema, maxDaysLate: z.number().int().min(0), }), + z.object({ + kind: z.literal("on_event"), + event: z.enum(Object.keys(TRIGGERING_EVENTS) as [TriggeringEventType]), + when: z + .object({ + field: z.string().trim().min(1), + in: z.array(z.string().nullable()).min(1), + }) + .optional(), + run: z.literal("in_same_transaction"), + faultMode: z.literal("rollback_transaction"), + }), +]); + +const definitionSchema = z.object({ + subject: z.object({ recordType: z.enum(["employee", "task"]) }), + trigger: triggerSchema, startableByAdmin: z.boolean(), steps: z .array( diff --git a/packages/api/src/modules/processes/domain/processRun.ts b/packages/api/src/modules/processes/domain/processRun.ts index 684221ff5..2644cfa46 100644 --- a/packages/api/src/modules/processes/domain/processRun.ts +++ b/packages/api/src/modules/processes/domain/processRun.ts @@ -1,11 +1,16 @@ import { ApplicationError } from "../../../core/applicationError"; import type { CalendarDate } from "../../../utils"; +import type { TriggerEvent } from "./eventTrigger"; import type { AssigneeRule } from "./processDefinition"; export const RUN_STATUSES = ["in_progress", "finished", "cancelled"] as const; export type RunStatus = (typeof RUN_STATUSES)[number]; -export const START_CAUSES = ["started_on_record_date", "started_by_admin"] as const; +export const START_CAUSES = [ + "started_on_record_date", + "started_by_admin", + "started_on_event", +] as const; export type StartCause = (typeof START_CAUSES)[number]; export const CANCEL_CAUSES = [ @@ -49,6 +54,8 @@ export interface ProcessRun { /** The date the trigger fired for; with the process, `startKey` keeps it from starting twice. */ triggerDate: CalendarDate | null; startKey: string | null; + /** The event that started the run, for `started_on_event`; steps read who acted from it. */ + triggerEvent: TriggerEvent | null; /** The day due dates count from. */ startDay: CalendarDate; status: RunStatus; diff --git a/packages/api/src/modules/processes/index.ts b/packages/api/src/modules/processes/index.ts index 10342cb08..a6a1d6ea6 100644 --- a/packages/api/src/modules/processes/index.ts +++ b/packages/api/src/modules/processes/index.ts @@ -1,4 +1,5 @@ import { readSdl } from "../../core/graphql"; +import { PrincipalSecurityContext } from "../../core/security"; import type { ModuleDependencies, ModuleManifest } from "../../core/moduleManifest"; import { type ProcessService, startDueRunsJob } from "./app"; @@ -15,5 +16,7 @@ export function processesModule({ typeDefs: readSdl(import.meta.url, "./graphql/schema.graphql"), resolvers: {}, jobs: startDueRunsJob({ processes, logger }), + eventsBeforeCommit: (tx, events) => + processes.startRunsOnEvents(PrincipalSecurityContext.system(), tx, events), }; } diff --git a/packages/api/src/modules/processes/module.md b/packages/api/src/modules/processes/module.md index 7b49bc92d..e41dea42d 100644 --- a/packages/api/src/modules/processes/module.md +++ b/packages/api/src/modules/processes/module.md @@ -5,7 +5,8 @@ A process gives people tasks when something happens to a record, and follows eac ## Rules - A process is off until an admin turns it on (`MANAGE_WORKSPACE`); turning it on or off appends a history line for record type `process`, which only those who manage the workspace read. Turning it on starts today's due runs at once, and never anyone whose date was before that day. -- What a process does lives in its published version's definition (JSON, read by `parseProcessDefinition` for the `definition_schema_version` it was written in). A published version never changes; a new release ships a new version. The trigger fires on a record's date plus an offset; event and schedule triggers wait for durable record events and are refused. +- What a process does lives in its published version's definition (JSON, read by `parseProcessDefinition` for the `definition_schema_version` it was written in). A published version never changes; a new release ships a new version. The trigger fires on a record's date plus an offset (`on_record_date`, employees only), or on a domain event (`on_event`); schedule triggers are refused. +- An `on_event` trigger names an event listed in `TRIGGERING_EVENTS` (`employees.added`, `tasks.task_created`, `tasks.task_status_changed`), with an optional `when` on one payload field, and runs `in_same_transaction` with `faultMode` `rollback_transaction`: before the change commits, `startRunsOnEvents` starts one run per event and record the event is about, batched per process, skipping a record whose run is in progress or that is gone. The run keeps the event (`trigger_event`); a run that cannot start fails the change. - The daily job (`processes.startDueRuns`, 06:00 London) starts a run for each record whose trigger date is today or up to the trigger's `maxDaysLate` days ago, if it never had a run of that process, one transaction each. It also cancels runs that can no longer run. - An admin starts a run by hand (`create` on `process_run` for the person), whether the process is on or off, unless one is in progress for them. Before the person's start date, the run counts from their start date. - Steps run in parallel. Each step's task goes to its assignee rule's person on the run's start day, else its fallback, with a note when the owner covers a missing manager; with nobody, the step is skipped. Due dates count calendar days, or business days by the person's own working weeks and bank holidays, refusing a year with no known bank holidays. @@ -17,5 +18,5 @@ A process gives people tasks when something happens to a record, and follows eac - **`processes`:** `key`, `name`, `description`, `active_version_id` (one of its own published versions), `turned_on_at` (null while off). - **`process_versions`:** `version`, `status` (`draft` or `published`, one draft per process), `definition_schema_version`, `definition`. -- **`process_runs`:** the version it started with, the record it is about (`record_type`, `record_id`) and its subject (`subject_employee_id`, no foreign key so a removal is noticed), how it started, `trigger_date` and `start_key` (unique per process, so a trigger starts once), `start_day`, `status` (`in_progress`, `finished`, `cancelled`) with its columns. One run in progress per process and record. +- **`process_runs`:** the version it started with, the record it is about (`record_type`, `record_id`: an employee or a task) and its subject (`subject_employee_id`, no foreign key so a removal is noticed; a task's subject, which may be null), how it started, `trigger_date` or `trigger_event` and `start_key` (unique per process, so a trigger starts once), `start_day`, `status` (`in_progress`, `finished`, `cancelled`) with its columns. One run in progress per process and record. - **`process_run_steps`:** one row per step: `status`, `task_id` (set null when the task goes with its assignee), `assignee_note`, `skip_reason`, `finished_at`. diff --git a/packages/api/src/modules/tasks/app/createTask.int.test.ts b/packages/api/src/modules/tasks/app/createTask.int.test.ts index 7e88d4248..950f3a28f 100644 --- a/packages/api/src/modules/tasks/app/createTask.int.test.ts +++ b/packages/api/src/modules/tasks/app/createTask.int.test.ts @@ -1,4 +1,12 @@ -import { employeeServices, expect, OWNER, signedIn, taskService, test } from "#test/support"; +import { + domainEvents, + employeeServices, + expect, + OWNER, + signedIn, + taskService, + test, +} from "#test/support"; test("creates a task needing its assignee and awaited by its asker", async ({ orm, @@ -177,3 +185,78 @@ test("deletes a person's held tasks when the person is deleted", async ({ orm, s message: "No task has this id.", }); }); + +test("publishes the created task's ids and the person who created it", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const subject = await seed.employee(); + const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); + const { transact, events } = domainEvents(orm); + const tasks = taskService(orm, { transact }); + + const task = await transact((tx) => + tasks.createTask(ctx, tx, { + type: "manual_to_do", + title: "Read the handbook", + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + subjectEmployeeId: subject.id, + processSource: { processKey: "onboarding", processRunId: null, stepKey: "handbook" }, + }), + ); + + expect(events).toEqual([ + { + type: "tasks.task_created", + payload: { + taskId: task.id, + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + subjectEmployeeId: subject.id, + processKey: "onboarding", + byEmployeeId: asker.id, + }, + }, + ]); +}); + +test("returns bulk-created tasks in input order and publishes each task", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); + const { transact, events } = domainEvents(orm); + const tasks = taskService(orm, { transact }); + + const created = await transact((tx) => + tasks.createTasks(ctx, tx, [ + { + type: "manual_to_do", + title: "Read the handbook", + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + }, + { + type: "manual_to_do", + title: "Arrange a meeting", + assigneeEmployeeId: asker.id, + askedByEmployeeId: asker.id, + }, + ]), + ); + + expect(created.map(({ title }) => title)).toEqual(["Read the handbook", "Arrange a meeting"]); + expect(created[0]!.id).not.toBe(created[1]!.id); + expect(events).toEqual( + created.map((task) => ({ + type: "tasks.task_created", + payload: { + taskId: task.id, + assigneeEmployeeId: task.assigneeEmployeeId, + askedByEmployeeId: asker.id, + subjectEmployeeId: null, + processKey: null, + byEmployeeId: asker.id, + }, + })), + ); +}); diff --git a/packages/api/src/modules/tasks/app/createTasks.ts b/packages/api/src/modules/tasks/app/createTasks.ts new file mode 100644 index 000000000..45b40441b --- /dev/null +++ b/packages/api/src/modules/tasks/app/createTasks.ts @@ -0,0 +1,95 @@ +import { invalidInput } from "../../../core/applicationError"; +import type { Tx } from "../../../core/db"; +import { createRecordHistoryService, type CauseKinds } from "../../../core/records"; +import type { SecurityContext } from "../../../core/security"; +import type { CalendarDate } from "../../../utils"; +import { type NewTaskRow, taskRepository } from "../db"; +import type { Task, TaskProcessSource, TaskRecord, TaskType } from "../domain"; + +/** A task a feature, a process step or the demo asks of someone: a person who asked, a process step, or both. */ +export type NewTask = NewTaskFields & + ( + | { askedByEmployeeId: string; processSource?: TaskProcessSource | null } + | { askedByEmployeeId?: null; processSource: TaskProcessSource } + ); + +interface NewTaskFields { + type: TaskType; + title: string; + description?: string | null; + assigneeEmployeeId: string; + subjectEmployeeId?: string | null; + record?: TaskRecord | null; + dueDate?: CalendarDate | null; + /** Why the task exists, for its history; a direct edit when left out. */ + cause?: TaskCause; +} + +/** The cause a task's history line records. */ +export type TaskCause = { kind: keyof CauseKinds; ref?: string | null }; + +/** Inserts the tasks to do, appends their history lines and publishes their creation. */ +export async function createTasks( + ctx: SecurityContext, + tx: Tx, + newTasks: readonly NewTask[], +): Promise { + const created = await taskRepository(tx).insert(newTasks.map(newTaskRow)); + await history.append(ctx, tx, { + recordType: "task", + operation: "CREATED", + cause: DIRECT_EDIT, + records: created.map((task, index) => ({ + recordId: task.id, + cause: newTasks[index]!.cause ?? DIRECT_EDIT, + changes: [ + { field: "title", oldValue: null, newValue: task.title }, + { field: "status", oldValue: null, newValue: task.status }, + ], + })), + }); + for (const task of created) { + tx.publish("tasks.task_created", { + taskId: task.id, + assigneeEmployeeId: task.assigneeEmployeeId, + askedByEmployeeId: task.askedByEmployeeId, + subjectEmployeeId: task.subjectEmployeeId, + processKey: task.processSource?.processKey ?? null, + byEmployeeId: ctx.user?.employeeId ?? null, + }); + } + return created; +} + +function newTaskRow({ title, description = null, ...task }: NewTask): NewTaskRow { + return { + type: task.type, + status: "to_do", + title: requireText(title, "title"), + description: description?.trim() || null, + assigneeEmployeeId: task.assigneeEmployeeId, + askedByEmployeeId: task.askedByEmployeeId ?? null, + subjectEmployeeId: task.subjectEmployeeId ?? null, + processSource: task.processSource ?? null, + record: task.record ?? null, + dueDate: task.dueDate ?? null, + doneAt: null, + doneByEmployeeId: null, + cancelledAt: null, + cancelReason: null, + }; +} + +/** The text trimmed, refused as invalid input when blank. */ +export function requireText(value: string, field: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw invalidInput([{ field, code: "required" }]); + } + return trimmed; +} + +/** A change a person made directly, the cause of most task history lines. */ +export const DIRECT_EDIT: TaskCause = { kind: "EDITED_DIRECTLY" }; + +const history = createRecordHistoryService(); diff --git a/packages/api/src/modules/tasks/app/index.ts b/packages/api/src/modules/tasks/app/index.ts index 877c704d7..dc4fbf1c9 100644 --- a/packages/api/src/modules/tasks/app/index.ts +++ b/packages/api/src/modules/tasks/app/index.ts @@ -1,3 +1,4 @@ -export { createTaskService, type NewTask, type TaskChange, type TaskService } from "./taskService"; +export type { NewTask } from "./createTasks"; +export { createTaskService, type TaskChange, type TaskService } from "./taskService"; export { taskRecordType } from "./taskRecordType"; export type { MyTasks } from "../domain"; diff --git a/packages/api/src/modules/tasks/app/markDone.int.test.ts b/packages/api/src/modules/tasks/app/markDone.int.test.ts index 0a027e74e..8d3bca866 100644 --- a/packages/api/src/modules/tasks/app/markDone.int.test.ts +++ b/packages/api/src/modules/tasks/app/markDone.int.test.ts @@ -1,4 +1,4 @@ -import { expect, signedIn, taskService, test } from "#test/support"; +import { domainEvents, expect, signedIn, taskService, test } from "#test/support"; test("marks a held task done and shows the asker who completed it", async ({ orm, seed }) => { const assignee = await seed.employee(); @@ -118,3 +118,28 @@ test("reports an unknown task as not found when completing it", async ({ orm, se taskService(orm).markDone(ctx, "00000000-0000-4000-8000-000000000001"), ).rejects.toMatchObject({ code: "NOT_FOUND", message: "No task has this id." }); }); + +test("publishes a status change with the assignee who marked the task done", async ({ + orm, + seed, +}) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id }); + const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); + const { transact, events } = domainEvents(orm); + + await taskService(orm, { transact }).markDone(ctx, task.id); + + expect(events).toEqual([ + { + type: "tasks.task_status_changed", + payload: { + taskId: task.id, + from: "to_do", + to: "done", + byEmployeeId: assignee.id, + }, + }, + ]); +}); diff --git a/packages/api/src/modules/tasks/app/registrations.ts b/packages/api/src/modules/tasks/app/registrations.ts index edbc98d62..0dd16ef44 100644 --- a/packages/api/src/modules/tasks/app/registrations.ts +++ b/packages/api/src/modules/tasks/app/registrations.ts @@ -1,4 +1,5 @@ // What this module adds to core's shared registries. +import type { TaskStatus } from "../domain"; declare module "../../../core/records" { interface RecordTypes { @@ -7,4 +8,24 @@ declare module "../../../core/records" { } } -export {}; +declare module "../../../core/events" { + interface DomainEvents { + /** A task to do was created; `byEmployeeId` is the person who created it, null for Broccoli. */ + "tasks.task_created": { + taskId: string; + assigneeEmployeeId: string; + askedByEmployeeId: string | null; + subjectEmployeeId: string | null; + /** The process whose step created it, else null. */ + processKey: string | null; + byEmployeeId: string | null; + }; + /** A task's status changed; `byEmployeeId` is the person who changed it, null for Broccoli. */ + "tasks.task_status_changed": { + taskId: string; + from: TaskStatus; + to: TaskStatus; + byEmployeeId: string | null; + }; + } +} diff --git a/packages/api/src/modules/tasks/app/taskService.ts b/packages/api/src/modules/tasks/app/taskService.ts index 4fa69318e..33dd8b353 100644 --- a/packages/api/src/modules/tasks/app/taskService.ts +++ b/packages/api/src/modules/tasks/app/taskService.ts @@ -1,16 +1,16 @@ -import { ApplicationError, invalidInput } from "../../../core/applicationError"; +import { ApplicationError } from "../../../core/applicationError"; import type { Transact, Tx } from "../../../core/db"; import type { ModuleDependencies } from "../../../core/moduleManifest"; import { createRecordHistoryReaderService, createRecordHistoryService, - type CauseKinds, type HistoryLine, } from "../../../core/records"; import type { Connection, PageArgs } from "../../../core/pagination"; import type { SecurityContext } from "../../../core/security"; -import { type CalendarDate, parseUuid } from "../../../utils"; +import { parseUuid } from "../../../utils"; import { taskRepository } from "../db"; +import { createTasks, DIRECT_EDIT, type NewTask, requireText, type TaskCause } from "./createTasks"; import { cancelTask, groupMyTasks, @@ -21,34 +21,9 @@ import { reopenTask, resolvedRecentlySince, type Task, - type TaskProcessSource, - type TaskRecord, - type TaskType, } from "../domain"; import { taskRecordType } from "./taskRecordType"; -/** A task a feature, a process step or the demo asks of someone: a person who asked, a process step, or both. */ -export type NewTask = NewTaskFields & - ( - | { askedByEmployeeId: string; processSource?: TaskProcessSource | null } - | { askedByEmployeeId?: null; processSource: TaskProcessSource } - ); - -interface NewTaskFields { - type: TaskType; - title: string; - description?: string | null; - assigneeEmployeeId: string; - subjectEmployeeId?: string | null; - record?: TaskRecord | null; - dueDate?: CalendarDate | null; - /** Why the task exists, for its history; a direct edit when left out. */ - cause?: TaskCause; -} - -/** The cause a task's history line records. */ -export type TaskCause = { kind: keyof CauseKinds; ref?: string | null }; - /** * Work asked of people: created by features, read and finished by the people who hold or asked * for it. Only they see a task; anyone else gets NOT_FOUND, so its existence does not leak. @@ -56,6 +31,8 @@ export type TaskCause = { kind: keyof CauseKinds; ref?: string | null }; export interface TaskService { /** Trusted, for features and the demo fill: a new task to do in `tx`; the caller authorizes. */ createTask(ctx: SecurityContext, tx: Tx, task: NewTask): Promise; + /** Trusted, for processes: new tasks to do in `tx`, with one insert, returned in the same order. */ + createTasks(ctx: SecurityContext, tx: Tx, tasks: readonly NewTask[]): Promise; /** Trusted, for processes: the tasks among these ids, in `tx`; the caller authorizes. */ readTasksInTransaction(tx: Tx, taskIds: readonly string[]): Promise; /** Trusted, for processes: its assignee marks a feature's task done in `tx`, first change winning. */ @@ -106,40 +83,13 @@ export function createTaskService({ }: Pick): TaskService { const historyReader = createRecordHistoryReaderService(orm, { task: taskRecordType(orm) }); return { - async createTask(ctx, tx, { title, description = null, cause = DIRECT_EDIT, ...task }) { - const created = await taskRepository(tx).insert({ - type: task.type, - status: "to_do", - title: requireText(title, "title"), - description: description?.trim() || null, - assigneeEmployeeId: task.assigneeEmployeeId, - askedByEmployeeId: task.askedByEmployeeId ?? null, - subjectEmployeeId: task.subjectEmployeeId ?? null, - processSource: task.processSource ?? null, - record: task.record ?? null, - dueDate: task.dueDate ?? null, - doneAt: null, - doneByEmployeeId: null, - cancelledAt: null, - cancelReason: null, - }); - await history.append(ctx, tx, { - recordType: "task", - operation: "CREATED", - cause, - records: [ - { - recordId: created.id, - changes: [ - { field: "title", oldValue: null, newValue: created.title }, - { field: "status", oldValue: null, newValue: created.status }, - ], - }, - ], - }); - return created; + async createTask(ctx, tx, task) { + const [created] = await createTasks(ctx, tx, [task]); + return created!; }, + createTasks, + readTasksInTransaction: (tx, taskIds) => taskRepository(tx).findByIds(taskIds), async markDoneInTransaction(ctx, tx, taskId) { @@ -265,6 +215,7 @@ async function changeLockedTask( return { task: saved, historyLine: await appendStatusChange(ctx, tx, task, saved) }; } +/** Appends the status change to the task's history and publishes it. */ async function appendStatusChange( ctx: SecurityContext, tx: Tx, @@ -272,6 +223,12 @@ async function appendStatusChange( after: Task, cause: TaskCause = DIRECT_EDIT, ): Promise { + tx.publish("tasks.task_status_changed", { + taskId: after.id, + from: before.status, + to: after.status, + byEmployeeId: ctx.user?.employeeId ?? null, + }); const [line] = await history.append(ctx, tx, { recordType: "task", operation: "UPDATED", @@ -312,20 +269,11 @@ function refuseFeatureTask(task: Task): void { } } -function requireText(value: string, field: string): string { - const trimmed = value.trim(); - if (!trimmed) { - throw invalidInput([{ field, code: "required" }]); - } - return trimmed; -} - function notFound(): ApplicationError { return new ApplicationError("NOT_FOUND", "No task has this id."); } const history = createRecordHistoryService(); -const DIRECT_EDIT: TaskCause = { kind: "EDITED_DIRECTLY" }; const MARK_DONE_REFUSAL = "Only the person who holds this task can mark it done."; const REOPEN_REFUSAL = "Only the person who holds this task can reopen it."; diff --git a/packages/api/src/modules/tasks/db/taskRepository.ts b/packages/api/src/modules/tasks/db/taskRepository.ts index ea82f86a1..c7c70bbce 100644 --- a/packages/api/src/modules/tasks/db/taskRepository.ts +++ b/packages/api/src/modules/tasks/db/taskRepository.ts @@ -9,7 +9,8 @@ export type NewTaskRow = Omit; /** Stored tasks. */ export interface TaskRepository { - insert(task: NewTaskRow): Promise; + /** Inserts the tasks in one statement; returns them in the same order. */ + insert(tasks: readonly NewTaskRow[]): Promise; findById(id: string): Promise; findByIds(ids: readonly string[]): Promise; /** The task with this id, or null, locked until the transaction ends, so the first change wins. */ @@ -28,9 +29,10 @@ export interface TaskRepository { export function taskRepository(db: Orm | Tx): TaskRepository { const orm = ormOf(db); return { - async insert(task) { - const [row] = await orm.insert(tasks).values(toRow(task)).returning(); - return toTask(row!); + async insert(newTasks) { + if (newTasks.length === 0) return []; + const rows = await orm.insert(tasks).values(newTasks.map(toRow)).returning(); + return rows.map(toTask); }, async findById(id) { diff --git a/packages/api/test/support/domainEvents.ts b/packages/api/test/support/domainEvents.ts new file mode 100644 index 000000000..2b9c9bc10 --- /dev/null +++ b/packages/api/test/support/domainEvents.ts @@ -0,0 +1,20 @@ +import { type Orm, transactor } from "../../src/core/db"; +import { createEventBus, type DomainEvent } from "../../src/core/events"; +import { createLogger } from "../../src/core/logger"; + +/** Task events delivered after the transaction commits. */ +export function domainEvents(orm: Orm) { + const events: DomainEvent[] = []; + const eventBus = createEventBus(createLogger("silent")); + eventBus.subscribe({ + "tasks.task_created": (payload) => { + events.push({ type: "tasks.task_created", payload }); + return Promise.resolve(); + }, + "tasks.task_status_changed": (payload) => { + events.push({ type: "tasks.task_status_changed", payload }); + return Promise.resolve(); + }, + }); + return { transact: transactor(orm, eventBus), events }; +} diff --git a/packages/api/test/support/index.ts b/packages/api/test/support/index.ts index fab9472f8..373491cca 100644 --- a/packages/api/test/support/index.ts +++ b/packages/api/test/support/index.ts @@ -78,7 +78,8 @@ export { passwordResetEmail, } from "./services"; export type { EmployeeServices } from "./services"; -export { processService } from "./processService"; +export { processService, transactWithProcesses } from "./processService"; +export { domainEvents } from "./domainEvents"; export { employeeSetup, fileStorage, diff --git a/packages/api/test/support/processService.ts b/packages/api/test/support/processService.ts index 58fbdaf26..8ee2f28ad 100644 --- a/packages/api/test/support/processService.ts +++ b/packages/api/test/support/processService.ts @@ -1,6 +1,7 @@ -import type { Orm } from "../../src/core/db"; +import { createBeforeCommitBus, type Orm, type Transact, transactor } from "../../src/core/db"; +import { createEventBus } from "../../src/core/events"; import { createLogger } from "../../src/core/logger"; -import { createProcessService } from "../../src/modules/processes"; +import { createProcessService, processesModule } from "../../src/modules/processes"; import { type CalendarDate, todayInLondon } from "../../src/utils"; import { bankHolidayService } from "./bankHolidayService"; import { taskService } from "./services"; @@ -9,14 +10,33 @@ import { testTransactor } from "./testTransactor"; /** The process service with real tasks, offline bank holidays and an injectable day, at noon. */ export function processService( orm: Orm, - { today = todayInLondon }: { today?: () => CalendarDate } = {}, + { + today = todayInLondon, + transact = testTransactor(orm), + }: { + today?: () => CalendarDate; + transact?: Transact; + } = {}, ) { return createProcessService({ orm, - transact: testTransactor(orm), + transact, logger: createLogger("silent"), - tasks: taskService(orm), + tasks: taskService(orm, { transact }), bankHolidays: bankHolidayService(), now: () => new Date(`${today()}T12:00:00Z`), }); } + +/** Transactions whose events start processes before the commit, using the supplied day. */ +export function transactWithProcesses( + orm: Orm, + { today }: { today: () => CalendarDate }, +): Transact { + const logger = createLogger("silent"); + const beforeCommit = createBeforeCommitBus(); + const transact = transactor(orm, createEventBus(logger), beforeCommit); + const processes = processService(orm, { today, transact }); + beforeCommit.subscribe(processesModule({ logger, processes }).eventsBeforeCommit!); + return transact; +} diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts new file mode 100644 index 000000000..5834aa669 --- /dev/null +++ b/packages/api/test/support/seedProcess.ts @@ -0,0 +1,28 @@ +import type { Orm } from "../../src/core/db"; +import { processRepository } from "../../src/modules/processes/db"; +import type { ProcessDefinition } from "../../src/modules/processes/domain"; +import { testTransactor } from "./testTransactor"; + +/** A process with its first published version active, without invoking use cases or events. */ +export function seedProcess( + orm: Orm, + { + key, + definition, + turnedOn = true, + }: { + key: string; + definition: ProcessDefinition; + turnedOn?: boolean; + }, +): Promise<{ id: string; key: string }> { + return testTransactor(orm)((tx) => + processRepository(tx).insertProcess({ + key, + name: key, + description: "Test process", + definition, + turnedOnAt: turnedOn ? new Date() : null, + }), + ); +} diff --git a/packages/api/test/support/seedTask.ts b/packages/api/test/support/seedTask.ts index b1c13c5e9..11f009c8d 100644 --- a/packages/api/test/support/seedTask.ts +++ b/packages/api/test/support/seedTask.ts @@ -6,21 +6,24 @@ export function seedTask( orm: Orm, overrides: Pick & Partial, ) { - return testTransactor(orm)((tx) => - taskRepository(tx).insert({ - type: "manual_to_do", - status: "to_do", - description: null, - askedByEmployeeId: null, - subjectEmployeeId: null, - processSource: null, - record: null, - dueDate: null, - doneAt: null, - doneByEmployeeId: null, - cancelledAt: null, - cancelReason: null, - ...overrides, - }), - ); + return testTransactor(orm)(async (tx) => { + const [task] = await taskRepository(tx).insert([ + { + type: "manual_to_do", + status: "to_do", + description: null, + askedByEmployeeId: null, + subjectEmployeeId: null, + processSource: null, + record: null, + dueDate: null, + doneAt: null, + doneByEmployeeId: null, + cancelledAt: null, + cancelReason: null, + ...overrides, + }, + ]); + return task!; + }); } diff --git a/packages/api/test/support/services.ts b/packages/api/test/support/services.ts index 06a7d70cc..88f954e1f 100644 --- a/packages/api/test/support/services.ts +++ b/packages/api/test/support/services.ts @@ -39,7 +39,6 @@ import { import { createImportServices } from "../../src/modules/imports"; import { createExternalAgentServices } from "../../src/modules/externalAgents"; import { createSystemService } from "../../src/modules/system"; -import { createTaskService } from "../../src/modules/tasks"; import { parseCalendarDate } from "../../src/utils"; import { createSlackConnectionService } from "../../src/modules/slack"; import { fakeMail } from "./fakeMail"; @@ -62,8 +61,7 @@ export function companyService( return createCompanyService({ orm, transact: testTransactor(orm), logger, brandLookup }); } -export const taskService = (orm: Orm) => - createTaskService({ orm, transact: testTransactor(orm), logger: createLogger("silent") }); +export { taskService } from "./taskService"; /** Every employee service a person's actions reach, together. */ export type EmployeeServices = EmployeeService & diff --git a/packages/api/test/support/taskService.ts b/packages/api/test/support/taskService.ts new file mode 100644 index 000000000..52692b6ba --- /dev/null +++ b/packages/api/test/support/taskService.ts @@ -0,0 +1,11 @@ +import type { Orm, Transact } from "../../src/core/db"; +import { createLogger } from "../../src/core/logger"; +import { createTaskService } from "../../src/modules/tasks"; +import { testTransactor } from "./testTransactor"; + +export function taskService( + orm: Orm, + { transact = testTransactor(orm) }: { transact?: Transact } = {}, +) { + return createTaskService({ orm, transact, logger: createLogger("silent") }); +} diff --git a/packages/api/test/support/testSeed.ts b/packages/api/test/support/testSeed.ts index 83adaf07d..820645818 100644 --- a/packages/api/test/support/testSeed.ts +++ b/packages/api/test/support/testSeed.ts @@ -20,6 +20,7 @@ import { defaultTermsId, insertTerms, leaveTerms, moveToTerms } from "./seedTerm import { seedUser } from "./seedUser"; import { seedAgentConnection } from "./seedAgentConnection"; import { seedTask } from "./seedTask"; +import { seedProcess } from "./seedProcess"; import { testTransactor } from "./testTransactor"; type SeedEmployee = Partial< @@ -186,6 +187,9 @@ export class TestSeed { agentConnection = (options: Parameters[1]) => seedAgentConnection(this.orm, options); + /** A process with its first version published and active. */ + process = (options: Parameters[1]) => seedProcess(this.orm, options); + /** The id of the seeded "England and Wales" terms, which every new employee joins. */ defaultTermsId = () => defaultTermsId(this.orm); From 6c4443e27ee111f58a5a7a65137979766b251dca Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Thu, 12 Nov 2026 12:10:05 +0100 Subject: [PATCH 2/8] fix(processes): name the process and events when a triggered start fails the change --- packages/api/src/core/db/transaction.ts | 3 +- .../modules/processes/app/eventRunService.ts | 50 ++++++++++++------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/api/src/core/db/transaction.ts b/packages/api/src/core/db/transaction.ts index 9654d7073..316bad30f 100644 --- a/packages/api/src/core/db/transaction.ts +++ b/packages/api/src/core/db/transaction.ts @@ -97,8 +97,9 @@ async function handBeforeCommit(transaction: Transaction, beforeCommit: BeforeCo let handed = 0; for (let round = 0; handed < transaction.events.length; round += 1) { if (round === MAX_BEFORE_COMMIT_ROUNDS) { + const types = [...new Set(transaction.events.slice(handed).map(({ type }) => type))]; throw new Error( - `Events kept publishing more events before the commit after ${MAX_BEFORE_COMMIT_ROUNDS} rounds.`, + `Events kept publishing more events before the commit after ${MAX_BEFORE_COMMIT_ROUNDS} rounds. The last round published ${types.join(", ")}.`, ); } const events = transaction.events.slice(handed); diff --git a/packages/api/src/modules/processes/app/eventRunService.ts b/packages/api/src/modules/processes/app/eventRunService.ts index 48d4a6548..b61c0bef6 100644 --- a/packages/api/src/modules/processes/app/eventRunService.ts +++ b/packages/api/src/modules/processes/app/eventRunService.ts @@ -33,24 +33,8 @@ export function createEventRunService(dependencies: EventRunDependencies): Event const processes = await processRepository(tx).lockProcessesStartedBy([ ...new Set(triggering.map(({ type }) => type)), ]); - const definitions = new Map( - processes.map(({ id, activeVersion }) => [ - id, - parseProcessDefinition(activeVersion.schemaVersion, activeVersion.definition), - ]), - ); - const starts = matchEventStarts( - processes.map(({ id }) => ({ processId: id, trigger: definitions.get(id)!.trigger })), - triggering, - ); for (const process of processes) { - const own = starts.filter(({ processId }) => processId === process.id); - if (own.length === 0) continue; - await startProcessRuns(dependencies, tx, { - process, - definition: definitions.get(process.id)!, - starts: own, - }); + await startRunsOf(dependencies, tx, process, triggering); } }, }; @@ -66,6 +50,38 @@ interface EventRunDependencies { type Start = EventStart; +/** Starts the runs one process's trigger matches; a failure is logged with the process and rethrown. */ +async function startRunsOf( + dependencies: EventRunDependencies, + tx: Tx, + process: StoredProcess, + events: readonly PublishedEvent[], +): Promise { + try { + const { activeVersion } = process; + const definition = parseProcessDefinition( + activeVersion.schemaVersion, + activeVersion.definition, + ); + const starts = matchEventStarts( + [{ processId: process.id, trigger: definition.trigger }], + events, + ); + if (starts.length > 0) + await startProcessRuns(dependencies, tx, { process, definition, starts }); + } catch (error) { + dependencies.logger.error( + { + processKey: process.key, + eventIds: events.map(({ id }) => id), + kind: (error as Error | undefined)?.constructor?.name, + }, + "Process runs not started, so the change that triggered them rolls back", + ); + throw error; + } +} + /** * Starts one process's runs in waves, each record at most once a wave, so a second event for a * record finds the first one's run when it is still in progress. From 61a310b3210ac23c8c335669557d0c793a95fc7f Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Thu, 12 Nov 2026 12:14:37 +0100 Subject: [PATCH 3/8] refactor(processes): declare event run dependencies before their factory --- .../src/modules/processes/app/eventRunService.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/api/src/modules/processes/app/eventRunService.ts b/packages/api/src/modules/processes/app/eventRunService.ts index b61c0bef6..3caabdbef 100644 --- a/packages/api/src/modules/processes/app/eventRunService.ts +++ b/packages/api/src/modules/processes/app/eventRunService.ts @@ -24,6 +24,14 @@ export interface EventRunService { startRunsOnEvents(ctx: SecurityContext, tx: Tx, events: readonly PublishedEvent[]): Promise; } +interface EventRunDependencies { + logger: Logger; + tasks: TaskService; + starter: RunStarter; + /** Today in London: the start day of the runs. */ + today: () => CalendarDate; +} + export function createEventRunService(dependencies: EventRunDependencies): EventRunService { return { async startRunsOnEvents(ctx, tx, events) { @@ -40,14 +48,6 @@ export function createEventRunService(dependencies: EventRunDependencies): Event }; } -interface EventRunDependencies { - logger: Logger; - tasks: TaskService; - starter: RunStarter; - /** Today in London: the start day of the runs. */ - today: () => CalendarDate; -} - type Start = EventStart; /** Starts the runs one process's trigger matches; a failure is logged with the process and rethrown. */ From fb6cd147f2c52a5884430a752703033298cbcb77 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Thu, 12 Nov 2026 13:40:10 +0100 Subject: [PATCH 4/8] fix(processes): start event runs without process locks, in insert batches, keeping each run's own event --- .../modules/processes/app/eventRunService.ts | 38 ++++------- .../modules/processes/app/registrations.ts | 7 ++ .../app/startRunsOnEvents.int.test.ts | 34 ++-------- .../app/startRunsOnEventsAtScale.int.test.ts | 68 +++++++++++++++++++ .../modules/processes/db/processRepository.ts | 49 +++++++------ .../modules/processes/domain/eventTrigger.ts | 30 ++++++-- .../api/src/modules/processes/domain/index.ts | 1 + packages/api/src/modules/processes/index.ts | 7 ++ .../src/modules/tasks/db/taskRepository.ts | 15 ++-- packages/api/test/support/index.ts | 1 + packages/api/test/support/seedProcess.ts | 28 ++++++++ 11 files changed, 195 insertions(+), 83 deletions(-) create mode 100644 packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts diff --git a/packages/api/src/modules/processes/app/eventRunService.ts b/packages/api/src/modules/processes/app/eventRunService.ts index 3caabdbef..883787d4e 100644 --- a/packages/api/src/modules/processes/app/eventRunService.ts +++ b/packages/api/src/modules/processes/app/eventRunService.ts @@ -6,6 +6,7 @@ import type { CalendarDate } from "../../../utils"; import type { TaskService } from "../../tasks"; import { processRepository, type StoredProcess } from "../db"; import { + eventAboutRecord, type EventStart, isTriggeringEvent, matchEventStarts, @@ -38,7 +39,7 @@ export function createEventRunService(dependencies: EventRunDependencies): Event ctx.requireSystem(); const triggering = events.filter(({ type }) => isTriggeringEvent(type)); if (triggering.length === 0) return; - const processes = await processRepository(tx).lockProcessesStartedBy([ + const processes = await processRepository(tx).findProcessesStartedBy([ ...new Set(triggering.map(({ type }) => type)), ]); for (const process of processes) { @@ -119,12 +120,18 @@ async function startProcessRuns( startCause: "started_on_event", startedByEmployeeId: null, triggerDate: null, - triggerEvent: event, + triggerEvent: eventAboutRecord(event, recordId), startKey: `on_event:${event.id}:${recordId}`, startDay: today(), })), ); - if (started.length > 0) logStarted(logger, process, started, ready); + if (started.length > 0) { + tx.publish("processes.runs_started_on_event", { + processKey: process.key, + runIds: started.map(({ id }) => id), + eventIds: [...new Set(ready.map(({ event }) => event.id))], + }); + } } } @@ -168,12 +175,11 @@ async function readRecordSubjects( /** The starts split so each record appears at most once a wave, keeping their order. */ function wavesOf(starts: readonly Start[]): Start[][] { const waves: Start[][] = []; + const seen = new Map(); for (const start of starts) { - const wave = waves.find((candidate) => - candidate.every(({ recordId }) => recordId !== start.recordId), - ); - if (wave) wave.push(start); - else waves.push([start]); + const wave = seen.get(start.recordId) ?? 0; + seen.set(start.recordId, wave + 1); + (waves[wave] ??= []).push(start); } return waves; } @@ -189,19 +195,3 @@ function logSkip( "Process run not started for an event", ); } - -function logStarted( - logger: Logger, - process: StoredProcess, - runs: readonly { id: string }[], - starts: readonly Start[], -): void { - logger.info( - { - processKey: process.key, - runIds: runs.map(({ id }) => id), - eventIds: [...new Set(starts.map(({ event }) => event.id))], - }, - "Process runs started with the change that triggered them", - ); -} diff --git a/packages/api/src/modules/processes/app/registrations.ts b/packages/api/src/modules/processes/app/registrations.ts index e7c68c9a7..2d9ac7859 100644 --- a/packages/api/src/modules/processes/app/registrations.ts +++ b/packages/api/src/modules/processes/app/registrations.ts @@ -18,4 +18,11 @@ declare module "../../../core/jobs" { } } +declare module "../../../core/events" { + interface DomainEvents { + /** Runs of the process started with the change whose events triggered them, now committed. */ + "processes.runs_started_on_event": { processKey: string; runIds: string[]; eventIds: string[] }; + } +} + export {}; diff --git a/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts b/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts index 9b825e95c..c24b4853a 100644 --- a/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts +++ b/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts @@ -10,10 +10,9 @@ import { taskService, test, transactWithProcesses, + eventProcess, } from "#test/support"; -import type { ProcessDefinition } from "../domain"; - test("adding a person commits exactly one process run and a task for their manager", async ({ orm, seed, @@ -99,6 +98,10 @@ test("one event for three people starts three runs", async ({ orm, seed }) => { const runs = await processService(orm).listRuns(ADMIN, key); expect(runs.map(({ record }) => record.recordId).sort()).toEqual([...ids].sort()); expect(new Set(runs.map(({ triggerEvent }) => triggerEvent!.id)).size).toBe(1); + for (const run of runs) { + expect(run.triggerEvent!.type).toBe("employees.added"); + expect(run.triggerEvent!.payload).toEqual({ employeeIds: [run.record.recordId] }); + } }); test("three events in one transaction start three runs", async ({ orm, seed }) => { @@ -311,30 +314,3 @@ test("a matching event during a run in progress is skipped and the save commits" }); const today = () => calendarDate("2026-12-03"); - -function eventProcess( - event: "employees.added" | "tasks.task_created" | "tasks.task_status_changed", - when?: { field: string; in: (string | null)[] }, -): ProcessDefinition { - return { - subject: { recordType: event === "employees.added" ? "employee" : "task" }, - trigger: { - kind: "on_event", - event, - run: "in_same_transaction", - faultMode: "rollback_transaction", - ...(when && { when }), - }, - startableByAdmin: false, - steps: [ - { - key: "follow_up", - kind: "task", - title: "Follow up", - description: null, - assignee: { kind: "subject" }, - due: { amount: 0, unit: "calendar_days" }, - }, - ], - }; -} diff --git a/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts b/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts new file mode 100644 index 000000000..94c8bf0c8 --- /dev/null +++ b/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts @@ -0,0 +1,68 @@ +import { sql } from "drizzle-orm"; + +import { + ADMIN, + calendarDate, + eventProcess, + expect, + processService, + test, + transactWithProcesses, +} from "#test/support"; + +import { ormOf } from "../../../core/db"; +import { processRepository } from "../db"; + +test("one event for more people than one insert can hold starts a run for each", async ({ + orm, + seed, +}) => { + const people = await seed.employees(5_000); + const definition = eventProcess("employees.added"); + definition.steps.push({ ...definition.steps[0]!, key: "second_follow_up" }); + const { key } = await seed.process({ key: "welcome", definition }); + const transact = transactWithProcesses(orm, { today }); + + await transact((tx) => { + tx.publish("employees.added", { employeeIds: people.map(({ id }) => id) }); + return Promise.resolve(); + }); + + expect(await processService(orm).listRuns(ADMIN, key)).toHaveLength(5_000); +}); + +test( + "an event starts its run while another transaction holds the process lock", + { timeout: 5_000 }, + async ({ orm, seed, transact }) => { + const employee = await seed.employee(); + const process = await seed.process({ + key: "welcome", + definition: eventProcess("employees.added"), + }); + const locked = Promise.withResolvers(); + const release = Promise.withResolvers(); + const holding = transact(async (tx) => { + await processRepository(tx).lockProcess(process.id); + locked.resolve(); + await release.promise; + }); + + try { + await Promise.race([locked.promise, holding]); + await transactWithProcesses(orm, { today })(async (tx) => { + await ormOf(tx).execute(sql`set local lock_timeout = '2s'`); + tx.publish("employees.added", { employeeIds: [employee.id] }); + }); + } finally { + release.resolve(); + await holding; + } + + expect(await processService(orm).listRuns(ADMIN, process.key)).toMatchObject([ + { record: { recordType: "employee", recordId: employee.id } }, + ]); + }, +); + +const today = () => calendarDate("2026-12-03"); diff --git a/packages/api/src/modules/processes/db/processRepository.ts b/packages/api/src/modules/processes/db/processRepository.ts index 87ac9200d..0d8f4cbdd 100644 --- a/packages/api/src/modules/processes/db/processRepository.ts +++ b/packages/api/src/modules/processes/db/processRepository.ts @@ -35,20 +35,20 @@ export interface ProcessRepository { findProcessesByIds(ids: readonly string[]): Promise; /** A version's stored definition, not yet parsed; null when no version has the id. */ findVersion(id: string): Promise<{ schemaVersion: number; definition: unknown } | null>; - /** The process, locked until the transaction ends, so every way of starting a run takes turns. */ - lockProcess(id: string): Promise; - /** Every process, locked until the transaction ends, in id order. */ - lockAllProcesses(): Promise; /** - * The turned-on processes whose active version starts on one of these events, share-locked until - * the transaction ends: starts from concurrent changes never wait for each other, while turning - * a process off or a reset waits for them. + * The process, locked until the transaction ends, so the daily job, an admin's start, step + * commands and resets take turns. The lock leaves new runs' foreign keys free, so a change that + * starts a run from an event never waits for it. */ - lockProcessesStartedBy(eventTypes: readonly string[]): Promise; + lockProcess(id: string): Promise; + /** Every process, locked as `lockProcess` does, in id order. */ + lockAllProcesses(): Promise; + /** The turned-on processes whose active version starts on one of these events, unlocked. */ + findProcessesStartedBy(eventTypes: readonly string[]): Promise; /** A new process with its first version, published and active; for tests and seeds. */ insertProcess(process: NewProcessRow): Promise<{ id: string; key: string }>; setTurnedOnAt(id: string, turnedOnAt: Date | null): Promise; - /** Inserts the runs in one statement; returns them in the same order. */ + /** Inserts the runs in batches; returns them in the same order. */ insertRuns(runs: readonly NewRunRow[]): Promise; insertSteps(steps: readonly RunStep[]): Promise; findRun(id: string): Promise; @@ -129,14 +129,18 @@ export function processRepository(db: Orm | Tx): ProcessRepository { .select({ id: processes.id }) .from(processes) .where(eq(processes.id, id)) - .for("update"); + .for("no key update"); }, async lockAllProcesses() { - await orm.select({ id: processes.id }).from(processes).orderBy(processes.id).for("update"); + await orm + .select({ id: processes.id }) + .from(processes) + .orderBy(processes.id) + .for("no key update"); }, - async lockProcessesStartedBy(eventTypes) { + async findProcessesStartedBy(eventTypes) { const rows = await selectProcesses() .where( and( @@ -145,8 +149,7 @@ export function processRepository(db: Orm | Tx): ProcessRepository { sql`${processVersions.definition} -> 'trigger' ->> 'event' = any(${sql.param([...eventTypes])}::text[])`, ), ) - .orderBy(processes.id) - .for("share", { of: processes }); + .orderBy(processes.id); return rows.map(toStoredProcess); }, @@ -177,15 +180,18 @@ export function processRepository(db: Orm | Tx): ProcessRepository { }, async insertRuns(runs) { - if (runs.length === 0) return []; - return await orm - .insert(processRuns) - .values([...runs]) - .returning(); + const inserted: ProcessRun[] = []; + for (let start = 0; start < runs.length; start += INSERT_BATCH_SIZE) { + const batch = runs.slice(start, start + INSERT_BATCH_SIZE); + inserted.push(...(await orm.insert(processRuns).values(batch).returning())); + } + return inserted; }, async insertSteps(steps) { - if (steps.length > 0) await orm.insert(processRunSteps).values([...steps]); + for (let start = 0; start < steps.length; start += INSERT_BATCH_SIZE) { + await orm.insert(processRunSteps).values(steps.slice(start, start + INSERT_BATCH_SIZE)); + } }, async findRun(id) { @@ -298,6 +304,9 @@ async function findRecordsWithRunsWhere( return new Set(rows.map(({ recordId }) => recordId)); } +/** Rows per insert, well below Postgres's limit of 65,535 parameters. */ +const INSERT_BATCH_SIZE = 1_000; + type ProcessWithVersionRow = { process: typeof processes.$inferSelect; version: StoredProcess["activeVersion"]; diff --git a/packages/api/src/modules/processes/domain/eventTrigger.ts b/packages/api/src/modules/processes/domain/eventTrigger.ts index d8d1e6779..8e66d8017 100644 --- a/packages/api/src/modules/processes/domain/eventTrigger.ts +++ b/packages/api/src/modules/processes/domain/eventTrigger.ts @@ -2,22 +2,27 @@ import type { DomainEvents } from "../../../core/events"; import type { EventTrigger, ProcessDefinition } from "./processDefinition"; /** - * The events that may start a process, with the record type each is about and the ids of those - * records in its payload: one run per record. + * The events that may start a process, with the record type each is about, the ids of those + * records in its payload (one run per record), and the payload a run keeps for its own record. */ // shortcut: the list lives here; move it to the publishing modules' registrations when modules become dynamic. export const TRIGGERING_EVENTS = { "employees.added": { recordType: "employee", recordIds: ({ employeeIds }: DomainEvents["employees.added"]) => employeeIds, + payloadFor: (_: DomainEvents["employees.added"], employeeId: string) => ({ + employeeIds: [employeeId], + }), }, "tasks.task_created": { recordType: "task", recordIds: ({ taskId }: DomainEvents["tasks.task_created"]) => [taskId], + payloadFor: (payload: DomainEvents["tasks.task_created"]) => payload, }, "tasks.task_status_changed": { recordType: "task", recordIds: ({ taskId }: DomainEvents["tasks.task_status_changed"]) => [taskId], + payloadFor: (payload: DomainEvents["tasks.task_status_changed"]) => payload, }, } as const; @@ -54,6 +59,14 @@ export function matchEventStarts( ); } +/** The event as it concerns one of its records, as that record's run keeps it. */ +export function eventAboutRecord( + { id, type, payload }: TriggerEvent, + recordId: string, +): TriggerEvent { + return { id, type, payload: entryOf(type).payloadFor(payload, recordId) }; +} + export function isTriggeringEvent(type: string): type is TriggeringEventType { return Object.hasOwn(TRIGGERING_EVENTS, type); } @@ -66,8 +79,13 @@ function isTriggeredBy(trigger: EventTrigger, { type, payload }: TriggerEvent): } function recordIdsOf({ type, payload }: TriggerEvent): readonly string[] { - if (!isTriggeringEvent(type)) return []; - // Each entry reads its own event's payload, which `type` names. - const { recordIds } = TRIGGERING_EVENTS[type] as { recordIds: (payload: unknown) => string[] }; - return recordIds(payload); + return isTriggeringEvent(type) ? entryOf(type).recordIds(payload) : []; +} + +/** An event's entry, typed for any payload: each entry reads its own event's, which `type` names. */ +function entryOf(type: string) { + return TRIGGERING_EVENTS[type as TriggeringEventType] as { + recordIds: (payload: unknown) => string[]; + payloadFor: (payload: unknown, recordId: string) => unknown; + }; } diff --git a/packages/api/src/modules/processes/domain/index.ts b/packages/api/src/modules/processes/domain/index.ts index 64893cfd1..0ce6ed2ff 100644 --- a/packages/api/src/modules/processes/domain/index.ts +++ b/packages/api/src/modules/processes/domain/index.ts @@ -1,6 +1,7 @@ export { addDayOffset, type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "./dayOffset"; export { dueTriggerDate } from "./recordDateTrigger"; export { + eventAboutRecord, type EventStart, isTriggeringEvent, matchEventStarts, diff --git a/packages/api/src/modules/processes/index.ts b/packages/api/src/modules/processes/index.ts index a6a1d6ea6..e425d43df 100644 --- a/packages/api/src/modules/processes/index.ts +++ b/packages/api/src/modules/processes/index.ts @@ -16,6 +16,13 @@ export function processesModule({ typeDefs: readSdl(import.meta.url, "./graphql/schema.graphql"), resolvers: {}, jobs: startDueRunsJob({ processes, logger }), + events: { + // Logged once the change commits, never for runs a rollback dropped. + "processes.runs_started_on_event": (started) => + Promise.resolve( + logger.info(started, "Process runs started with the change that triggered them"), + ), + }, eventsBeforeCommit: (tx, events) => processes.startRunsOnEvents(PrincipalSecurityContext.system(), tx, events), }; diff --git a/packages/api/src/modules/tasks/db/taskRepository.ts b/packages/api/src/modules/tasks/db/taskRepository.ts index c7c70bbce..c6934e1bd 100644 --- a/packages/api/src/modules/tasks/db/taskRepository.ts +++ b/packages/api/src/modules/tasks/db/taskRepository.ts @@ -9,7 +9,7 @@ export type NewTaskRow = Omit; /** Stored tasks. */ export interface TaskRepository { - /** Inserts the tasks in one statement; returns them in the same order. */ + /** Inserts the tasks in batches; returns them in the same order. */ insert(tasks: readonly NewTaskRow[]): Promise; findById(id: string): Promise; findByIds(ids: readonly string[]): Promise; @@ -30,9 +30,13 @@ export function taskRepository(db: Orm | Tx): TaskRepository { const orm = ormOf(db); return { async insert(newTasks) { - if (newTasks.length === 0) return []; - const rows = await orm.insert(tasks).values(newTasks.map(toRow)).returning(); - return rows.map(toTask); + const inserted: Task[] = []; + for (let start = 0; start < newTasks.length; start += INSERT_BATCH_SIZE) { + const batch = newTasks.slice(start, start + INSERT_BATCH_SIZE); + const rows = await orm.insert(tasks).values(batch.map(toRow)).returning(); + inserted.push(...rows.map(toTask)); + } + return inserted; }, async findById(id) { @@ -94,6 +98,9 @@ export function taskRepository(db: Orm | Tx): TaskRepository { type TaskRow = typeof tasks.$inferSelect; +/** Rows per insert, well below Postgres's limit of 65,535 parameters. */ +const INSERT_BATCH_SIZE = 1_000; + function toRow({ processSource, record, ...task }: NewTaskRow) { return { ...task, diff --git a/packages/api/test/support/index.ts b/packages/api/test/support/index.ts index 373491cca..7bfcf1698 100644 --- a/packages/api/test/support/index.ts +++ b/packages/api/test/support/index.ts @@ -79,6 +79,7 @@ export { } from "./services"; export type { EmployeeServices } from "./services"; export { processService, transactWithProcesses } from "./processService"; +export { eventProcess } from "./seedProcess"; export { domainEvents } from "./domainEvents"; export { employeeSetup, diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts index 5834aa669..655c540e0 100644 --- a/packages/api/test/support/seedProcess.ts +++ b/packages/api/test/support/seedProcess.ts @@ -26,3 +26,31 @@ export function seedProcess( }), ); } + +/** A process on `event`, with one task step for its subject, due the day it starts. */ +export function eventProcess( + event: "employees.added" | "tasks.task_created" | "tasks.task_status_changed", + when?: { field: string; in: (string | null)[] }, +): ProcessDefinition { + return { + subject: { recordType: event === "employees.added" ? "employee" : "task" }, + trigger: { + kind: "on_event", + event, + run: "in_same_transaction", + faultMode: "rollback_transaction", + ...(when && { when }), + }, + startableByAdmin: false, + steps: [ + { + key: "follow_up", + kind: "task", + title: "Follow up", + description: null, + assignee: { kind: "subject" }, + due: { amount: 0, unit: "calendar_days" }, + }, + ], + }; +} From 690c2d9452eed25113fee6385ee48c8b6e76d5fd Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Thu, 12 Nov 2026 13:51:23 +0100 Subject: [PATCH 5/8] fix(processes): lock tasks before a reset deletes employees, so it waits for a task change --- packages/api/src/modules/index.ts | 2 + .../modules/processes/app/registrations.ts | 2 +- .../app/startRunsOnEventsAtScale.int.test.ts | 54 +++++++++++++++++++ .../api/src/modules/tasks/app/taskService.ts | 10 ++++ .../src/modules/tasks/db/taskRepository.ts | 6 +++ packages/api/test/support/timeOffService.ts | 1 + 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts index 467971105..b08de2ff6 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -63,6 +63,8 @@ export function createModules( await timeOff.lockPolicies(ctx, tx); // Then the processes: a step command locks its process before its run and task. await processes.lockProcesses(ctx, tx); + // Then the tasks: a task change can create tasks for employees before it commits. + await tasks.lockTasks(ctx, tx); // Employees next: their assignments, allowances and ledger lines point at the policies. await employeeSetup.deleteAllEmployees(ctx, tx); await timeOff.resetTimeOff(ctx, tx); diff --git a/packages/api/src/modules/processes/app/registrations.ts b/packages/api/src/modules/processes/app/registrations.ts index 2d9ac7859..b6a50488b 100644 --- a/packages/api/src/modules/processes/app/registrations.ts +++ b/packages/api/src/modules/processes/app/registrations.ts @@ -20,7 +20,7 @@ declare module "../../../core/jobs" { declare module "../../../core/events" { interface DomainEvents { - /** Runs of the process started with the change whose events triggered them, now committed. */ + /** Runs started in the transaction whose events triggered them; after-commit handlers get it only once it commits. */ "processes.runs_started_on_event": { processKey: string; runIds: string[]; eventIds: string[] }; } } diff --git a/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts b/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts index 94c8bf0c8..76458cd08 100644 --- a/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts +++ b/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts @@ -3,9 +3,13 @@ import { sql } from "drizzle-orm"; import { ADMIN, calendarDate, + deleteAllEmployees, eventProcess, expect, + expectBlockedBy, processService, + signedIn, + taskService, test, transactWithProcesses, } from "#test/support"; @@ -65,4 +69,54 @@ test( }, ); +test( + "a reset waits for a task change whose event starts a run, and removes that run", + { timeout: 5_000 }, + async ({ orm, seed, transact }) => { + const employee = await seed.employee(); + const task = await seed.task({ + assigneeEmployeeId: employee.id, + askedByEmployeeId: employee.id, + subjectEmployeeId: employee.id, + }); + const { key } = await seed.process({ + key: "follow_up", + definition: eventProcess("tasks.task_status_changed", { field: "to", in: ["cancelled"] }), + }); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + const locked = Promise.withResolvers(); + const release = Promise.withResolvers(); + const changing = transactWithProcesses(orm, { today })(async (tx) => { + await taskService(orm, { transact: (work) => work(tx) }).cancel( + ctx, + task.id, + "No longer needed", + ); + const { rows } = await ormOf(tx).execute<{ pid: number }>( + sql`select pg_backend_pid() as pid`, + ); + locked.resolve(rows[0]!.pid); + await release.promise; + }); + await Promise.race([locked.promise, changing]); + const resetting = transact((tx) => deleteAllEmployees(orm)(ADMIN, tx)); + const results = Promise.allSettled([changing, resetting]); + + try { + await expectBlockedBy(orm, await locked.promise); + } finally { + release.resolve(); + await results; + } + + expect(await results).toMatchObject([{ status: "fulfilled" }, { status: "fulfilled" }]); + expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); + expect(await taskService(orm).listMyTasks(ctx)).toEqual({ + needsYou: [], + waitingOnOthers: [], + resolvedRecently: [], + }); + }, +); + const today = () => calendarDate("2026-12-03"); diff --git a/packages/api/src/modules/tasks/app/taskService.ts b/packages/api/src/modules/tasks/app/taskService.ts index 33dd8b353..23ec9b2c1 100644 --- a/packages/api/src/modules/tasks/app/taskService.ts +++ b/packages/api/src/modules/tasks/app/taskService.ts @@ -63,6 +63,11 @@ export interface TaskService { reopen(ctx: SecurityContext, taskId: string): Promise; /** The person who asked withdraws an open task, saying why. */ cancel(ctx: SecurityContext, taskId: string, reason: string): Promise; + /** + * Trusted, for resets: locks every task until the transaction ends, before the employees whose + * deletion takes their tasks, so a reset waits for a task change instead of deadlocking with it. + */ + lockTasks(ctx: SecurityContext, tx: Tx): Promise; /** * Back to a fresh workspace when setup or the demo goes back to its start, after the employees, * whose deletion took their tasks: forgets the tasks' history. @@ -184,6 +189,11 @@ export function createTaskService({ return cancelled; }, + async lockTasks(ctx, tx) { + ctx.requireSetupAccess("MANAGE_WORKSPACE"); + await taskRepository(tx).lockAll(); + }, + async resetTasks(ctx, tx) { ctx.requireSetupAccess("MANAGE_WORKSPACE"); await history.purge(tx, "task"); diff --git a/packages/api/src/modules/tasks/db/taskRepository.ts b/packages/api/src/modules/tasks/db/taskRepository.ts index c6934e1bd..e2029c373 100644 --- a/packages/api/src/modules/tasks/db/taskRepository.ts +++ b/packages/api/src/modules/tasks/db/taskRepository.ts @@ -17,6 +17,8 @@ export interface TaskRepository { lockById(id: string): Promise; /** The tasks among these ids, locked in id order until the transaction ends. */ lockByIds(ids: readonly string[]): Promise; + /** Every task, locked in id order until the transaction ends. */ + lockAll(): Promise; /** Writes the task's status and the columns that follow it. */ saveStatus(task: Task): Promise; /** @@ -52,6 +54,10 @@ export function taskRepository(db: Orm | Tx): TaskRepository { return rows.map(toTask); }, + async lockAll() { + await orm.select({ id: tasks.id }).from(tasks).orderBy(tasks.id).for("update"); + }, + async lockByIds(ids) { if (ids.length === 0) return []; const rows = await orm diff --git a/packages/api/test/support/timeOffService.ts b/packages/api/test/support/timeOffService.ts index b5e7c1564..9e4749602 100644 --- a/packages/api/test/support/timeOffService.ts +++ b/packages/api/test/support/timeOffService.ts @@ -257,6 +257,7 @@ export function deleteAllEmployees(orm: Orm) { return async (ctx: SecurityContext, tx: Tx) => { await timeOff.lockPolicies(ctx, tx); await processes.lockProcesses(ctx, tx); + await tasks.lockTasks(ctx, tx); await employeeSetup.deleteAllEmployees(ctx, tx); await timeOff.resetTimeOff(ctx, tx); await processes.resetProcesses(ctx, tx); From 8da12c697ed0b7d415efb6bef42d27837c34505b Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Thu, 12 Nov 2026 14:00:29 +0100 Subject: [PATCH 6/8] test(processes): seed event processes with seedProcess, keeping the test seed under its line limit --- .../app/startRunsOnEvents.int.test.ts | 41 ++++++++++--------- .../app/startRunsOnEventsAtScale.int.test.ts | 7 ++-- packages/api/test/support/index.ts | 2 +- packages/api/test/support/testSeed.ts | 4 -- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts b/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts index c24b4853a..2ca81ea4a 100644 --- a/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts +++ b/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts @@ -6,6 +6,7 @@ import { employeeServices, expect, processService, + seedProcess, signedIn, taskService, test, @@ -24,7 +25,7 @@ test("adding a person commits exactly one process run and a task for their manag const manager = await seed.employee(); const definition = eventProcess("employees.added"); definition.steps[0]!.assignee = { kind: "subjects_manager" }; - const { key } = await seed.process({ key: "welcome", definition }); + const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today }); const employee = await transact(async (tx) => { @@ -63,7 +64,7 @@ test("adding a person commits exactly one process run and a task for their manag test("a failed transaction leaves no event-started run", async ({ orm, seed }) => { const employee = await seed.employee(); - const { key } = await seed.process({ + const { key } = await seedProcess(orm, { key: "welcome", definition: eventProcess("employees.added"), }); @@ -84,7 +85,7 @@ test("a failed transaction leaves no event-started run", async ({ orm, seed }) = test("one event for three people starts three runs", async ({ orm, seed }) => { const people = await seed.employees(3); const ids = people.map(({ id }) => id); - const { key } = await seed.process({ + const { key } = await seedProcess(orm, { key: "welcome", definition: eventProcess("employees.added"), }); @@ -107,7 +108,7 @@ test("one event for three people starts three runs", async ({ orm, seed }) => { test("three events in one transaction start three runs", async ({ orm, seed }) => { const people = await seed.employees(3); const ids = people.map(({ id }) => id); - const { key } = await seed.process({ + const { key } = await seedProcess(orm, { key: "welcome", definition: eventProcess("employees.added"), }); @@ -123,13 +124,10 @@ test("three events in one transaction start three runs", async ({ orm, seed }) = expect(new Set(runs.map(({ triggerEvent }) => triggerEvent!.id)).size).toBe(3); }); -test("a run that cannot calculate its due date rolls back adding the person", async ({ - orm, - seed, -}) => { +test("a run that cannot calculate its due date rolls back adding the person", async ({ orm }) => { const definition = eventProcess("employees.added"); definition.steps[0]!.due = { amount: 1, unit: "business_days" }; - const { key } = await seed.process({ key: "welcome", definition }); + const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today: () => calendarDate("2031-06-02") }); const employees = employeeServices(orm, { transact }); @@ -145,8 +143,8 @@ test("a run that cannot calculate its due date rolls back adding the person", as expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); }); -test("an off process starts nothing when its event happens", async ({ orm, seed }) => { - const { key } = await seed.process({ +test("an off process starts nothing when its event happens", async ({ orm }) => { + const { key } = await seedProcess(orm, { key: "welcome", definition: eventProcess("employees.added"), turnedOn: false, @@ -162,9 +160,9 @@ test("an off process starts nothing when its event happens", async ({ orm, seed expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); }); -test("an unmatched event condition starts nothing", async ({ orm, seed }) => { +test("an unmatched event condition starts nothing", async ({ orm }) => { const definition = eventProcess("employees.added", { field: "source", in: ["import"] }); - const { key } = await seed.process({ key: "welcome", definition }); + const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today }); await employeeServices(orm, { transact }).createEmployee(ADMIN, { @@ -191,7 +189,7 @@ test("marking a task done starts a process carrying the person who acted", async field: "to", in: ["done", "cancelled"], }); - const { key } = await seed.process({ key: "follow_up", definition }); + const { key } = await seedProcess(orm, { key: "follow_up", definition }); const transact = transactWithProcesses(orm, { today }); const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); @@ -222,7 +220,7 @@ test("marking a task waiting does not start a done-or-cancelled process", async field: "to", in: ["done", "cancelled"], }); - const { key } = await seed.process({ key: "follow_up", definition }); + const { key } = await seedProcess(orm, { key: "follow_up", definition }); const transact = transactWithProcesses(orm, { today }); const tasks = taskService(orm, { transact }); const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); @@ -233,10 +231,13 @@ test("marking a task waiting does not start a done-or-cancelled process", async expect(await processService(orm).listRuns(ADMIN, key)).toEqual([]); }); -test("a run's tasks start another process in the same transaction", async ({ orm, seed }) => { - const first = await seed.process({ key: "welcome", definition: eventProcess("employees.added") }); +test("a run's tasks start another process in the same transaction", async ({ orm }) => { + const first = await seedProcess(orm, { + key: "welcome", + definition: eventProcess("employees.added"), + }); const definition = eventProcess("tasks.task_created", { field: "processKey", in: [first.key] }); - const second = await seed.process({ key: "follow_up", definition }); + const second = await seedProcess(orm, { key: "follow_up", definition }); const transact = transactWithProcesses(orm, { today }); const added = await employeeServices(orm, { transact }).createEmployee(ADMIN, { @@ -268,7 +269,7 @@ test("two matching events start two runs when every step is skipped", async ({ o const employee = await seed.employee(); const definition = eventProcess("employees.added"); definition.steps[0]!.assignee = { kind: "subjects_manager" }; - const { key } = await seed.process({ key: "welcome", definition }); + const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today }); await transact((tx) => { @@ -287,7 +288,7 @@ test("a matching event during a run in progress is skipped and the save commits" seed, }) => { const employee = await seed.employee(); - const { key } = await seed.process({ + const { key } = await seedProcess(orm, { key: "welcome", definition: eventProcess("employees.added"), }); diff --git a/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts b/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts index 76458cd08..dded330b4 100644 --- a/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts +++ b/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts @@ -8,6 +8,7 @@ import { expect, expectBlockedBy, processService, + seedProcess, signedIn, taskService, test, @@ -24,7 +25,7 @@ test("one event for more people than one insert can hold starts a run for each", const people = await seed.employees(5_000); const definition = eventProcess("employees.added"); definition.steps.push({ ...definition.steps[0]!, key: "second_follow_up" }); - const { key } = await seed.process({ key: "welcome", definition }); + const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today }); await transact((tx) => { @@ -40,7 +41,7 @@ test( { timeout: 5_000 }, async ({ orm, seed, transact }) => { const employee = await seed.employee(); - const process = await seed.process({ + const process = await seedProcess(orm, { key: "welcome", definition: eventProcess("employees.added"), }); @@ -79,7 +80,7 @@ test( askedByEmployeeId: employee.id, subjectEmployeeId: employee.id, }); - const { key } = await seed.process({ + const { key } = await seedProcess(orm, { key: "follow_up", definition: eventProcess("tasks.task_status_changed", { field: "to", in: ["cancelled"] }), }); diff --git a/packages/api/test/support/index.ts b/packages/api/test/support/index.ts index 7bfcf1698..d7773f3a9 100644 --- a/packages/api/test/support/index.ts +++ b/packages/api/test/support/index.ts @@ -79,7 +79,7 @@ export { } from "./services"; export type { EmployeeServices } from "./services"; export { processService, transactWithProcesses } from "./processService"; -export { eventProcess } from "./seedProcess"; +export { eventProcess, seedProcess } from "./seedProcess"; export { domainEvents } from "./domainEvents"; export { employeeSetup, diff --git a/packages/api/test/support/testSeed.ts b/packages/api/test/support/testSeed.ts index 820645818..83adaf07d 100644 --- a/packages/api/test/support/testSeed.ts +++ b/packages/api/test/support/testSeed.ts @@ -20,7 +20,6 @@ import { defaultTermsId, insertTerms, leaveTerms, moveToTerms } from "./seedTerm import { seedUser } from "./seedUser"; import { seedAgentConnection } from "./seedAgentConnection"; import { seedTask } from "./seedTask"; -import { seedProcess } from "./seedProcess"; import { testTransactor } from "./testTransactor"; type SeedEmployee = Partial< @@ -187,9 +186,6 @@ export class TestSeed { agentConnection = (options: Parameters[1]) => seedAgentConnection(this.orm, options); - /** A process with its first version published and active. */ - process = (options: Parameters[1]) => seedProcess(this.orm, options); - /** The id of the seeded "England and Wales" terms, which every new employee joins. */ defaultTermsId = () => defaultTermsId(this.orm); From 40d0ebad22d9a71a9981021735989f36b9817a23 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Thu, 12 Nov 2026 14:13:42 +0100 Subject: [PATCH 7/8] refactor(processes): export only what other folders use --- packages/api/src/core/db/index.ts | 4 ++-- packages/api/src/core/db/transaction.int.test.ts | 3 ++- packages/api/src/modules/processes/db/index.ts | 7 +------ packages/api/src/modules/processes/domain/index.ts | 2 -- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/api/src/core/db/index.ts b/packages/api/src/core/db/index.ts index b1cc9140a..7b75873d0 100644 --- a/packages/api/src/core/db/index.ts +++ b/packages/api/src/core/db/index.ts @@ -6,11 +6,11 @@ export { anyOf } from "./anyOf"; export { jsonb } from "./jsonb"; export { afterKeyset, keysetOrder, nullsLast, orderByKeyset } from "./keyset"; export { createBeforeCommitBus } from "./beforeCommitBus"; -export { MAX_BEFORE_COMMIT_ROUNDS, ormOf, transactor } from "./transaction"; +export { ormOf, transactor } from "./transaction"; export { isForeignKeyViolation, isUniqueViolation } from "./uniqueViolation"; // Public types. -export type { BeforeCommitBus, BeforeCommitHandler } from "./beforeCommitBus"; +export type { BeforeCommitHandler } from "./beforeCommitBus"; export type { Database, Orm, OrmTransaction } from "./database"; export type { KeysetOrder } from "./keyset"; export type { Transact, Tx } from "./transaction"; diff --git a/packages/api/src/core/db/transaction.int.test.ts b/packages/api/src/core/db/transaction.int.test.ts index ecbed6bd2..15bb626cb 100644 --- a/packages/api/src/core/db/transaction.int.test.ts +++ b/packages/api/src/core/db/transaction.int.test.ts @@ -2,7 +2,8 @@ import { EXAMPLE_PAYLOAD, expect, SYSTEM, test, instances, setupSteps } from "#t import { createEventBus, type PublishedEvent } from "../events"; import { createLogger } from "../logger"; -import { createBeforeCommitBus, MAX_BEFORE_COMMIT_ROUNDS, transactor } from "."; +import { createBeforeCommitBus, transactor } from "."; +import { MAX_BEFORE_COMMIT_ROUNDS } from "./transaction"; test("dispatches queued events after the write commits", async ({ orm, seed }) => { await seed.owner(); diff --git a/packages/api/src/modules/processes/db/index.ts b/packages/api/src/modules/processes/db/index.ts index 51b2fa362..ca42d14d2 100644 --- a/packages/api/src/modules/processes/db/index.ts +++ b/packages/api/src/modules/processes/db/index.ts @@ -1,6 +1 @@ -export { - type NewProcessRow, - type NewRunRow, - processRepository, - type StoredProcess, -} from "./processRepository"; +export { type NewRunRow, processRepository, type StoredProcess } from "./processRepository"; diff --git a/packages/api/src/modules/processes/domain/index.ts b/packages/api/src/modules/processes/domain/index.ts index 0ce6ed2ff..ee029cd2e 100644 --- a/packages/api/src/modules/processes/domain/index.ts +++ b/packages/api/src/modules/processes/domain/index.ts @@ -5,12 +5,10 @@ export { type EventStart, isTriggeringEvent, matchEventStarts, - TRIGGERING_EVENTS, type TriggerEvent, } from "./eventTrigger"; export { type DayOffset, - type EventTrigger, parseProcessDefinition, PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessDefinition, From 968d16bd3ae12dab2b5799452feecb7a8da7b4f1 Mon Sep 17 00:00:00 2001 From: Artem Broccoli Date: Sun, 15 Nov 2026 09:18:57 +0300 Subject: [PATCH 8/8] refactor(api): tasks and processes become core primitives features plug into (#233) ## Summary Tasks and processes become shared foundations in `core/`, beside records, and import no business module. Features plug in instead: - **Record types** register what a process needs from their records: who a record is about (`readSubjects`) and the dates a record-date trigger may start on (`dateFields`, `findByDateBetween`). Employees registers `employee` (its `startDate`); tasks registers `task`. - **Triggering events** are registered by the module that publishes them (`employees.added` by employees; `tasks.task_created` and `tasks.task_status_changed` by tasks), collected once at startup; a key registered twice stops startup. - **People and working days** are read through `PeoplePort` and `WorkingCalendarsPort`, which employees fills with adapters, together with the bank holiday rule. Ports live in a module's `ports/` folder and their implementations in the implementing module's `adapters/` folder, both enforced as lint layers. - **A process step's task is marked done with the ordinary `tasks.markDone`.** Processes listens to the task's status event before the commit and completes the step and the run in the same transaction. Every transaction that locks a task and its run takes the task first; the daily check now locks a run's open tasks before the run. A run that can no longer go on (its record or person is gone) is left for the daily check, and a task done meanwhile stays done. - Tasks no longer store which process step created them; `Task.processSource` is served by processes, and the Employee fields of `Task` by employees, so the GraphQL shape the web app reads stays the same. - **Tasks and runs are domain aggregates.** `Task` owns who sees it, who marks it done or deletes it, and that done and cancelled are final. `ProcessRun` holds its steps and owns completing them, finishing, the removal check and cancelling; changes load it with `lockRun` and write it back with `saveRun`. - **Process definitions:** a step keeps `key` and `kind` at its root and its kind's fields under it (`task: { assignee, title, description, due }`); `startableByAdmin` is gone, so an admin may start any process by hand with `create` on `process_run`. The run steps table is now `process_run_step_progress`: the steps live in the version's definition, the table holds a run's progress on each. - Counting working days moves to `utils` (`addWorkingDays`); processes keeps what a day offset means and its limit. - New rule RULE77: a `module.md` opens with JSON examples of its main flow; `processes/module.md` shows a run started by a change and by a date. Behaviour changes: - Done and cancelled tasks are final: Reopen, the Undo of Mark done and the reopen step command are gone. - People no longer cancel tasks. The person who asked can delete an open task they asked for (`tasks.delete`), with a confirmation; only a process cancels the tasks it created, with a reason. - Cancelling a run by hand is gone from the process service (it had no API or screen). Stacked on #227. ## Testing - `pnpm check`, all API integration tests (1546) and end-to-end tests (304) pass; web unit tests pass. - New tests: deleting a task (asker, assignee, unrelated caller, done, cancelled, process task), finishing steps through `tasks.markDone` (both steps, one step, concurrent completion, removed subject or assignee, manual tasks untouched), the daily check cancelling a run whose task record was deleted, definition parsing against registrations, and refusing a key registered twice. - The migrations are edited in place; `drizzle-kit generate` reports no change for tasks and processes. The Docker image builds and `pnpm dev` starts on a fresh database. - `docs/qa/tasks.md` is updated for Delete and the removed Reopen and Undo. - After the review changes: processes, tasks and employees integration tests (544) and processes unit tests pass on a fresh database; typecheck and lint pass. - Not done: screenshots of the Delete menu and dialog. Release Notes: - Improved: marking a process task done now works from Tasks like any other task. - Changed: a task you asked for can be deleted while it is open; done tasks can no longer be reopened and tasks can no longer be cancelled by hand. --- docs/architecture.md | 12 +- docs/code-quality.md | 3 +- docs/qa/tasks.md | 9 +- packages/api/codegen.ts | 2 +- packages/api/drizzle/0036_tasks.sql | 7 +- packages/api/drizzle/0038_processes.sql | 37 +-- packages/api/drizzle/meta/0036_snapshot.json | 24 +- packages/api/drizzle/meta/0037_snapshot.json | 24 +- packages/api/drizzle/meta/0038_snapshot.json | 93 ++---- packages/api/importRules.json | 58 ++-- .../src/core/processes/app/dueRunService.ts | 186 +++++++++++ .../processes/app/eventRunService.ts | 86 +++--- .../core/processes/app/finishStep.int.test.ts | 249 +++++++++++++++ .../{modules => core}/processes/app/index.ts | 1 + .../processes/app/processRecordType.ts | 4 +- .../processes/app/processService.ts | 162 ++++++---- .../processes/app/processViews.ts | 11 +- .../processes/app/readRuns.int.test.ts | 37 +-- .../processes/app/registrations.ts | 8 +- .../core/processes/app/runLifecycleService.ts | 125 ++++++++ .../processes/app/runReader.ts | 8 +- .../processes/app/runStarter.ts | 163 +++++----- .../processes/app/startDueRuns.int.test.ts | 54 +++- .../processes/app/startDueRunsJob.ts | 6 +- .../processes/app/startRun.int.test.ts | 76 +++-- .../app/startRunsOnEvents.int.test.ts | 12 +- .../app/startRunsOnEventsAtScale.int.test.ts | 10 +- .../processes/app/turnOnProcess.int.test.ts | 0 .../{modules => core}/processes/db/index.ts | 0 .../processes/db/processRepository.ts | 90 ++++-- .../processes/db/processRunsTable.ts | 20 +- .../processes/db/processesTable.ts | 0 .../processes/domain/dayOffset.test.ts | 0 .../processes/domain/dayOffset.ts | 18 +- .../processes/domain/eventTrigger.test.ts | 64 +++- .../src/core/processes/domain/eventTrigger.ts | 67 ++++ .../processes/domain/index.ts | 13 +- .../domain/processDefinition.test.ts | 104 ++++--- .../processes/domain/processDefinition.ts | 131 +++++--- .../core/processes/domain/processRun.test.ts | 251 +++++++++++++++ .../src/core/processes/domain/processRun.ts | 222 ++++++++++++++ .../processes/domain/recordDateTrigger.ts | 0 .../tasks => core/processes}/graphql/index.ts | 0 .../src/core/processes/graphql/resolvers.ts | 19 ++ .../src/core/processes/graphql/schema.graphql | 21 ++ packages/api/src/core/processes/index.ts | 41 +++ packages/api/src/core/processes/module.md | 115 +++++++ .../api/src/core/processes/ports/index.ts | 36 +++ packages/api/src/core/records/app/index.ts | 1 + .../records/app/recordHistoryReaderService.ts | 16 + packages/api/src/core/records/index.ts | 10 +- .../tasks/app/createTask.int.test.ts | 26 +- .../tasks/app/createTasks.ts | 38 +-- .../src/core/tasks/app/deleteTask.int.test.ts | 135 ++++++++ .../src/{modules => core}/tasks/app/index.ts | 1 + .../tasks/app/listMyTasks.int.test.ts | 2 +- .../tasks/app/markDone.int.test.ts | 0 .../tasks/app/readTask.int.test.ts | 2 +- .../tasks/app/readTaskHistory.int.test.ts | 0 .../tasks/app/registrations.ts | 10 +- .../api/src/core/tasks/app/taskRecordType.ts | 32 ++ .../tasks/app/taskService.ts | 160 ++++------ .../core/tasks/app/taskTriggeringEvents.ts | 18 ++ .../{modules => core}/tasks/codegen/index.ts | 4 +- .../src/{modules => core}/tasks/db/index.ts | 0 .../tasks/db/taskRepository.ts | 31 +- .../{modules => core}/tasks/db/tasksTable.ts | 33 +- .../{modules => core}/tasks/domain/index.ts | 12 +- .../{modules => core}/tasks/domain/myTasks.ts | 0 .../api/src/core/tasks/domain/task.test.ts | 170 ++++++++++ packages/api/src/core/tasks/domain/task.ts | 129 ++++++++ packages/api/src/core/tasks/graphql/index.ts | 1 + .../api/src/core/tasks/graphql/resolvers.ts | 42 +++ .../tasks/graphql/schema.graphql | 47 +-- .../api/src/{modules => core}/tasks/index.ts | 16 +- packages/api/src/core/tasks/module.md | 19 ++ .../src/modules/demo/app/demoFillService.ts | 2 +- .../src/modules/employees/adapters/index.ts | 1 + .../adapters/processAdapters.ts} | 58 ++-- .../employees/app/employeeReadService.ts | 20 -- .../employees/app/employeeRecordType.ts | 16 +- .../employees/app/employeeTriggeringEvents.ts | 12 + .../api/src/modules/employees/app/index.ts | 1 + .../src/modules/employees/graphql/loaders.ts | 6 + .../modules/employees/graphql/resolvers.ts | 11 + .../modules/employees/graphql/schema.graphql | 11 + packages/api/src/modules/employees/index.ts | 3 + packages/api/src/modules/index.ts | 36 ++- .../processes/app/cancelRun.int.test.ts | 100 ------ .../modules/processes/app/dueRunService.ts | 150 --------- .../processes/app/markStepDone.int.test.ts | 290 ------------------ .../processes/app/runLifecycleService.ts | 255 --------------- .../modules/processes/domain/eventTrigger.ts | 91 ------ .../modules/processes/domain/processRun.ts | 203 ------------ .../modules/processes/graphql/schema.graphql | 5 - packages/api/src/modules/processes/index.ts | 29 -- packages/api/src/modules/processes/module.md | 22 -- .../modules/system/app/storedText.pbt.test.ts | 1 - .../system/app/storedTextExamples.int.test.ts | 2 - .../modules/tasks/app/cancelTask.int.test.ts | 117 ------- .../modules/tasks/app/reopenTask.int.test.ts | 107 ------- .../src/modules/tasks/app/taskRecordType.ts | 23 -- .../api/src/modules/tasks/domain/task.test.ts | 125 -------- packages/api/src/modules/tasks/domain/task.ts | 95 ------ .../src/modules/tasks/graphql/resolvers.ts | 75 ----- packages/api/src/modules/tasks/module.md | 19 -- packages/api/src/utils/calendarDate.test.ts | 13 +- packages/api/src/utils/calendarDate.ts | 20 ++ .../src/utils/collectRegistrations.test.ts | 19 ++ .../api/src/utils/collectRegistrations.ts | 17 + packages/api/src/utils/index.ts | 2 + packages/api/test/e2e/tasks.test.ts | 40 +++ packages/api/test/support/processService.ts | 36 ++- packages/api/test/support/seedProcess.ts | 15 +- packages/api/test/support/seedTask.ts | 7 +- packages/api/test/support/storedTextProbe.ts | 17 - packages/api/test/support/taskService.ts | 2 +- packages/api/test/support/timeOffService.ts | 2 +- .../tasks/TaskPanel/CancelTaskDialog.tsx | 87 ------ .../tasks/TaskPanel/DeleteTaskDialog.tsx | 48 +++ .../features/tasks/TaskPanel/TaskDetails.tsx | 42 +-- .../features/tasks/TaskPanel/TaskPanel.tsx | 2 +- .../web/src/features/tasks/taskDescription.ts | 8 +- .../web/src/features/tasks/taskText.test.ts | 6 - packages/web/src/features/tasks/taskText.ts | 12 +- packages/web/src/features/tasks/tasks.graphql | 27 +- .../web/src/features/tasks/useTaskActions.ts | 38 +-- packages/web/src/i18n/en/tasks.ts | 16 +- 128 files changed, 3150 insertions(+), 2846 deletions(-) create mode 100644 packages/api/src/core/processes/app/dueRunService.ts rename packages/api/src/{modules => core}/processes/app/eventRunService.ts (70%) create mode 100644 packages/api/src/core/processes/app/finishStep.int.test.ts rename packages/api/src/{modules => core}/processes/app/index.ts (71%) rename packages/api/src/{modules => core}/processes/app/processRecordType.ts (84%) rename packages/api/src/{modules => core}/processes/app/processService.ts (62%) rename packages/api/src/{modules => core}/processes/app/processViews.ts (76%) rename packages/api/src/{modules => core}/processes/app/readRuns.int.test.ts (77%) rename packages/api/src/{modules => core}/processes/app/registrations.ts (71%) create mode 100644 packages/api/src/core/processes/app/runLifecycleService.ts rename packages/api/src/{modules => core}/processes/app/runReader.ts (93%) rename packages/api/src/{modules => core}/processes/app/runStarter.ts (64%) rename packages/api/src/{modules => core}/processes/app/startDueRuns.int.test.ts (72%) rename packages/api/src/{modules => core}/processes/app/startDueRunsJob.ts (86%) rename packages/api/src/{modules => core}/processes/app/startRun.int.test.ts (83%) rename packages/api/src/{modules => core}/processes/app/startRunsOnEvents.int.test.ts (97%) rename packages/api/src/{modules => core}/processes/app/startRunsOnEventsAtScale.int.test.ts (94%) rename packages/api/src/{modules => core}/processes/app/turnOnProcess.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/db/index.ts (100%) rename packages/api/src/{modules => core}/processes/db/processRepository.ts (78%) rename packages/api/src/{modules => core}/processes/db/processRunsTable.ts (89%) rename packages/api/src/{modules => core}/processes/db/processesTable.ts (100%) rename packages/api/src/{modules => core}/processes/domain/dayOffset.test.ts (100%) rename packages/api/src/{modules => core}/processes/domain/dayOffset.ts (76%) rename packages/api/src/{modules => core}/processes/domain/eventTrigger.test.ts (66%) create mode 100644 packages/api/src/core/processes/domain/eventTrigger.ts rename packages/api/src/{modules => core}/processes/domain/index.ts (84%) rename packages/api/src/{modules => core}/processes/domain/processDefinition.test.ts (62%) rename packages/api/src/{modules => core}/processes/domain/processDefinition.ts (66%) create mode 100644 packages/api/src/core/processes/domain/processRun.test.ts create mode 100644 packages/api/src/core/processes/domain/processRun.ts rename packages/api/src/{modules => core}/processes/domain/recordDateTrigger.ts (100%) rename packages/api/src/{modules/tasks => core/processes}/graphql/index.ts (100%) create mode 100644 packages/api/src/core/processes/graphql/resolvers.ts create mode 100644 packages/api/src/core/processes/graphql/schema.graphql create mode 100644 packages/api/src/core/processes/index.ts create mode 100644 packages/api/src/core/processes/module.md create mode 100644 packages/api/src/core/processes/ports/index.ts rename packages/api/src/{modules => core}/tasks/app/createTask.int.test.ts (92%) rename packages/api/src/{modules => core}/tasks/app/createTasks.ts (76%) create mode 100644 packages/api/src/core/tasks/app/deleteTask.int.test.ts rename packages/api/src/{modules => core}/tasks/app/index.ts (77%) rename packages/api/src/{modules => core}/tasks/app/listMyTasks.int.test.ts (98%) rename packages/api/src/{modules => core}/tasks/app/markDone.int.test.ts (100%) rename packages/api/src/{modules => core}/tasks/app/readTask.int.test.ts (96%) rename packages/api/src/{modules => core}/tasks/app/readTaskHistory.int.test.ts (100%) rename packages/api/src/{modules => core}/tasks/app/registrations.ts (74%) create mode 100644 packages/api/src/core/tasks/app/taskRecordType.ts rename packages/api/src/{modules => core}/tasks/app/taskService.ts (57%) create mode 100644 packages/api/src/core/tasks/app/taskTriggeringEvents.ts rename packages/api/src/{modules => core}/tasks/codegen/index.ts (68%) rename packages/api/src/{modules => core}/tasks/db/index.ts (100%) rename packages/api/src/{modules => core}/tasks/db/taskRepository.ts (85%) rename packages/api/src/{modules => core}/tasks/db/tasksTable.ts (68%) rename packages/api/src/{modules => core}/tasks/domain/index.ts (51%) rename packages/api/src/{modules => core}/tasks/domain/myTasks.ts (100%) create mode 100644 packages/api/src/core/tasks/domain/task.test.ts create mode 100644 packages/api/src/core/tasks/domain/task.ts create mode 100644 packages/api/src/core/tasks/graphql/index.ts create mode 100644 packages/api/src/core/tasks/graphql/resolvers.ts rename packages/api/src/{modules => core}/tasks/graphql/schema.graphql (59%) rename packages/api/src/{modules => core}/tasks/index.ts (53%) create mode 100644 packages/api/src/core/tasks/module.md create mode 100644 packages/api/src/modules/employees/adapters/index.ts rename packages/api/src/modules/{processes/app/workingCalendars.ts => employees/adapters/processAdapters.ts} (56%) create mode 100644 packages/api/src/modules/employees/app/employeeTriggeringEvents.ts delete mode 100644 packages/api/src/modules/processes/app/cancelRun.int.test.ts delete mode 100644 packages/api/src/modules/processes/app/dueRunService.ts delete mode 100644 packages/api/src/modules/processes/app/markStepDone.int.test.ts delete mode 100644 packages/api/src/modules/processes/app/runLifecycleService.ts delete mode 100644 packages/api/src/modules/processes/domain/eventTrigger.ts delete mode 100644 packages/api/src/modules/processes/domain/processRun.ts delete mode 100644 packages/api/src/modules/processes/graphql/schema.graphql delete mode 100644 packages/api/src/modules/processes/index.ts delete mode 100644 packages/api/src/modules/processes/module.md delete mode 100644 packages/api/src/modules/tasks/app/cancelTask.int.test.ts delete mode 100644 packages/api/src/modules/tasks/app/reopenTask.int.test.ts delete mode 100644 packages/api/src/modules/tasks/app/taskRecordType.ts delete mode 100644 packages/api/src/modules/tasks/domain/task.test.ts delete mode 100644 packages/api/src/modules/tasks/domain/task.ts delete mode 100644 packages/api/src/modules/tasks/graphql/resolvers.ts delete mode 100644 packages/api/src/modules/tasks/module.md create mode 100644 packages/api/src/utils/collectRegistrations.test.ts create mode 100644 packages/api/src/utils/collectRegistrations.ts delete mode 100644 packages/web/src/features/tasks/TaskPanel/CancelTaskDialog.tsx create mode 100644 packages/web/src/features/tasks/TaskPanel/DeleteTaskDialog.tsx diff --git a/docs/architecture.md b/docs/architecture.md index 1a630ca33..812078914 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ This document records Broccoli's architecture decisions and their reasons. It is ``` packages/api/src/ -├── core/ shared infrastructure and instance-wide concerns +├── core/ shared infrastructure, instance-wide concerns and the primitives features build on ├── modules/ business modules, one folder per domain ├── integrations/ clients of external services; know no business module └── utils/ helpers that know nothing about Broccoli @@ -22,7 +22,7 @@ Each package declares its allowed dependencies, enforced by lint with no cycles - **Composition.** Startup supplies each module with only the dependencies it needs and collects its API, routes, event handlers and jobs. Modules communicate through public operations or events, never through another module's tables ([RULE21](code-quality.md#layers-and-place)). - **Domain-driven design is tiered.** A plain domain layer with unit tests is the default. Aggregates, value objects and domain events are introduced where invariants demand them, with each business decision owned in one place ([RULE52](code-quality.md#layers-and-place)). - **Demo data.** The demo module owns the sample company and coordinates other modules' public operations in one transaction; it owns no tables. Exiting the demo removes the sample company and people added during it, then returns to setup. A regular owner keeps their account; a provisional owner follows the separate lifecycle in §6. -- **Four primitives.** HR features share records, tasks, calendar and processes, so each feature does not grow its own task queue, history and approval engine. A record stays a typed entity in its owning module; shared capabilities do not turn records into a generic storage engine. Shared record capabilities live in `core/records`, which modules use without core knowing them; history is the first. Tasks live in the tasks module: a feature or process step asks one person for work, and the feature's own command finishes it, so a task never grants authority. Processes live in the processes module: a published version of a process says when it starts and which steps give whom a task; each run of it follows those tasks to its end. Calendar is not built yet. Domain-specific rules, such as leave balances, stay with the feature that owns them. +- **Four primitives.** HR features share records, tasks, calendar and processes, so each feature does not grow its own task queue, history and approval engine. A record stays a typed entity in its owning module; shared capabilities do not turn records into a generic storage engine. Shared record capabilities live in `core/records`, which modules use without core knowing them; history is the first. Tasks and processes live in core beside records, and features plug into them through registrations and contracts they fill, so core never imports a module. Tasks are in `core/tasks`: a person, a feature or a process step asks one person for work, its assignee marks it done, and only the person who asked deletes it; a task never grants authority. Processes are in `core/processes`: a published version of a process says when it starts and which steps give whom a task; each run of it follows those tasks to its end. A record type a run may be about registers how processes read it (who it is about, its dates), the module that publishes an event registers it as triggering, and the employees module fills the people and working-calendar ports processes reads through. Calendar is not built yet. Domain-specific rules, such as leave balances, stay with the feature that owns them. ### Module layers @@ -31,10 +31,12 @@ Layers live inside the module that owns the feature. For example, a company logo - **`domain` — business decisions.** Holds business facts, invariants, calculations and valid state transitions. It works on domain values without I/O or knowledge of storage, requests or frameworks. A business decision has one owner here, so every caller applies the same rule. - **`db` — persistence.** Owns tables, queries, persistence constraints, locking and mapping between stored data and domain objects. Repositories hide SQL and ORM details and return whole domain items, with encrypted secrets and facts derived from other records read separately, so callers do not assemble business state from storage fragments. - **`app` — application coordination.** Use cases and services coordinate authorization, domain decisions, repositories, integrations, transactions and events or jobs. This layer owns the operation's workflow and transaction boundary. It gathers the facts a domain decision needs and applies its result without duplicating that decision. +- **`ports` — what a module needs from others.** Interfaces the module's `app` reads through, which another module implements, such as the people processes reads. Keeping them in their own folder shows each module's outside dependencies at a glance. +- **`adapters` — implementations of another module's ports.** Each adapter fills a port with this module's services and repositories; startup hands it to the module that owns the port. - **`graphql`, `http`, `mcp` — transport adapters.** Handle request parsing, caller identity, transport-specific responses and error translation. They delegate operations to the application layer, so permissions and business behavior remain the same for the web app and agents. - **`helpers` — module-local technical work.** Holds concerns such as parsing an origin or hashing a secret. Every layer may use these helpers, which cannot depend back on those layers. This keeps technical concerns out of the domain model; helpers with no Broccoli knowledge belong in `utils`. -Dependencies point inward: adapters → `app` → `db` → `domain`; `app` also uses `domain` directly. Layers expose their public contracts through their folder's entry point. The allowed imports are enforced, rather than relying on convention ([RULE16, RULE17, RULE58](code-quality.md#layers-and-place)). +Dependencies point inward: transport and `adapters` → `app` → `db` → `domain`; `app` also uses `domain` and its own `ports` directly. Layers expose their public contracts through their folder's entry point. The allowed imports are enforced, rather than relying on convention ([RULE16, RULE17, RULE58](code-quality.md#layers-and-place)). **Use cases and services.** A use case represents an action a caller can perform. Related operations on one subject form a service, keeping that subject's lifecycle and formats together; every `app` subject is a service, even with one operation. Adapters translate each operation into one use-case call and retain only transport concerns such as cookies and redirects ([RULE18](code-quality.md#layers-and-place)). @@ -82,8 +84,8 @@ For example, a domain rule decides whether an import source may retry. The appli - **Events reach handlers at two moments.** Before the commit, a module's before-commit handler receives all of a transaction's new events at once, inside it, round after round while handlers publish more (at most ten rounds); its failure rolls the transaction back, so its reaction commits with the change or not at all. After the commit, in-process handlers run sequentially and are awaited; their failures are logged without undoing the committed operation, and a crash between commit and dispatch can lose an event, an accepted limitation for them. Rollback drops both. - **Record changes publish `records.changed`**, one event per history line with the record, the operation, the changed field keys and the cause, never values. It notifies; processes trigger on domain events instead. - **Durable delivery after the commit is a transactional outbox**, a pg-boss job queued with the business change, for reactions that are slow or call outside systems. It is not implemented yet; this avoids an external queue or a second workflow engine. -- **Workflows** such as approvals and onboarding are domain state machines, with durable jobs executing their steps. A process starts from a trigger, never from a feature calling it: a record's date starts it by a daily job, and a domain event a module lists as triggering (a person added, a task created or its status changed) starts it in the transaction that published the event, one run per event and record, batched per process like a bulk save. A run that cannot start fails that change. A step's task is finished through the process, which changes the task and the run in one transaction. -- **A process acts as the system on its own and as the person when they act.** A run's own writes, such as creating its tasks or cancelling itself, run as the system with the run as their history cause (`PROCESS_RUN`); an assignee marking their step done, or an admin starting or cancelling a run, acts as themselves under their own permissions. Who acts for a step that calls another module is not decided yet. +- **Workflows** such as approvals and onboarding are domain state machines, with durable jobs executing their steps. A process starts from a trigger, never from a feature calling it: a record's date starts it by a daily job, and a domain event its publishing module registers as triggering (a person added, a task created or its status changed) starts it in the transaction that published the event, one run per event and record, batched per process like a bulk save. A run that cannot start fails that change. A step's task is finished with the ordinary task command; processes follows the task's status event before the commit and completes the step and the run in the same transaction. A transaction that locks a task and its run takes the task first. +- **A process acts as the system on its own and as the person when they act.** A run's own writes, such as creating its tasks or cancelling itself, run as the system with the run as their history cause (`PROCESS_RUN`); an assignee marking their step's task done, or an admin starting a run, acts as themselves under their own permissions. Who acts for a step that calls another module is not decided yet. - **A run notices removals when it next runs.** Nothing reacts to a person's removal: a step still waiting on a task whose assignee was removed, or a removed subject, cancels the run the next time a command or the daily job touches it, and the cancellation commits instead of being thrown. ## 6. Auth and sessions diff --git a/docs/code-quality.md b/docs/code-quality.md index 3c93f948e..3f83074ac 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -23,7 +23,7 @@ A rule that lint enforces ends with its lint rule, and `pnpm check` fails when i - **RULE74.** Keep the main path flat: guard clauses return early, and a condition that needs reading becomes a named predicate (`canBeInvited`, `noteHasLink`). - **RULE75.** A literal whose meaning is not obvious becomes a named constant with a doc comment saying why it is that value (`MAX_BODY_BYTES`, `FILE_REQUESTS_PER_MINUTE`). - **RULE71.** Types read top-down: the types in the main exported function's signature come first, then each type they reference, in the order they are first referenced. -- **RULE10.** A service or repository opens its file with its contract: an exported interface carrying the doc comments. Its factory returns that interface, not `ReturnType`. A repository's factory takes the caller's `Orm` or `Tx`, per operation. A service's factory, `createXService`, takes only the dependencies it needs; its members are declared as functions (`readOwner: (db: Orm | Tx) => …`), so callers may pass them on as callbacks. A service file exports no instance: each caller calls the factory once, at the top of its file, or inside its own factory when it needs that factory's dependencies or an import cycle requires it. Only `#test/support` shares instances, of services without dependencies. An `app` folder's `index.ts` exports services, ports, registrations, constants and types, never a loose business function. `lint: no-restricted-syntax` (the `ReturnType` part) +- **RULE10.** A service or repository opens its file with its contract: an exported interface carrying the doc comments. Its factory returns that interface, not `ReturnType`. A repository's factory takes the caller's `Orm` or `Tx`, per operation. A service's factory, `createXService`, takes only the dependencies it needs; its members are declared as functions (`readOwner: (db: Orm | Tx) => …`), so callers may pass them on as callbacks. A service file exports no instance: each caller calls the factory once, at the top of its file, or inside its own factory when it needs that factory's dependencies or an import cycle requires it. Only `#test/support` shares instances, of services without dependencies. An `app` folder's `index.ts` exports services, registrations, constants and types, never a loose business function. `lint: no-restricted-syntax` (the `ReturnType` part) - **RULE11.** Parse, don't validate: turn raw input into a typed value once, at the boundary (`parseCalendarDate`, `parseNewEmployee`, `parseSearch`), and pass that value on. Never check with an `isX` function and then keep using the raw input. - **RULE12.** Build only what the task needs: no wrappers, pass-through files, steps, options or interfaces nobody uses. Call the helper directly. - **RULE55.** Prefer deep modules to wide ones: a module, service or function hides much work behind a small interface. Callers make one call that does the whole job (`storeFile`), never a series of small steps they must know to run in order; a new need adds work behind the existing interface before it adds another export or parameter. @@ -108,3 +108,4 @@ How to write each kind of test is in [`testing.md`](testing.md). - **RULE76.** A code comment says why, never what the next lines do; code that is no longer used is deleted, not commented out. - **RULE49.** `thesaurus.md` gets a term only when it is Broccoli's own, not a well-known one such as job, model or mapping. A term names its context, so it reads alone (`Change author`, not `Author`). - **RULE50.** A change that needs a new architecture decision changes `architecture.md` in the same pull request. +- **RULE77.** A module's `module.md` opens, after its one-paragraph summary, with JSON examples of its main flow: what comes in, the rows it writes and what goes out. Rules and the data model follow. diff --git a/docs/qa/tasks.md b/docs/qa/tasks.md index bc1a55e01..83cf7a4d9 100644 --- a/docs/qa/tasks.md +++ b/docs/qa/tasks.md @@ -19,15 +19,14 @@ A manual check of My tasks, against a development stack. ## Mark done -- [ ] Mark done on a row turns it to Done ("Done by you · "), the count goes down by one and a toast says "Marked done" with Undo. -- [ ] Undo turns it back to To do. After a reload a done task sits under Resolved recently. +- [ ] Mark done on a row turns it to Done ("Done by you · "), the count goes down by one and a toast says "Marked done", with no Undo. After a reload the done task sits under Resolved recently. ## A task - [ ] Clicking a row opens the task at `/tasks/`: the asker's avatar, the title, the status, "What Priya asks" with the description and due date, "If you mark it done: Priya sees it as done in Tasks." and "Asked by Priya Shah on …". -- [ ] History lists "Broccoli asked for “…”" for a demo task; Mark done adds "You marked it done" at the top without a reload, and Reopen adds "You reopened it". +- [ ] History lists "Broccoli asked for “…”" for a demo task; Mark done adds "You marked it done" at the top without a reload. - [ ] A waiting task has no Mark done and says "It cannot be marked done while it waits." -- [ ] On a done task you hold, "…" offers Reopen, which turns it back to To do. -- [ ] On "Check the laptop budget for new starters", which you asked for, "…" offers Cancel task. Confirming without a reason says "Say why you are cancelling it."; with one, the task shows Cancelled and "Cancelled on …: ". +- [ ] A done task you hold has no "…" menu: done is final. +- [ ] On "Check the laptop budget for new starters", which you asked for, "…" offers Delete task. "Delete this task?" names Priya; Keep it leaves it, Delete task closes the panel, says "Task deleted" and the task is gone from Waiting on others. - [ ] An unknown id, such as `/tasks/00000000-0000-4000-8000-000000000001`, says "This task is not yours to see, or it is gone." - [ ] At phone width the panel is full screen. diff --git a/packages/api/codegen.ts b/packages/api/codegen.ts index ca8124ff5..04e92bded 100644 --- a/packages/api/codegen.ts +++ b/packages/api/codegen.ts @@ -8,7 +8,7 @@ import { codegenMappers as externalAgentMappers } from "./src/modules/externalAg import { codegenMappers as importMappers } from "./src/modules/imports/codegen"; import { codegenMappers as mcpAgentMappers } from "./src/modules/mcpAgents/codegen"; import { codegenMappers as systemMappers } from "./src/modules/system/codegen"; -import { codegenMappers as taskMappers } from "./src/modules/tasks/codegen"; +import { codegenMappers as taskMappers } from "./src/core/tasks/codegen"; import { codegenMappers as timeOffMappers } from "./src/modules/timeOff/codegen"; const config: CodegenConfig = { diff --git a/packages/api/drizzle/0036_tasks.sql b/packages/api/drizzle/0036_tasks.sql index 49e5f8a0c..5a8121f1f 100644 --- a/packages/api/drizzle/0036_tasks.sql +++ b/packages/api/drizzle/0036_tasks.sql @@ -7,9 +7,6 @@ CREATE TABLE "tasks" ( "assignee_employee_id" uuid NOT NULL, "asked_by_employee_id" uuid, "subject_employee_id" uuid, - "process_key" text, - "process_run_id" uuid, - "step_key" text, "record_type" text, "record_id" text, "due_date" date, @@ -20,9 +17,7 @@ CREATE TABLE "tasks" ( "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "tasks_title_not_blank" CHECK (btrim("tasks"."title") <> ''), - CONSTRAINT "tasks_asked_by_someone" CHECK ("tasks"."asked_by_employee_id" is not null or "tasks"."process_key" is not null), - CONSTRAINT "tasks_process_source_complete" CHECK (("tasks"."process_key" is null) = ("tasks"."step_key" is null) - and ("tasks"."process_run_id" is null or "tasks"."process_key" is not null)), + CONSTRAINT "tasks_asked_by_someone" CHECK (("tasks"."type" = 'manual_to_do') = ("tasks"."asked_by_employee_id" is not null)), CONSTRAINT "tasks_record_complete" CHECK (("tasks"."record_type" is null) = ("tasks"."record_id" is null)), CONSTRAINT "tasks_columns_follow_status" CHECK (("tasks"."status" = 'done') = ("tasks"."done_at" is not null) and ("tasks"."done_at" is null or "tasks"."cancelled_at" is null) diff --git a/packages/api/drizzle/0038_processes.sql b/packages/api/drizzle/0038_processes.sql index 2fc43a446..206af60fc 100644 --- a/packages/api/drizzle/0038_processes.sql +++ b/packages/api/drizzle/0038_processes.sql @@ -26,7 +26,7 @@ CREATE TABLE "processes" ( CONSTRAINT "processes_name_not_blank" CHECK (btrim("processes"."name") <> '') ); --> statement-breakpoint -CREATE TABLE "process_run_steps" ( +CREATE TABLE "process_run_step_progress" ( "run_id" uuid NOT NULL, "step_key" text NOT NULL, "status" text NOT NULL, @@ -34,14 +34,14 @@ CREATE TABLE "process_run_steps" ( "assignee_note" text, "skip_reason" text, "finished_at" timestamp with time zone, - CONSTRAINT "process_run_steps_run_id_step_key_pk" PRIMARY KEY("run_id","step_key"), - CONSTRAINT "process_run_steps_task_id_unique" UNIQUE("task_id"), - CONSTRAINT "process_run_steps_key_not_blank" CHECK (btrim("process_run_steps"."step_key") <> ''), - CONSTRAINT "process_run_steps_status_known" CHECK ("process_run_steps"."status" in ('step_awaiting_task_completion', 'step_completed', 'step_skipped', 'step_cancelled')), - CONSTRAINT "process_run_steps_assignee_note_known" CHECK ("process_run_steps"."assignee_note" in ('assignee_is_owner_because_subject_has_no_manager')), - CONSTRAINT "process_run_steps_columns_follow_status" CHECK (("process_run_steps"."status" = 'step_awaiting_task_completion') = ("process_run_steps"."finished_at" is null) - and ("process_run_steps"."status" = 'step_skipped') = ("process_run_steps"."skip_reason" is not null) - and ("process_run_steps"."skip_reason" is null or "process_run_steps"."skip_reason" = 'no_assignee_for_step')) + CONSTRAINT "process_run_step_progress_run_id_step_key_pk" PRIMARY KEY("run_id","step_key"), + CONSTRAINT "process_run_step_progress_task_id_unique" UNIQUE("task_id"), + CONSTRAINT "process_run_step_progress_key_not_blank" CHECK (btrim("process_run_step_progress"."step_key") <> ''), + CONSTRAINT "process_run_step_progress_status_known" CHECK ("process_run_step_progress"."status" in ('step_awaiting_task_completion', 'step_completed', 'step_skipped', 'step_cancelled')), + CONSTRAINT "process_run_step_progress_assignee_note_known" CHECK ("process_run_step_progress"."assignee_note" in ('assignee_is_owner_because_subject_has_no_manager')), + CONSTRAINT "process_run_step_progress_columns_follow_status" CHECK (("process_run_step_progress"."status" = 'step_awaiting_task_completion') = ("process_run_step_progress"."finished_at" is null) + and ("process_run_step_progress"."status" = 'step_skipped') = ("process_run_step_progress"."skip_reason" is not null) + and ("process_run_step_progress"."skip_reason" is null or "process_run_step_progress"."skip_reason" = 'no_assignee_for_step')) ); --> statement-breakpoint CREATE TABLE "process_runs" ( @@ -68,7 +68,7 @@ CREATE TABLE "process_runs" ( CONSTRAINT "process_runs_status_known" CHECK ("process_runs"."status" in ('in_progress', 'finished', 'cancelled')), CONSTRAINT "process_runs_start_cause_known" CHECK ("process_runs"."start_cause" in ('started_on_record_date', 'started_by_admin', 'started_on_event')), CONSTRAINT "process_runs_trigger_event_follows_start_cause" CHECK (("process_runs"."start_cause" = 'started_on_event') = ("process_runs"."trigger_event" is not null)), - CONSTRAINT "process_runs_cancel_cause_known" CHECK ("process_runs"."cancel_cause" in ('cancelled_by_admin', 'cancelled_because_step_assignee_was_removed', 'cancelled_because_subject_was_removed')), + CONSTRAINT "process_runs_cancel_cause_known" CHECK ("process_runs"."cancel_cause" in ('cancelled_because_step_assignee_was_removed', 'cancelled_because_subject_was_removed')), CONSTRAINT "process_runs_columns_follow_status" CHECK (("process_runs"."status" = 'finished') = ("process_runs"."finished_at" is not null) and ("process_runs"."status" = 'cancelled') = ("process_runs"."cancelled_at" is not null) and ("process_runs"."cancelled_at" is null) = ("process_runs"."cancel_cause" is null) @@ -78,7 +78,7 @@ CREATE TABLE "process_runs" ( --> statement-breakpoint ALTER TABLE "process_versions" ADD CONSTRAINT "process_versions_process_id_processes_id_fk" FOREIGN KEY ("process_id") REFERENCES "public"."processes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "processes" ADD CONSTRAINT "processes_active_version_fk" FOREIGN KEY ("id","active_version_id") REFERENCES "public"."process_versions"("process_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "process_run_steps" ADD CONSTRAINT "process_run_steps_run_fk" FOREIGN KEY ("run_id") REFERENCES "public"."process_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "process_run_step_progress" ADD CONSTRAINT "process_run_step_progress_run_fk" FOREIGN KEY ("run_id") REFERENCES "public"."process_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "process_runs" ADD CONSTRAINT "process_runs_process_fk" FOREIGN KEY ("process_id") REFERENCES "public"."processes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "process_runs" ADD CONSTRAINT "process_runs_version_fk" FOREIGN KEY ("process_id","process_version_id") REFERENCES "public"."process_versions"("process_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "process_versions_one_draft_per_process" ON "process_versions" USING btree ("process_id") WHERE "process_versions"."status" = 'draft';--> statement-breakpoint @@ -87,13 +87,9 @@ CREATE UNIQUE INDEX "process_runs_one_in_progress_per_record" ON "process_runs" CREATE INDEX "process_runs_process_status_idx" ON "process_runs" USING btree ("process_id","status");--> statement-breakpoint CREATE INDEX "process_runs_record_idx" ON "process_runs" USING btree ("process_id","record_type","record_id");--> statement-breakpoint CREATE INDEX "process_runs_subject_idx" ON "process_runs" USING btree ("subject_employee_id");--> statement-breakpoint -CREATE UNIQUE INDEX "tasks_one_per_run_step" ON "tasks" USING btree ("process_run_id","step_key");--> statement-breakpoint -ALTER TABLE "tasks" ADD CONSTRAINT "tasks_process_step_from_a_run" CHECK ("tasks"."type" <> 'process_step_to_do' - or ("tasks"."process_run_id" is not null and "tasks"."asked_by_employee_id" is null));--> statement-breakpoint ALTER TABLE "process_runs" ADD CONSTRAINT "process_runs_started_by_employee_fk" FOREIGN KEY ("started_by_employee_id") REFERENCES "public"."employees"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "process_runs" ADD CONSTRAINT "process_runs_cancelled_by_employee_fk" FOREIGN KEY ("cancelled_by_employee_id") REFERENCES "public"."employees"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "process_run_steps" ADD CONSTRAINT "process_run_steps_task_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "tasks" ADD CONSTRAINT "tasks_process_run_fk" FOREIGN KEY ("process_run_id") REFERENCES "public"."process_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "process_run_step_progress" ADD CONSTRAINT "process_run_step_progress_task_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -- The ready-made Welcome a new starter, off, with its first published version active. INSERT INTO "processes" ("key", "name", "description") VALUES ( 'welcome_new_starter', @@ -105,18 +101,17 @@ INSERT INTO "process_versions" ("process_id", "version", "status", "definition_s "subject": { "recordType": "employee" }, "trigger": { "kind": "on_record_date", "recordType": "employee", "dateField": "startDate", "offset": { "amount": 0, "unit": "calendar_days" }, "maxDaysLate": 7 }, - "startableByAdmin": true, "steps": [ - { "key": "welcome_one_to_one", "kind": "task", + { "key": "welcome_one_to_one", "kind": "task", "task": { "assignee": { "kind": "subjects_manager", "otherwise": { "kind": "workspace_owner" } }, "title": "Have a welcome 1:1 with {firstName}", "description": "Meet {firstName} on their first day: say hello, walk through their first week and answer their questions.", - "due": { "amount": 0, "unit": "calendar_days" } }, - { "key": "coffee_calls", "kind": "task", + "due": { "amount": 0, "unit": "calendar_days" } } }, + { "key": "coffee_calls", "kind": "task", "task": { "assignee": { "kind": "subject" }, "title": "Have coffee calls with 3 colleagues", "description": "Book three short calls with people outside your team to get to know them.", - "due": { "amount": 5, "unit": "business_days" } } + "due": { "amount": 5, "unit": "business_days" } } } ] }'::jsonb FROM "processes" WHERE "key" = 'welcome_new_starter';--> statement-breakpoint UPDATE "processes" SET "active_version_id" = v."id" FROM "process_versions" v diff --git a/packages/api/drizzle/meta/0036_snapshot.json b/packages/api/drizzle/meta/0036_snapshot.json index fa1dec34b..970a57df4 100644 --- a/packages/api/drizzle/meta/0036_snapshot.json +++ b/packages/api/drizzle/meta/0036_snapshot.json @@ -2277,24 +2277,6 @@ "primaryKey": false, "notNull": false }, - "process_key": { - "name": "process_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "process_run_id": { - "name": "process_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "step_key": { - "name": "step_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, "record_type": { "name": "record_type", "type": "text", @@ -2416,11 +2398,7 @@ }, "tasks_asked_by_someone": { "name": "tasks_asked_by_someone", - "value": "\"tasks\".\"asked_by_employee_id\" is not null or \"tasks\".\"process_key\" is not null" - }, - "tasks_process_source_complete": { - "name": "tasks_process_source_complete", - "value": "(\"tasks\".\"process_key\" is null) = (\"tasks\".\"step_key\" is null)\n and (\"tasks\".\"process_run_id\" is null or \"tasks\".\"process_key\" is not null)" + "value": "(\"tasks\".\"type\" = 'manual_to_do') = (\"tasks\".\"asked_by_employee_id\" is not null)" }, "tasks_record_complete": { "name": "tasks_record_complete", diff --git a/packages/api/drizzle/meta/0037_snapshot.json b/packages/api/drizzle/meta/0037_snapshot.json index f7c424e08..fd4b085af 100644 --- a/packages/api/drizzle/meta/0037_snapshot.json +++ b/packages/api/drizzle/meta/0037_snapshot.json @@ -3428,24 +3428,6 @@ "primaryKey": false, "notNull": false }, - "process_key": { - "name": "process_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "process_run_id": { - "name": "process_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "step_key": { - "name": "step_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, "record_type": { "name": "record_type", "type": "text", @@ -3567,11 +3549,7 @@ }, "tasks_asked_by_someone": { "name": "tasks_asked_by_someone", - "value": "\"tasks\".\"asked_by_employee_id\" is not null or \"tasks\".\"process_key\" is not null" - }, - "tasks_process_source_complete": { - "name": "tasks_process_source_complete", - "value": "(\"tasks\".\"process_key\" is null) = (\"tasks\".\"step_key\" is null)\n and (\"tasks\".\"process_run_id\" is null or \"tasks\".\"process_key\" is not null)" + "value": "(\"tasks\".\"type\" = 'manual_to_do') = (\"tasks\".\"asked_by_employee_id\" is not null)" }, "tasks_record_complete": { "name": "tasks_record_complete", diff --git a/packages/api/drizzle/meta/0038_snapshot.json b/packages/api/drizzle/meta/0038_snapshot.json index 3de22da07..789b5ce3e 100644 --- a/packages/api/drizzle/meta/0038_snapshot.json +++ b/packages/api/drizzle/meta/0038_snapshot.json @@ -3437,8 +3437,8 @@ }, "isRLSEnabled": false }, - "public.process_run_steps": { - "name": "process_run_steps", + "public.process_run_step_progress": { + "name": "process_run_step_progress", "schema": "", "columns": { "run_id": { @@ -3486,9 +3486,9 @@ }, "indexes": {}, "foreignKeys": { - "process_run_steps_run_fk": { - "name": "process_run_steps_run_fk", - "tableFrom": "process_run_steps", + "process_run_step_progress_run_fk": { + "name": "process_run_step_progress_run_fk", + "tableFrom": "process_run_step_progress", "tableTo": "process_runs", "columnsFrom": ["run_id"], "columnsTo": ["id"], @@ -3497,35 +3497,35 @@ } }, "compositePrimaryKeys": { - "process_run_steps_run_id_step_key_pk": { - "name": "process_run_steps_run_id_step_key_pk", + "process_run_step_progress_run_id_step_key_pk": { + "name": "process_run_step_progress_run_id_step_key_pk", "columns": ["run_id", "step_key"] } }, "uniqueConstraints": { - "process_run_steps_task_id_unique": { - "name": "process_run_steps_task_id_unique", + "process_run_step_progress_task_id_unique": { + "name": "process_run_step_progress_task_id_unique", "nullsNotDistinct": false, "columns": ["task_id"] } }, "policies": {}, "checkConstraints": { - "process_run_steps_key_not_blank": { - "name": "process_run_steps_key_not_blank", - "value": "btrim(\"process_run_steps\".\"step_key\") <> ''" + "process_run_step_progress_key_not_blank": { + "name": "process_run_step_progress_key_not_blank", + "value": "btrim(\"process_run_step_progress\".\"step_key\") <> ''" }, - "process_run_steps_status_known": { - "name": "process_run_steps_status_known", - "value": "\"process_run_steps\".\"status\" in ('step_awaiting_task_completion', 'step_completed', 'step_skipped', 'step_cancelled')" + "process_run_step_progress_status_known": { + "name": "process_run_step_progress_status_known", + "value": "\"process_run_step_progress\".\"status\" in ('step_awaiting_task_completion', 'step_completed', 'step_skipped', 'step_cancelled')" }, - "process_run_steps_assignee_note_known": { - "name": "process_run_steps_assignee_note_known", - "value": "\"process_run_steps\".\"assignee_note\" in ('assignee_is_owner_because_subject_has_no_manager')" + "process_run_step_progress_assignee_note_known": { + "name": "process_run_step_progress_assignee_note_known", + "value": "\"process_run_step_progress\".\"assignee_note\" in ('assignee_is_owner_because_subject_has_no_manager')" }, - "process_run_steps_columns_follow_status": { - "name": "process_run_steps_columns_follow_status", - "value": "(\"process_run_steps\".\"status\" = 'step_awaiting_task_completion') = (\"process_run_steps\".\"finished_at\" is null)\n and (\"process_run_steps\".\"status\" = 'step_skipped') = (\"process_run_steps\".\"skip_reason\" is not null)\n and (\"process_run_steps\".\"skip_reason\" is null or \"process_run_steps\".\"skip_reason\" = 'no_assignee_for_step')" + "process_run_step_progress_columns_follow_status": { + "name": "process_run_step_progress_columns_follow_status", + "value": "(\"process_run_step_progress\".\"status\" = 'step_awaiting_task_completion') = (\"process_run_step_progress\".\"finished_at\" is null)\n and (\"process_run_step_progress\".\"status\" = 'step_skipped') = (\"process_run_step_progress\".\"skip_reason\" is not null)\n and (\"process_run_step_progress\".\"skip_reason\" is null or \"process_run_step_progress\".\"skip_reason\" = 'no_assignee_for_step')" } }, "isRLSEnabled": false @@ -3811,7 +3811,7 @@ }, "process_runs_cancel_cause_known": { "name": "process_runs_cancel_cause_known", - "value": "\"process_runs\".\"cancel_cause\" in ('cancelled_by_admin', 'cancelled_because_step_assignee_was_removed', 'cancelled_because_subject_was_removed')" + "value": "\"process_runs\".\"cancel_cause\" in ('cancelled_because_step_assignee_was_removed', 'cancelled_because_subject_was_removed')" }, "process_runs_columns_follow_status": { "name": "process_runs_columns_follow_status", @@ -3923,24 +3923,6 @@ "primaryKey": false, "notNull": false }, - "process_key": { - "name": "process_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "process_run_id": { - "name": "process_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "step_key": { - "name": "step_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, "record_type": { "name": "record_type", "type": "text", @@ -4049,27 +4031,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "tasks_one_per_run_step": { - "name": "tasks_one_per_run_step", - "columns": [ - { - "expression": "process_run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "step_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": {}, @@ -4083,15 +4044,7 @@ }, "tasks_asked_by_someone": { "name": "tasks_asked_by_someone", - "value": "\"tasks\".\"asked_by_employee_id\" is not null or \"tasks\".\"process_key\" is not null" - }, - "tasks_process_source_complete": { - "name": "tasks_process_source_complete", - "value": "(\"tasks\".\"process_key\" is null) = (\"tasks\".\"step_key\" is null)\n and (\"tasks\".\"process_run_id\" is null or \"tasks\".\"process_key\" is not null)" - }, - "tasks_process_step_from_a_run": { - "name": "tasks_process_step_from_a_run", - "value": "\"tasks\".\"type\" <> 'process_step_to_do'\n or (\"tasks\".\"process_run_id\" is not null and \"tasks\".\"asked_by_employee_id\" is null)" + "value": "(\"tasks\".\"type\" = 'manual_to_do') = (\"tasks\".\"asked_by_employee_id\" is not null)" }, "tasks_record_complete": { "name": "tasks_record_complete", diff --git a/packages/api/importRules.json b/packages/api/importRules.json index 9447198b0..836fc1514 100644 --- a/packages/api/importRules.json +++ b/packages/api/importRules.json @@ -24,7 +24,10 @@ "src/core/instance", "src/core/moduleManifest", "src/core/records", - "src/modules/*" + "src/core/processes", + "src/core/tasks", + "src/modules/*", + "src/utils" ], "src/modules/bankHolidays": ["src/core/*", "!src/core/server", "src/utils"], "src/modules/company": [ @@ -37,7 +40,6 @@ "src/core/*", "!src/core/server", "src/modules/employees", - "src/modules/tasks", "src/modules/timeOff", "src/utils" ], @@ -63,17 +65,8 @@ "src/modules/system" ], "src/modules/mcpAgents": ["src/core/*", "!src/core/server", "src/modules/employees"], - "src/modules/processes": [ - "src/core/*", - "!src/core/server", - "src/modules/bankHolidays", - "src/modules/employees", - "src/modules/tasks", - "src/utils" - ], "src/modules/slack": ["src/core/*", "!src/core/server", "src/integrations/slack", "src/utils"], "src/modules/system": ["src/core/*", "!src/core/server"], - "src/modules/tasks": ["src/core/*", "!src/core/server", "src/modules/employees", "src/utils"], "src/modules/timeOff": [ "src/core/*", "!src/core/server", @@ -149,7 +142,8 @@ "src/core/graphql", "src/core/logger", "src/core/pagination", - "src/core/security" + "src/core/security", + "src/utils" ], "src/core/jobs": ["src/core/db", "src/core/logger"], "src/core/db": ["src/core/applicationError", "src/core/events", "src/core/logger"], @@ -173,22 +167,50 @@ "src/modules/employees/db", "src/modules/imports/db", "src/modules/imports/domain", - "src/modules/processes/db", - "src/modules/processes/domain", + "src/core/processes/db", + "src/core/processes/domain", "src/modules/slack/db", - "src/modules/tasks/db", - "src/modules/tasks/domain", + "src/core/tasks/db", + "src/core/tasks/domain", "src/modules/timeOff/db", "src/modules/timeOff/domain", "src/utils" ], - "test/e2e": ["src/core/db", "src/core/logger", "src/core/server", "test/support"] + "test/e2e": ["src/core/db", "src/core/logger", "src/core/server", "test/support"], + "src/core/tasks": [ + "src/core/applicationError", + "src/core/db", + "src/core/events", + "src/core/graphql", + "src/core/logger", + "src/core/moduleManifest", + "src/core/pagination", + "src/core/records", + "src/core/security", + "src/utils" + ], + "src/core/processes": [ + "src/core/applicationError", + "src/core/db", + "src/core/events", + "src/core/graphql", + "src/core/jobs", + "src/core/logger", + "src/core/moduleManifest", + "src/core/pagination", + "src/core/records", + "src/core/security", + "src/core/tasks", + "src/utils" + ] }, "layers": { "domain": ["helpers"], "helpers": [], "db": ["domain", "helpers"], - "app": ["db", "domain", "helpers"], + "ports": ["domain", "helpers"], + "app": ["db", "domain", "helpers", "ports"], + "adapters": ["app", "db", "domain", "helpers"], "graphql": ["app", "domain", "helpers", "http"], "http": ["app", "domain", "helpers", "jwt"], "mcp": ["app", "domain", "helpers"], diff --git a/packages/api/src/core/processes/app/dueRunService.ts b/packages/api/src/core/processes/app/dueRunService.ts new file mode 100644 index 000000000..cd012793d --- /dev/null +++ b/packages/api/src/core/processes/app/dueRunService.ts @@ -0,0 +1,186 @@ +import { isUniqueViolation, type Orm, type Transact, type Tx } from "../../db"; +import type { Logger } from "../../logger"; +import type { SecurityContext } from "../../security"; +import { addDays, type CalendarDate, dateInLondon } from "../../../utils"; +import { processRepository, type StoredProcess } from "../db"; +import type { TaskService } from "../../tasks"; +import { + dueTriggerDate, + openTaskIdsOf, + type ProcessRun, + type ProcessRunFields, + type RecordDateTrigger, +} from "../domain"; +import type { RunLifecycleService } from "./runLifecycleService"; +import type { DefinitionReader, ProcessRecordTypes, RunStarter } from "./runStarter"; + +/** Broccoli's daily pass over processes. */ +export interface DueRunService { + /** + * Starts the runs whose record date has come and cancels runs in progress that can no longer run. + * One run per transaction, so one failure stops no other. + */ + startDueRuns( + ctx: SecurityContext, + today: CalendarDate, + ): Promise<{ runsStarted: number; runsCancelled: number }>; +} + +interface DueRunDependencies { + orm: Orm; + transact: Transact; + logger: Logger; + tasks: TaskService; + starter: RunStarter; + lifecycle: RunLifecycleService; + recordTypes: ProcessRecordTypes; + definitionOf: DefinitionReader; +} + +export function createDueRunService(dependencies: DueRunDependencies): DueRunService { + const { orm, transact, logger, starter, definitionOf } = dependencies; + return { + async startDueRuns(ctx, today) { + ctx.requireSystem(); + let runsStarted = 0; + for (const process of await processRepository(orm).listProcesses()) { + if (!process.turnedOnAt) continue; + const { trigger } = definitionOf(process.activeVersion); + if (trigger.kind !== "on_record_date") continue; + const turnedOnDay = dateInLondon(process.turnedOnAt); + const due = await transact((tx) => starter.findDue(tx, trigger, { today, turnedOnDay })); + const withRuns = await processRepository(orm).findRecordsWithRuns( + process.id, + trigger.recordType, + due.map(({ recordId }) => recordId), + ); + for (const { recordId } of due) { + if (!withRuns.has(recordId) && (await startOne(dependencies, process, recordId, today))) + runsStarted += 1; + } + } + let runsCancelled = 0; + for (const run of await processRepository(orm).listRunsInProgress()) { + try { + const checked = await transact((tx) => checkRun(dependencies, tx, run.id)); + if (!checked) logger.info({ runId: run.id }, "Process run check left for the next pass"); + if (checked?.status !== "cancelled") continue; + runsCancelled += 1; + logger.info({ runId: run.id, cancelCause: checked.cancelCause }, "Process run cancelled"); + } catch (error) { + logger.error({ runId: run.id, kind: errorKind(error) }, "Process run check failed"); + } + } + return { runsStarted, runsCancelled }; + }, + }; +} + +/** + * Cancels the run if it can no longer run, taking its open tasks before the run, as a task's + * change does; null when its open tasks changed meanwhile, left for the next pass. + */ +async function checkRun( + { tasks, lifecycle }: DueRunDependencies, + tx: Tx, + runId: string, +): Promise { + const runs = processRepository(tx); + const locked = openTaskIdsOf(await runs.findSteps(runId)); + await tasks.lockInTransaction(tx, locked); + const run = (await runs.lockRun(runId))!; + if (String(run.openTaskIds()) !== String(locked)) return null; + return lifecycle.cancelIfCannotRun(tx, run); +} + +/** Starts one record's run in its own transaction; logs a failure instead of throwing. */ +async function startOne( + dependencies: DueRunDependencies, + process: StoredProcess, + recordId: string, + today: CalendarDate, +): Promise { + const { transact, logger } = dependencies; + try { + const run = await transact((tx) => + startIfStillDue(dependencies, tx, { processId: process.id, recordId, today }), + ); + if (run) { + logger.info( + { runId: run.id, processKey: process.key, startCause: run.startCause }, + "Process run started", + ); + } + return run; + } catch (error) { + const reason = isUniqueViolation(error, "process_runs_start_key_unique") + ? "already_started" + : errorKind(error); + logger.error({ processKey: process.key, recordId, reason }, "Process run not started"); + return null; + } +} + +/** + * Starts the record's run once the process is locked, if the process is still on and the record is + * still due and without a run: either may have changed since the pass looked. + */ +async function startIfStillDue( + { starter, recordTypes, definitionOf }: DueRunDependencies, + tx: Tx, + { processId, recordId, today }: { processId: string; recordId: string; today: CalendarDate }, +): Promise { + const processes = processRepository(tx); + await processes.lockProcess(processId); + const [process] = await processes.findProcessesByIds([processId]); + if (!process?.turnedOnAt) return null; + const definition = definitionOf(process.activeVersion); + const { trigger } = definition; + if (trigger.kind !== "on_record_date") return null; + const triggerDate = await stillDueOn(recordTypes, tx, trigger, recordId, { + today, + turnedOnDay: dateInLondon(process.turnedOnAt), + }); + const subjects = await starter.readRecordSubjects(tx, trigger.recordType, [recordId]); + const hasRun = + (await processes.findRecordsWithRuns(processId, trigger.recordType, [recordId])).size > 0; + if (!triggerDate || !subjects.has(recordId) || hasRun) return null; + const [run] = await starter.startMany(tx, [ + { + process, + definition, + record: { recordType: trigger.recordType, recordId }, + subject: subjects.get(recordId)!, + startCause: "started_on_record_date", + startedByEmployeeId: null, + triggerDate, + triggerEvent: null, + startKey: `on_record_date:${trigger.recordType}:${recordId}:${triggerDate}`, + startDay: today, + }, + ]); + return run!; +} + +/** The trigger date of the record's date, read again, if it still triggers a run today. */ +async function stillDueOn( + recordTypes: ProcessRecordTypes, + tx: Tx, + trigger: RecordDateTrigger, + recordId: string, + days: { today: CalendarDate; turnedOnDay: CalendarDate }, +): Promise { + // A trigger's offset counts calendar days, so a date that triggers today lies in this window. + const { amount } = trigger.offset; + const dates = await recordTypes[trigger.recordType]!.findByDateBetween!(tx, { + field: trigger.dateField, + from: addDays(days.today, -amount - trigger.maxDaysLate), + until: addDays(days.today, -amount), + }); + const date = dates.get(recordId); + return date ? dueTriggerDate(trigger, date, days) : null; +} + +function errorKind(error: unknown): string | undefined { + return (error as Error | undefined)?.constructor?.name; +} diff --git a/packages/api/src/modules/processes/app/eventRunService.ts b/packages/api/src/core/processes/app/eventRunService.ts similarity index 70% rename from packages/api/src/modules/processes/app/eventRunService.ts rename to packages/api/src/core/processes/app/eventRunService.ts index 883787d4e..f62dae314 100644 --- a/packages/api/src/modules/processes/app/eventRunService.ts +++ b/packages/api/src/core/processes/app/eventRunService.ts @@ -1,19 +1,18 @@ -import type { Tx } from "../../../core/db"; -import type { PublishedEvent } from "../../../core/events"; -import type { Logger } from "../../../core/logger"; -import type { SecurityContext } from "../../../core/security"; +import type { Tx } from "../../db"; +import type { PublishedEvent } from "../../events"; +import type { Logger } from "../../logger"; +import type { SecurityContext } from "../../security"; import type { CalendarDate } from "../../../utils"; -import type { TaskService } from "../../tasks"; import { processRepository, type StoredProcess } from "../db"; import { eventAboutRecord, type EventStart, - isTriggeringEvent, matchEventStarts, - parseProcessDefinition, type ProcessDefinition, + type TriggeringEvents, } from "../domain"; -import type { RunStarter, RunSubject } from "./runStarter"; +import type { RunSubject } from "../ports"; +import type { DefinitionReader, RunStarter } from "./runStarter"; /** Starts the runs that a transaction's events trigger, inside that transaction. */ export interface EventRunService { @@ -27,8 +26,9 @@ export interface EventRunService { interface EventRunDependencies { logger: Logger; - tasks: TaskService; starter: RunStarter; + triggeringEvents: TriggeringEvents; + definitionOf: DefinitionReader; /** Today in London: the start day of the runs. */ today: () => CalendarDate; } @@ -37,7 +37,9 @@ export function createEventRunService(dependencies: EventRunDependencies): Event return { async startRunsOnEvents(ctx, tx, events) { ctx.requireSystem(); - const triggering = events.filter(({ type }) => isTriggeringEvent(type)); + const triggering = events.filter(({ type }) => + Object.hasOwn(dependencies.triggeringEvents, type), + ); if (triggering.length === 0) return; const processes = await processRepository(tx).findProcessesStartedBy([ ...new Set(triggering.map(({ type }) => type)), @@ -59,14 +61,11 @@ async function startRunsOf( events: readonly PublishedEvent[], ): Promise { try { - const { activeVersion } = process; - const definition = parseProcessDefinition( - activeVersion.schemaVersion, - activeVersion.definition, - ); + const definition = dependencies.definitionOf(process.activeVersion); const starts = matchEventStarts( [{ processId: process.id, trigger: definition.trigger }], events, + dependencies.triggeringEvents, ); if (starts.length > 0) await startProcessRuns(dependencies, tx, { process, definition, starts }); @@ -88,7 +87,7 @@ async function startRunsOf( * record finds the first one's run when it is still in progress. */ async function startProcessRuns( - { logger, tasks, starter, today }: EventRunDependencies, + { logger, starter, today, triggeringEvents }: EventRunDependencies, tx: Tx, { process, @@ -97,7 +96,11 @@ async function startProcessRuns( }: { process: StoredProcess; definition: ProcessDefinition; starts: readonly Start[] }, ): Promise { const { recordType } = definition.subject; - const subjects = await readRecordSubjects({ tasks, starter }, tx, recordType, starts); + const subjects = await starter.readRecordSubjects( + tx, + recordType, + starts.map(({ recordId }) => recordId), + ); const runs = processRepository(tx); for (const wave of wavesOf(starts)) { const inProgress = await runs.findRecordsWithRunInProgress( @@ -120,21 +123,29 @@ async function startProcessRuns( startCause: "started_on_event", startedByEmployeeId: null, triggerDate: null, - triggerEvent: eventAboutRecord(event, recordId), + triggerEvent: eventAboutRecord(event, recordId, triggeringEvents), startKey: `on_event:${event.id}:${recordId}`, startDay: today(), })), ); - if (started.length > 0) { - tx.publish("processes.runs_started_on_event", { - processKey: process.key, - runIds: started.map(({ id }) => id), - eventIds: [...new Set(ready.map(({ event }) => event.id))], - }); - } + publishStarted(tx, process, started, ready); } } +function publishStarted( + tx: Tx, + process: StoredProcess, + started: readonly { id: string }[], + ready: readonly Start[], +): void { + if (started.length === 0) return; + tx.publish("processes.runs_started_on_event", { + processKey: process.key, + runIds: started.map(({ id }) => id), + eventIds: [...new Set(ready.map(({ event }) => event.id))], + }); +} + /** Why a start is skipped: its record is gone or already has a run of the process in progress. */ function skipReasonOf( { recordId }: Start, @@ -147,31 +158,6 @@ function skipReasonOf( return inProgress.has(recordId) ? "run_in_progress" : null; } -/** - * The subject of each record that still exists, by record id: the employee itself, or a task's - * subject, null for a task about nobody. - */ -async function readRecordSubjects( - { tasks, starter }: Pick, - tx: Tx, - recordType: ProcessDefinition["subject"]["recordType"], - starts: readonly Start[], -): Promise> { - const recordIds = [...new Set(starts.map(({ recordId }) => recordId))]; - if (recordType === "employee") return starter.readSubjects(tx, recordIds); - const found = await tasks.readTasksInTransaction(tx, recordIds); - const subjects = await starter.readSubjects( - tx, - found.flatMap(({ subjectEmployeeId }) => (subjectEmployeeId ? [subjectEmployeeId] : [])), - ); - return new Map( - found.map(({ id, subjectEmployeeId }) => [ - id, - subjectEmployeeId ? (subjects.get(subjectEmployeeId) ?? null) : null, - ]), - ); -} - /** The starts split so each record appears at most once a wave, keeping their order. */ function wavesOf(starts: readonly Start[]): Start[][] { const waves: Start[][] = []; diff --git a/packages/api/src/core/processes/app/finishStep.int.test.ts b/packages/api/src/core/processes/app/finishStep.int.test.ts new file mode 100644 index 000000000..5cc009cd8 --- /dev/null +++ b/packages/api/src/core/processes/app/finishStep.int.test.ts @@ -0,0 +1,249 @@ +import { + ADMIN, + calendarDate, + employeeServices, + expect, + processService, + signedIn, + taskService, + test, + transactWithProcesses, +} from "#test/support"; + +import { WELCOME_NEW_STARTER } from "../domain"; + +test("completing both assignees' tasks finishes the welcome and its steps", async ({ + orm, + seed, +}) => { + const manager = await seed.employee(); + const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); + const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + const meetingId = started.steps[0]!.task!.id; + const coffeeId = started.steps[1]!.task!.id; + + await tasks.markDone(managerCtx, meetingId); + await tasks.markDone(ctx, coffeeId); + + const completed = await processes.readRun(ADMIN, started.run.id); + expect(completed.run).toMatchObject({ + status: "finished", + finishedAt: expect.any(Date) as unknown, + }); + expect(completed.steps).toMatchObject([ + { status: "step_completed", task: { status: "done" } }, + { status: "step_completed", task: { status: "done" } }, + ]); + expect(await processes.readStepProgress(managerCtx, meetingId)).toMatchObject({ + status: "finished", + doneSteps: 2, + totalSteps: 2, + }); + expect(await processes.readStepProgress(ctx, coffeeId)).toMatchObject({ + status: "finished", + doneSteps: 2, + totalSteps: 2, + }); +}); + +test("completing the first of two tasks leaves the welcome in progress", async ({ orm, seed }) => { + const manager = await seed.employee(); + const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); + const ctx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + const taskId = started.steps[0]!.task!.id; + + await tasks.markDone(ctx, taskId); + + const progress = await processes.readRun(ADMIN, started.run.id); + expect(progress.run).toMatchObject({ status: "in_progress", finishedAt: null }); + expect(progress.steps).toMatchObject([ + { status: "step_completed", task: { status: "done" } }, + { status: "step_awaiting_task_completion", task: { status: "to_do" } }, + ]); + expect(await processes.readStepProgress(ctx, taskId)).toMatchObject({ + status: "in_progress", + doneSteps: 1, + totalSteps: 2, + }); +}); + +test("completing a manual task leaves an existing welcome unchanged", async ({ orm, seed }) => { + const employee = await seed.employee(); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + const task = await seed.task({ assigneeEmployeeId: employee.id, askedByEmployeeId: employee.id }); + const before = await processes.readRun(ADMIN, started.run.id); + + await tasks.markDone(ctx, task.id); + + expect((await tasks.readTask(ctx, task.id)).status).toBe("done"); + expect(await processes.readRun(ADMIN, started.run.id)).toEqual(before); + expect(await processes.listRuns(ADMIN, WELCOME_NEW_STARTER)).toHaveLength(1); +}); + +test("completing a task after the subject was removed leaves the run for the daily check", async ({ + orm, + seed, +}) => { + const manager = await seed.employee(); + const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); + const ctx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + const taskId = started.steps[0]!.task!.id; + await employeeServices(orm).deleteEmployee(ADMIN, { id: employee.id }); + + await tasks.markDone(ctx, taskId); + + expect(await tasks.readTask(ctx, taskId)).toMatchObject({ + status: "done", + doneByEmployeeId: manager.id, + }); + const progress = await processes.readRun(ADMIN, started.run.id); + expect(progress.run).toMatchObject({ status: "in_progress", finishedAt: null }); + expect(progress.steps[0]).toMatchObject({ status: "step_completed" }); +}); + +test("completing the last task after the subject was removed leaves the run for the daily check", async ({ + orm, + seed, +}) => { + const manager = await seed.employee(); + const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); + const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + const meetingId = started.steps[0]!.task!.id; + await tasks.markDone(ctx, started.steps[1]!.task!.id); + await employeeServices(orm).deleteEmployee(ADMIN, { id: employee.id }); + + await tasks.markDone(managerCtx, meetingId); + + expect(await tasks.readTask(managerCtx, meetingId)).toMatchObject({ + status: "done", + doneByEmployeeId: manager.id, + }); + const progress = await processes.readRun(ADMIN, started.run.id); + expect(progress.run).toMatchObject({ status: "in_progress", finishedAt: null }); + expect(progress.steps[0]).toMatchObject({ status: "step_completed" }); +}); + +test("two assignees completing their tasks concurrently both succeed and finish one run", async ({ + orm, + seed, +}) => { + const manager = await seed.employee(); + const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); + const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + + const results = await Promise.all([ + tasks.markDone(managerCtx, started.steps[0]!.task!.id), + tasks.markDone(ctx, started.steps[1]!.task!.id), + ]); + + expect(results).toMatchObject([{ task: { status: "done" } }, { task: { status: "done" } }]); + expect(await processes.listRuns(ADMIN, WELCOME_NEW_STARTER)).toMatchObject([ + { id: started.run.id, status: "finished", finishedAt: expect.any(Date) as unknown }, + ]); + expect((await processes.readRun(ADMIN, started.run.id)).steps).toMatchObject([ + { status: "step_completed", task: { status: "done" } }, + { status: "step_completed", task: { status: "done" } }, + ]); +}); + +test("completing a task after the other assignee was removed leaves the run for the daily check", async ({ + orm, + seed, +}) => { + const manager = await seed.employee(); + const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + const taskId = started.steps[1]!.task!.id; + await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); + + await tasks.markDone(ctx, taskId); + + expect((await tasks.readTask(ctx, taskId)).status).toBe("done"); + expect((await processes.readRun(ADMIN, started.run.id)).run.status).toBe("in_progress"); +}); + +test("a completed step whose assignee left does not stop the remaining work", async ({ + orm, + seed, +}) => { + const manager = await seed.employee(); + const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); + const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + const transact = transactWithProcesses(orm, { today }); + const processes = processService(orm, { today, transact }); + const tasks = taskService(orm, { transact }); + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id, + }); + await tasks.markDone(managerCtx, started.steps[0]!.task!.id); + await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); + + await tasks.markDone(ctx, started.steps[1]!.task!.id); + + const completed = await processes.readRun(ADMIN, started.run.id); + expect(completed.run.status).toBe("finished"); + expect(completed.steps[0]).toMatchObject({ status: "step_completed", task: null }); +}); + +const today = () => calendarDate("2026-12-03"); diff --git a/packages/api/src/modules/processes/app/index.ts b/packages/api/src/core/processes/app/index.ts similarity index 71% rename from packages/api/src/modules/processes/app/index.ts rename to packages/api/src/core/processes/app/index.ts index d18fc800b..f1d438230 100644 --- a/packages/api/src/modules/processes/app/index.ts +++ b/packages/api/src/core/processes/app/index.ts @@ -1,2 +1,3 @@ export { createProcessService, type ProcessService } from "./processService"; +export { PROCESS_STEP_TO_DO } from "./runStarter"; export { startDueRunsJob } from "./startDueRunsJob"; diff --git a/packages/api/src/modules/processes/app/processRecordType.ts b/packages/api/src/core/processes/app/processRecordType.ts similarity index 84% rename from packages/api/src/modules/processes/app/processRecordType.ts rename to packages/api/src/core/processes/app/processRecordType.ts index d178df0e1..5139b6d85 100644 --- a/packages/api/src/modules/processes/app/processRecordType.ts +++ b/packages/api/src/core/processes/app/processRecordType.ts @@ -1,5 +1,5 @@ -import type { Orm } from "../../../core/db"; -import type { RecordTypeRegistration } from "../../../core/records"; +import type { Orm } from "../../db"; +import type { RecordTypeRegistration } from "../../records"; import { parseUuid } from "../../../utils"; import { processRepository } from "../db"; diff --git a/packages/api/src/modules/processes/app/processService.ts b/packages/api/src/core/processes/app/processService.ts similarity index 62% rename from packages/api/src/modules/processes/app/processService.ts rename to packages/api/src/core/processes/app/processService.ts index 3c28493aa..c92f78d19 100644 --- a/packages/api/src/modules/processes/app/processService.ts +++ b/packages/api/src/core/processes/app/processService.ts @@ -1,35 +1,30 @@ -import { ApplicationError } from "../../../core/applicationError"; -import type { Transact, Tx } from "../../../core/db"; -import type { ModuleDependencies } from "../../../core/moduleManifest"; -import type { Connection, PageArgs } from "../../../core/pagination"; +import { ApplicationError } from "../../applicationError"; +import type { Transact, Tx } from "../../db"; +import type { ModuleDependencies } from "../../moduleManifest"; +import type { Connection, PageArgs } from "../../pagination"; import { createRecordHistoryReaderService, createRecordHistoryService, type HistoryLine, -} from "../../../core/records"; -import { PrincipalSecurityContext, type SecurityContext } from "../../../core/security"; -import { type CalendarDate, dateInLondon, parseUuid } from "../../../utils"; -import type { BankHolidayService } from "../../bankHolidays"; + type RecordTypeRegistry, +} from "../../records"; +import { PrincipalSecurityContext, type SecurityContext } from "../../security"; +import { type CalendarDate, dateInLondon } from "../../../utils"; import type { TaskService } from "../../tasks"; import { processRepository, type StoredProcess } from "../db"; -import { parseProcessDefinition } from "../domain"; +import { parseProcessDefinition, type TriggeringEvents } from "../domain"; import { createDueRunService } from "./dueRunService"; import { createEventRunService, type EventRunService } from "./eventRunService"; import { processRecordType } from "./processRecordType"; -import type { - Process, - ProcessRun, - ProcessRunDetails, - StepChange, - StepProgress, -} from "./processViews"; -import { createRunLifecycleService } from "./runLifecycleService"; +import type { PeoplePort, WorkingCalendarsPort } from "../ports"; +import type { Process, ProcessRun, ProcessRunDetails, StepProgress } from "./processViews"; +import { createRunLifecycleService, type RunLifecycleService } from "./runLifecycleService"; import { createRunReader, type RunReader } from "./runReader"; -import { createRunStarter } from "./runStarter"; +import { createRunStarter, type DefinitionReader, type ProcessRecordTypes } from "./runStarter"; /** * Processes give people tasks when something happens to a record, and follow each run to its end. - * Admins turn them on, start and cancel runs; a step's assignee finishes it through its task here. + * Admins turn them on and start runs; a step's assignee finishes it by marking its task done. */ export interface ProcessService { /** Every process, for those who manage the workspace. */ @@ -44,20 +39,14 @@ export interface ProcessService { processKey: string, args: PageArgs, ): Promise>; - /** An admin starts the process for one person by hand, whether it is on or off. */ + /** An admin starts the process by hand for one record of its record type, whether it is on or off. */ startRun( ctx: SecurityContext, - input: { processKey: string; employeeId: string }, + input: { processKey: string; recordId: string }, ): Promise; - /** Cancels a run in progress: its open tasks are cancelled with the reason, done steps stay done. */ - cancelRun(ctx: SecurityContext, runId: string, reason: string): Promise; readRun(ctx: SecurityContext, runId: string): Promise; /** The process's runs the caller may read, newest first. */ listRuns(ctx: SecurityContext, processKey: string): Promise; - /** The assignee marks their step's task done; the run finishes when every step is done. */ - markStepDone(ctx: SecurityContext, taskId: string): Promise; - /** The assignee takes their step's done task back to do; a finished run goes back in progress. */ - reopenStep(ctx: SecurityContext, taskId: string): Promise; /** The run behind a task its assignee or asker reads, as counts. */ readStepProgress(ctx: SecurityContext, taskId: string): Promise; /** @@ -69,17 +58,26 @@ export interface ProcessService { today: CalendarDate, ): Promise<{ runsStarted: number; runsCancelled: number }>; /** - * Trusted, for resets: locks every process until the transaction ends, before anything a step - * command locks after its process, so a reset and a step command take turns. + * Trusted, for resets: locks every process until the transaction ends, before the tasks, so a + * reset waits for a run's start instead of deadlocking with it. */ lockProcesses(ctx: SecurityContext, tx: Tx): Promise; - /** Back to a fresh workspace: deletes every run with its tasks, turns every process off, forgets their history. */ + /** Back to a fresh workspace: deletes every run, turns every process off, forgets their history; the people's deletion took the tasks. */ resetProcesses(ctx: SecurityContext, tx: Tx): Promise; /** * Trusted, as the system, before a transaction commits: starts the runs its events trigger, so * they commit with the change or not at all. */ startRunsOnEvents: EventRunService["startRunsOnEvents"]; + /** + * The process and step behind each of these tasks that a step created, by task id; the caller + * shows it only on tasks the person may read. + */ + readTaskSources( + taskIds: readonly string[], + ): Promise>; + /** Trusted, before the commit: completes the steps of the tasks its events marked done. */ + completeStepsOfDoneTasks: RunLifecycleService["completeStepsOfDoneTasks"]; } export function createProcessService({ @@ -87,20 +85,44 @@ export function createProcessService({ transact, logger, tasks, - bankHolidays, + people, + workingCalendars, + recordTypes: registeredRecordTypes, + triggeringEvents, now = () => new Date(), }: Pick & { tasks: TaskService; - bankHolidays: BankHolidayService; + people: PeoplePort; + workingCalendars: WorkingCalendarsPort; + /** Every registered record type; those with process capabilities may be a run's subject. */ + recordTypes: RecordTypeRegistry; + triggeringEvents: TriggeringEvents; /** The clock: when a process is turned on, and from it today in London. */ now?: () => Date; }): ProcessService { const today = () => dateInLondon(now()); - const starter = createRunStarter({ orm, transact, tasks, bankHolidays }); + const recordTypes = processRecordTypesOf(registeredRecordTypes); + const definitionOf = definitionReader(recordTypes, triggeringEvents); + const starter = createRunStarter({ tasks, people, workingCalendars, recordTypes, definitionOf }); const reader = createRunReader({ transact, tasks, starter }); - const lifecycle = createRunLifecycleService({ orm, transact, logger, tasks, starter, reader }); - const dueRuns = createDueRunService({ orm, transact, logger, starter, lifecycle }); - const eventRuns = createEventRunService({ logger, tasks, starter, today }); + const lifecycle = createRunLifecycleService({ orm, transact, tasks, starter }); + const dueRuns = createDueRunService({ + orm, + transact, + logger, + tasks, + starter, + lifecycle, + recordTypes, + definitionOf, + }); + const eventRuns = createEventRunService({ + logger, + starter, + today, + triggeringEvents, + definitionOf, + }); const historyReader = createRecordHistoryReaderService(orm, { process: processRecordType(orm) }); return { @@ -129,35 +151,29 @@ export function createProcessService({ return historyReader.readHistory(ctx, "process", process.id, args); }, - async startRun(ctx, { processKey, employeeId }) { + async startRun(ctx, { processKey, recordId }) { const process = await reader.requireProcess(orm, processKey); - const { activeVersion } = process; - const definition = parseProcessDefinition( - activeVersion.schemaVersion, - activeVersion.definition, - ); - const subjectId = parseUuid(employeeId); - if (!subjectId) throw employeeNotFound(); - await ctx.requireInScope("process_run", "create", subjectId); - if (!definition.startableByAdmin || definition.subject.recordType !== "employee") { - throw new ApplicationError("FORBIDDEN", "This process starts only by itself."); - } + const definition = definitionOf(process.activeVersion); + const { recordType } = definition.subject; const run = await transact(async (tx) => { await processRepository(tx).lockProcess(process.id); - const subject = await starter.readSubject(tx, subjectId); - if (!subject) throw employeeNotFound(); - if (await processRepository(tx).findRunInProgress(process.id, "employee", subjectId)) { + // Keyed by the record's own id, such as an employee's lowercase uuid. + const [found] = await starter.readRecordSubjects(tx, recordType, [recordId]); + if (!found) throw recordNotFound(); + const [canonicalId, subject] = found; + await ctx.requireInScope("process_run", "create", subject?.id ?? "public"); + if (await processRepository(tx).findRunInProgress(process.id, recordType, canonicalId)) { throw new ApplicationError( "CONFLICT", - `${process.name} is already running for ${subject.firstName}.`, + `${process.name} is already running for ${subject?.firstName ?? "this record"}.`, ); } - const startDay = laterOf(today(), subject.startDate); + const startDay = laterOf(today(), subject?.startDate ?? null); const [started] = await starter.startMany(tx, [ { process, definition, - record: { recordType: "employee", recordId: subjectId }, + record: { recordType, recordId: canonicalId }, subject, startCause: "started_by_admin", startedByEmployeeId: ctx.user?.employeeId ?? null, @@ -173,8 +189,6 @@ export function createProcessService({ return reader.readDetails(run); }, - cancelRun: (ctx, runId, reason) => lifecycle.cancelRun(ctx, runId, reason), - async readRun(ctx, runId) { const run = await reader.requireRun(orm, runId); await ctx.requireInScope("process_run", "read", run.subjectEmployeeId ?? "public"); @@ -188,10 +202,6 @@ export function createProcessService({ return runs.map((run) => reader.view(run, process)); }, - markStepDone: (ctx, taskId) => lifecycle.markStepDone(ctx, taskId), - - reopenStep: (ctx, taskId) => lifecycle.reopenStep(ctx, taskId), - readStepProgress: (ctx, taskId) => lifecycle.readStepProgress(ctx, taskId), startDueRuns: (ctx, day) => dueRuns.startDueRuns(ctx, day), @@ -203,6 +213,10 @@ export function createProcessService({ startRunsOnEvents: (ctx, tx, events) => eventRuns.startRunsOnEvents(ctx, tx, events), + completeStepsOfDoneTasks: (tx, events) => lifecycle.completeStepsOfDoneTasks(tx, events), + + readTaskSources: (taskIds) => processRepository(orm).findTaskSources(taskIds), + async resetProcesses(ctx, tx) { ctx.requireSetupAccess("MANAGE_WORKSPACE"); await processRepository(tx).reset(); @@ -260,12 +274,36 @@ function processView({ }; } +/** The process capabilities of the record types that registered them, by type. */ +function processRecordTypesOf(registry: RecordTypeRegistry): ProcessRecordTypes { + return Object.fromEntries( + Object.entries(registry).flatMap(([type, registration]) => + registration?.processes ? [[type, registration.processes]] : [], + ), + ); +} + +/** Parses stored definitions against the record types and events the modules registered. */ +function definitionReader( + recordTypes: ProcessRecordTypes, + triggeringEvents: TriggeringEvents, +): DefinitionReader { + const catalog = { + recordTypes: Object.fromEntries( + Object.entries(recordTypes).map(([type, { dateFields = [] }]) => [type, { dateFields }]), + ), + triggeringEvents, + }; + return ({ schemaVersion, definition }) => + parseProcessDefinition(schemaVersion, definition, catalog); +} + function laterOf(day: CalendarDate, other: CalendarDate | null): CalendarDate { return other !== null && other > day ? other : day; } -function employeeNotFound(): ApplicationError { - return new ApplicationError("NOT_FOUND", "No employee has this id."); +function recordNotFound(): ApplicationError { + return new ApplicationError("NOT_FOUND", "No record has this id."); } const history = createRecordHistoryService(); diff --git a/packages/api/src/modules/processes/app/processViews.ts b/packages/api/src/core/processes/app/processViews.ts similarity index 76% rename from packages/api/src/modules/processes/app/processViews.ts rename to packages/api/src/core/processes/app/processViews.ts index 1a3635cb1..9d823caad 100644 --- a/packages/api/src/modules/processes/app/processViews.ts +++ b/packages/api/src/core/processes/app/processViews.ts @@ -1,6 +1,6 @@ import type { CalendarDate } from "../../../utils"; -import type { Task, TaskStatus } from "../../tasks"; -import type { ProcessRun as StoredRun, RunStatus, RunStep } from "../domain"; +import type { TaskStatus } from "../../tasks"; +import type { ProcessRunFields as StoredRun, RunStatus, RunStep } from "../domain"; // The shapes the process service returns. @@ -54,13 +54,6 @@ export interface ProcessRunDetails { steps: ProcessRunStep[]; } -/** A step's task after a step command, and its run; a run cancelled on the way says so instead of throwing. */ -export interface StepChange { - /** Without why it was cancelled, which only admins may see: it can name another step. */ - run: Omit; - task: Task; -} - /** How far a run is, as one of its tasks' readers may see it: never the other steps. */ export interface StepProgress { processName: string; diff --git a/packages/api/src/modules/processes/app/readRuns.int.test.ts b/packages/api/src/core/processes/app/readRuns.int.test.ts similarity index 77% rename from packages/api/src/modules/processes/app/readRuns.int.test.ts rename to packages/api/src/core/processes/app/readRuns.int.test.ts index e125835e1..17b22bd47 100644 --- a/packages/api/src/modules/processes/app/readRuns.int.test.ts +++ b/packages/api/src/core/processes/app/readRuns.int.test.ts @@ -17,11 +17,11 @@ test("lists an admin's runs newest first", async ({ orm, seed }) => { const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); const first = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: firstPerson.id, + recordId: firstPerson.id, }); const second = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: secondPerson.id, + recordId: secondPerson.id, }); const runs = await processes.listRuns(ADMIN, WELCOME_NEW_STARTER); @@ -35,7 +35,7 @@ test("an employee cannot list runs or read another step through the run", async const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); const started = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); await expect(processes.listRuns(EMPLOYEE, WELCOME_NEW_STARTER)).rejects.toMatchObject({ @@ -55,7 +55,7 @@ test("the coffee-calls assignee sees progress without the other step's details", const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); const started = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(await processes.readStepProgress(ctx, started.steps[1]!.task!.id)).toEqual({ @@ -81,7 +81,7 @@ test("hides a task's progress and completion commands from an unrelated caller", const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); const started = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); const taskId = started.steps[1]!.task!.id; @@ -89,11 +89,7 @@ test("hides a task's progress and completion commands from an unrelated caller", code: "NOT_FOUND", message: "No task has this id.", }); - await expect(processes.markStepDone(ctx, taskId)).rejects.toMatchObject({ - code: "NOT_FOUND", - message: "No task has this id.", - }); - await expect(processes.reopenStep(ctx, taskId)).rejects.toMatchObject({ + await expect(taskService(orm).markDone(ctx, taskId)).rejects.toMatchObject({ code: "NOT_FOUND", message: "No task has this id.", }); @@ -108,33 +104,18 @@ test("reports an unknown run", async ({ orm }) => { }); }); -test("reset removes runs and their tasks and turns automatic starts off", async ({ - orm, - seed, - transact, -}) => { +// The tasks go with the people: every path that resets processes deletes the employees first. +test("reset removes runs and turns automatic starts off", async ({ orm, seed, transact }) => { const manager = await seed.employee(); const employee = await seed.employee(); await seed.manager(employee.id, manager.id); - const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); const [original] = await processes.listProcesses(ADMIN); await processes.turnOn(ADMIN, WELCOME_NEW_STARTER); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); + await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, recordId: employee.id }); await transact((tx) => processes.resetProcesses(ADMIN, tx)); expect(await processes.listRuns(ADMIN, WELCOME_NEW_STARTER)).toEqual([]); expect(await processes.listProcesses(ADMIN)).toEqual([original]); - await expect( - taskService(orm).readTask(managerCtx, started.steps[0]!.task!.id), - ).rejects.toMatchObject({ code: "NOT_FOUND", message: "No task has this id." }); - await expect(taskService(orm).readTask(ctx, started.steps[1]!.task!.id)).rejects.toMatchObject({ - code: "NOT_FOUND", - message: "No task has this id.", - }); }); diff --git a/packages/api/src/modules/processes/app/registrations.ts b/packages/api/src/core/processes/app/registrations.ts similarity index 71% rename from packages/api/src/modules/processes/app/registrations.ts rename to packages/api/src/core/processes/app/registrations.ts index b6a50488b..5e66e7712 100644 --- a/packages/api/src/modules/processes/app/registrations.ts +++ b/packages/api/src/core/processes/app/registrations.ts @@ -1,6 +1,6 @@ // What this module adds to core's shared registries. -declare module "../../../core/records" { +declare module "../../records" { interface RecordTypes { /** A process's own changes, such as turning it on; admins read them through the process. */ process: true; @@ -12,16 +12,18 @@ declare module "../../../core/records" { } } -declare module "../../../core/jobs" { +declare module "../../jobs" { interface Jobs { "processes.startDueRuns": Record; } } -declare module "../../../core/events" { +declare module "../../events" { interface DomainEvents { /** Runs started in the transaction whose events triggered them; after-commit handlers get it only once it commits. */ "processes.runs_started_on_event": { processKey: string; runIds: string[]; eventIds: string[] }; + /** A run's steps completed as their tasks were marked done; logged once the change commits. */ + "processes.steps_completed": { runId: string; taskIds: string[]; runStatus: string }; } } diff --git a/packages/api/src/core/processes/app/runLifecycleService.ts b/packages/api/src/core/processes/app/runLifecycleService.ts new file mode 100644 index 000000000..a33d3d1a7 --- /dev/null +++ b/packages/api/src/core/processes/app/runLifecycleService.ts @@ -0,0 +1,125 @@ +import { ApplicationError } from "../../applicationError"; +import type { Orm, Transact, Tx } from "../../db"; +import type { PublishedEvent } from "../../events"; +import { PrincipalSecurityContext, type SecurityContext } from "../../security"; +import type { TaskService } from "../../tasks"; +import { processRepository } from "../db"; +import { PROCESS_CANCELLED, type ProcessRun, SUBJECT_REMOVED } from "../domain"; +import type { StepProgress } from "./processViews"; +import type { RunStarter } from "./runStarter"; + +/** + * A run while it runs: it follows its steps' tasks as their assignees mark them done, and cancels + * itself once its subject or a waiting step's assignee is gone. + */ +export interface RunLifecycleService { + /** + * Trusted, before the commit of the transaction that marked tasks done: completes their steps + * and finishes each run with no step left awaiting its task. Takes each run's lock after the + * task's, as the change that marked it done holds it already. + */ + completeStepsOfDoneTasks(tx: Tx, events: readonly PublishedEvent[]): Promise; + /** The run behind a task its assignee or asker reads, as counts. */ + readStepProgress(ctx: SecurityContext, taskId: string): Promise; + /** + * The locked run, cancelled and saved when its subject or a waiting step's assignee is gone; + * else as it was. The run cancels itself, so Broccoli is the author. The caller holds the run's + * open tasks before the run. + */ + cancelIfCannotRun(tx: Tx, run: ProcessRun): Promise; +} + +export function createRunLifecycleService({ + orm, + transact, + tasks, + starter, +}: { + orm: Orm; + transact: Transact; + tasks: TaskService; + starter: RunStarter; +}): RunLifecycleService { + return { + async completeStepsOfDoneTasks(tx, events) { + const doneTaskIds = new Set( + events.flatMap(({ type, payload }) => + type === "tasks.task_status_changed" && payload.to === "done" ? [payload.taskId] : [], + ), + ); + if (doneTaskIds.size === 0) return; + const runs = processRepository(tx); + const awaiting = (await runs.findStepsByTaskIds([...doneTaskIds])).filter( + ({ status }) => status === "step_awaiting_task_completion", + ); + const now = new Date(); + for (const runId of [...new Set(awaiting.map((step) => step.runId))].sort()) { + const run = (await runs.lockRun(runId))!; + // A run that can no longer run stays in progress for the daily check, which cancels it. + const canGoOn = (await removalOf(starter, tx, run)) === null; + const changed = run.completeTasks(doneTaskIds, now, canGoOn); + await runs.saveRun(changed); + tx.publish("processes.steps_completed", { + runId, + taskIds: run.openTaskIds().filter((taskId) => doneTaskIds.has(taskId)), + runStatus: changed.status, + }); + } + }, + + async readStepProgress(ctx, taskId) { + const task = await tasks.readTask(ctx, taskId); + const [step] = await processRepository(orm).findStepsByTaskIds([task.id]); + if (!step) throw new ApplicationError("CONFLICT", "This task does not belong to a process."); + return transact(async (tx) => { + const runs = processRepository(tx); + // Locked, so a step change committing meanwhile cannot pair its steps with the old status. + const run = (await runs.lockRun(step.runId))!; + const [process] = await runs.findProcessesByIds([run.processId]); + const steps = run.steps.filter(({ status }) => status !== "step_skipped"); + return { + processName: process!.name, + subjectEmployeeId: run.subjectEmployeeId, + status: run.status, + doneSteps: steps.filter(({ status }) => status === "step_completed").length, + totalSteps: steps.length, + }; + }); + }, + + async cancelIfCannotRun(tx, run) { + if (run.status !== "in_progress") return run; + const removal = await removalOf(starter, tx, run); + if (!removal) return run; + const reason = + removal.cause === "cancelled_because_subject_was_removed" + ? SUBJECT_REMOVED + : `The assignee of '${(await starter.stepTitles(tx, run)).get(removal.stepKey!)}' was removed from Broccoli.`; + const cancelled = run.cancel({ + cause: removal.cause, + reason, + byEmployeeId: null, + now: new Date(), + }); + await tasks.cancelInTransaction(PrincipalSecurityContext.system(), tx, { + taskIds: run.openTaskIds(), + reason: PROCESS_CANCELLED, + cause: { kind: "PROCESS_RUN", ref: run.id }, + }); + await processRepository(tx).saveRun(cancelled); + return cancelled; + }, + }; +} + +/** + * Why the run can no longer run, or null: its record or the person it is about is gone, or a step + * still waiting lost its task with its assignee. + */ +async function removalOf(starter: RunStarter, tx: Tx, run: ProcessRun) { + const subjects = await starter.readRecordSubjects(tx, run.recordType, [run.recordId]); + const subjectExists = + subjects.has(run.recordId) && + (run.subjectEmployeeId === null || subjects.get(run.recordId) !== null); + return run.removal(subjectExists); +} diff --git a/packages/api/src/modules/processes/app/runReader.ts b/packages/api/src/core/processes/app/runReader.ts similarity index 93% rename from packages/api/src/modules/processes/app/runReader.ts rename to packages/api/src/core/processes/app/runReader.ts index ba4dbf51f..562301a03 100644 --- a/packages/api/src/modules/processes/app/runReader.ts +++ b/packages/api/src/core/processes/app/runReader.ts @@ -1,9 +1,9 @@ -import { ApplicationError } from "../../../core/applicationError"; -import type { Orm, Transact, Tx } from "../../../core/db"; +import { ApplicationError } from "../../applicationError"; +import type { Orm, Transact, Tx } from "../../db"; import { parseUuid } from "../../../utils"; import type { Task, TaskService } from "../../tasks"; import { processRepository, type StoredProcess } from "../db"; -import type { ProcessRun as StoredRun, RunStep } from "../domain"; +import type { ProcessRunFields as StoredRun, RunStep } from "../domain"; import type { ProcessRun, ProcessRunDetails, ProcessRunStep } from "./processViews"; import type { RunStarter } from "./runStarter"; @@ -47,7 +47,7 @@ export function createRunReader({ const runs = processRepository(tx); const current = (await runs.lockRun(run.id))!; const [process] = await runs.findProcessesByIds([current.processId]); - const steps = await runs.findSteps(current.id); + const { steps } = current; const taskIds = steps.flatMap(({ taskId }) => (taskId ? [taskId] : [])); const runTasks = await tasks.readTasksInTransaction(tx, taskIds); const titles = await starter.stepTitles(tx, current); diff --git a/packages/api/src/modules/processes/app/runStarter.ts b/packages/api/src/core/processes/app/runStarter.ts similarity index 64% rename from packages/api/src/modules/processes/app/runStarter.ts rename to packages/api/src/core/processes/app/runStarter.ts index 3931266b3..d2d5a9e11 100644 --- a/packages/api/src/modules/processes/app/runStarter.ts +++ b/packages/api/src/core/processes/app/runStarter.ts @@ -1,13 +1,7 @@ -import type { Orm, Transact, Tx } from "../../../core/db"; -import { PrincipalSecurityContext } from "../../../core/security"; +import type { Tx } from "../../db"; +import type { RecordProcessCapabilities } from "../../records"; +import { PrincipalSecurityContext } from "../../security"; import { addDays, type CalendarDate } from "../../../utils"; -import type { BankHolidayService } from "../../bankHolidays"; -import { - createEmployeeReadService, - createEmployeeWorkingPatternService, - employeeDirectory, - type EmployeeReadService, -} from "../../employees"; import type { NewTask, TaskService } from "../../tasks"; import { type NewRunRow, processRepository, type StoredProcess } from "../db"; import { @@ -15,9 +9,8 @@ import { type DayOffset, dueTriggerDate, type IsWorkingDay, - parseProcessDefinition, type ProcessDefinition, - type ProcessRun, + type ProcessRunFields, type RecordDateTrigger, resolveAssignee, type ResolvedAssignee, @@ -25,22 +18,26 @@ import { type StartCause, type TriggerEvent, } from "../domain"; -import { createWorkingCalendars } from "./workingCalendars"; +import type { PeoplePort, RunSubject, WorkingCalendarsPort } from "../ports"; -/** The person a run is about, as starting it needs them. */ -export interface RunSubject { - id: string; - firstName: string; - /** Their first employment's start. */ - startDate: CalendarDate | null; -} +/** A process's own task type: a step's to-do, whose done step processes follows. */ +export const PROCESS_STEP_TO_DO = "process_step_to_do"; + +/** What processes may do with each record type that registered its process capabilities. */ +export type ProcessRecordTypes = Readonly>; + +/** A stored version's definition, parsed against what the modules registered. */ +export type DefinitionReader = (version: { + schemaVersion: number; + definition: unknown; +}) => ProcessDefinition; /** What starting one run needs; the caller holds the process's lock and has checked it may start. */ export interface RunStart { process: StoredProcess; definition: ProcessDefinition; /** The record the run is about. */ - record: { recordType: ProcessDefinition["subject"]["recordType"]; recordId: string }; + record: { recordType: string; recordId: string }; /** The person the run is about: the employee, or a task's subject; null for nobody. */ subject: RunSubject | null; startCause: StartCause; @@ -53,54 +50,66 @@ export interface RunStart { /** Starts runs: their steps' assignees, tasks and due dates. */ export interface RunStarter { - /** The person with this id, or null when there is none. */ - readSubject(tx: Tx, employeeId: string): Promise; - /** The people among these ids that exist, by id. */ - readSubjects(tx: Tx, employeeIds: readonly string[]): Promise>; + /** + * The subject of each record among these that still exists, by record id: the person it is + * about, null for nobody. A record that is gone is left out. + */ + readRecordSubjects( + tx: Tx, + recordType: string, + recordIds: readonly string[], + ): Promise>; /** * Trusted: creates the runs, their steps and their tasks in `tx`, as Broccoli, with each run as * its tasks' cause, in a fixed number of statements; a run whose steps are all skipped is * finished at once. Returns the runs in the order of `starts`. */ - startMany(tx: Tx, starts: readonly RunStart[]): Promise; - /** The people whose start date triggers a run today, with the date it triggered on. */ + startMany(tx: Tx, starts: readonly RunStart[]): Promise; + /** The records whose date triggers a run today, with the date it triggered on. */ findDue( tx: Tx, trigger: RecordDateTrigger, days: { today: CalendarDate; turnedOnDay: CalendarDate }, - ): Promise<{ employeeId: string; triggerDate: CalendarDate }[]>; + ): Promise<{ recordId: string; triggerDate: CalendarDate }[]>; /** Each step's title as its task gets it, in the order the run's version lists the steps. */ - stepTitles(tx: Tx, run: ProcessRun): Promise>; + stepTitles(tx: Tx, run: ProcessRunFields): Promise>; } export function createRunStarter({ - orm, - transact, tasks, - bankHolidays, + people, + workingCalendars, + recordTypes, + definitionOf, }: { - orm: Orm; - transact: Transact; tasks: TaskService; - bankHolidays: BankHolidayService; + people: PeoplePort; + workingCalendars: WorkingCalendarsPort; + recordTypes: ProcessRecordTypes; + definitionOf: DefinitionReader; }): RunStarter { - const employees = createEmployeeReadService({ orm }); - const calendars = createWorkingCalendars({ - workingPatterns: createEmployeeWorkingPatternService({ transact }), - bankHolidays, - }); - const readSubjects = (tx: Tx, ids: readonly string[]) => readSubjectsOf(employees, tx, ids); + const readPeople = (tx: Tx, ids: readonly string[]) => + ids.length === 0 ? Promise.resolve(new Map()) : people.readPeople(tx, ids); return { - readSubject: async (tx, employeeId) => - (await readSubjects(tx, [employeeId])).get(employeeId) ?? null, - - readSubjects, + async readRecordSubjects(tx, recordType, recordIds) { + const subjectIds = await recordTypes[recordType]!.readSubjects(tx, [...new Set(recordIds)]); + const found = await readPeople( + tx, + [...subjectIds.values()].flatMap((id) => (id ? [id] : [])), + ); + return new Map( + [...subjectIds].map(([recordId, subjectId]) => [ + recordId, + subjectId ? (found.get(subjectId) ?? null) : null, + ]), + ); + }, async startMany(tx, starts) { if (starts.length === 0) return []; - const assignees = await resolveAssignees(employees, tx, starts); - const workingDays = await calendars.readWorkingDays(tx, { + const assignees = await resolveAssignees(people, tx, starts); + const workingDays = await workingCalendars.readWorkingDays(tx, { employeeIds: subjectsCountingBusinessDays(starts), days: starts.map(({ startDay }) => startDay), }); @@ -124,71 +133,52 @@ export function createRunStarter({ }, async findDue(tx, trigger, days) { - // A start date's trigger date is at most the offset's span away from it. + // A record's trigger date is at most the offset's span away from its date. const span = spanOf(trigger.offset) + trigger.maxDaysLate; - const starts = await employees.readEmployeesStartingBetween(tx, { + const dates = await recordTypes[trigger.recordType]!.findByDateBetween!(tx, { + field: trigger.dateField, from: addDays(days.today, -span), until: addDays(days.today, span), }); - return [...starts].flatMap(([employeeId, startDate]) => { - const triggerDate = dueTriggerDate(trigger, startDate, days); - return triggerDate ? [{ employeeId, triggerDate }] : []; + return [...dates].flatMap(([recordId, date]) => { + const triggerDate = dueTriggerDate(trigger, date, days); + return triggerDate ? [{ recordId, triggerDate }] : []; }); }, async stepTitles(tx, run) { const version = (await processRepository(tx).findVersion(run.processVersionId))!; - const { steps } = parseProcessDefinition(version.schemaVersion, version.definition); + const { steps } = definitionOf(version); const subject = run.subjectEmployeeId - ? ((await readSubjects(tx, [run.subjectEmployeeId])).get(run.subjectEmployeeId) ?? null) + ? ((await readPeople(tx, [run.subjectEmployeeId])).get(run.subjectEmployeeId) ?? null) : null; - return new Map(steps.map(({ key, title }) => [key, filled(title, subject)])); + return new Map(steps.map(({ key, task }) => [key, filled(task.title, subject)])); }, }; } /** One run being started, with what its steps need. */ interface RunPlan { - run: ProcessRun; + run: ProcessRunFields; start: RunStart; /** Each step's assignee, in the definition's order; null for a step skipped for want of one. */ assignees: (ResolvedAssignee | null)[]; isWorkingDay: IsWorkingDay | null; } -/** The people among these ids that exist, with their first employment's start, by id. */ -async function readSubjectsOf( - employees: EmployeeReadService, - tx: Tx, - employeeIds: readonly string[], -): Promise> { - if (employeeIds.length === 0) return new Map(); - const found = await employees.readEmployeesByName(tx, employeeIds); - const periods = await employees.readEmploymentPeriods( - tx, - found.map(({ id }) => id), - ); - return new Map( - found.map(({ id, firstName }) => [ - id, - { id, firstName, startDate: periods.get(id)?.[0]?.startDate ?? null }, - ]), - ); -} - /** Each start's step assignees, with each subject's manager on its start day and the owner. */ async function resolveAssignees( - employees: EmployeeReadService, + people: PeoplePort, tx: Tx, starts: readonly RunStart[], ): Promise<(ResolvedAssignee | null)[][]> { - const owner = await employeeDirectory(tx).ownersEmployeeId(); + const owner = await people.readOwner(tx); const managersByDay = new Map>(); for (const day of new Set(starts.map(({ startDay }) => startDay))) { const employeeIds = starts.flatMap((start) => start.startDay === day && start.subject ? [start.subject.id] : [], ); - managersByDay.set(day, await employees.readManagerIdsOn(tx, { employeeIds, date: day })); + managersByDay.set(day, await people.readManagersOn(tx, { employeeIds, date: day })); } return starts.map(({ definition, subject, startDay }) => { const candidates = { @@ -196,14 +186,16 @@ async function resolveAssignees( subjects_manager: subject ? (managersByDay.get(startDay)!.get(subject.id) ?? null) : null, workspace_owner: owner, }; - return definition.steps.map((step) => resolveAssignee(step.assignee, candidates)); + return definition.steps.map((step) => resolveAssignee(step.task.assignee, candidates)); }); } /** The subjects of runs with a step due in business days, which count their own working days. */ function subjectsCountingBusinessDays(starts: readonly RunStart[]): string[] { const ids = starts.flatMap(({ definition, subject }) => - subject && definition.steps.some(({ due }) => due.unit === "business_days") ? [subject.id] : [], + subject && definition.steps.some(({ task }) => task.due.unit === "business_days") + ? [subject.id] + : [], ); return [...new Set(ids)]; } @@ -238,14 +230,15 @@ function newTasksOf({ run, start, assignees, isWorkingDay }: RunPlan): NewTask[] if (!assignee) return []; return [ { - type: "process_step_to_do", - title: filled(step.title, start.subject), - description: step.description === null ? null : filled(step.description, start.subject), + type: PROCESS_STEP_TO_DO, + sourceKey: start.process.key, + title: filled(step.task.title, start.subject), + description: + step.task.description === null ? null : filled(step.task.description, start.subject), assigneeEmployeeId: assignee.employeeId, - processSource: { processKey: start.process.key, processRunId: run.id, stepKey: step.key }, subjectEmployeeId: start.subject?.id ?? null, record: { recordType: run.recordType, recordId: run.recordId }, - dueDate: addDayOffset(start.startDay, step.due, isWorkingDay), + dueDate: addDayOffset(start.startDay, step.task.due, isWorkingDay), cause: { kind: "PROCESS_RUN", ref: run.id }, }, ]; diff --git a/packages/api/src/modules/processes/app/startDueRuns.int.test.ts b/packages/api/src/core/processes/app/startDueRuns.int.test.ts similarity index 72% rename from packages/api/src/modules/processes/app/startDueRuns.int.test.ts rename to packages/api/src/core/processes/app/startDueRuns.int.test.ts index ca7f4e3e0..2c7f41851 100644 --- a/packages/api/src/modules/processes/app/startDueRuns.int.test.ts +++ b/packages/api/src/core/processes/app/startDueRuns.int.test.ts @@ -4,9 +4,14 @@ import { SYSTEM, calendarDate, employeeServices, + eventProcess, expect, processService, + seedProcess, + signedIn, + taskService, test, + transactWithProcesses, } from "#test/support"; import { WELCOME_NEW_STARTER } from "../domain"; @@ -61,12 +66,15 @@ test("does not start automatically after a cancelled manual welcome", async ({ o const today = calendarDate("2026-12-03"); const processes = processService(orm, { today: () => today }); await processes.turnOn(ADMIN, WELCOME_NEW_STARTER); + const manager = await seed.employee(); const employee = await seed.employee({ startDate: today }); + await seed.manager(employee.id, manager.id); const manual = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); - await processes.cancelRun(ADMIN, manual.run.id, "Already welcomed"); + await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); + expect(await processes.startDueRuns(SYSTEM, today)).toEqual({ runsStarted: 0, runsCancelled: 1 }); expect(await processes.startDueRuns(SYSTEM, today)).toEqual({ runsStarted: 0, runsCancelled: 0 }); expect(await processes.listRuns(ADMIN, WELCOME_NEW_STARTER)).toMatchObject([ @@ -104,7 +112,7 @@ test("the daily pass cancels a run whose outstanding assignee was removed even w const processes = processService(orm, { today: () => today }); const started = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); expect((await processes.readRun(ADMIN, started.run.id)).run.status).toBe("in_progress"); @@ -119,6 +127,46 @@ test("the daily pass cancels a run whose outstanding assignee was removed even w expect(cancelled.steps[1]!.task!.status).toBe("cancelled"); }); +test("the daily pass cancels a run whose subject task was deleted", async ({ orm, seed }) => { + const asker = await seed.employee(); + const employee = await seed.employee(); + const askerCtx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); + const today = calendarDate("2026-12-03"); + const { key } = await seedProcess(orm, { + key: "follow_up", + definition: eventProcess("tasks.task_created", { field: "type", in: ["manual_to_do"] }), + }); + const transact = transactWithProcesses(orm, { today: () => today }); + const processes = processService(orm, { today: () => today, transact }); + const tasks = taskService(orm, { transact }); + const task = await transact((tx) => + tasks.createTask(askerCtx, tx, { + type: "manual_to_do", + title: "Read the handbook", + askedByEmployeeId: asker.id, + assigneeEmployeeId: employee.id, + subjectEmployeeId: employee.id, + }), + ); + const [run] = await processes.listRuns(ADMIN, key); + const started = await processes.readRun(ADMIN, run!.id); + expect(started.run).toMatchObject({ + status: "in_progress", + record: { recordType: "task", recordId: task.id }, + }); + expect(started.steps).toMatchObject([{ task: { status: "to_do" } }]); + await tasks.deleteTask(askerCtx, task.id); + + await processes.startDueRuns(SYSTEM, today); + + const cancelled = await processes.readRun(ADMIN, run!.id); + expect(cancelled.run).toMatchObject({ + status: "cancelled", + cancelCause: "cancelled_because_subject_was_removed", + }); + expect(cancelled.steps).toMatchObject([{ task: { status: "cancelled" } }]); +}); + test("only the system may run the daily pass", async ({ orm }) => { const processes = processService(orm); diff --git a/packages/api/src/modules/processes/app/startDueRunsJob.ts b/packages/api/src/core/processes/app/startDueRunsJob.ts similarity index 86% rename from packages/api/src/modules/processes/app/startDueRunsJob.ts rename to packages/api/src/core/processes/app/startDueRunsJob.ts index 2bb1b3645..0d70a8317 100644 --- a/packages/api/src/modules/processes/app/startDueRunsJob.ts +++ b/packages/api/src/core/processes/app/startDueRunsJob.ts @@ -1,6 +1,6 @@ -import type { JobHandlers } from "../../../core/jobs"; -import type { Logger } from "../../../core/logger"; -import { PrincipalSecurityContext } from "../../../core/security"; +import type { JobHandlers } from "../../jobs"; +import type { Logger } from "../../logger"; +import { PrincipalSecurityContext } from "../../security"; import { type CalendarDate, todayInLondon } from "../../../utils"; import type { ProcessService } from "./processService"; diff --git a/packages/api/src/modules/processes/app/startRun.int.test.ts b/packages/api/src/core/processes/app/startRun.int.test.ts similarity index 83% rename from packages/api/src/modules/processes/app/startRun.int.test.ts rename to packages/api/src/core/processes/app/startRun.int.test.ts index be18171d3..28288c698 100644 --- a/packages/api/src/modules/processes/app/startRun.int.test.ts +++ b/packages/api/src/core/processes/app/startRun.int.test.ts @@ -1,10 +1,14 @@ import { ADMIN, EMPLOYEE, + SYSTEM, + employeeServices, calendarDate, + eventProcess, expect, processService, seedOwnWeek, + seedProcess, seedWorkingPattern, signedIn, taskService, @@ -13,7 +17,7 @@ import { import { WELCOME_NEW_STARTER } from "../domain"; -test("an admin starts two parallel tasks with their assignees, source and due dates", async ({ +test("an admin starts two parallel tasks with their assignees and due dates", async ({ orm, seed, }) => { @@ -26,7 +30,7 @@ test("an admin starts two parallel tasks with their assignees, source and due da const details = await processes.startRun(ctx, { processKey: WELCOME_NEW_STARTER, - employeeId: sam.id, + recordId: sam.id, }); expect(details.run).toMatchObject({ @@ -59,22 +63,27 @@ test("an admin starts two parallel tasks with their assignees, source and due da askedByEmployeeId: null, subjectEmployeeId: sam.id, record: { recordType: "employee", recordId: sam.id }, - processSource: { - processKey: WELCOME_NEW_STARTER, - processRunId: details.run.id, - stepKey: "welcome_one_to_one", - }, }); expect(await tasks.readTask(samCtx, details.steps[1]!.task!.id)).toMatchObject({ type: "process_step_to_do", askedByEmployeeId: null, subjectEmployeeId: sam.id, record: { recordType: "employee", recordId: sam.id }, - processSource: { - processKey: WELCOME_NEW_STARTER, - processRunId: details.run.id, - stepKey: "coffee_calls", - }, + }); +}); + +test("starts a welcome for an employee id written in uppercase", async ({ orm, seed }) => { + const employee = await seed.employee(); + const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); + + const started = await processes.startRun(ADMIN, { + processKey: WELCOME_NEW_STARTER, + recordId: employee.id.toUpperCase(), + }); + + expect(started.run).toMatchObject({ + status: "in_progress", + record: { recordType: "employee", recordId: employee.id }, }); }); @@ -90,7 +99,7 @@ test("tasks from an admin's welcome are authored by Broccoli with the run as cau const started = await processes.startRun(adminCtx, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); const history = await taskService(orm).readTaskHistory(assigneeCtx, started.steps[1]!.task!.id, { @@ -116,7 +125,7 @@ test("an early manual welcome uses the person's future start day and manager", a const details = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(details.run.startDay).toBe("2026-12-10"); @@ -135,7 +144,7 @@ test("the owner's employee covers a missing manager with an explanation", async const details = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(details.steps[0]).toMatchObject({ @@ -156,7 +165,7 @@ test("skips the welcome meeting when neither a manager nor an owner employee exi const details = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(details.steps[0]).toMatchObject({ @@ -172,10 +181,10 @@ test("skips the welcome meeting when neither a manager nor an owner employee exi test("refuses a second welcome while the first is in progress", async ({ orm, seed }) => { const employee = await seed.employee({ firstName: "Sam" }); const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, employeeId: employee.id }); + await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, recordId: employee.id }); await expect( - processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, employeeId: employee.id }), + processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, recordId: employee.id }), ).rejects.toMatchObject({ code: "CONFLICT", message: "Welcome a new starter is already running for Sam.", @@ -184,17 +193,21 @@ test("refuses a second welcome while the first is in progress", async ({ orm, se }); test("allows another manual welcome after cancellation", async ({ orm, seed }) => { + const manager = await seed.employee(); const employee = await seed.employee(); + await seed.manager(employee.id, manager.id); const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); const first = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); - await processes.cancelRun(ADMIN, first.run.id, "Start again"); + await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); + await processes.startDueRuns(SYSTEM, calendarDate("2026-12-03")); + expect((await processes.readRun(ADMIN, first.run.id)).run.status).toBe("cancelled"); const next = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(next.run.id).not.toBe(first.run.id); @@ -208,7 +221,7 @@ test("allows a manual welcome while automatic starts are off", async ({ orm, see const details = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(details.run.status).toBe("in_progress"); @@ -221,11 +234,22 @@ test("refuses a manual welcome without a create grant", async ({ orm, seed }) => await expect( processService(orm).startRun(EMPLOYEE, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }), ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); +test("refuses a malformed task record id as not found", async ({ orm }) => { + const { key } = await seedProcess(orm, { + key: "follow_up", + definition: eventProcess("tasks.task_created"), + }); + + await expect( + processService(orm).startRun(ADMIN, { processKey: key, recordId: "not-a-uuid" }), + ).rejects.toMatchObject({ code: "NOT_FOUND", message: "No record has this id." }); +}); + test("refuses a due date beyond the bank holiday data without leaving a run or tasks", async ({ orm, seed, @@ -235,7 +259,7 @@ test("refuses a due date beyond the bank holiday data without leaving a run or t const processes = processService(orm, { today: () => calendarDate("2028-12-28") }); await expect( - processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, employeeId: employee.id }), + processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, recordId: employee.id }), ).rejects.toMatchObject({ code: "CONFLICT", message: "No bank holidays are known for 2029.", @@ -256,7 +280,7 @@ test("counts the subject's own part-time working days for coffee calls", async ( const details = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(details.steps[1]!.task!.dueDate).toBe("2026-12-21"); @@ -269,7 +293,7 @@ test("uses calendar days for a person without a working pattern", async ({ orm, const details = await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, + recordId: employee.id, }); expect(details.steps[1]!.task!.dueDate).toBe("2026-12-08"); diff --git a/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts b/packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts similarity index 97% rename from packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts rename to packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts index 2ca81ea4a..e5b7a799a 100644 --- a/packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts +++ b/packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts @@ -24,7 +24,7 @@ test("adding a person commits exactly one process run and a task for their manag }); const manager = await seed.employee(); const definition = eventProcess("employees.added"); - definition.steps[0]!.assignee = { kind: "subjects_manager" }; + definition.steps[0]!.task.assignee = { kind: "subjects_manager" }; const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today }); @@ -57,7 +57,7 @@ test("adding a person commits exactly one process run and a task for their manag expect((await taskService(orm).listMyTasks(ctx)).needsYou).toMatchObject([ { subjectEmployeeId: employee.id, - processSource: { processKey: key }, + type: "process_step_to_do", }, ]); }); @@ -126,7 +126,7 @@ test("three events in one transaction start three runs", async ({ orm, seed }) = test("a run that cannot calculate its due date rolls back adding the person", async ({ orm }) => { const definition = eventProcess("employees.added"); - definition.steps[0]!.due = { amount: 1, unit: "business_days" }; + definition.steps[0]!.task.due = { amount: 1, unit: "business_days" }; const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today: () => calendarDate("2031-06-02") }); const employees = employeeServices(orm, { transact }); @@ -236,7 +236,7 @@ test("a run's tasks start another process in the same transaction", async ({ orm key: "welcome", definition: eventProcess("employees.added"), }); - const definition = eventProcess("tasks.task_created", { field: "processKey", in: [first.key] }); + const definition = eventProcess("tasks.task_created", { field: "sourceKey", in: [first.key] }); const second = await seedProcess(orm, { key: "follow_up", definition }); const transact = transactWithProcesses(orm, { today }); @@ -257,7 +257,7 @@ test("a run's tasks start another process in the same transaction", async ({ orm record: { recordType: "task", recordId: task.id }, triggerEvent: { type: "tasks.task_created", - payload: { taskId: task.id, processKey: first.key }, + payload: { taskId: task.id, sourceKey: first.key }, }, }, ]); @@ -268,7 +268,7 @@ test("a run's tasks start another process in the same transaction", async ({ orm test("two matching events start two runs when every step is skipped", async ({ orm, seed }) => { const employee = await seed.employee(); const definition = eventProcess("employees.added"); - definition.steps[0]!.assignee = { kind: "subjects_manager" }; + definition.steps[0]!.task.assignee = { kind: "subjects_manager" }; const { key } = await seedProcess(orm, { key: "welcome", definition }); const transact = transactWithProcesses(orm, { today }); diff --git a/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts b/packages/api/src/core/processes/app/startRunsOnEventsAtScale.int.test.ts similarity index 94% rename from packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts rename to packages/api/src/core/processes/app/startRunsOnEventsAtScale.int.test.ts index dded330b4..82fe975b0 100644 --- a/packages/api/src/modules/processes/app/startRunsOnEventsAtScale.int.test.ts +++ b/packages/api/src/core/processes/app/startRunsOnEventsAtScale.int.test.ts @@ -15,7 +15,7 @@ import { transactWithProcesses, } from "#test/support"; -import { ormOf } from "../../../core/db"; +import { ormOf } from "../../db"; import { processRepository } from "../db"; test("one event for more people than one insert can hold starts a run for each", async ({ @@ -82,17 +82,13 @@ test( }); const { key } = await seedProcess(orm, { key: "follow_up", - definition: eventProcess("tasks.task_status_changed", { field: "to", in: ["cancelled"] }), + definition: eventProcess("tasks.task_status_changed", { field: "to", in: ["done"] }), }); const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); const locked = Promise.withResolvers(); const release = Promise.withResolvers(); const changing = transactWithProcesses(orm, { today })(async (tx) => { - await taskService(orm, { transact: (work) => work(tx) }).cancel( - ctx, - task.id, - "No longer needed", - ); + await taskService(orm, { transact: (work) => work(tx) }).markDone(ctx, task.id); const { rows } = await ormOf(tx).execute<{ pid: number }>( sql`select pg_backend_pid() as pid`, ); diff --git a/packages/api/src/modules/processes/app/turnOnProcess.int.test.ts b/packages/api/src/core/processes/app/turnOnProcess.int.test.ts similarity index 100% rename from packages/api/src/modules/processes/app/turnOnProcess.int.test.ts rename to packages/api/src/core/processes/app/turnOnProcess.int.test.ts diff --git a/packages/api/src/modules/processes/db/index.ts b/packages/api/src/core/processes/db/index.ts similarity index 100% rename from packages/api/src/modules/processes/db/index.ts rename to packages/api/src/core/processes/db/index.ts diff --git a/packages/api/src/modules/processes/db/processRepository.ts b/packages/api/src/core/processes/db/processRepository.ts similarity index 78% rename from packages/api/src/modules/processes/db/processRepository.ts rename to packages/api/src/core/processes/db/processRepository.ts index 0d8f4cbdd..a3caed7bb 100644 --- a/packages/api/src/modules/processes/db/processRepository.ts +++ b/packages/api/src/core/processes/db/processRepository.ts @@ -1,10 +1,15 @@ import { and, desc, eq, inArray, isNotNull, type SQL, sql } from "drizzle-orm"; -import { ormOf, type Orm, type OrmTransaction, type Tx } from "../../../core/db"; -import { inRecordScope, type RecordScope } from "../../../core/security"; -import { PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessRun, type RunStep } from "../domain"; +import { ormOf, type Orm, type OrmTransaction, type Tx } from "../../db"; +import { inRecordScope, type RecordScope } from "../../security"; +import { + PROCESS_DEFINITION_SCHEMA_VERSION, + ProcessRun, + type ProcessRunFields, + type RunStep, +} from "../domain"; import { processes, processVersions } from "./processesTable"; -import { processRuns, processRunSteps } from "./processRunsTable"; +import { processRuns, runStepProgress } from "./processRunsTable"; /** A process with the stored definition of its active version, not yet parsed. */ export interface StoredProcess { @@ -26,7 +31,7 @@ export interface NewProcessRow { } /** A run as it is stored before it has an id and timestamps. */ -export type NewRunRow = Omit; +export type NewRunRow = Omit; /** Stored processes, their versions, runs and run steps. */ export interface ProcessRepository { @@ -49,19 +54,25 @@ export interface ProcessRepository { insertProcess(process: NewProcessRow): Promise<{ id: string; key: string }>; setTurnedOnAt(id: string, turnedOnAt: Date | null): Promise; /** Inserts the runs in batches; returns them in the same order. */ - insertRuns(runs: readonly NewRunRow[]): Promise; + insertRuns(runs: readonly NewRunRow[]): Promise; insertSteps(steps: readonly RunStep[]): Promise; - findRun(id: string): Promise; - /** The run, locked until the transaction ends, so step changes on it take turns. */ + findRun(id: string): Promise; + /** The run with its steps, locked until the transaction ends, so changes to it take turns. */ lockRun(id: string): Promise; - saveRun(run: ProcessRun): Promise; - saveSteps(steps: readonly RunStep[]): Promise; + /** Writes the run and its steps' status. */ + saveRun(run: ProcessRun): Promise; /** The run's steps, in no particular order. */ findSteps(runId: string): Promise; + /** The steps whose tasks are among these, whatever their run. */ + findStepsByTaskIds(taskIds: readonly string[]): Promise; + /** The process key and step key behind each of these tasks that a step created, by task id. */ + findTaskSources( + taskIds: readonly string[], + ): Promise>; /** The runs of the process whose subject is in `scope`, newest first; FORBIDDEN for "none". */ - listRuns(processId: string, scope: RecordScope): Promise; + listRuns(processId: string, scope: RecordScope): Promise; /** Every run in progress, of every process. */ - listRunsInProgress(): Promise; + listRunsInProgress(): Promise; /** The records among these that already had any run of the process. */ findRecordsWithRuns( processId: string, @@ -79,7 +90,7 @@ export interface ProcessRepository { processId: string, recordType: string, recordId: string, - ): Promise; + ): Promise; /** Back to a fresh workspace: deletes every run (their steps and tasks with them), turns every process off. */ reset(): Promise; } @@ -180,7 +191,7 @@ export function processRepository(db: Orm | Tx): ProcessRepository { }, async insertRuns(runs) { - const inserted: ProcessRun[] = []; + const inserted: ProcessRunFields[] = []; for (let start = 0; start < runs.length; start += INSERT_BATCH_SIZE) { const batch = runs.slice(start, start + INSERT_BATCH_SIZE); inserted.push(...(await orm.insert(processRuns).values(batch).returning())); @@ -190,7 +201,7 @@ export function processRepository(db: Orm | Tx): ProcessRepository { async insertSteps(steps) { for (let start = 0; start < steps.length; start += INSERT_BATCH_SIZE) { - await orm.insert(processRunSteps).values(steps.slice(start, start + INSERT_BATCH_SIZE)); + await orm.insert(runStepProgress).values(steps.slice(start, start + INSERT_BATCH_SIZE)); } }, @@ -205,29 +216,50 @@ export function processRepository(db: Orm | Tx): ProcessRepository { .from(processRuns) .where(eq(processRuns.id, id)) .for("update"); - return row ?? null; + if (!row) return null; + const steps = await orm.select().from(runStepProgress).where(eq(runStepProgress.runId, id)); + return ProcessRun.restore(row, steps); }, - async saveRun(run) { - const [row] = await orm + async saveRun({ steps, ...run }) { + await orm .update(processRuns) .set({ ...run, updatedAt: new Date() }) - .where(eq(processRuns.id, run.id)) - .returning(); - return row!; - }, - - async saveSteps(steps) { - for (const { runId, stepKey, ...step } of steps) { + .where(eq(processRuns.id, run.id)); + // Only what a run changes: a step's task reference is the database's to clear. + for (const { runId, stepKey, status, finishedAt } of steps) { await orm - .update(processRunSteps) - .set(step) - .where(and(eq(processRunSteps.runId, runId), eq(processRunSteps.stepKey, stepKey))); + .update(runStepProgress) + .set({ status, finishedAt }) + .where(and(eq(runStepProgress.runId, runId), eq(runStepProgress.stepKey, stepKey))); } }, async findSteps(runId) { - return orm.select().from(processRunSteps).where(eq(processRunSteps.runId, runId)); + return orm.select().from(runStepProgress).where(eq(runStepProgress.runId, runId)); + }, + + async findStepsByTaskIds(taskIds) { + if (taskIds.length === 0) return []; + return orm + .select() + .from(runStepProgress) + .where(inArray(runStepProgress.taskId, [...taskIds])); + }, + + async findTaskSources(taskIds) { + if (taskIds.length === 0) return new Map(); + const rows = await orm + .select({ + taskId: runStepProgress.taskId, + processKey: processes.key, + stepKey: runStepProgress.stepKey, + }) + .from(runStepProgress) + .innerJoin(processRuns, eq(processRuns.id, runStepProgress.runId)) + .innerJoin(processes, eq(processes.id, processRuns.processId)) + .where(inArray(runStepProgress.taskId, [...taskIds])); + return new Map(rows.map(({ taskId, ...source }) => [taskId!, source])); }, async listRuns(processId, scope) { diff --git a/packages/api/src/modules/processes/db/processRunsTable.ts b/packages/api/src/core/processes/db/processRunsTable.ts similarity index 89% rename from packages/api/src/modules/processes/db/processRunsTable.ts rename to packages/api/src/core/processes/db/processRunsTable.ts index d39e4a768..2c62bd923 100644 --- a/packages/api/src/modules/processes/db/processRunsTable.ts +++ b/packages/api/src/core/processes/db/processRunsTable.ts @@ -12,7 +12,7 @@ import { uuid, } from "drizzle-orm/pg-core"; -import { jsonb } from "../../../core/db"; +import { jsonb } from "../../db"; import type { CalendarDate } from "../../../utils"; import { ASSIGNEE_NOTES, @@ -91,7 +91,7 @@ export const processRuns = pgTable( ), check( "process_runs_cancel_cause_known", - sql`${table.cancelCause} in ('cancelled_by_admin', 'cancelled_because_step_assignee_was_removed', 'cancelled_because_subject_was_removed')`, + sql`${table.cancelCause} in ('cancelled_because_step_assignee_was_removed', 'cancelled_because_subject_was_removed')`, ), check( "process_runs_columns_follow_status", @@ -104,9 +104,9 @@ export const processRuns = pgTable( ], ); -/** Where each step of a run stands; created with the run, one per step of its version. */ -export const processRunSteps = pgTable( - "process_run_steps", +/** A run's progress on each step of its version's definition, by `step_key`; created with the run. */ +export const runStepProgress = pgTable( + "process_run_step_progress", { runId: uuid("run_id").notNull(), stepKey: text("step_key").notNull(), @@ -120,21 +120,21 @@ export const processRunSteps = pgTable( (table) => [ primaryKey({ columns: [table.runId, table.stepKey] }), foreignKey({ - name: "process_run_steps_run_fk", + name: "process_run_step_progress_run_fk", columns: [table.runId], foreignColumns: [processRuns.id], }).onDelete("cascade"), - check("process_run_steps_key_not_blank", sql`btrim(${table.stepKey}) <> ''`), + check("process_run_step_progress_key_not_blank", sql`btrim(${table.stepKey}) <> ''`), check( - "process_run_steps_status_known", + "process_run_step_progress_status_known", sql`${table.status} in ('step_awaiting_task_completion', 'step_completed', 'step_skipped', 'step_cancelled')`, ), check( - "process_run_steps_assignee_note_known", + "process_run_step_progress_assignee_note_known", sql`${table.assigneeNote} in ('assignee_is_owner_because_subject_has_no_manager')`, ), check( - "process_run_steps_columns_follow_status", + "process_run_step_progress_columns_follow_status", sql`(${table.status} = 'step_awaiting_task_completion') = (${table.finishedAt} is null) and (${table.status} = 'step_skipped') = (${table.skipReason} is not null) and (${table.skipReason} is null or ${table.skipReason} = 'no_assignee_for_step')`, diff --git a/packages/api/src/modules/processes/db/processesTable.ts b/packages/api/src/core/processes/db/processesTable.ts similarity index 100% rename from packages/api/src/modules/processes/db/processesTable.ts rename to packages/api/src/core/processes/db/processesTable.ts diff --git a/packages/api/src/modules/processes/domain/dayOffset.test.ts b/packages/api/src/core/processes/domain/dayOffset.test.ts similarity index 100% rename from packages/api/src/modules/processes/domain/dayOffset.test.ts rename to packages/api/src/core/processes/domain/dayOffset.test.ts diff --git a/packages/api/src/modules/processes/domain/dayOffset.ts b/packages/api/src/core/processes/domain/dayOffset.ts similarity index 76% rename from packages/api/src/modules/processes/domain/dayOffset.ts rename to packages/api/src/core/processes/domain/dayOffset.ts index 797ba60c5..c5e209e70 100644 --- a/packages/api/src/modules/processes/domain/dayOffset.ts +++ b/packages/api/src/core/processes/domain/dayOffset.ts @@ -1,5 +1,5 @@ -import { ApplicationError } from "../../../core/applicationError"; -import { addDays, type CalendarDate, weekdayOf } from "../../../utils"; +import { ApplicationError } from "../../applicationError"; +import { addDays, addWorkingDays, type CalendarDate, weekdayOf } from "../../../utils"; import type { DayOffset } from "./processDefinition"; /** Whether a person works on a date: a day of their working week that is not a bank holiday. */ @@ -17,17 +17,9 @@ export function addDayOffset( if (unit === "calendar_days" || isWorkingDay === null) { return addDays(from, amount); } - const step = Math.sign(amount); - let date = from; - for (let left = Math.abs(amount), searched = 0; left > 0; searched += 1) { - if (searched === MAX_SEARCH_DAYS) { - throw new ApplicationError( - "CONFLICT", - `No working day comes within ${MAX_SEARCH_DAYS} days.`, - ); - } - date = addDays(date, step); - if (isWorkingDay(date)) left -= 1; + const date = addWorkingDays(from, amount, isWorkingDay, MAX_SEARCH_DAYS); + if (date === null) { + throw new ApplicationError("CONFLICT", `No working day comes within ${MAX_SEARCH_DAYS} days.`); } return date; } diff --git a/packages/api/src/modules/processes/domain/eventTrigger.test.ts b/packages/api/src/core/processes/domain/eventTrigger.test.ts similarity index 66% rename from packages/api/src/modules/processes/domain/eventTrigger.test.ts rename to packages/api/src/core/processes/domain/eventTrigger.test.ts index 7a9c57913..dc0a196cf 100644 --- a/packages/api/src/modules/processes/domain/eventTrigger.test.ts +++ b/packages/api/src/core/processes/domain/eventTrigger.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { matchEventStarts } from "./eventTrigger"; +import { eventAboutRecord, matchEventStarts } from "./eventTrigger"; import type { ProcessDefinition } from "./processDefinition"; test("starts each added person in event, process and record order", () => { @@ -14,7 +14,7 @@ test("starts each added person in event, process and record order", () => { payload: { employeeIds: ["sam", "alex", "jo"] }, }; - expect(matchEventStarts(processes, [event])).toEqual([ + expect(matchEventStarts(processes, [event], triggeringEvents)).toEqual([ { processId: "welcome", event, recordId: "sam" }, { processId: "welcome", event, recordId: "alex" }, { processId: "welcome", event, recordId: "jo" }, @@ -29,9 +29,13 @@ test.each(["tasks.task_created", "tasks.task_status_changed"] as const)( (type) => { const event = { id: "event-1", type, payload: { taskId: "task-1" } }; - expect(matchEventStarts([{ processId: "follow_up", trigger: onEvent(type) }], [event])).toEqual( - [{ processId: "follow_up", event, recordId: "task-1" }], - ); + expect( + matchEventStarts( + [{ processId: "follow_up", trigger: onEvent(type) }], + [event], + triggeringEvents, + ), + ).toEqual([{ processId: "follow_up", event, recordId: "task-1" }]); }, ); @@ -51,9 +55,9 @@ test.each([ const event = { id: "event-1", type: "tasks.task_status_changed", payload }; const trigger = onEvent("tasks.task_status_changed", { field: "to", in: accepted }); - expect(matchEventStarts([{ processId: "follow_up", trigger }], [event])).toEqual( - matches ? [{ processId: "follow_up", event, recordId: "task-1" }] : [], - ); + expect( + matchEventStarts([{ processId: "follow_up", trigger }], [event], triggeringEvents), + ).toEqual(matches ? [{ processId: "follow_up", event, recordId: "task-1" }] : []); }); test("does not start a date-triggered process from an event", () => { @@ -66,7 +70,9 @@ test("does not start a date-triggered process from an event", () => { }; const event = { id: "event-1", type: "employees.added", payload: { employeeIds: ["sam"] } }; - expect(matchEventStarts([{ processId: "welcome", trigger }], [event])).toEqual([]); + expect(matchEventStarts([{ processId: "welcome", trigger }], [event], triggeringEvents)).toEqual( + [], + ); }); test("keeps two matching events for the same record as separate starts", () => { @@ -77,7 +83,7 @@ test("keeps two matching events for the same record as separate starts", () => { { processId: "induction", trigger: onEvent("employees.added") }, ]; - expect(matchEventStarts(processes, [first, second])).toEqual([ + expect(matchEventStarts(processes, [first, second], triggeringEvents)).toEqual([ { processId: "welcome", event: first, recordId: "sam" }, { processId: "induction", event: first, recordId: "sam" }, { processId: "welcome", event: second, recordId: "sam" }, @@ -89,10 +95,28 @@ test("does not start a process for an event of another type", () => { const event = { id: "event-1", type: "tasks.task_created", payload: { taskId: "task-1" } }; expect( - matchEventStarts([{ processId: "welcome", trigger: onEvent("employees.added") }], [event]), + matchEventStarts( + [{ processId: "welcome", trigger: onEvent("employees.added") }], + [event], + triggeringEvents, + ), ).toEqual([]); }); +test("keeps only the selected record in a multi-record event's payload", () => { + const event = { + id: "event-1", + type: "employees.added", + payload: { employeeIds: ["sam", "alex"] }, + }; + + expect(eventAboutRecord(event, "alex", triggeringEvents)).toEqual({ + id: "event-1", + type: "employees.added", + payload: { employeeIds: ["alex"] }, + }); +}); + function onEvent( event: "employees.added" | "tasks.task_created" | "tasks.task_status_changed", when?: { field: string; in: (string | null)[] }, @@ -105,3 +129,21 @@ function onEvent( ...(when && { when }), }; } + +const triggeringEvents = { + "employees.added": { + recordType: "employee", + recordIds: (payload: unknown) => (payload as { employeeIds: string[] }).employeeIds, + payloadFor: (_payload: unknown, recordId: string) => ({ employeeIds: [recordId] }), + }, + "tasks.task_created": { + recordType: "task", + recordIds: (payload: unknown) => [(payload as { taskId: string }).taskId], + payloadFor: (payload: unknown) => payload, + }, + "tasks.task_status_changed": { + recordType: "task", + recordIds: (payload: unknown) => [(payload as { taskId: string }).taskId], + payloadFor: (payload: unknown) => payload, + }, +}; diff --git a/packages/api/src/core/processes/domain/eventTrigger.ts b/packages/api/src/core/processes/domain/eventTrigger.ts new file mode 100644 index 000000000..37ff2a7c7 --- /dev/null +++ b/packages/api/src/core/processes/domain/eventTrigger.ts @@ -0,0 +1,67 @@ +import type { EventTrigger, ProcessDefinition } from "./processDefinition"; + +/** + * What an event that may start a process means to processes, registered by the module that + * publishes it: the record type it is about, the ids of those records in its payload (one run per + * record), and the payload a run keeps for its own record. + */ +export interface TriggeringEventRegistration { + recordType: string; + recordIds(payload: unknown): readonly string[]; + payloadFor(payload: unknown, recordId: string): unknown; +} + +/** The events that may start a process, by type. */ +export type TriggeringEvents = Readonly>; + +/** An event as a trigger reads it. */ +export interface TriggerEvent { + id: string; + type: string; + payload: unknown; +} + +/** One run to start: a process, the event that triggered it and one record the event is about. */ +export interface EventStart { + processId: string; + event: Event; + recordId: string; +} + +/** + * The runs the events start, one per matching process and record, in the order of the events, + * then the processes, then the event's records. + */ +export function matchEventStarts( + processes: readonly { processId: string; trigger: ProcessDefinition["trigger"] }[], + events: readonly Event[], + triggeringEvents: TriggeringEvents, +): EventStart[] { + return events.flatMap((event) => + processes.flatMap(({ processId, trigger }) => + trigger.kind === "on_event" && isTriggeredBy(trigger, event) + ? (triggeringEvents[event.type]?.recordIds(event.payload) ?? []).map((recordId) => ({ + processId, + event, + recordId, + })) + : [], + ), + ); +} + +/** The event as it concerns one of its records, as that record's run keeps it. */ +export function eventAboutRecord( + { id, type, payload }: TriggerEvent, + recordId: string, + triggeringEvents: TriggeringEvents, +): TriggerEvent { + return { id, type, payload: triggeringEvents[type]!.payloadFor(payload, recordId) }; +} + +function isTriggeredBy(trigger: EventTrigger, { type, payload }: TriggerEvent): boolean { + if (type !== trigger.event) return false; + if (!trigger.when) return true; + const value = (payload as Record)[trigger.when.field] ?? null; + return trigger.when.in.some((expected) => expected === value); +} diff --git a/packages/api/src/modules/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts similarity index 84% rename from packages/api/src/modules/processes/domain/index.ts rename to packages/api/src/core/processes/domain/index.ts index ee029cd2e..3f614bd21 100644 --- a/packages/api/src/modules/processes/domain/index.ts +++ b/packages/api/src/core/processes/domain/index.ts @@ -3,13 +3,15 @@ export { dueTriggerDate } from "./recordDateTrigger"; export { eventAboutRecord, type EventStart, - isTriggeringEvent, matchEventStarts, type TriggerEvent, + type TriggeringEventRegistration, + type TriggeringEvents, } from "./eventTrigger"; export { type DayOffset, parseProcessDefinition, + type ProcessCatalog, PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessDefinition, type RecordDateTrigger, @@ -20,18 +22,15 @@ export { ASSIGNEE_NOTES, CANCEL_CAUSES, type CancelCause, - cancelRun, - completeStep, PROCESS_CANCELLED, - type ProcessRun, - removalCancellation, - reopenStep, + openTaskIdsOf, + ProcessRun, + type ProcessRunFields, resolveAssignee, type ResolvedAssignee, RUN_STATUSES, type RunStatus, type RunStep, - settleRun, SKIP_REASONS, START_CAUSES, SUBJECT_REMOVED, diff --git a/packages/api/src/modules/processes/domain/processDefinition.test.ts b/packages/api/src/core/processes/domain/processDefinition.test.ts similarity index 62% rename from packages/api/src/modules/processes/domain/processDefinition.test.ts rename to packages/api/src/core/processes/domain/processDefinition.test.ts index 3bcd050d6..7b4b4c996 100644 --- a/packages/api/src/modules/processes/domain/processDefinition.test.ts +++ b/packages/api/src/core/processes/domain/processDefinition.test.ts @@ -5,13 +5,13 @@ import { parseProcessDefinition, PROCESS_DEFINITION_SCHEMA_VERSION } from "./pro test("reads a welcome definition with its trigger, fallback and parallel steps", () => { const definition = welcomeDefinition(); - const parsed = parseProcessDefinition(PROCESS_DEFINITION_SCHEMA_VERSION, definition); + const parsed = parseProcessDefinition(PROCESS_DEFINITION_SCHEMA_VERSION, definition, catalog); expect(parsed).toEqual(definition); }); test("refuses an unknown definition schema version", () => { - expect(() => parseProcessDefinition(2, welcomeDefinition())).toThrow( + expect(() => parseProcessDefinition(2, welcomeDefinition(), catalog)).toThrow( "Process definition schema version 2 is not known.", ); }); @@ -20,7 +20,7 @@ test("refuses an unbuilt record-created trigger", () => { const definition = welcomeDefinition(); definition.trigger.kind = "on_record_created"; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "Trigger kind on_record_created is not built.", ); }); @@ -29,7 +29,7 @@ test("refuses an unbuilt record-changed trigger", () => { const definition = welcomeDefinition(); definition.trigger.kind = "on_record_changed"; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "Trigger kind on_record_changed is not built.", ); }); @@ -38,7 +38,7 @@ test("refuses a schedule trigger until it is supported", () => { const definition = welcomeDefinition(); definition.trigger.kind = "on_schedule"; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "Trigger kind on_schedule is not built.", ); }); @@ -47,7 +47,7 @@ test("refuses duplicate step keys", () => { const definition = welcomeDefinition(); definition.steps[0]!.key = "coffee_calls"; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "Step key coffee_calls is used twice.", ); }); @@ -56,16 +56,16 @@ test("refuses a trigger more than 366 calendar days after its date", () => { const definition = welcomeDefinition(); definition.trigger.offset.amount = 400; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "An offset of 400 calendar_days is beyond the limit of 366.", ); }); test("refuses a task due more than 366 calendar days before the start", () => { const definition = welcomeDefinition(); - definition.steps[0]!.due.amount = -367; + definition.steps[0]!.task.due.amount = -367; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "An offset of -367 calendar_days is beyond the limit of 366.", ); }); @@ -74,16 +74,16 @@ test("refuses a trigger more than 260 business days before its date", () => { const definition = welcomeDefinition(); definition.trigger.offset = { amount: -261, unit: "business_days" }; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "An offset of -261 business_days is beyond the limit of 260.", ); }); test("refuses a task due more than 260 business days after the start", () => { const definition = welcomeDefinition(); - definition.steps[1]!.due.amount = 261; + definition.steps[1]!.task.due.amount = 261; - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "An offset of 261 business_days is beyond the limit of 260.", ); }); @@ -91,31 +91,35 @@ test("refuses a task due more than 260 business days after the start", () => { test("accepts offsets at the calendar and business day limits", () => { const definition = welcomeDefinition(); definition.trigger.offset.amount = -366; - definition.steps[0]!.due.amount = 366; - definition.steps[1]!.due.amount = 260; + definition.steps[0]!.task.due.amount = 366; + definition.steps[1]!.task.due.amount = 260; - expect(parseProcessDefinition(1, definition)).toEqual(definition); + expect(parseProcessDefinition(1, definition, catalog)).toEqual(definition); }); test("refuses an unknown assignee kind", () => { const definition = welcomeDefinition(); - definition.steps[0]!.assignee.kind = "department"; + definition.steps[0]!.task.assignee.kind = "department"; - expect(() => parseProcessDefinition(1, definition)).toThrow(); + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow(); }); -test("refuses a subject record other than an employee", () => { +test("refuses an unregistered subject record type", () => { const definition = welcomeDefinition(); - definition.subject.recordType = "company"; + definition.subject.recordType = "leave_request"; - expect(() => parseProcessDefinition(1, definition)).toThrow(); + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( + "Record type leave_request cannot be the subject of a process.", + ); }); -test("refuses a trigger date other than the employee's start date", () => { +test("refuses a date field not registered for the record type", () => { const definition = welcomeDefinition(); - definition.trigger.dateField = "birthday"; + definition.trigger.dateField = "endDate"; - expect(() => parseProcessDefinition(1, definition)).toThrow(); + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( + "Record type employee has no date field endDate.", + ); }); test.each([ @@ -125,11 +129,11 @@ test.each([ ])("accepts %s as an event trigger for %s records", (event, recordType) => { const definition = eventDefinition(event, recordType); - expect(parseProcessDefinition(1, definition)).toEqual(definition); + expect(parseProcessDefinition(1, definition, catalog)).toEqual(definition); }); test("refuses an event that cannot trigger a process", () => { - expect(() => parseProcessDefinition(1, eventDefinition("example.happened"))).toThrow( + expect(() => parseProcessDefinition(1, eventDefinition("example.happened"), catalog)).toThrow( "Event example.happened cannot trigger a process.", ); }); @@ -138,21 +142,21 @@ test("refuses an event condition without any accepted values", () => { const definition = eventDefinition("tasks.task_status_changed", "task"); const trigger = { ...definition.trigger, when: { field: "to", in: [] } }; - expect(() => parseProcessDefinition(1, { ...definition, trigger })).toThrow(); + expect(() => parseProcessDefinition(1, { ...definition, trigger }, catalog)).toThrow(); }); test("refuses an event trigger that runs after commit", () => { const definition = eventDefinition("employees.added"); definition.trigger.run = "after_commit"; - expect(() => parseProcessDefinition(1, definition)).toThrow(); + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow(); }); test("refuses an event trigger that silently ignores failures", () => { const definition = eventDefinition("employees.added"); definition.trigger.faultMode = "silent"; - expect(() => parseProcessDefinition(1, definition)).toThrow(); + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow(); }); test("refuses an event trigger without a run mode", () => { @@ -160,17 +164,21 @@ test("refuses an event trigger without a run mode", () => { const { kind, event, faultMode } = definition.trigger; expect(() => - parseProcessDefinition(1, { - ...definition, - trigger: { kind, event, faultMode }, - }), + parseProcessDefinition( + 1, + { + ...definition, + trigger: { kind, event, faultMode }, + }, + catalog, + ), ).toThrow(); }); test("refuses a subject differing from the triggering event's record type", () => { const definition = eventDefinition("tasks.task_created", "employee"); - expect(() => parseProcessDefinition(1, definition)).toThrow( + expect(() => parseProcessDefinition(1, definition, catalog)).toThrow( "The subject employee differs from the record type task of event tasks.task_created.", ); }); @@ -198,24 +206,36 @@ function welcomeDefinition() { offset: { amount: 0, unit: "calendar_days" }, maxDaysLate: 7, }, - startableByAdmin: true, steps: [ { key: "welcome_one_to_one", kind: "task", - assignee: { kind: "subjects_manager", otherwise: { kind: "workspace_owner" } }, - title: "Have a welcome 1:1 with {firstName}", - description: "Welcome the new person.", - due: { amount: 0, unit: "calendar_days" }, + task: { + assignee: { kind: "subjects_manager", otherwise: { kind: "workspace_owner" } }, + title: "Have a welcome 1:1 with {firstName}", + description: "Welcome the new person.", + due: { amount: 0, unit: "calendar_days" }, + }, }, { key: "coffee_calls", kind: "task", - assignee: { kind: "subject" }, - title: "Have coffee calls with 3 colleagues", - description: "Meet the team.", - due: { amount: 5, unit: "business_days" }, + task: { + assignee: { kind: "subject" }, + title: "Have coffee calls with 3 colleagues", + description: "Meet the team.", + due: { amount: 5, unit: "business_days" }, + }, }, ], }; } + +const catalog = { + recordTypes: { employee: { dateFields: ["startDate"] }, task: { dateFields: [] } }, + triggeringEvents: { + "employees.added": { recordType: "employee" }, + "tasks.task_created": { recordType: "task" }, + "tasks.task_status_changed": { recordType: "task" }, + }, +}; diff --git a/packages/api/src/modules/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts similarity index 66% rename from packages/api/src/modules/processes/domain/processDefinition.ts rename to packages/api/src/core/processes/domain/processDefinition.ts index 112e492ca..42f74c739 100644 --- a/packages/api/src/modules/processes/domain/processDefinition.ts +++ b/packages/api/src/core/processes/domain/processDefinition.ts @@ -1,42 +1,33 @@ import { z } from "zod"; -import { isTriggeringEvent, TRIGGERING_EVENTS, type TriggeringEventType } from "./eventTrigger"; - /** The shape of `process_versions.definition` this code reads; a migration moves older shapes on. */ export const PROCESS_DEFINITION_SCHEMA_VERSION = 1; /** The ready-made process that welcomes a new starter on their start date. */ export const WELCOME_NEW_STARTER = "welcome_new_starter"; -const OFFSET_LIMITS = { calendar_days: 366, business_days: 260 } as const; - -/** Days before (negative) or after (positive) a date. */ -export interface DayOffset { - amount: number; - unit: keyof typeof OFFSET_LIMITS; +/** What one version of a process does; its steps run in parallel, listed in display order. */ +export interface ProcessDefinition { + /** The record a run is about, the trigger's record type. */ + subject: { recordType: string }; + trigger: RecordDateTrigger | EventTrigger; + steps: TaskStep[]; } -/** Who will be a step task's assignee, and who covers when there is no one. */ -export type AssigneeRule = { - kind: "subject" | "subjects_manager" | "workspace_owner"; - otherwise?: AssigneeRule; -}; - -/** A step that gives one person a task; `title` and `description` may name `{firstName}`. */ -export interface TaskStep { - key: string; - kind: "task"; - assignee: AssigneeRule; - title: string; - description: string | null; - due: DayOffset; +/** What the modules registered that a definition may name. */ +export interface ProcessCatalog { + /** Record types a run may be about, with the date fields an `on_record_date` trigger may name. */ + recordTypes: Readonly>; + /** Events that may start a run, with the record type each is about. */ + triggeringEvents: Readonly>; } /** Starts a run on a date of the subject record, plus an offset, by a daily pass. */ export interface RecordDateTrigger { kind: "on_record_date"; - recordType: "employee"; - dateField: "startDate"; + recordType: string; + /** One of the date fields its record type registered, such as an employee's `startDate`. */ + dateField: string; offset: DayOffset; /** How many days after that date a missed run still starts. */ maxDaysLate: number; @@ -48,36 +39,57 @@ export interface RecordDateTrigger { */ export interface EventTrigger { kind: "on_event"; - event: TriggeringEventType; + /** An event a module registered as triggering. */ + event: string; /** Only events whose payload field holds one of these values. */ when?: { field: string; in: (string | null)[] }; run: "in_same_transaction"; faultMode: "rollback_transaction"; } -/** What one version of a process does; its steps run in parallel, listed in display order. */ -export interface ProcessDefinition { - /** The record a run is about, the trigger's record type. */ - subject: { recordType: "employee" | "task" }; - trigger: RecordDateTrigger | EventTrigger; - startableByAdmin: boolean; - steps: TaskStep[]; +/** A step that gives one person a task. */ +export interface TaskStep { + key: string; + kind: "task"; + task: StepTask; +} + +/** The task a task step gives; `title` and `description` may name `{firstName}`. */ +export interface StepTask { + assignee: AssigneeRule; + title: string; + description: string | null; + due: DayOffset; +} + +/** Days before (negative) or after (positive) a date. */ +export interface DayOffset { + amount: number; + unit: keyof typeof OFFSET_LIMITS; } -/** Reads a stored definition, refusing a shape, trigger or step this code cannot run. */ +/** Who will be a step task's assignee, and who covers when there is no one. */ +export type AssigneeRule = { + kind: "subject" | "subjects_manager" | "workspace_owner"; + otherwise?: AssigneeRule; +}; + +/** Reads a stored definition, refusing a shape, trigger, record type or step this code cannot run. */ export function parseProcessDefinition( schemaVersion: number, definition: unknown, + catalog: ProcessCatalog, ): ProcessDefinition { if (schemaVersion !== PROCESS_DEFINITION_SCHEMA_VERSION) { throw new Error(`Process definition schema version ${schemaVersion} is not known.`); } - refuseUnbuiltTrigger(definition); + refuseUnbuiltTrigger(definition, catalog); const parsed: ProcessDefinition = definitionSchema.parse(definition); - refuseSubjectOtherThanTrigger(parsed); + refuseUnregisteredRecord(parsed, catalog); + refuseSubjectOtherThanTrigger(parsed, catalog); refuseRepeatedStepKeys(parsed.steps); const triggerOffsets = parsed.trigger.kind === "on_record_date" ? [parsed.trigger.offset] : []; - for (const offset of [...triggerOffsets, ...parsed.steps.map(({ due }) => due)]) { + for (const offset of [...triggerOffsets, ...parsed.steps.map(({ task }) => task.due)]) { refuseOffsetBeyondLimit(offset); } return parsed; @@ -85,20 +97,38 @@ export function parseProcessDefinition( const UNBUILT_TRIGGER_KINDS = new Set(["on_record_created", "on_record_changed", "on_schedule"]); -function refuseUnbuiltTrigger(definition: unknown): void { +function refuseUnbuiltTrigger(definition: unknown, { triggeringEvents }: ProcessCatalog): void { const trigger = (definition as { trigger?: { kind?: unknown; event?: unknown } } | null)?.trigger; if (typeof trigger?.kind === "string" && UNBUILT_TRIGGER_KINDS.has(trigger.kind)) { throw new Error(`Trigger kind ${trigger.kind} is not built.`); } - if (trigger?.kind === "on_event" && !isTriggeringEvent(String(trigger.event))) { + if (trigger?.kind === "on_event" && !Object.hasOwn(triggeringEvents, String(trigger.event))) { throw new Error(`Event ${String(trigger.event)} cannot trigger a process.`); } } -function refuseSubjectOtherThanTrigger({ subject, trigger }: ProcessDefinition): void { +function refuseUnregisteredRecord( + { subject, trigger }: ProcessDefinition, + { recordTypes }: ProcessCatalog, +): void { + if (!Object.hasOwn(recordTypes, subject.recordType)) { + throw new Error(`Record type ${subject.recordType} cannot be the subject of a process.`); + } + if ( + trigger.kind === "on_record_date" && + !recordTypes[trigger.recordType]?.dateFields.includes(trigger.dateField) + ) { + throw new Error(`Record type ${trigger.recordType} has no date field ${trigger.dateField}.`); + } +} + +function refuseSubjectOtherThanTrigger( + { subject, trigger }: ProcessDefinition, + { triggeringEvents }: ProcessCatalog, +): void { const [recordType, source] = trigger.kind === "on_event" - ? [TRIGGERING_EVENTS[trigger.event].recordType, `event ${trigger.event}`] + ? [triggeringEvents[trigger.event]!.recordType, `event ${trigger.event}`] : [trigger.recordType, `trigger ${trigger.kind}`]; if (subject.recordType !== recordType) { throw new Error( @@ -123,6 +153,8 @@ function refuseOffsetBeyondLimit({ amount, unit }: DayOffset): void { } } +const OFFSET_LIMITS = { calendar_days: 366, business_days: 260 } as const; + const offsetSchema = z.object({ amount: z.number().int(), unit: z.enum(["calendar_days", "business_days"]), @@ -138,14 +170,14 @@ const assigneeRuleSchema: z.ZodType = z.lazy(() => const triggerSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("on_record_date"), - recordType: z.literal("employee"), - dateField: z.literal("startDate"), + recordType: z.string().min(1), + dateField: z.string().min(1), offset: offsetSchema, maxDaysLate: z.number().int().min(0), }), z.object({ kind: z.literal("on_event"), - event: z.enum(Object.keys(TRIGGERING_EVENTS) as [TriggeringEventType]), + event: z.string().min(1), when: z .object({ field: z.string().trim().min(1), @@ -158,18 +190,19 @@ const triggerSchema = z.discriminatedUnion("kind", [ ]); const definitionSchema = z.object({ - subject: z.object({ recordType: z.enum(["employee", "task"]) }), + subject: z.object({ recordType: z.string().min(1) }), trigger: triggerSchema, - startableByAdmin: z.boolean(), steps: z .array( z.object({ key: z.string().trim().min(1), kind: z.literal("task"), - assignee: assigneeRuleSchema, - title: z.string().trim().min(1), - description: z.string().nullable().default(null), - due: offsetSchema, + task: z.object({ + assignee: assigneeRuleSchema, + title: z.string().trim().min(1), + description: z.string().nullable().default(null), + due: offsetSchema, + }), }), ) .min(1), diff --git a/packages/api/src/core/processes/domain/processRun.test.ts b/packages/api/src/core/processes/domain/processRun.test.ts new file mode 100644 index 000000000..ae00b8165 --- /dev/null +++ b/packages/api/src/core/processes/domain/processRun.test.ts @@ -0,0 +1,251 @@ +import { expect, test } from "vitest"; + +import { parseCalendarDate } from "../../../utils"; +import { ProcessRun, type RunStatus, type RunStep } from "./processRun"; + +test("completes one task while another keeps the run in progress", () => { + const run = runIn("in_progress", [step("handbook"), step("equipment")]); + const original = structuredClone({ ...run }); + const now = new Date("2026-11-09T12:00:00Z"); + + const completed = run.completeTasks(new Set(["task-handbook"]), now, true); + + expect(completed).toBeInstanceOf(ProcessRun); + expect(completed).not.toBe(run); + expect(completed).toMatchObject({ status: "in_progress", finishedAt: null }); + expect(completed.steps).toEqual([ + { ...original.steps[0], status: "step_completed", finishedAt: now }, + original.steps[1], + ]); + expect(run).toEqual(original); +}); + +test("finishes the run when its last task completes", () => { + const run = runIn("in_progress", [step("handbook", "step_completed"), step("equipment")]); + const now = new Date("2026-11-09T12:00:00Z"); + + const finished = run.completeTasks(new Set(["task-equipment"]), now, true); + + expect(finished).toMatchObject({ status: "finished", finishedAt: now }); + expect(finished.steps).toEqual([ + run.steps[0], + { ...run.steps[1], status: "step_completed", finishedAt: now }, + ]); +}); + +test("counts a skipped step as done when finishing the run", () => { + const run = runIn("in_progress", [step("handbook", "step_skipped"), step("equipment")]); + const now = new Date("2026-11-09T12:00:00Z"); + + const finished = run.completeTasks(new Set(["task-equipment"]), now, true); + + expect(finished).toMatchObject({ status: "finished", finishedAt: now }); + expect(finished.steps[0]).toEqual(run.steps[0]); +}); + +test("completes a task but keeps a run that cannot go on in progress", () => { + const run = runIn("in_progress", [step("handbook")]); + const now = new Date("2026-11-09T12:00:00Z"); + + const completed = run.completeTasks(new Set(["task-handbook"]), now, false); + + expect(completed).toMatchObject({ status: "in_progress", finishedAt: null }); + expect(completed.steps).toEqual([{ ...run.steps[0], status: "step_completed", finishedAt: now }]); +}); + +test("completes all matching tasks together", () => { + const run = runIn("in_progress", [step("handbook"), step("equipment")]); + const now = new Date("2026-11-09T12:00:00Z"); + + const finished = run.completeTasks(new Set(["task-handbook", "task-equipment"]), now, true); + + expect(finished).toMatchObject({ status: "finished", finishedAt: now }); + expect(finished.steps).toEqual([ + { ...run.steps[0], status: "step_completed", finishedAt: now }, + { ...run.steps[1], status: "step_completed", finishedAt: now }, + ]); +}); + +test("leaves steps not awaiting tasks unchanged when their tasks complete", () => { + const run = runIn("in_progress", [ + step("handbook", "step_completed"), + step("equipment", "step_skipped"), + step("introduction", "step_cancelled"), + step("training"), + ]); + const original = structuredClone({ ...run }); + + const completed = run.completeTasks( + new Set(["task-handbook", "task-introduction", "unrelated-task"]), + new Date("2026-11-09T12:00:00Z"), + true, + ); + + expect(completed.steps).toEqual(original.steps); + expect(completed.status).toBe("in_progress"); + expect(run).toEqual(original); +}); + +test("refuses to complete tasks of a cancelled run", () => { + const run = runIn("cancelled", [step("handbook", "step_cancelled")]); + + expect(() => + run.completeTasks(new Set(["task-handbook"]), new Date("2026-11-09T12:00:00Z"), true), + ).toThrowError( + expect.objectContaining({ code: "CONFLICT", message: "This process was cancelled." }), + ); +}); + +test("reports a removed subject before a missing step task", () => { + const run = runIn("in_progress", [{ ...step("handbook"), taskId: null }]); + + expect(run.removal(false)).toEqual({ + cause: "cancelled_because_subject_was_removed", + stepKey: null, + }); +}); + +test("reports an awaiting step whose task is gone", () => { + const run = runIn("in_progress", [step("handbook"), { ...step("equipment"), taskId: null }]); + + expect(run.removal(true)).toEqual({ + cause: "cancelled_because_step_assignee_was_removed", + stepKey: "equipment", + }); +}); + +test("does not cancel for a completed step whose task is gone", () => { + const run = runIn("in_progress", [ + { ...step("handbook", "step_completed"), taskId: null }, + step("equipment"), + ]); + + expect(run.removal(true)).toBeNull(); +}); + +test.each(["finished", "cancelled"] as const)("does not report removal for a %s run", (status) => { + const run = runIn(status, [ + { + ...step("handbook", status === "finished" ? "step_completed" : "step_cancelled"), + taskId: null, + }, + ]); + + expect(run.removal(false)).toBeNull(); +}); + +test("cancels awaiting steps and keeps completed and skipped steps", () => { + const run = runIn("in_progress", [ + step("handbook"), + step("equipment", "step_completed"), + step("introduction", "step_skipped"), + { ...step("training"), taskId: null }, + ]); + const original = structuredClone({ ...run }); + const now = new Date("2026-11-09T12:00:00Z"); + + const cancelled = run.cancel({ + cause: "cancelled_because_subject_was_removed", + reason: "The person this process is about was removed from Broccoli.", + byEmployeeId: "employee-admin", + now, + }); + + expect(cancelled).toBeInstanceOf(ProcessRun); + expect(cancelled).not.toBe(run); + expect(cancelled).toMatchObject({ + status: "cancelled", + finishedAt: null, + cancelledAt: now, + cancelledByEmployeeId: "employee-admin", + cancelCause: "cancelled_because_subject_was_removed", + cancelReason: "The person this process is about was removed from Broccoli.", + }); + expect(cancelled.steps).toEqual([ + { ...original.steps[0], status: "step_cancelled", finishedAt: now }, + original.steps[1], + original.steps[2], + { ...original.steps[3], status: "step_cancelled", finishedAt: now }, + ]); + expect(run).toEqual(original); +}); + +test.each(["finished", "cancelled"] as const)("refuses to cancel a %s run", (status) => { + const run = runIn(status, []); + + expect(() => + run.cancel({ + cause: "cancelled_because_subject_was_removed", + reason: "The person this process is about was removed from Broccoli.", + byEmployeeId: null, + now: new Date("2026-11-09T12:00:00Z"), + }), + ).toThrowError( + expect.objectContaining({ + code: "CONFLICT", + message: "Only a run in progress can be cancelled.", + }), + ); +}); + +test("lists only the tasks of awaiting steps that still have a task", () => { + const run = runIn("in_progress", [ + step("handbook"), + step("equipment", "step_completed"), + step("introduction", "step_skipped"), + step("training", "step_cancelled"), + { ...step("access"), taskId: null }, + step("welcome"), + ]); + + expect(run.openTaskIds()).toEqual(["task-handbook", "task-welcome"]); +}); + +function runIn(status: RunStatus, steps: readonly RunStep[]): ProcessRun { + const createdAt = new Date("2026-11-01T12:00:00Z"); + const closedAt = new Date("2026-11-08T12:00:00Z"); + return ProcessRun.restore( + { + id: "run-onboarding", + processId: "process-onboarding", + processVersionId: "version-onboarding", + recordType: "employee", + recordId: "employee-subject", + subjectEmployeeId: "employee-subject", + startCause: "started_by_admin", + startedByEmployeeId: "employee-admin", + triggerDate: null, + startKey: null, + triggerEvent: null, + startDay: parseCalendarDate("2026-11-01")!, + status, + finishedAt: status === "finished" ? closedAt : null, + cancelledAt: status === "cancelled" ? closedAt : null, + cancelledByEmployeeId: null, + cancelCause: status === "cancelled" ? "cancelled_because_subject_was_removed" : null, + cancelReason: + status === "cancelled" + ? "The person this process is about was removed from Broccoli." + : null, + createdAt, + updatedAt: createdAt, + }, + steps, + ); +} + +function step( + stepKey: string, + status: RunStep["status"] = "step_awaiting_task_completion", +): RunStep { + return { + runId: "run-onboarding", + stepKey, + status, + taskId: status === "step_skipped" ? null : `task-${stepKey}`, + assigneeNote: null, + skipReason: status === "step_skipped" ? "no_assignee_for_step" : null, + finishedAt: + status === "step_awaiting_task_completion" ? null : new Date("2026-11-08T12:00:00Z"), + }; +} diff --git a/packages/api/src/core/processes/domain/processRun.ts b/packages/api/src/core/processes/domain/processRun.ts new file mode 100644 index 000000000..cb14cb950 --- /dev/null +++ b/packages/api/src/core/processes/domain/processRun.ts @@ -0,0 +1,222 @@ +import { ApplicationError } from "../../applicationError"; +import type { CalendarDate } from "../../../utils"; +import type { TriggerEvent } from "./eventTrigger"; +import type { AssigneeRule } from "./processDefinition"; + +export const RUN_STATUSES = ["in_progress", "finished", "cancelled"] as const; +export type RunStatus = (typeof RUN_STATUSES)[number]; + +export const START_CAUSES = [ + "started_on_record_date", + "started_by_admin", + "started_on_event", +] as const; +export type StartCause = (typeof START_CAUSES)[number]; + +export const CANCEL_CAUSES = [ + "cancelled_because_step_assignee_was_removed", + "cancelled_because_subject_was_removed", +] as const; +export type CancelCause = (typeof CANCEL_CAUSES)[number]; + +export const STEP_STATUSES = [ + "step_awaiting_task_completion", + "step_completed", + "step_skipped", + "step_cancelled", +] as const; +export type StepStatus = (typeof STEP_STATUSES)[number]; + +export const ASSIGNEE_NOTES = ["assignee_is_owner_because_subject_has_no_manager"] as const; +export type AssigneeNote = (typeof ASSIGNEE_NOTES)[number]; + +export const SKIP_REASONS = ["no_assignee_for_step"] as const; +export type SkipReason = (typeof SKIP_REASONS)[number]; + +/** Why a run whose subject was removed stops, on the run for admins. */ +export const SUBJECT_REMOVED = "The person this process is about was removed from Broccoli."; + +/** What the tasks of a run cancelled for a removal tell their assignees; the detail stays on the run. */ +export const PROCESS_CANCELLED = "This process was cancelled."; + +/** One run of a process version for one record, as stored. */ +export interface ProcessRunFields { + id: string; + processId: string; + processVersionId: string; + recordType: string; + recordId: string; + /** The employee the run is about, which permission scopes filter on; null for a record about nobody. */ + subjectEmployeeId: string | null; + startCause: StartCause; + startedByEmployeeId: string | null; + /** The date the trigger fired for; with the process, `startKey` keeps it from starting twice. */ + triggerDate: CalendarDate | null; + startKey: string | null; + /** The event that started the run, for `started_on_event`; steps read who acted from it. */ + triggerEvent: TriggerEvent | null; + /** The day due dates count from. */ + startDay: CalendarDate; + status: RunStatus; + finishedAt: Date | null; + cancelledAt: Date | null; + cancelledByEmployeeId: string | null; + cancelCause: CancelCause | null; + cancelReason: string | null; + createdAt: Date; + updatedAt: Date; +} + +/** Where one step of a run stands; its task, once created, is the work. */ +export interface RunStep { + runId: string; + stepKey: string; + status: StepStatus; + /** Null for a skipped step, and once the task's assignee was removed. */ + taskId: string | null; + assigneeNote: AssigneeNote | null; + skipReason: SkipReason | null; + finishedAt: Date | null; +} + +/** The person a step's task goes to, and why, when it is not the obvious one. */ +export interface ResolvedAssignee { + employeeId: string; + note: AssigneeNote | null; +} + +/** + * Follows a step's assignee rule and its fallbacks to the first kind with a person; null when none + * has one, so the step is skipped. + */ +export function resolveAssignee( + rule: AssigneeRule, + candidates: Readonly>, +): ResolvedAssignee | null { + const employeeId = candidates[rule.kind]; + if (employeeId !== null) { + return { employeeId, note: null }; + } + const fallback = rule.otherwise ? resolveAssignee(rule.otherwise, candidates) : null; + if (fallback && rule.kind === "subjects_manager" && rule.otherwise?.kind === "workspace_owner") { + return { ...fallback, note: "assignee_is_owner_because_subject_has_no_manager" }; + } + return fallback; +} + +/** + * A run with its steps, and the rules of its life: its steps complete as their tasks are done, it + * finishes when none awaits a task, and it is cancelled when it can no longer run. Every change + * returns a new run; the original stays. + */ +export class ProcessRun implements Readonly { + declare readonly id: string; + declare readonly processId: string; + declare readonly processVersionId: string; + declare readonly recordType: string; + declare readonly recordId: string; + declare readonly subjectEmployeeId: string | null; + declare readonly startCause: StartCause; + declare readonly startedByEmployeeId: string | null; + declare readonly triggerDate: CalendarDate | null; + declare readonly startKey: string | null; + declare readonly triggerEvent: TriggerEvent | null; + declare readonly startDay: CalendarDate; + declare readonly status: RunStatus; + declare readonly finishedAt: Date | null; + declare readonly cancelledAt: Date | null; + declare readonly cancelledByEmployeeId: string | null; + declare readonly cancelCause: CancelCause | null; + declare readonly cancelReason: string | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; + + readonly steps: readonly RunStep[]; + + private constructor(fields: ProcessRunFields, steps: readonly RunStep[]) { + Object.assign(this, fields); + this.steps = steps; + } + + /** The run and its steps, as stored. */ + static restore(fields: ProcessRunFields, steps: readonly RunStep[]): ProcessRun { + return new ProcessRun(fields, steps); + } + + /** The tasks of steps still awaiting them. */ + openTaskIds(): string[] { + return openTaskIdsOf(this.steps); + } + + /** + * Completes the steps awaiting these tasks, and finishes the run once no step awaits a task; a + * run that cannot go on stays in progress, for the daily check to cancel. + */ + completeTasks(taskIds: ReadonlySet, now: Date, canGoOn: boolean): ProcessRun { + if (this.status === "cancelled") { + throw new ApplicationError("CONFLICT", PROCESS_CANCELLED); + } + const steps = this.steps.map((step) => + step.status === "step_awaiting_task_completion" && step.taskId && taskIds.has(step.taskId) + ? { ...step, status: "step_completed" as const, finishedAt: now } + : step, + ); + const allDone = steps.every( + ({ status }) => status === "step_completed" || status === "step_skipped", + ); + if (!canGoOn || !allDone || this.status === "finished") return this.with({}, steps); + return this.with({ status: "finished", finishedAt: now }, steps); + } + + /** + * Why a run in progress can no longer run, if it cannot: its subject is gone, or a step still + * awaiting its task lost it with its assignee. A step done before its assignee left changes nothing. + */ + removal(subjectExists: boolean): { cause: CancelCause; stepKey: string | null } | null { + if (this.status !== "in_progress") return null; + if (!subjectExists) return { cause: "cancelled_because_subject_was_removed", stepKey: null }; + const orphan = this.steps.find( + ({ status, taskId }) => status === "step_awaiting_task_completion" && taskId === null, + ); + return orphan + ? { cause: "cancelled_because_step_assignee_was_removed", stepKey: orphan.stepKey } + : null; + } + + /** Cancels a run in progress: its awaiting steps are cancelled, done steps stay. */ + cancel(cancellation: { + cause: CancelCause; + reason: string; + byEmployeeId: string | null; + now: Date; + }): ProcessRun { + if (this.status !== "in_progress") { + throw new ApplicationError("CONFLICT", "Only a run in progress can be cancelled."); + } + return this.with( + { + status: "cancelled", + cancelledAt: cancellation.now, + cancelledByEmployeeId: cancellation.byEmployeeId, + cancelCause: cancellation.cause, + cancelReason: cancellation.reason, + }, + this.steps.map((step) => + step.status === "step_awaiting_task_completion" + ? { ...step, status: "step_cancelled", finishedAt: cancellation.now } + : step, + ), + ); + } + + private with(changes: Partial, steps: readonly RunStep[]): ProcessRun { + return new ProcessRun({ ...this, ...changes }, steps); + } +} + +/** The tasks of these steps still awaiting them. */ +export function openTaskIdsOf(steps: readonly RunStep[]): string[] { + return steps.flatMap(({ status, taskId }) => + status === "step_awaiting_task_completion" && taskId ? [taskId] : [], + ); +} diff --git a/packages/api/src/modules/processes/domain/recordDateTrigger.ts b/packages/api/src/core/processes/domain/recordDateTrigger.ts similarity index 100% rename from packages/api/src/modules/processes/domain/recordDateTrigger.ts rename to packages/api/src/core/processes/domain/recordDateTrigger.ts diff --git a/packages/api/src/modules/tasks/graphql/index.ts b/packages/api/src/core/processes/graphql/index.ts similarity index 100% rename from packages/api/src/modules/tasks/graphql/index.ts rename to packages/api/src/core/processes/graphql/index.ts diff --git a/packages/api/src/core/processes/graphql/resolvers.ts b/packages/api/src/core/processes/graphql/resolvers.ts new file mode 100644 index 000000000..36fc24730 --- /dev/null +++ b/packages/api/src/core/processes/graphql/resolvers.ts @@ -0,0 +1,19 @@ +import DataLoader from "dataloader"; + +import { perRequest, type Resolvers } from "../../graphql"; +import type { ProcessService } from "../app"; + +export function createResolvers(processes: ProcessService): Resolvers { + // The process steps behind a list of tasks, read in one query. + const loaders = perRequest(() => ({ + source: new DataLoader(async (taskIds: readonly string[]) => { + const sources = await processes.readTaskSources(taskIds); + return taskIds.map((id) => sources.get(id) ?? null); + }), + })); + return { + Task: { + processSource: ({ id }, _args, context) => loaders(context).source.load(id), + }, + }; +} diff --git a/packages/api/src/core/processes/graphql/schema.graphql b/packages/api/src/core/processes/graphql/schema.graphql new file mode 100644 index 000000000..cd0892f86 --- /dev/null +++ b/packages/api/src/core/processes/graphql/schema.graphql @@ -0,0 +1,21 @@ +# Processes have no API yet; their runs' changes to tasks show in the tasks' history with this cause. +enum RecordChangeCauseKind { + "A process run made the change on its own, such as creating or cancelling a step's task; the ref is the run." + PROCESS_RUN +} + +extend enum TaskType { + "A process step's to-do; marking it done completes the step, and the run once every step is done." + PROCESS_STEP_TO_DO +} + +"The process step that created a task, by key: `welcome_new_starter` and `welcome_one_to_one`." +type TaskProcessSource { + processKey: String! + stepKey: String! +} + +extend type Task { + "The process step that created it; null for a task a person asked for." + processSource: TaskProcessSource +} diff --git a/packages/api/src/core/processes/index.ts b/packages/api/src/core/processes/index.ts new file mode 100644 index 000000000..a38641cd3 --- /dev/null +++ b/packages/api/src/core/processes/index.ts @@ -0,0 +1,41 @@ +import { readSdl } from "../graphql"; +import { PrincipalSecurityContext } from "../security"; +import type { ModuleDependencies, ModuleManifest } from "../moduleManifest"; +import { type ProcessService, startDueRunsJob } from "./app"; +import { createResolvers } from "./graphql"; + +// Public values. +export { createProcessService, PROCESS_STEP_TO_DO } from "./app"; +export { type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "./domain"; + +// Public types. +export type { ProcessService } from "./app"; +export type { PeoplePort, RunSubject, WorkingCalendarsPort } from "./ports"; +export type { TriggeringEventRegistration, TriggeringEvents } from "./domain"; + +/** Processes that give people tasks when something happens to a record, and their runs. */ +export function processesModule({ + logger, + processes, +}: Pick & { processes: ProcessService }): ModuleManifest { + return { + name: "processes", + typeDefs: readSdl(import.meta.url, "./graphql/schema.graphql"), + resolvers: createResolvers(processes), + jobs: startDueRunsJob({ processes, logger }), + events: { + // Logged once the change commits, never for runs a rollback dropped. + "processes.runs_started_on_event": (started) => + Promise.resolve( + logger.info(started, "Process runs started with the change that triggered them"), + ), + "processes.steps_completed": (completed) => + Promise.resolve(logger.info(completed, "Process step done")), + }, + // A run follows its done tasks first, so the events that start runs see it settled. + eventsBeforeCommit: async (tx, events) => { + await processes.completeStepsOfDoneTasks(tx, events); + await processes.startRunsOnEvents(PrincipalSecurityContext.system(), tx, events); + }, + }; +} diff --git a/packages/api/src/core/processes/module.md b/packages/api/src/core/processes/module.md new file mode 100644 index 000000000..c219a6412 --- /dev/null +++ b/packages/api/src/core/processes/module.md @@ -0,0 +1,115 @@ +# Processes + +A process gives people tasks when something happens to a record, and follows each run of it to its end. The first one is ready-made: **Welcome a new starter**, which on a person's start date gives their manager "Have a welcome 1:1" and the person "Have coffee calls with 3 colleagues". + +## How a run works + +Ids are shortened; columns are shown as rows are stored. + +### From a change: a person is added + +1. A process version's definition listens to the employees module's event: + + ```json + { + "subject": { "recordType": "employee" }, + "trigger": { + "kind": "on_event", + "event": "employees.added", + "run": "in_same_transaction", + "faultMode": "rollback_transaction" + }, + "steps": [ + { + "key": "order_laptop", + "kind": "task", + "task": { + "assignee": { "kind": "subjects_manager", "otherwise": { "kind": "workspace_owner" } }, + "title": "Order a laptop for {firstName}", + "description": null, + "due": { "amount": 3, "unit": "business_days" } + } + } + ] + } + ``` + +2. Adding Ada publishes, inside the transaction that adds her: + + ```json + { "id": "ev1", "type": "employees.added", "payload": { "employeeIds": ["ada"] } } + ``` + +3. Before that transaction commits, `startRunsOnEvents` starts one run per added person. Ada has no manager, so the owner gets the task with a note: + + ```json + // process_runs + { "id": "run1", "record_type": "employee", "record_id": "ada", "subject_employee_id": "ada", + "start_cause": "started_on_event", "start_key": "on_event:ev1:ada", "start_day": "2026-11-16", + "trigger_event": { "id": "ev1", "type": "employees.added", "payload": { "employeeIds": ["ada"] } }, + "status": "in_progress" } + // process_run_step_progress + { "run_id": "run1", "step_key": "order_laptop", "status": "step_awaiting_task_completion", + "task_id": "t1", "assignee_note": "assignee_is_owner_because_subject_has_no_manager" } + // tasks + { "id": "t1", "type": "process_step_to_do", "status": "to_do", "title": "Order a laptop for Ada", + "assignee_employee_id": "owner", "subject_employee_id": "ada", + "record_type": "employee", "record_id": "ada", "due_date": "2026-11-19" } + ``` + +4. The owner marks `t1` done with the ordinary task command, which publishes: + + ```json + { + "type": "tasks.task_status_changed", + "payload": { "taskId": "t1", "from": "to_do", "to": "done", "byEmployeeId": "owner" } + } + ``` + +5. In the same transaction, processes completes the step and, with no step left open, finishes the run: + + ```json + // process_run_step_progress + { "run_id": "run1", "step_key": "order_laptop", "status": "step_completed", "finished_at": "…" } + // process_runs + { "id": "run1", "status": "finished" } + ``` + +If starting the run fails, adding Ada fails too: the run and the change commit together or not at all. + +### From a date: Welcome a new starter + +The daily job finds people whose `startDate` is today, or up to `maxDaysLate` (7) days ago, without a run of this process, and starts one per person in its own transaction: + +```json +// process_runs +{ "id": "run2", "record_type": "employee", "record_id": "ben", "subject_employee_id": "ben", + "start_cause": "started_on_record_date", "trigger_date": "2026-11-16", + "start_key": "on_record_date:employee:ben:2026-11-16", "start_day": "2026-11-16", + "status": "in_progress" } +// process_run_step_progress: one row per step, each with its task +{ "run_id": "run2", "step_key": "welcome_one_to_one", "status": "step_awaiting_task_completion", "task_id": "t2" } +{ "run_id": "run2", "step_key": "coffee_calls", "status": "step_awaiting_task_completion", "task_id": "t3" } +``` + +Ben's manager's task is due the same day, and Ben's own task five of his working days later. From there the run finishes as in steps 4 and 5. The unique `start_key` keeps a second pass from starting it again. + +## Rules + +- A process is off until an admin turns it on (`MANAGE_WORKSPACE`); turning it on or off appends a history line for record type `process`, which only those who manage the workspace read. Turning it on starts today's due runs at once, and never anyone whose date was before that day. +- What a process does lives in its published version's definition (JSON, read by `parseProcessDefinition` for the `definition_schema_version` it was written in). A published version never changes; a new release ships a new version. The trigger fires on a record's date plus an offset (`on_record_date`, a date field its record type registered, such as an employee's `startDate`), or on a domain event (`on_event`); schedule triggers are refused. A definition naming a record type, date field or event nobody registered is refused. +- An `on_event` trigger names an event its publishing module registered as triggering (`employees.added`, and `tasks.task_created` and `tasks.task_status_changed`, which processes registers for tasks), with an optional `when` on one payload field, and runs `in_same_transaction` with `faultMode` `rollback_transaction`: before the change commits, `startRunsOnEvents` starts one run per event and record the event is about, batched per process, skipping a record whose run is in progress or that is gone. The run keeps the event (`trigger_event`); a run that cannot start fails the change. +- The daily job (`processes.startDueRuns`, 06:00 London) starts a run for each record whose trigger date is today or up to the trigger's `maxDaysLate` days ago, if it never had a run of that process, one transaction each. It also cancels runs that can no longer run. +- An admin starts a run by hand for a record of the process's record type (`create` on `process_run` for its subject), whether the process is on or off, unless one is in progress for it. Before the subject's start date, the run counts from their start date. +- Steps run in parallel. Each step's task goes to its assignee rule's person on the run's start day, else its fallback, with a note when the owner covers a missing manager; with nobody, the step is skipped. Due dates count calendar days, or business days by the person's own working weeks and bank holidays, refusing a year with no known bank holidays. +- A step's task (`process_step_to_do`) is marked done with the ordinary task command. Before the commit, processes completes the step from `tasks.task_status_changed` and finishes the run when every step is done or skipped. It locks the run after the task, never the process; every transaction that locks both takes the task first. +- The daily job cancels a run whose subject was removed, or whose step still waiting lost its task with its assignee, locking the run's open tasks before the run. A task marked done meanwhile stays done. Assignees read "This process was cancelled."; the cause stays on the run for admins. +- Processes reads people, managers, the owner and working days through `PeoplePort` and `WorkingCalendarsPort` in `ports/`, which the employees module fills from its `adapters/`, and the records it runs on through their record types' registered process capabilities. It imports no module. +- Runs are read through `process_run` permissions: admins and the owner read and change every run; others read none. A task's readers see the run's progress as counts, never the other steps. + +## Data model + +- **`processes`:** `key`, `name`, `description`, `active_version_id` (one of its own published versions), `turned_on_at` (null while off). +- **`process_versions`:** `version`, `status` (`draft` or `published`, one draft per process), `definition_schema_version`, `definition`. +- **`process_runs`:** the version it started with, the record it is about (`record_type`, `record_id`: a record type with process capabilities, today an employee or a task) and its subject (`subject_employee_id`, no foreign key so a removal is noticed; a task's subject, which may be null), how it started, `trigger_date` or `trigger_event` and `start_key` (unique per process, so a trigger starts once), `start_day`, `status` (`in_progress`, `finished`, `cancelled`) with its columns. One run in progress per process and record. +- **`process_run_step_progress`:** a run's progress on each step of its version's definition (the steps themselves live in `process_versions.definition`): `status`, `task_id` (set null when the task goes with its assignee), `assignee_note`, `skip_reason`, `finished_at`. diff --git a/packages/api/src/core/processes/ports/index.ts b/packages/api/src/core/processes/ports/index.ts new file mode 100644 index 000000000..c9673e6fd --- /dev/null +++ b/packages/api/src/core/processes/ports/index.ts @@ -0,0 +1,36 @@ +import type { Tx } from "../../db"; +import type { CalendarDate } from "../../../utils"; +import type { IsWorkingDay } from "../domain"; + +/** A person as a run needs them: whom it is about, or a step's assignee. */ +export interface RunSubject { + id: string; + firstName: string; + /** Their first employment's start. */ + startDate: CalendarDate | null; +} + +/** What a run needs about people, read in the run's transaction; the employees module fills it. */ +export interface PeoplePort { + /** The people among these ids that exist, by id. */ + readPeople(tx: Tx, employeeIds: readonly string[]): Promise>; + /** Each person's direct manager on this day; people without one are left out. */ + readManagersOn( + tx: Tx, + input: { employeeIds: readonly string[]; date: CalendarDate }, + ): Promise>; + /** The owner's employee, or null. */ + readOwner(tx: Tx): Promise; +} + +/** People's working days, for steps due in business days; the employees module fills it. */ +export interface WorkingCalendarsPort { + /** + * Each person's working days around these days, refusing a day whose working calendar is + * unknown; null for a person on no terms, whose offsets count calendar days. + */ + readWorkingDays( + tx: Tx, + input: { employeeIds: readonly string[]; days: readonly CalendarDate[] }, + ): Promise>; +} diff --git a/packages/api/src/core/records/app/index.ts b/packages/api/src/core/records/app/index.ts index dbf2a5a09..492fee5f9 100644 --- a/packages/api/src/core/records/app/index.ts +++ b/packages/api/src/core/records/app/index.ts @@ -2,6 +2,7 @@ export { createRecordHistoryReaderService, type LoadedRecord, type RecordHistoryReaderService, + type RecordProcessCapabilities, type RecordTypeRegistration, type RecordTypeRegistry, type VisibleFieldChange, diff --git a/packages/api/src/core/records/app/recordHistoryReaderService.ts b/packages/api/src/core/records/app/recordHistoryReaderService.ts index dda7ff0fa..72882b27e 100644 --- a/packages/api/src/core/records/app/recordHistoryReaderService.ts +++ b/packages/api/src/core/records/app/recordHistoryReaderService.ts @@ -8,6 +8,7 @@ import { type Subject, } from "../../security"; import { findUserEmails } from "../db"; +import type { CalendarDate } from "../../../utils"; import type { FieldChange, HistoryLine, RecordType } from "../domain"; import { createRecordHistoryService, type HistoryPageArgs } from "./recordHistoryService"; @@ -65,6 +66,21 @@ interface RecordTypeBasics { * absent when records of the type cannot be cloned. It checks the caller's permission. */ clone?: (ctx: SecurityContext, tx: Tx, input: { id: string; name: string }) => Promise; + /** What a process needs to run on its records; absent when no process may be about one. */ + processes?: RecordProcessCapabilities; +} + +/** How a process reads the records it runs on, inside the run's transaction. */ +export interface RecordProcessCapabilities { + /** The records among these ids that exist, each with the employee it is about, null for nobody. */ + readSubjects(tx: Tx, ids: readonly string[]): Promise>; + /** The date fields an `on_record_date` trigger may name; none when absent. */ + dateFields?: readonly string[]; + /** The records whose `field`, one of `dateFields`, falls between these days, inclusive: id → date. */ + findByDateBetween?( + tx: Tx, + input: { field: string; from: CalendarDate; until: CalendarDate }, + ): Promise>; } interface ScopedReading { diff --git a/packages/api/src/core/records/index.ts b/packages/api/src/core/records/index.ts index f5a757cf9..dff4f76d2 100644 --- a/packages/api/src/core/records/index.ts +++ b/packages/api/src/core/records/index.ts @@ -1,6 +1,7 @@ import type { Orm, Transact } from "../db"; import type { Logger } from "../logger"; import { readSdl, type Resolvers } from "../graphql"; +import { collectRegistrations } from "../../utils"; import { createRecordCloneService, createRecordHistoryReaderService, @@ -20,6 +21,7 @@ export { changeReasonSchema, parseChangeReason } from "./domain"; export type { LoadedRecord, RecordChanged, + RecordProcessCapabilities, RecordTypeRegistration, RecordTypeRegistry, } from "./app"; @@ -42,10 +44,10 @@ export function recordsManifest( { orm, transact, logger }: { orm: Orm; transact: Transact; logger: Logger }, modules: readonly { recordTypes?: RecordTypeRegistry }[], ): { name: string; typeDefs: string; resolvers: Resolvers } { - const registry: RecordTypeRegistry = {}; - for (const { recordTypes } of modules) { - Object.assign(registry, recordTypes); - } + const registry: RecordTypeRegistry = collectRegistrations( + "record type", + modules.flatMap(({ recordTypes }) => (recordTypes ? [recordTypes] : [])), + ); return { name: "records", typeDefs: readSdl(import.meta.url, "./graphql/schema.graphql"), diff --git a/packages/api/src/modules/tasks/app/createTask.int.test.ts b/packages/api/src/core/tasks/app/createTask.int.test.ts similarity index 92% rename from packages/api/src/modules/tasks/app/createTask.int.test.ts rename to packages/api/src/core/tasks/app/createTask.int.test.ts index 950f3a28f..e0ba14e54 100644 --- a/packages/api/src/modules/tasks/app/createTask.int.test.ts +++ b/packages/api/src/core/tasks/app/createTask.int.test.ts @@ -114,29 +114,23 @@ test("refuses a blank title without creating a task", async ({ orm, seed, transa }); }); -test("shows a process task without an asker only to its assignee", async ({ - orm, - seed, - transact, -}) => { +test("shows a task without an asker only to its assignee", async ({ orm, seed, transact }) => { const assignee = await seed.employee(); const about = await seed.employee(); const assigneeCtx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); const aboutCtx = signedIn(orm, { employeeId: about.id, profiles: ["EMPLOYEE"] }); const tasks = taskService(orm); - const processSource = { processKey: "onboarding", processRunId: null, stepKey: "read_handbook" }; const task = await transact((tx) => tasks.createTask(assigneeCtx, tx, { - type: "manual_to_do", + type: "process_step_to_do", title: "Read the handbook", assigneeEmployeeId: assignee.id, subjectEmployeeId: about.id, - processSource, }), ); - expect(task).toMatchObject({ askedByEmployeeId: null, processSource }); + expect(task).toMatchObject({ askedByEmployeeId: null }); expect(await tasks.listMyTasks(assigneeCtx)).toEqual({ needsYou: [task], waitingOnOthers: [], @@ -186,7 +180,10 @@ test("deletes a person's held tasks when the person is deleted", async ({ orm, s }); }); -test("publishes the created task's ids and the person who created it", async ({ orm, seed }) => { +test("publishes the created task's type, source and people without storing the source", async ({ + orm, + seed, +}) => { const assignee = await seed.employee(); const asker = await seed.employee(); const subject = await seed.employee(); @@ -201,7 +198,7 @@ test("publishes the created task's ids and the person who created it", async ({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id, subjectEmployeeId: subject.id, - processSource: { processKey: "onboarding", processRunId: null, stepKey: "handbook" }, + sourceKey: "onboarding", }), ); @@ -213,11 +210,13 @@ test("publishes the created task's ids and the person who created it", async ({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id, subjectEmployeeId: subject.id, - processKey: "onboarding", + type: "manual_to_do", + sourceKey: "onboarding", byEmployeeId: asker.id, }, }, ]); + expect(await tasks.readTask(ctx, task.id)).not.toHaveProperty("sourceKey"); }); test("returns bulk-created tasks in input order and publishes each task", async ({ orm, seed }) => { @@ -254,7 +253,8 @@ test("returns bulk-created tasks in input order and publishes each task", async assigneeEmployeeId: task.assigneeEmployeeId, askedByEmployeeId: asker.id, subjectEmployeeId: null, - processKey: null, + type: "manual_to_do", + sourceKey: null, byEmployeeId: asker.id, }, })), diff --git a/packages/api/src/modules/tasks/app/createTasks.ts b/packages/api/src/core/tasks/app/createTasks.ts similarity index 76% rename from packages/api/src/modules/tasks/app/createTasks.ts rename to packages/api/src/core/tasks/app/createTasks.ts index 45b40441b..8e2f07546 100644 --- a/packages/api/src/modules/tasks/app/createTasks.ts +++ b/packages/api/src/core/tasks/app/createTasks.ts @@ -1,20 +1,20 @@ -import { invalidInput } from "../../../core/applicationError"; -import type { Tx } from "../../../core/db"; -import { createRecordHistoryService, type CauseKinds } from "../../../core/records"; -import type { SecurityContext } from "../../../core/security"; +import { invalidInput } from "../../applicationError"; +import type { Tx } from "../../db"; +import { createRecordHistoryService, type CauseKinds } from "../../records"; +import type { SecurityContext } from "../../security"; import type { CalendarDate } from "../../../utils"; import { type NewTaskRow, taskRepository } from "../db"; -import type { Task, TaskProcessSource, TaskRecord, TaskType } from "../domain"; +import type { Task, TaskRecord } from "../domain"; -/** A task a feature, a process step or the demo asks of someone: a person who asked, a process step, or both. */ -export type NewTask = NewTaskFields & - ( - | { askedByEmployeeId: string; processSource?: TaskProcessSource | null } - | { askedByEmployeeId?: null; processSource: TaskProcessSource } - ); - -interface NewTaskFields { - type: TaskType; +/** + * A task a person, a feature or the demo asks of someone. A `manual_to_do` has the person who + * asked; a feature's own type has none. + */ +export interface NewTask { + type: string; + askedByEmployeeId?: string | null; + /** What created it, such as a process's key, published with its creation and never stored. */ + sourceKey?: string | null; title: string; description?: string | null; assigneeEmployeeId: string; @@ -48,16 +48,17 @@ export async function createTasks( ], })), }); - for (const task of created) { + created.forEach((task, index) => tx.publish("tasks.task_created", { taskId: task.id, + type: task.type, assigneeEmployeeId: task.assigneeEmployeeId, askedByEmployeeId: task.askedByEmployeeId, subjectEmployeeId: task.subjectEmployeeId, - processKey: task.processSource?.processKey ?? null, + sourceKey: newTasks[index]!.sourceKey ?? null, byEmployeeId: ctx.user?.employeeId ?? null, - }); - } + }), + ); return created; } @@ -70,7 +71,6 @@ function newTaskRow({ title, description = null, ...task }: NewTask): NewTaskRow assigneeEmployeeId: task.assigneeEmployeeId, askedByEmployeeId: task.askedByEmployeeId ?? null, subjectEmployeeId: task.subjectEmployeeId ?? null, - processSource: task.processSource ?? null, record: task.record ?? null, dueDate: task.dueDate ?? null, doneAt: null, diff --git a/packages/api/src/core/tasks/app/deleteTask.int.test.ts b/packages/api/src/core/tasks/app/deleteTask.int.test.ts new file mode 100644 index 000000000..2c230e180 --- /dev/null +++ b/packages/api/src/core/tasks/app/deleteTask.int.test.ts @@ -0,0 +1,135 @@ +import { expect, signedIn, taskService, test } from "#test/support"; + +test.for(["to_do", "waiting"] as const)( + "lets the asker delete a %s task for both people", + async (status, { orm, seed }) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const task = await seed.task({ + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + status, + }); + const assigneeCtx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); + const askerCtx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); + const tasks = taskService(orm); + + expect(await tasks.deleteTask(askerCtx, task.id)).toEqual({ deletedTaskId: task.id }); + + await expect(tasks.readTask(askerCtx, task.id)).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "No task has this id.", + }); + await expect(tasks.readTask(assigneeCtx, task.id)).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "No task has this id.", + }); + expect(await tasks.listMyTasks(askerCtx)).toEqual({ + needsYou: [], + waitingOnOthers: [], + resolvedRecently: [], + }); + expect(await tasks.listMyTasks(assigneeCtx)).toEqual({ + needsYou: [], + waitingOnOthers: [], + resolvedRecently: [], + }); + }, +); + +test("refuses deletion by the assignee who did not ask for the task", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id }); + const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); + const tasks = taskService(orm); + + await expect(tasks.deleteTask(ctx, task.id)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Only the person who asked can delete this task.", + }); + expect(await tasks.readTask(ctx, task.id)).toEqual(task); +}); + +test("hides a task from an unrelated caller trying to delete it", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const other = await seed.employee(); + const task = await seed.task({ + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + subjectEmployeeId: other.id, + }); + const ctx = signedIn(orm, { employeeId: other.id, profiles: ["OWNER"] }); + + await expect(taskService(orm).deleteTask(ctx, task.id)).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "No task has this id.", + }); +}); + +test("reports an unknown task as not found when deleting it", async ({ orm, seed }) => { + const employee = await seed.employee(); + const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); + + await expect( + taskService(orm).deleteTask(ctx, "00000000-0000-4000-8000-000000000001"), + ).rejects.toMatchObject({ code: "NOT_FOUND", message: "No task has this id." }); +}); + +test("refuses deletion of a done task", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const task = await seed.task({ + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + status: "done", + doneAt: new Date("2026-11-08T12:00:00Z"), + doneByEmployeeId: assignee.id, + }); + const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); + const tasks = taskService(orm); + + await expect(tasks.deleteTask(ctx, task.id)).rejects.toMatchObject({ + code: "CONFLICT", + message: "This task is already done.", + }); + expect(await tasks.readTask(ctx, task.id)).toEqual(task); +}); + +test("refuses deletion of a cancelled task", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const asker = await seed.employee(); + const task = await seed.task({ + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + status: "cancelled", + cancelledAt: new Date("2026-11-08T12:00:00Z"), + cancelReason: "No longer needed", + }); + const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); + const tasks = taskService(orm); + + await expect(tasks.deleteTask(ctx, task.id)).rejects.toMatchObject({ + code: "CONFLICT", + message: "This task was cancelled.", + }); + expect(await tasks.readTask(ctx, task.id)).toEqual(task); +}); + +test("refuses deletion of a process step task by its assignee", async ({ orm, seed }) => { + const assignee = await seed.employee(); + const task = await seed.task({ + type: "process_step_to_do", + assigneeEmployeeId: assignee.id, + askedByEmployeeId: null, + }); + const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); + const tasks = taskService(orm); + + await expect(tasks.deleteTask(ctx, task.id)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Only the person who asked can delete this task.", + }); + expect(await tasks.readTask(ctx, task.id)).toEqual(task); +}); diff --git a/packages/api/src/modules/tasks/app/index.ts b/packages/api/src/core/tasks/app/index.ts similarity index 77% rename from packages/api/src/modules/tasks/app/index.ts rename to packages/api/src/core/tasks/app/index.ts index dc4fbf1c9..d163a305f 100644 --- a/packages/api/src/modules/tasks/app/index.ts +++ b/packages/api/src/core/tasks/app/index.ts @@ -2,3 +2,4 @@ export type { NewTask } from "./createTasks"; export { createTaskService, type TaskChange, type TaskService } from "./taskService"; export { taskRecordType } from "./taskRecordType"; export type { MyTasks } from "../domain"; +export { TASK_TRIGGERING_EVENTS } from "./taskTriggeringEvents"; diff --git a/packages/api/src/modules/tasks/app/listMyTasks.int.test.ts b/packages/api/src/core/tasks/app/listMyTasks.int.test.ts similarity index 98% rename from packages/api/src/modules/tasks/app/listMyTasks.int.test.ts rename to packages/api/src/core/tasks/app/listMyTasks.int.test.ts index f5fd252b5..afd7d27dd 100644 --- a/packages/api/src/modules/tasks/app/listMyTasks.int.test.ts +++ b/packages/api/src/core/tasks/app/listMyTasks.int.test.ts @@ -203,8 +203,8 @@ test("keeps tasks closed within fourteen days and excludes tasks closed fifteen test("returns empty groups for a caller without an employee", async ({ orm, seed }) => { const assignee = await seed.employee(); await seed.task({ + type: "process_step_to_do", assigneeEmployeeId: assignee.id, - processSource: { processKey: "onboarding", processRunId: null, stepKey: "read_handbook" }, }); expect(await taskService(orm).listMyTasks(EMPLOYEE)).toEqual({ diff --git a/packages/api/src/modules/tasks/app/markDone.int.test.ts b/packages/api/src/core/tasks/app/markDone.int.test.ts similarity index 100% rename from packages/api/src/modules/tasks/app/markDone.int.test.ts rename to packages/api/src/core/tasks/app/markDone.int.test.ts diff --git a/packages/api/src/modules/tasks/app/readTask.int.test.ts b/packages/api/src/core/tasks/app/readTask.int.test.ts similarity index 96% rename from packages/api/src/modules/tasks/app/readTask.int.test.ts rename to packages/api/src/core/tasks/app/readTask.int.test.ts index 414b765eb..aca01495c 100644 --- a/packages/api/src/modules/tasks/app/readTask.int.test.ts +++ b/packages/api/src/core/tasks/app/readTask.int.test.ts @@ -57,8 +57,8 @@ test("does not give access to the person a task is about", async ({ orm, seed }) test("hides tasks from a caller without an employee", async ({ orm, seed }) => { const assignee = await seed.employee(); const task = await seed.task({ + type: "process_step_to_do", assigneeEmployeeId: assignee.id, - processSource: { processKey: "onboarding", processRunId: null, stepKey: "read_handbook" }, }); await expect(taskService(orm).readTask(EMPLOYEE, task.id)).rejects.toMatchObject({ diff --git a/packages/api/src/modules/tasks/app/readTaskHistory.int.test.ts b/packages/api/src/core/tasks/app/readTaskHistory.int.test.ts similarity index 100% rename from packages/api/src/modules/tasks/app/readTaskHistory.int.test.ts rename to packages/api/src/core/tasks/app/readTaskHistory.int.test.ts diff --git a/packages/api/src/modules/tasks/app/registrations.ts b/packages/api/src/core/tasks/app/registrations.ts similarity index 74% rename from packages/api/src/modules/tasks/app/registrations.ts rename to packages/api/src/core/tasks/app/registrations.ts index 0dd16ef44..5be91a963 100644 --- a/packages/api/src/modules/tasks/app/registrations.ts +++ b/packages/api/src/core/tasks/app/registrations.ts @@ -1,23 +1,25 @@ // What this module adds to core's shared registries. import type { TaskStatus } from "../domain"; -declare module "../../../core/records" { +declare module "../../records" { interface RecordTypes { /** A task's own changes; its assignee and asker read them through the task, not `recordHistory`. */ task: true; } } -declare module "../../../core/events" { +declare module "../../events" { interface DomainEvents { /** A task to do was created; `byEmployeeId` is the person who created it, null for Broccoli. */ "tasks.task_created": { taskId: string; + /** `manual_to_do`, or the type of the feature that created it. */ + type: string; assigneeEmployeeId: string; askedByEmployeeId: string | null; subjectEmployeeId: string | null; - /** The process whose step created it, else null. */ - processKey: string | null; + /** What created it, as its creator named it, such as a process's key; null for a person. */ + sourceKey: string | null; byEmployeeId: string | null; }; /** A task's status changed; `byEmployeeId` is the person who changed it, null for Broccoli. */ diff --git a/packages/api/src/core/tasks/app/taskRecordType.ts b/packages/api/src/core/tasks/app/taskRecordType.ts new file mode 100644 index 000000000..0a9180402 --- /dev/null +++ b/packages/api/src/core/tasks/app/taskRecordType.ts @@ -0,0 +1,32 @@ +import type { Orm } from "../../db"; +import type { RecordTypeRegistration } from "../../records"; +import { parseUuid } from "../../../utils"; +import { taskRepository } from "../db"; + +/** + * How core reads a task's history: only its assignee and the person who asked, as for the task; + * and how a process reads the task it runs on. + */ +export function taskRecordType(orm: Orm): RecordTypeRegistration { + return { + graphqlType: "Task", + canonicalId: parseUuid, + loadRecords: async (ids) => { + const tasks = await taskRepository(orm).findByIds(ids); + return new Map(tasks.map((task) => [task.id, task])); + }, + readableIds: async (ctx, ids) => { + const employeeId = ctx.user?.employeeId; + if (!employeeId) return new Set(); + const tasks = await taskRepository(orm).findByIds(ids); + return new Set(tasks.filter((task) => task.isVisibleTo(employeeId)).map(({ id }) => id)); + }, + // A process on a task is about the task's subject, who gains no access to the task by it. + processes: { + readSubjects: async (tx, ids) => { + const tasks = await taskRepository(tx).findByIds(ids.flatMap((id) => parseUuid(id) ?? [])); + return new Map(tasks.map(({ id, subjectEmployeeId }) => [id, subjectEmployeeId])); + }, + }, + }; +} diff --git a/packages/api/src/modules/tasks/app/taskService.ts b/packages/api/src/core/tasks/app/taskService.ts similarity index 57% rename from packages/api/src/modules/tasks/app/taskService.ts rename to packages/api/src/core/tasks/app/taskService.ts index 23ec9b2c1..22ad07ce5 100644 --- a/packages/api/src/modules/tasks/app/taskService.ts +++ b/packages/api/src/core/tasks/app/taskService.ts @@ -1,27 +1,17 @@ -import { ApplicationError } from "../../../core/applicationError"; -import type { Transact, Tx } from "../../../core/db"; -import type { ModuleDependencies } from "../../../core/moduleManifest"; +import { ApplicationError } from "../../applicationError"; +import type { Tx } from "../../db"; +import type { ModuleDependencies } from "../../moduleManifest"; import { createRecordHistoryReaderService, createRecordHistoryService, type HistoryLine, -} from "../../../core/records"; -import type { Connection, PageArgs } from "../../../core/pagination"; -import type { SecurityContext } from "../../../core/security"; +} from "../../records"; +import type { Connection, PageArgs } from "../../pagination"; +import type { SecurityContext } from "../../security"; import { parseUuid } from "../../../utils"; import { taskRepository } from "../db"; import { createTasks, DIRECT_EDIT, type NewTask, requireText, type TaskCause } from "./createTasks"; -import { - cancelTask, - groupMyTasks, - isTaskVisibleTo, - markTaskDone, - markTaskWaiting, - type MyTasks, - reopenTask, - resolvedRecentlySince, - type Task, -} from "../domain"; +import { groupMyTasks, type MyTasks, resolvedRecentlySince, type Task } from "../domain"; import { taskRecordType } from "./taskRecordType"; /** @@ -35,16 +25,17 @@ export interface TaskService { createTasks(ctx: SecurityContext, tx: Tx, tasks: readonly NewTask[]): Promise; /** Trusted, for processes: the tasks among these ids, in `tx`; the caller authorizes. */ readTasksInTransaction(tx: Tx, taskIds: readonly string[]): Promise; - /** Trusted, for processes: its assignee marks a feature's task done in `tx`, first change winning. */ - markDoneInTransaction(ctx: SecurityContext, tx: Tx, taskId: string): Promise; - /** Trusted, for processes: its assignee takes a feature's done task back to do, in `tx`. */ - reopenInTransaction(ctx: SecurityContext, tx: Tx, taskId: string): Promise; /** Trusted, for processes: cancels open tasks in `tx` with a reason their assignees read. */ cancelInTransaction( ctx: SecurityContext, tx: Tx, input: { taskIds: readonly string[]; reason: string; cause: TaskCause }, ): Promise; + /** + * Trusted, for processes: locks these tasks in id order until `tx` ends, before what a task's + * change locks after it, such as its run. + */ + lockInTransaction(tx: Tx, taskIds: readonly string[]): Promise; /** Trusted: puts a task to do on hold in `tx`, until what it waits for happens. */ markWaiting(ctx: SecurityContext, tx: Tx, taskId: string): Promise; /** The signed-in caller's tasks by where the ball is; all empty for a caller with no employee. */ @@ -57,12 +48,13 @@ export interface TaskService { taskId: string, args: PageArgs, ): Promise>; - /** The assignee finishes a task to do. */ + /** + * The assignee finishes a task to do, whatever created it; a feature follows from the task's + * status change before the commit, in the same transaction. + */ markDone(ctx: SecurityContext, taskId: string): Promise; - /** The assignee takes a done task back to do. */ - reopen(ctx: SecurityContext, taskId: string): Promise; - /** The person who asked withdraws an open task, saying why. */ - cancel(ctx: SecurityContext, taskId: string, reason: string): Promise; + /** The person who asked deletes an open task; a feature's task has nobody who asked. */ + deleteTask(ctx: SecurityContext, taskId: string): Promise<{ deletedTaskId: string }>; /** * Trusted, for resets: locks every task until the transaction ends, before the employees whose * deletion takes their tasks, so a reset waits for a task change instead of deadlocking with it. @@ -97,40 +89,28 @@ export function createTaskService({ readTasksInTransaction: (tx, taskIds) => taskRepository(tx).findByIds(taskIds), - async markDoneInTransaction(ctx, tx, taskId) { - const { task } = await changeLockedTask(ctx, tx, taskId, (locked, employeeId) => { - refuseUnlessAssignee(locked, employeeId, MARK_DONE_REFUSAL); - return markTaskDone(locked, employeeId, new Date()); - }); - return task; - }, - - async reopenInTransaction(ctx, tx, taskId) { - const { task } = await changeLockedTask(ctx, tx, taskId, (locked, employeeId) => { - refuseUnlessAssignee(locked, employeeId, REOPEN_REFUSAL); - return reopenTask(locked); - }); - return task; - }, - async cancelInTransaction(ctx, tx, { taskIds, reason, cause }) { const tasks = taskRepository(tx); for (const task of await tasks.lockByIds(taskIds)) { if (task.status === "done" || task.status === "cancelled") continue; const saved = await tasks.saveStatus( - cancelTask(task, requireText(reason, "reason"), new Date()), + task.cancel(requireText(reason, "reason"), new Date()), ); await appendStatusChange(ctx, tx, task, saved, cause); } }, + async lockInTransaction(tx, taskIds) { + await taskRepository(tx).lockByIds(taskIds); + }, + async markWaiting(ctx, tx, taskId) { const tasks = taskRepository(tx); const task = await tasks.lockById(taskId); if (!task) { throw notFound(); } - const saved = await tasks.saveStatus(markTaskWaiting(task)); + const saved = await tasks.saveStatus(task.markWaiting()); await appendStatusChange(ctx, tx, task, saved); return saved; }, @@ -155,38 +135,38 @@ export function createTaskService({ readTaskHistory: (ctx, taskId, args) => historyReader.readHistory(ctx, "task", taskId, args), async markDone(ctx, taskId) { - const done = await changeTask(transact, ctx, taskId, (task, employeeId) => { - refuseFeatureTask(task); - refuseUnlessAssignee(task, employeeId, MARK_DONE_REFUSAL); - return markTaskDone(task, employeeId, new Date()); + const done = await transact(async (tx) => { + const { task, employeeId } = await lockVisible(ctx, tx, taskId); + const saved = await taskRepository(tx).saveStatus(task.markDone(employeeId, new Date())); + return { task: saved, historyLine: await appendStatusChange(ctx, tx, task, saved) }; }); logger.info({ taskId: done.task.id, type: done.task.type }, "Task marked done"); return done; }, - async reopen(ctx, taskId) { - const reopened = await changeTask(transact, ctx, taskId, (task, employeeId) => { - refuseFeatureTask(task); - refuseUnlessAssignee(task, employeeId, REOPEN_REFUSAL); - return reopenTask(task); + async deleteTask(ctx, taskId) { + const deleted = await transact(async (tx) => { + const { task, employeeId } = await lockVisible(ctx, tx, taskId); + task.requireDeletableBy(employeeId); + await taskRepository(tx).delete(task.id); + await history.append(ctx, tx, { + recordType: "task", + operation: "DELETED", + cause: DIRECT_EDIT, + records: [ + { + recordId: task.id, + changes: [ + { field: "title", oldValue: task.title, newValue: null }, + { field: "status", oldValue: task.status, newValue: null }, + ], + }, + ], + }); + return task; }); - logger.info({ taskId: reopened.task.id, type: reopened.task.type }, "Task reopened"); - return reopened; - }, - - async cancel(ctx, taskId, reason) { - const cancelled = await changeTask(transact, ctx, taskId, (task, employeeId) => { - refuseFeatureTask(task); - if (task.askedByEmployeeId !== employeeId) { - throw new ApplicationError( - "FORBIDDEN", - "Only the person who asked can cancel this task.", - ); - } - return cancelTask(task, requireText(reason, "reason"), new Date()); - }); - logger.info({ taskId: cancelled.task.id, type: cancelled.task.type }, "Task cancelled"); - return cancelled; + logger.info({ taskId: deleted.id, type: deleted.type }, "Task deleted"); + return { deletedTaskId: deleted.id }; }, async lockTasks(ctx, tx) { @@ -201,28 +181,14 @@ export function createTaskService({ }; } -/** Changes one task the caller may see, inside one transaction, with its history line. */ -async function changeTask( - transact: Transact, - ctx: SecurityContext, - taskId: string, - change: (task: Task, employeeId: string) => Task, -): Promise { - return transact((tx) => changeLockedTask(ctx, tx, taskId, change)); -} - -/** Changes one task the caller may see, locked in `tx`, with the history line it appends. */ -async function changeLockedTask( +/** The task, locked in `tx` so the first change wins, if the caller holds or asked for it. */ +async function lockVisible( ctx: SecurityContext, tx: Tx, taskId: string, - change: (task: Task, employeeId: string) => Task, -): Promise { - const tasks = taskRepository(tx); +): Promise<{ task: Task; employeeId: string }> { const id = parseUuid(taskId); - const { task, employeeId } = requireVisible(ctx, id ? await tasks.lockById(id) : null); - const saved = await tasks.saveStatus(change(task, employeeId)); - return { task: saved, historyLine: await appendStatusChange(ctx, tx, task, saved) }; + return requireVisible(ctx, id ? await taskRepository(tx).lockById(id) : null); } /** Appends the status change to the task's history and publishes it. */ @@ -260,30 +226,14 @@ function requireVisible( task: Task | null, ): { task: Task; employeeId: string } { const employeeId = ctx.user?.employeeId ?? null; - if (task === null || employeeId === null || !isTaskVisibleTo(task, employeeId)) { + if (task === null || employeeId === null || !task.isVisibleTo(employeeId)) { throw notFound(); } return { task, employeeId }; } -function refuseUnlessAssignee(task: Task, employeeId: string, message: string): void { - if (task.assigneeEmployeeId !== employeeId) { - throw new ApplicationError("FORBIDDEN", message); - } -} - -/** A feature's own task is finished through the feature, never the generic commands. */ -function refuseFeatureTask(task: Task): void { - if (task.type === "process_step_to_do") { - throw new ApplicationError("CONFLICT", "This task belongs to a process."); - } -} - function notFound(): ApplicationError { return new ApplicationError("NOT_FOUND", "No task has this id."); } const history = createRecordHistoryService(); - -const MARK_DONE_REFUSAL = "Only the person who holds this task can mark it done."; -const REOPEN_REFUSAL = "Only the person who holds this task can reopen it."; diff --git a/packages/api/src/core/tasks/app/taskTriggeringEvents.ts b/packages/api/src/core/tasks/app/taskTriggeringEvents.ts new file mode 100644 index 000000000..b94662ca9 --- /dev/null +++ b/packages/api/src/core/tasks/app/taskTriggeringEvents.ts @@ -0,0 +1,18 @@ +import type { DomainEvents } from "../../events"; + +/** + * Tasks' events that may start a process on the task, each run keeping the event as it came; the + * composition gives them to processes. + */ +export const TASK_TRIGGERING_EVENTS = { + "tasks.task_created": { + recordType: "task", + recordIds: ({ taskId }: DomainEvents["tasks.task_created"]) => [taskId], + payloadFor: (payload: DomainEvents["tasks.task_created"]) => payload, + }, + "tasks.task_status_changed": { + recordType: "task", + recordIds: ({ taskId }: DomainEvents["tasks.task_status_changed"]) => [taskId], + payloadFor: (payload: DomainEvents["tasks.task_status_changed"]) => payload, + }, +}; diff --git a/packages/api/src/modules/tasks/codegen/index.ts b/packages/api/src/core/tasks/codegen/index.ts similarity index 68% rename from packages/api/src/modules/tasks/codegen/index.ts rename to packages/api/src/core/tasks/codegen/index.ts index 61b47f77e..fd5c320aa 100644 --- a/packages/api/src/modules/tasks/codegen/index.ts +++ b/packages/api/src/core/tasks/codegen/index.ts @@ -2,6 +2,6 @@ export const codegenMappers = { // A mutation namespace holds no data; its field resolves to an empty object. TasksMutations: "Record", - Task: "../../modules/tasks/domain#Task as TaskModel", - MyTasks: "../../modules/tasks/domain#MyTasks as MyTasksModel", + Task: "../tasks/domain#Task as TaskModel", + MyTasks: "../tasks/domain#MyTasks as MyTasksModel", }; diff --git a/packages/api/src/modules/tasks/db/index.ts b/packages/api/src/core/tasks/db/index.ts similarity index 100% rename from packages/api/src/modules/tasks/db/index.ts rename to packages/api/src/core/tasks/db/index.ts diff --git a/packages/api/src/modules/tasks/db/taskRepository.ts b/packages/api/src/core/tasks/db/taskRepository.ts similarity index 85% rename from packages/api/src/modules/tasks/db/taskRepository.ts rename to packages/api/src/core/tasks/db/taskRepository.ts index e2029c373..231227e59 100644 --- a/packages/api/src/modules/tasks/db/taskRepository.ts +++ b/packages/api/src/core/tasks/db/taskRepository.ts @@ -1,11 +1,11 @@ import { and, eq, gte, inArray, or } from "drizzle-orm"; -import { ormOf, type Orm, type Tx } from "../../../core/db"; -import type { Task } from "../domain"; +import { ormOf, type Orm, type Tx } from "../../db"; +import { Task, type TaskFields } from "../domain"; import { tasks } from "./tasksTable"; /** A task as it is stored before it has an id and timestamps. */ -export type NewTaskRow = Omit; +export type NewTaskRow = Omit; /** Stored tasks. */ export interface TaskRepository { @@ -19,6 +19,8 @@ export interface TaskRepository { lockByIds(ids: readonly string[]): Promise; /** Every task, locked in id order until the transaction ends. */ lockAll(): Promise; + /** Deletes the task. */ + delete(id: string): Promise; /** Writes the task's status and the columns that follow it. */ saveStatus(task: Task): Promise; /** @@ -74,6 +76,10 @@ export function taskRepository(db: Orm | Tx): TaskRepository { return row ? toTask(row) : null; }, + async delete(id) { + await orm.delete(tasks).where(eq(tasks.id, id)); + }, + async saveStatus({ id, status, doneAt, doneByEmployeeId, cancelledAt, cancelReason }) { const [row] = await orm .update(tasks) @@ -107,28 +113,17 @@ type TaskRow = typeof tasks.$inferSelect; /** Rows per insert, well below Postgres's limit of 65,535 parameters. */ const INSERT_BATCH_SIZE = 1_000; -function toRow({ processSource, record, ...task }: NewTaskRow) { +function toRow({ record, ...task }: NewTaskRow) { return { ...task, - processKey: processSource?.processKey ?? null, - processRunId: processSource?.processRunId ?? null, - stepKey: processSource?.stepKey ?? null, recordType: record?.recordType ?? null, recordId: record?.recordId ?? null, }; } -function toTask({ - processKey, - processRunId, - stepKey, - recordType, - recordId, - ...row -}: TaskRow): Task { - return { +function toTask({ recordType, recordId, ...row }: TaskRow): Task { + return Task.restore({ ...row, - processSource: processKey && stepKey ? { processKey, processRunId, stepKey } : null, record: recordType && recordId ? { recordType, recordId } : null, - }; + }); } diff --git a/packages/api/src/modules/tasks/db/tasksTable.ts b/packages/api/src/core/tasks/db/tasksTable.ts similarity index 68% rename from packages/api/src/modules/tasks/db/tasksTable.ts rename to packages/api/src/core/tasks/db/tasksTable.ts index 0392cb589..256aecea7 100644 --- a/packages/api/src/modules/tasks/db/tasksTable.ts +++ b/packages/api/src/core/tasks/db/tasksTable.ts @@ -1,17 +1,8 @@ import { sql } from "drizzle-orm"; -import { - check, - date, - index, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, -} from "drizzle-orm/pg-core"; +import { check, date, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import type { CalendarDate } from "../../../utils"; -import { TASK_STATUSES, TASK_TYPES } from "../domain"; +import { TASK_STATUSES } from "../domain"; /** * Work asked of one person. The migration adds the foreign keys to employees, which another @@ -23,17 +14,13 @@ export const tasks = pgTable( id: uuid("id") .primaryKey() .default(sql`uuidv7()`), - type: text("type", { enum: TASK_TYPES }).notNull(), + type: text("type").notNull(), status: text("status", { enum: TASK_STATUSES }).notNull(), title: text("title").notNull(), description: text("description"), assigneeEmployeeId: uuid("assignee_employee_id").notNull(), askedByEmployeeId: uuid("asked_by_employee_id"), subjectEmployeeId: uuid("subject_employee_id"), - /** The process step that created it; processes' migration adds the foreign key to runs. */ - processKey: text("process_key"), - processRunId: uuid("process_run_id"), - stepKey: text("step_key"), /** The record the task acts on, through the records registry. */ recordType: text("record_type"), recordId: text("record_id"), @@ -49,21 +36,11 @@ export const tasks = pgTable( index("tasks_assignee_idx").on(table.assigneeEmployeeId), index("tasks_asked_by_idx").on(table.askedByEmployeeId), index("tasks_record_idx").on(table.recordType, table.recordId), - uniqueIndex("tasks_one_per_run_step").on(table.processRunId, table.stepKey), check("tasks_title_not_blank", sql`btrim(${table.title}) <> ''`), + // A manual to-do has the person who asked; a feature's task never has one, so no person deletes it. check( "tasks_asked_by_someone", - sql`${table.askedByEmployeeId} is not null or ${table.processKey} is not null`, - ), - check( - "tasks_process_source_complete", - sql`(${table.processKey} is null) = (${table.stepKey} is null) - and (${table.processRunId} is null or ${table.processKey} is not null)`, - ), - check( - "tasks_process_step_from_a_run", - sql`${table.type} <> 'process_step_to_do' - or (${table.processRunId} is not null and ${table.askedByEmployeeId} is null)`, + sql`(${table.type} = 'manual_to_do') = (${table.askedByEmployeeId} is not null)`, ), check( "tasks_record_complete", diff --git a/packages/api/src/modules/tasks/domain/index.ts b/packages/api/src/core/tasks/domain/index.ts similarity index 51% rename from packages/api/src/modules/tasks/domain/index.ts rename to packages/api/src/core/tasks/domain/index.ts index 515c2f297..91110f417 100644 --- a/packages/api/src/modules/tasks/domain/index.ts +++ b/packages/api/src/core/tasks/domain/index.ts @@ -1,15 +1,9 @@ export { - cancelTask, - isTaskVisibleTo, - markTaskDone, - markTaskWaiting, - reopenTask, TASK_STATUSES, - TASK_TYPES, - type Task, - type TaskProcessSource, + MANUAL_TO_DO, + Task, + type TaskFields, type TaskStatus, type TaskRecord, - type TaskType, } from "./task"; export { groupMyTasks, type MyTasks, resolvedRecentlySince } from "./myTasks"; diff --git a/packages/api/src/modules/tasks/domain/myTasks.ts b/packages/api/src/core/tasks/domain/myTasks.ts similarity index 100% rename from packages/api/src/modules/tasks/domain/myTasks.ts rename to packages/api/src/core/tasks/domain/myTasks.ts diff --git a/packages/api/src/core/tasks/domain/task.test.ts b/packages/api/src/core/tasks/domain/task.test.ts new file mode 100644 index 000000000..85bff1c5f --- /dev/null +++ b/packages/api/src/core/tasks/domain/task.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "vitest"; + +import { Task, type TaskStatus } from "./task"; + +test("marks a task to do as done by its assignee", () => { + const task = taskIn("to_do"); + const original = { ...task }; + const now = new Date("2026-11-09T12:00:00Z"); + + const done = task.markDone(task.assigneeEmployeeId, now); + + expect(done).toBeInstanceOf(Task); + expect(done).toMatchObject({ + status: "done", + doneAt: now, + doneByEmployeeId: task.assigneeEmployeeId, + cancelledAt: null, + cancelReason: null, + }); + expect(task).toEqual(original); +}); + +test("refuses to mark a task done by someone other than its assignee", () => { + const task = taskIn("to_do"); + + expect(() => + task.markDone("00000000-0000-4000-8000-000000000003", new Date("2026-11-09T12:00:00Z")), + ).toThrowError( + expect.objectContaining({ + code: "FORBIDDEN", + message: "Only the person who holds this task can mark it done.", + }), + ); +}); + +test.each([ + ["waiting", "This task is waiting."], + ["done", "This task is already done."], + ["cancelled", "This task was cancelled."], +] as const)("refuses to mark a %s task done", (status, message) => { + const task = taskIn(status); + + expect(() => + task.markDone(task.assigneeEmployeeId, new Date("2026-11-09T12:00:00Z")), + ).toThrowError(expect.objectContaining({ code: "CONFLICT", message })); +}); + +test.each(["to_do", "waiting"] as const)("cancels a %s task with a reason", (status) => { + const task = taskIn(status); + const original = { ...task }; + const now = new Date("2026-11-09T12:00:00Z"); + + const cancelled = task.cancel("No longer needed", now); + + expect(cancelled).toBeInstanceOf(Task); + expect(cancelled).toMatchObject({ + status: "cancelled", + cancelledAt: now, + cancelReason: "No longer needed", + doneAt: null, + doneByEmployeeId: null, + }); + expect(task).toEqual(original); +}); + +test.each([ + ["done", "This task is already done."], + ["cancelled", "This task was cancelled."], +] as const)("refuses to cancel a %s task", (status, message) => { + const task = taskIn(status); + + expect(() => task.cancel("No longer needed", new Date("2026-11-09T12:00:00Z"))).toThrowError( + expect.objectContaining({ code: "CONFLICT", message }), + ); +}); + +test("puts a task to do into waiting", () => { + const task = taskIn("to_do"); + const original = { ...task }; + + const waiting = task.markWaiting(); + + expect(waiting).toBeInstanceOf(Task); + expect(waiting.status).toBe("waiting"); + expect(task).toEqual(original); +}); + +test.each([ + ["waiting", "Only a task to do can wait."], + ["done", "Only a task to do can wait."], + ["cancelled", "This task was cancelled."], +] as const)("refuses to put a %s task into waiting", (status, message) => { + const task = taskIn(status); + + expect(() => task.markWaiting()).toThrowError( + expect.objectContaining({ + code: "CONFLICT", + message, + }), + ); +}); + +test("shows a task to its assignee", () => { + const task = taskIn("to_do"); + + expect(task.isVisibleTo(task.assigneeEmployeeId)).toBe(true); +}); + +test("shows a task to its asker", () => { + const task = taskIn("to_do"); + + expect(task.isVisibleTo("00000000-0000-4000-8000-000000000003")).toBe(true); +}); + +test("hides a task from its subject", () => { + const task = taskIn("to_do"); + + expect(task.isVisibleTo("00000000-0000-4000-8000-000000000004")).toBe(false); +}); + +test.each(["to_do", "waiting"] as const)("allows the asker to delete a %s task", (status) => { + const task = taskIn(status); + + expect(() => task.requireDeletableBy("00000000-0000-4000-8000-000000000003")).not.toThrow(); +}); + +test("refuses to let the assignee delete a task asked by someone else", () => { + const task = taskIn("to_do"); + + expect(() => task.requireDeletableBy(task.assigneeEmployeeId)).toThrowError( + expect.objectContaining({ + code: "FORBIDDEN", + message: "Only the person who asked can delete this task.", + }), + ); +}); + +test.each([ + ["done", "This task is already done."], + ["cancelled", "This task was cancelled."], +] as const)("refuses to let the asker delete a %s task", (status, message) => { + const task = taskIn(status); + + expect(() => task.requireDeletableBy("00000000-0000-4000-8000-000000000003")).toThrowError( + expect.objectContaining({ code: "CONFLICT", message }), + ); +}); + +function taskIn(status: TaskStatus): Task { + const createdAt = new Date("2026-11-01T12:00:00Z"); + const closedAt = new Date("2026-11-08T12:00:00Z"); + return Task.restore({ + id: "00000000-0000-4000-8000-000000000001", + type: "manual_to_do", + status, + title: "Read the handbook", + description: null, + assigneeEmployeeId: "00000000-0000-4000-8000-000000000002", + askedByEmployeeId: "00000000-0000-4000-8000-000000000003", + subjectEmployeeId: "00000000-0000-4000-8000-000000000004", + record: null, + dueDate: null, + doneAt: status === "done" ? closedAt : null, + doneByEmployeeId: status === "done" ? "00000000-0000-4000-8000-000000000002" : null, + cancelledAt: status === "cancelled" ? closedAt : null, + cancelReason: status === "cancelled" ? "No longer needed" : null, + createdAt, + updatedAt: createdAt, + }); +} diff --git a/packages/api/src/core/tasks/domain/task.ts b/packages/api/src/core/tasks/domain/task.ts new file mode 100644 index 000000000..6bf8ece19 --- /dev/null +++ b/packages/api/src/core/tasks/domain/task.ts @@ -0,0 +1,129 @@ +import { ApplicationError } from "../../applicationError"; +import type { CalendarDate } from "../../../utils"; + +/** A task a person asked for; features that create tasks, such as processes, name their own types. */ +export const MANUAL_TO_DO = "manual_to_do"; + +export const TASK_STATUSES = ["to_do", "waiting", "done", "cancelled"] as const; +export type TaskStatus = (typeof TASK_STATUSES)[number]; + +/** The record a task acts on, as the records registry names it. */ +export interface TaskRecord { + recordType: string; + recordId: string; +} + +/** A task's facts, as stored. */ +export interface TaskFields { + id: string; + /** `manual_to_do`, or the type of the feature that created it. */ + type: string; + status: TaskStatus; + title: string; + description: string | null; + assigneeEmployeeId: string; + askedByEmployeeId: string | null; + subjectEmployeeId: string | null; + record: TaskRecord | null; + dueDate: CalendarDate | null; + doneAt: Date | null; + doneByEmployeeId: string | null; + cancelledAt: Date | null; + cancelReason: string | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * Work asked of one person, and the rules of its life: who sees it, who finishes or deletes it, + * and that done and cancelled are final. Every change returns a new task; the original stays. + */ +export class Task implements Readonly { + declare readonly id: string; + declare readonly type: string; + declare readonly status: TaskStatus; + declare readonly title: string; + declare readonly description: string | null; + declare readonly assigneeEmployeeId: string; + declare readonly askedByEmployeeId: string | null; + declare readonly subjectEmployeeId: string | null; + declare readonly record: TaskRecord | null; + declare readonly dueDate: CalendarDate | null; + declare readonly doneAt: Date | null; + declare readonly doneByEmployeeId: string | null; + declare readonly cancelledAt: Date | null; + declare readonly cancelReason: string | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; + + private constructor(fields: TaskFields) { + Object.assign(this, fields); + } + + /** The task as stored. */ + static restore(fields: TaskFields): Task { + return new Task(fields); + } + + /** Only the assignee and the person who asked see a task; whom it is about does not count. */ + isVisibleTo(employeeId: string): boolean { + return this.assigneeEmployeeId === employeeId || this.askedByEmployeeId === employeeId; + } + + /** Its assignee finishes it, whatever created it. */ + markDone(byEmployeeId: string, now: Date): Task { + if (this.assigneeEmployeeId !== byEmployeeId) { + throw new ApplicationError( + "FORBIDDEN", + "Only the person who holds this task can mark it done.", + ); + } + this.refuseClosed(); + if (this.status === "waiting") { + throw new ApplicationError("CONFLICT", "This task is waiting."); + } + return this.with({ status: "done", doneAt: now, doneByEmployeeId: byEmployeeId }); + } + + /** `reason` is the trimmed, non-blank reason the caller checked. */ + cancel(reason: string, now: Date): Task { + this.refuseClosed(); + return this.with({ status: "cancelled", cancelledAt: now, cancelReason: reason }); + } + + markWaiting(): Task { + if (this.status === "cancelled") { + throw cancelledConflict(); + } + if (this.status !== "to_do") { + throw new ApplicationError("CONFLICT", "Only a task to do can wait."); + } + return this.with({ status: "waiting" }); + } + + /** Only the person who asked deletes it, while it is open; a feature's task has nobody who asked. */ + requireDeletableBy(employeeId: string): void { + if (this.askedByEmployeeId !== employeeId) { + throw new ApplicationError("FORBIDDEN", "Only the person who asked can delete this task."); + } + this.refuseClosed(); + } + + /** Done and cancelled are final: nothing changes such a task again. */ + private refuseClosed(): void { + if (this.status === "done") { + throw new ApplicationError("CONFLICT", "This task is already done."); + } + if (this.status === "cancelled") { + throw cancelledConflict(); + } + } + + private with(changes: Partial): Task { + return new Task({ ...this, ...changes }); + } +} + +function cancelledConflict(): ApplicationError { + return new ApplicationError("CONFLICT", "This task was cancelled."); +} diff --git a/packages/api/src/core/tasks/graphql/index.ts b/packages/api/src/core/tasks/graphql/index.ts new file mode 100644 index 000000000..fa52daef8 --- /dev/null +++ b/packages/api/src/core/tasks/graphql/index.ts @@ -0,0 +1 @@ +export { createResolvers } from "./resolvers"; diff --git a/packages/api/src/core/tasks/graphql/resolvers.ts b/packages/api/src/core/tasks/graphql/resolvers.ts new file mode 100644 index 000000000..c3f80d7cb --- /dev/null +++ b/packages/api/src/core/tasks/graphql/resolvers.ts @@ -0,0 +1,42 @@ +import { ApplicationError } from "../../applicationError"; +import { mutationPayload, toEnum, type Resolvers, type ResolversTypes } from "../../graphql"; +import type { TaskService } from "../app"; + +const EXPECTED_ERRORS = ["INVALID_INPUT", "NOT_FOUND", "CONFLICT"] as const; + +export function createResolvers(tasks: TaskService): Resolvers { + return { + Query: { + myTasks: (_parent, _args, context) => tasks.listMyTasks(context.getActor()), + task: async (_parent, { id }, context) => { + try { + return await tasks.readTask(context.getActor(), id); + } catch (error) { + if (error instanceof ApplicationError && error.code === "NOT_FOUND") return null; + throw error; + } + }, + }, + Mutation: { + tasks: () => ({}), + }, + TasksMutations: { + markDone: (_parent, { input }, context) => + mutationPayload( + () => tasks.markDone(context.getActor(), input.id), + EXPECTED_ERRORS, + ), + delete: (_parent, { input }, context) => + mutationPayload( + () => tasks.deleteTask(context.getActor(), input.id), + EXPECTED_ERRORS, + ), + }, + Task: { + type: ({ type }) => toEnum(type) as ResolversTypes["TaskType"], + status: ({ status }) => toEnum(status), + history: ({ id }, { first, after }, context) => + tasks.readTaskHistory(context.getActor(), id, { first: first ?? undefined, after }), + }, + }; +} diff --git a/packages/api/src/modules/tasks/graphql/schema.graphql b/packages/api/src/core/tasks/graphql/schema.graphql similarity index 59% rename from packages/api/src/modules/tasks/graphql/schema.graphql rename to packages/api/src/core/tasks/graphql/schema.graphql index 0d6886d8a..a6c7bda5a 100644 --- a/packages/api/src/modules/tasks/graphql/schema.graphql +++ b/packages/api/src/core/tasks/graphql/schema.graphql @@ -1,9 +1,7 @@ -"What kind of work a task asks for, and how it is finished." +"What created a task; every task is finished with Mark done. Features that create tasks add their own." enum TaskType { - "Finished by hand with Mark done; it produces nothing else." + "Asked by a person, who may delete it while it is open." MANUAL_TO_DO - "A process step's to-do, finished through its process so the run keeps up." - PROCESS_STEP_TO_DO } enum TaskStatus { @@ -12,16 +10,10 @@ enum TaskStatus { "It cannot be done until something else happens." WAITING DONE - "Withdrawn by the person who asked; cancelling keeps the task and its reason." + "Stopped by the feature that created it, such as a process; the task keeps its reason." CANCELLED } -"The process step that created a task, by key: `leave_request` and `manager_approves`." -type TaskProcessSource { - processKey: String! - stepKey: String! -} - "Work asked of one person. Only its assignee and the person who asked see it and its history." type Task implements AnyRecord { id: ID! @@ -30,18 +22,9 @@ type Task implements AnyRecord { title: String! description: String dueDate: Date - "Who holds it: the one person who can do it." - assignee: Employee! - "Who asked for it; null when a process step created it." - askedBy: Employee - "Who it is about, when it is about someone; being its subject shows nobody the task." - subject: Employee - processSource: TaskProcessSource doneAt: DateTime - "Null once that person is removed." - doneBy: Employee cancelledAt: DateTime - "Why the person who asked cancelled it." + "Why the feature that created it cancelled it." cancelReason: String createdAt: DateTime! "Changes to this task, newest first." @@ -75,14 +58,8 @@ input TaskMarkDoneInput { id: ID! } -input TaskReopenInput { - id: ID! -} - -input TaskCancelInput { +input TaskDeleteInput { id: ID! - "Required; the assignee reads it." - reason: String! } "The task after a change, or why it was refused." @@ -93,15 +70,19 @@ type TaskPayload { error: TaskError } +"The deleted task's id, or why it was refused." +type TaskDeletePayload { + deletedTaskId: ID + error: TaskError +} + union TaskError = InvalidInputError | NotFoundError | ConflictError type TasksMutations { - "The assignee finishes a task to do." + "The assignee finishes a task to do, whatever created it." markDone(input: TaskMarkDoneInput!): TaskPayload! - "The assignee takes a done task back to do." - reopen(input: TaskReopenInput!): TaskPayload! - "The person who asked withdraws an open task." - cancel(input: TaskCancelInput!): TaskPayload! + "The person who asked deletes an open task, with its place in everyone's tasks." + delete(input: TaskDeleteInput!): TaskDeletePayload! } type Mutation { diff --git a/packages/api/src/modules/tasks/index.ts b/packages/api/src/core/tasks/index.ts similarity index 53% rename from packages/api/src/modules/tasks/index.ts rename to packages/api/src/core/tasks/index.ts index 4e669f397..0ea9c8d82 100644 --- a/packages/api/src/modules/tasks/index.ts +++ b/packages/api/src/core/tasks/index.ts @@ -1,29 +1,25 @@ -import { readSdl } from "../../core/graphql"; -import type { ModuleDependencies, ModuleManifest } from "../../core/moduleManifest"; -import type { EmployeeReadService } from "../employees"; +import { readSdl } from "../graphql"; +import type { ModuleDependencies, ModuleManifest } from "../moduleManifest"; import { taskRecordType, type TaskService } from "./app"; import { createResolvers } from "./graphql"; // Public values. -export { createTaskService } from "./app"; +export { createTaskService, TASK_TRIGGERING_EVENTS, taskRecordType } from "./app"; // Public types. export type { NewTask, TaskService } from "./app"; +export { MANUAL_TO_DO } from "./domain"; export type { Task, TaskStatus } from "./domain"; /** Work asked of people, and each person's My tasks. */ export function tasksModule({ orm, tasks, - employees, -}: Pick & { - tasks: TaskService; - employees: Pick; -}): ModuleManifest { +}: Pick & { tasks: TaskService }): ModuleManifest { return { name: "tasks", typeDefs: readSdl(import.meta.url, "./graphql/schema.graphql"), - resolvers: createResolvers(tasks, employees), + resolvers: createResolvers(tasks), recordTypes: { task: taskRecordType(orm) }, }; } diff --git a/packages/api/src/core/tasks/module.md b/packages/api/src/core/tasks/module.md new file mode 100644 index 000000000..cb3bdea29 --- /dev/null +++ b/packages/api/src/core/tasks/module.md @@ -0,0 +1,19 @@ +# Tasks + +A task is work asked of one person, its assignee: by a person who asked (`manual_to_do`), or by a feature such as a process step, which names its own type and has nobody who asked. Tasks know no feature: a feature follows its tasks through their events. Only the assignee and the person who asked see a task; anyone else gets "No task has this id.", so a task's existence does not leak. The person a task is about never sees it for that reason alone. + +## Rules + +- A task asks for work and never grants authority. A feature creates its tasks with `TaskService.createTasks` in its own transaction, passing an unstored `sourceKey` that `tasks.task_created` publishes. +- Statuses: `to_do`, `waiting`, `done` and `cancelled`; done and cancelled are final. Only the assignee marks a to-do done, whatever created it; a feature follows from `tasks.task_status_changed` before the commit. Only the person who asked deletes an open task. Only the feature that created a task cancels it, with a reason its assignee reads (`cancelInTransaction`). +- The first change wins: each change locks the task's row, so a second Mark done gets "This task is already done." +- Every change appends a history line for record type `task`, in its transaction, and returns it. Tasks register their own reading rule with core's records: `recordHistory` and `Task.history` serve a task's lines to its assignee and the person who asked, and refuse anyone else. +- Deleting the assignee or the person who asked deletes the task; deleting the person it is about or who finished it leaves it, without them. Reset setup and Exit demo forget the tasks' history. A deleted task's history line records its deletion. + +## Data model + +- **`tasks`:** `type` (`manual_to_do`, or a feature's own type such as `process_step_to_do`), `status`, `title`, `description`, `assignee_employee_id` (the assignee), `asked_by_employee_id`, `subject_employee_id`, the record it acts on (`record_type`, `record_id`), `due_date`, and the done and cancelled columns that follow the status. Checks keep a manual to-do, and only it, with someone who asked, and the columns matching its status. + +## My tasks + +`TaskService.listMyTasks` groups the caller's tasks by where the ball is: **Needs you** (tasks they hold to do, by due date), **Waiting on others** (open tasks they asked someone else for, and their own waiting tasks) and **Resolved recently** (done or cancelled in the last 14 days, newest first). diff --git a/packages/api/src/modules/demo/app/demoFillService.ts b/packages/api/src/modules/demo/app/demoFillService.ts index a74146c25..317ad9272 100644 --- a/packages/api/src/modules/demo/app/demoFillService.ts +++ b/packages/api/src/modules/demo/app/demoFillService.ts @@ -4,7 +4,7 @@ import type { Tx } from "../../../core/db"; import { PrincipalSecurityContext, type SecurityContext } from "../../../core/security"; import { type CalendarDate, parseRasterImage, type RasterImage, todayInUtc } from "../../../utils"; import { createEmployeeSetupService } from "../../employees"; -import type { TaskService } from "../../tasks"; +import type { TaskService } from "../../../core/tasks"; import type { TimeOffService } from "../../timeOff"; import { type DemoTask, demoPeople, demoTasks } from "../domain"; diff --git a/packages/api/src/modules/employees/adapters/index.ts b/packages/api/src/modules/employees/adapters/index.ts new file mode 100644 index 000000000..2956e92f7 --- /dev/null +++ b/packages/api/src/modules/employees/adapters/index.ts @@ -0,0 +1 @@ +export { createPeopleAdapter, createWorkingCalendarsAdapter } from "./processAdapters"; diff --git a/packages/api/src/modules/processes/app/workingCalendars.ts b/packages/api/src/modules/employees/adapters/processAdapters.ts similarity index 56% rename from packages/api/src/modules/processes/app/workingCalendars.ts rename to packages/api/src/modules/employees/adapters/processAdapters.ts index 9b48e3815..fff463363 100644 --- a/packages/api/src/modules/processes/app/workingCalendars.ts +++ b/packages/api/src/modules/employees/adapters/processAdapters.ts @@ -1,30 +1,50 @@ import { ApplicationError } from "../../../core/applicationError"; -import type { Tx } from "../../../core/db"; +import type { ModuleDependencies } from "../../../core/moduleManifest"; +import { + type IsWorkingDay, + MAX_SEARCH_DAYS, + type PeoplePort, + type WorkingCalendarsPort, + workingDaysOf, +} from "../../../core/processes"; import { PrincipalSecurityContext } from "../../../core/security"; import { addDays, type CalendarDate } from "../../../utils"; import type { BankHolidayService } from "../../bankHolidays"; -import type { EmployeeWorkingPatternService } from "../../employees"; -import { type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "../domain"; +import { + createEmployeeReadService, + createEmployeeWorkingPatternService, + employeeDirectory, +} from "../app"; +import { employmentRepository } from "../db"; -/** People's working days, by their own working weeks and bank holidays, for counting business days. */ -export interface WorkingCalendars { - /** - * Each person's working days around these days, refusing a year with no bank holidays known; - * null for a person on no terms, whose offsets count calendar days. - */ - readWorkingDays( - tx: Tx, - input: { employeeIds: readonly string[]; days: readonly CalendarDate[] }, - ): Promise>; +/** Processes read people through this: whom a run is about, their managers and the owner. */ +export function createPeopleAdapter({ orm }: Pick): PeoplePort { + const employeeReads = createEmployeeReadService({ orm }); + return { + async readPeople(tx, employeeIds) { + const found = await employeeReads.readEmployeesByName(tx, employeeIds); + const startDates = await employmentRepository(tx).findStartDates(found.map(({ id }) => id)); + return new Map( + found.map(({ id, firstName }) => [ + id, + { id, firstName, startDate: startDates.get(id) ?? null }, + ]), + ); + }, + readManagersOn: (tx, { employeeIds, date }) => + employmentRepository(tx).findManagerIds(employeeIds, date), + readOwner: (tx) => employeeDirectory(tx).ownersEmployeeId(), + }; } -export function createWorkingCalendars({ - workingPatterns, +/** Processes count business days through this: people's working weeks and bank holidays. */ +export function createWorkingCalendarsAdapter({ + transact, bankHolidays, -}: { - workingPatterns: EmployeeWorkingPatternService; +}: Pick & { bankHolidays: BankHolidayService; -}): WorkingCalendars { +}): WorkingCalendarsPort { + const workingPatterns = createEmployeeWorkingPatternService({ transact }); return { async readWorkingDays(tx, { employeeIds, days }) { if (employeeIds.length === 0) return new Map(); @@ -49,7 +69,7 @@ export function createWorkingCalendars({ until, }); return new Map( - employeeIds.map((employeeId) => { + employeeIds.map((employeeId): [string, IsWorkingDay | null] => { const weeks = weeksByEmployee.get(employeeId) ?? []; return [employeeId, workingDaysOf(weeks, isBankHolidayIn(weeks, holidays))]; }), diff --git a/packages/api/src/modules/employees/app/employeeReadService.ts b/packages/api/src/modules/employees/app/employeeReadService.ts index 31d0e8f40..cfe497118 100644 --- a/packages/api/src/modules/employees/app/employeeReadService.ts +++ b/packages/api/src/modules/employees/app/employeeReadService.ts @@ -65,22 +65,6 @@ export interface EmployeeReadService { tx: Tx, employeeIds: readonly string[], ) => Promise>; - /** - * Trusted, for processes: the employees whose first employment starts from `from` to `until`, both - * included, with that date, in the caller's transaction. - */ - readEmployeesStartingBetween: ( - tx: Tx, - range: { from: CalendarDate; until: CalendarDate }, - ) => Promise>; - /** - * Trusted, for processes: each employee's direct manager on `date`, by id, in the caller's - * transaction; those without one on that date are left out. - */ - readManagerIdsOn: ( - tx: Tx, - input: { employeeIds: readonly string[]; date: CalendarDate }, - ) => Promise>; /** Trusted: the employees among `employeeIds`, by first then last name, in the caller's transaction. */ readEmployeesByName: (tx: Tx, employeeIds: readonly string[]) => Promise; /** @@ -164,10 +148,6 @@ export function createEmployeeReadService({ lockEmployees: (tx, employeeIds) => employeeRepository(tx).lockByIds(employeeIds), holdEmployeeWrites: (tx) => employmentRepository(tx).lockForReads(), readEmploymentPeriods: (tx, employeeIds) => employmentRepository(tx).findPeriods(employeeIds), - readEmployeesStartingBetween: (tx, { from, until }) => - employmentRepository(tx).findStartingBetween(from, until), - readManagerIdsOn: (tx, { employeeIds, date }) => - employmentRepository(tx).findManagerIds(employeeIds, date), readEmployeesByName, readTermsMemberships: readMemberships, async readTermsMemberIds(tx, termsId) { diff --git a/packages/api/src/modules/employees/app/employeeRecordType.ts b/packages/api/src/modules/employees/app/employeeRecordType.ts index 3f61757f7..490de433a 100644 --- a/packages/api/src/modules/employees/app/employeeRecordType.ts +++ b/packages/api/src/modules/employees/app/employeeRecordType.ts @@ -1,6 +1,6 @@ import type { Orm } from "../../../core/db"; import type { RecordTypeRegistration } from "../../../core/records"; -import { employeeRepository } from "../db"; +import { employeeRepository, employmentRepository } from "../db"; import { parseUuid } from "../../../utils"; /** The fields an employee update may change, by their input names; `avatar` arrives with the photo upload. */ @@ -18,7 +18,8 @@ const EDITABLE_FIELDS = [ /** * The employee as a record type that keeps history: the id history keeps, and how core finds * employees and their subjects, each employee being their own. `recordHistory` and - * `Employee.history` both read through it, so one rule covers both. + * `Employee.history` both read through it, so one rule covers both. A process may run on an + * employee, starting on their first employment's `startDate`. */ export function employeeRecordType(orm: Orm): RecordTypeRegistration { return { @@ -34,5 +35,16 @@ export function employeeRecordType(orm: Orm): RecordTypeRegistration { }, permissions: { object: "employee", editableFields: EDITABLE_FIELDS }, references: { manager: "employee" }, + processes: { + readSubjects: async (tx, ids) => { + const employees = await employeeRepository(tx).findByIds( + ids.flatMap((id) => parseUuid(id) ?? []), + ); + return new Map(employees.map(({ id }) => [id, id])); + }, + dateFields: ["startDate"], + findByDateBetween: (tx, { from, until }) => + employmentRepository(tx).findStartingBetween(from, until), + }, }; } diff --git a/packages/api/src/modules/employees/app/employeeTriggeringEvents.ts b/packages/api/src/modules/employees/app/employeeTriggeringEvents.ts new file mode 100644 index 000000000..d83cd7555 --- /dev/null +++ b/packages/api/src/modules/employees/app/employeeTriggeringEvents.ts @@ -0,0 +1,12 @@ +import type { DomainEvents } from "../../../core/events"; + +/** Employees' events that may start a process: one run per employee added. */ +export const EMPLOYEE_TRIGGERING_EVENTS = { + "employees.added": { + recordType: "employee", + recordIds: ({ employeeIds }: DomainEvents["employees.added"]) => employeeIds, + payloadFor: (_: DomainEvents["employees.added"], employeeId: string) => ({ + employeeIds: [employeeId], + }), + }, +}; diff --git a/packages/api/src/modules/employees/app/index.ts b/packages/api/src/modules/employees/app/index.ts index 639db251e..d42033b72 100644 --- a/packages/api/src/modules/employees/app/index.ts +++ b/packages/api/src/modules/employees/app/index.ts @@ -42,6 +42,7 @@ export { type OwnWorkingWeekService, } from "./employmentTerms/ownWorkingWeekService"; export { employeeRecordType } from "./employeeRecordType"; +export { EMPLOYEE_TRIGGERING_EVENTS } from "./employeeTriggeringEvents"; export { type CopyTermsParts, createTermsService, diff --git a/packages/api/src/modules/employees/graphql/loaders.ts b/packages/api/src/modules/employees/graphql/loaders.ts index b11ff7887..919fbf777 100644 --- a/packages/api/src/modules/employees/graphql/loaders.ts +++ b/packages/api/src/modules/employees/graphql/loaders.ts @@ -36,6 +36,8 @@ export interface EmployeeLoaders { terms: DataLoader; /** A person's terms and working week, or null for a caller who may not read them. */ employmentTerms: DataLoader; + /** An employee by id, with the caller's access, or null; for the people a task names. */ + employee: DataLoader; } /** The `terms` loader's key for the default terms, which a location without terms starts people on. */ @@ -52,6 +54,10 @@ export function employeeLoaders( invites: EmployeeInviteService, ): (context: GraphqlContext) => EmployeeLoaders { return perRequest((context) => ({ + employee: new DataLoader(async (employeeIds) => { + const found = await services.readEmployees(context.getActor(), employeeIds); + return employeeIds.map((id) => found.get(id) ?? null); + }), manager: new DataLoader(async (employeeIds) => { const managers = await services.readManagers(context.getActor(), employeeIds); return employeeIds.map((id) => managers.get(id) ?? null); diff --git a/packages/api/src/modules/employees/graphql/resolvers.ts b/packages/api/src/modules/employees/graphql/resolvers.ts index ed477a773..b43ca5ece 100644 --- a/packages/api/src/modules/employees/graphql/resolvers.ts +++ b/packages/api/src/modules/employees/graphql/resolvers.ts @@ -53,6 +53,17 @@ export function createResolvers( terms: (_parent, { id }, context) => services.readTerms(context.getActor(), id), ...terms.Query, }, + Task: { + // The assignee's deletion deletes the task, so the assignee is always there. + assignee: async ({ assigneeEmployeeId }, _args, context) => + (await loaders(context).employee.load(assigneeEmployeeId))!, + askedBy: ({ askedByEmployeeId }, _args, context) => + askedByEmployeeId ? loaders(context).employee.load(askedByEmployeeId) : null, + subject: ({ subjectEmployeeId }, _args, context) => + subjectEmployeeId ? loaders(context).employee.load(subjectEmployeeId) : null, + doneBy: ({ doneByEmployeeId }, _args, context) => + doneByEmployeeId ? loaders(context).employee.load(doneByEmployeeId) : null, + }, Location: { // Never null: deleting terms gives their locations none, which reads as the default terms. terms: async ({ termsId }, _args, context) => diff --git a/packages/api/src/modules/employees/graphql/schema.graphql b/packages/api/src/modules/employees/graphql/schema.graphql index 064f8063f..5ca72d7b7 100644 --- a/packages/api/src/modules/employees/graphql/schema.graphql +++ b/packages/api/src/modules/employees/graphql/schema.graphql @@ -672,3 +672,14 @@ enum RuleSource { "Set for the person alone, on top of their terms." THEIR_OWN } + +extend type Task { + "Who holds it: the one person who can do it." + assignee: Employee! + "Who asked for it; null when a feature, such as a process step, created it." + askedBy: Employee + "Who it is about, when it is about someone; being its subject shows nobody the task." + subject: Employee + "Null once that person is removed." + doneBy: Employee +} diff --git a/packages/api/src/modules/employees/index.ts b/packages/api/src/modules/employees/index.ts index 98892a4f4..6ea4e20e4 100644 --- a/packages/api/src/modules/employees/index.ts +++ b/packages/api/src/modules/employees/index.ts @@ -40,10 +40,13 @@ export { createLocationService, createOwnWorkingWeekService, createTermsService, + EMPLOYEE_TRIGGERING_EVENTS, employeeDirectory, employeeNotFound, + employeeRecordType, termsRecordType, } from "./app"; +export { createPeopleAdapter, createWorkingCalendarsAdapter } from "./adapters"; export { cancelOwnChange, cancelPlannedVersion, diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts index b08de2ff6..70f8f9285 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -11,18 +11,31 @@ import { companyModule, createCompany } from "./company"; import { createDemoFillService, demoModule } from "./demo"; import { createEmployeeInviteService, - createEmployeeReadService, createEmployeeSetupService, + createPeopleAdapter, + createWorkingCalendarsAdapter, + EMPLOYEE_TRIGGERING_EVENTS, employeeDirectory, + employeeRecordType, employeesModule, } from "./employees"; import { externalAgentsModule } from "./externalAgents"; import { createImportServices, importsModule } from "./imports"; import { mcpAgentsModule } from "./mcpAgents"; -import { createProcessService, processesModule } from "./processes"; +import { + createProcessService, + processesModule, + type TriggeringEventRegistration, +} from "../core/processes"; import { createSlack, slackModule, type SlackUrls } from "./slack"; import { systemModule } from "./system"; -import { createTaskService, tasksModule } from "./tasks"; +import { + createTaskService, + TASK_TRIGGERING_EVENTS, + taskRecordType, + tasksModule, +} from "../core/tasks"; +import { collectRegistrations } from "../utils"; import { createTimeOffSetupService, createTimeOffService, timeOffModule } from "./timeOff"; const employeeSetup = createEmployeeSetupService(); @@ -56,12 +69,23 @@ export function createModules( }); const timeOff = createTimeOffService({ ...core, bankHolidays }); const tasks = createTaskService(core); - const processes = createProcessService({ ...core, tasks, bankHolidays }); + const processes = createProcessService({ + ...core, + tasks, + people: createPeopleAdapter(core), + workingCalendars: createWorkingCalendarsAdapter({ ...core, bankHolidays }), + // The record types a process may run on, registered by the modules that own them. + recordTypes: { employee: employeeRecordType(core.orm), task: taskRecordType(core.orm) }, + triggeringEvents: collectRegistrations("triggering event", [ + EMPLOYEE_TRIGGERING_EVENTS, + TASK_TRIGGERING_EVENTS, + ]), + }); // Every path that clears the people resets time off, processes and tasks too, back to a fresh workspace. const deleteAllEmployees: typeof employeeSetup.deleteAllEmployees = async (ctx, tx) => { // The policies' lock first: turning time off on holds it while its allowances wait on employee rows. await timeOff.lockPolicies(ctx, tx); - // Then the processes: a step command locks its process before its run and task. + // Then the processes: a run's start locks its process before it creates tasks. await processes.lockProcesses(ctx, tx); // Then the tasks: a task change can create tasks for employees before it commits. await tasks.lockTasks(ctx, tx); @@ -122,7 +146,7 @@ export function createModules( bankHolidays, }), }), - tasksModule({ orm: core.orm, tasks, employees: createEmployeeReadService(core) }), + tasksModule({ orm: core.orm, tasks }), processesModule({ logger: core.logger, processes }), ]; return { diff --git a/packages/api/src/modules/processes/app/cancelRun.int.test.ts b/packages/api/src/modules/processes/app/cancelRun.int.test.ts deleted file mode 100644 index db33d6b2c..000000000 --- a/packages/api/src/modules/processes/app/cancelRun.int.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - ADMIN, - calendarDate, - expect, - processService, - signedIn, - taskService, - test, -} from "#test/support"; - -import { WELCOME_NEW_STARTER } from "../domain"; - -test("an admin cancels open tasks with the reason and preserves completed steps", async ({ - orm, - seed, -}) => { - const admin = await seed.employee(); - const manager = await seed.employee(); - const employee = await seed.employee(); - await seed.manager(employee.id, manager.id); - const adminCtx = signedIn(orm, { employeeId: admin.id, profiles: ["ADMIN"] }); - const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); - const employeeCtx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(adminCtx, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - await processes.markStepDone(managerCtx, started.steps[0]!.task!.id); - - const cancelled = await processes.cancelRun(adminCtx, started.run.id, " No longer needed "); - - expect(cancelled.run).toMatchObject({ - status: "cancelled", - cancelledAt: expect.any(Date) as unknown, - cancelledByEmployeeId: admin.id, - cancelCause: "cancelled_by_admin", - cancelReason: "No longer needed", - }); - expect(cancelled.steps).toMatchObject([ - { status: "step_completed", task: { status: "done" } }, - { status: "step_cancelled", task: { status: "cancelled" } }, - ]); - expect(await taskService(orm).readTask(employeeCtx, started.steps[1]!.task!.id)).toMatchObject({ - status: "cancelled", - cancelReason: "No longer needed", - }); - expect((await processes.readRun(ADMIN, started.run.id)).run).toEqual(cancelled.run); -}); - -test("refuses a blank cancellation reason without changing the run", async ({ orm, seed }) => { - const employee = await seed.employee(); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - - await expect(processes.cancelRun(ADMIN, started.run.id, " \n\t ")).rejects.toMatchObject({ - code: "INVALID_INPUT", - fieldErrors: [{ field: "reason", code: "required" }], - }); - expect(await processes.readRun(ADMIN, started.run.id)).toEqual(started); -}); - -test("refuses cancellation of a finished run", async ({ orm, seed }) => { - const employee = await seed.employee(); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - await processes.markStepDone(ctx, started.steps[1]!.task!.id); - - await expect( - processes.cancelRun(ADMIN, started.run.id, "No longer needed"), - ).rejects.toMatchObject({ - code: "CONFLICT", - message: "Only a run in progress can be cancelled.", - }); -}); - -test("generic task cancellation refuses process tasks", async ({ orm, seed }) => { - const employee = await seed.employee(); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - - await expect( - taskService(orm).cancel(ctx, started.steps[1]!.task!.id, "No longer needed"), - ).rejects.toMatchObject({ - code: "CONFLICT", - message: "This task belongs to a process.", - }); - expect((await processes.readRun(ADMIN, started.run.id)).run.status).toBe("in_progress"); -}); diff --git a/packages/api/src/modules/processes/app/dueRunService.ts b/packages/api/src/modules/processes/app/dueRunService.ts deleted file mode 100644 index 98c49d546..000000000 --- a/packages/api/src/modules/processes/app/dueRunService.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { isUniqueViolation, type Orm, type Transact, type Tx } from "../../../core/db"; -import type { Logger } from "../../../core/logger"; -import type { SecurityContext } from "../../../core/security"; -import { type CalendarDate, dateInLondon } from "../../../utils"; -import { processRepository, type StoredProcess } from "../db"; -import { dueTriggerDate, parseProcessDefinition, type ProcessRun } from "../domain"; -import type { RunLifecycleService } from "./runLifecycleService"; -import type { RunStarter } from "./runStarter"; - -/** Broccoli's daily pass over processes. */ -export interface DueRunService { - /** - * Starts the runs whose record date has come and cancels runs in progress that can no longer run. - * One run per transaction, so one failure stops no other. - */ - startDueRuns( - ctx: SecurityContext, - today: CalendarDate, - ): Promise<{ runsStarted: number; runsCancelled: number }>; -} - -export function createDueRunService({ - orm, - transact, - logger, - starter, - lifecycle, -}: { - orm: Orm; - transact: Transact; - logger: Logger; - starter: RunStarter; - lifecycle: RunLifecycleService; -}): DueRunService { - return { - async startDueRuns(ctx, today) { - ctx.requireSystem(); - let runsStarted = 0; - for (const process of await processRepository(orm).listProcesses()) { - if (!process.turnedOnAt) continue; - const { activeVersion } = process; - const { trigger } = parseProcessDefinition( - activeVersion.schemaVersion, - activeVersion.definition, - ); - if (trigger.kind !== "on_record_date") continue; - const turnedOnDay = dateInLondon(process.turnedOnAt); - const due = await transact((tx) => starter.findDue(tx, trigger, { today, turnedOnDay })); - const withRuns = await processRepository(orm).findRecordsWithRuns( - process.id, - "employee", - due.map(({ employeeId }) => employeeId), - ); - for (const { employeeId } of due) { - if ( - !withRuns.has(employeeId) && - (await startOne({ transact, logger, starter }, process, employeeId, today)) - ) - runsStarted += 1; - } - } - let runsCancelled = 0; - for (const run of await processRepository(orm).listRunsInProgress()) { - try { - const checked = await transact(async (tx) => - lifecycle.cancelIfCannotRun(tx, (await processRepository(tx).lockRun(run.id))!), - ); - if (checked.status !== "cancelled") continue; - runsCancelled += 1; - logger.info({ runId: run.id, cancelCause: checked.cancelCause }, "Process run cancelled"); - } catch (error) { - logger.error({ runId: run.id, kind: errorKind(error) }, "Process run check failed"); - } - } - return { runsStarted, runsCancelled }; - }, - }; -} - -/** Starts one record's run in its own transaction; logs a failure instead of throwing. */ -async function startOne( - { transact, logger, starter }: { transact: Transact; logger: Logger; starter: RunStarter }, - process: StoredProcess, - employeeId: string, - today: CalendarDate, -): Promise { - try { - const run = await transact((tx) => - startIfStillDue(starter, tx, { processId: process.id, employeeId, today }), - ); - if (run) { - logger.info( - { runId: run.id, processKey: process.key, startCause: run.startCause }, - "Process run started", - ); - } - return run; - } catch (error) { - const reason = isUniqueViolation(error, "process_runs_start_key_unique") - ? "already_started" - : errorKind(error); - logger.error({ processKey: process.key, employeeId, reason }, "Process run not started"); - return null; - } -} - -/** - * Starts the record's run once the process is locked, if the process is still on and the record is - * still due and without a run: either may have changed since the pass looked. - */ -async function startIfStillDue( - starter: RunStarter, - tx: Tx, - { processId, employeeId, today }: { processId: string; employeeId: string; today: CalendarDate }, -): Promise { - const processes = processRepository(tx); - await processes.lockProcess(processId); - const [process] = await processes.findProcessesByIds([processId]); - const subject = await starter.readSubject(tx, employeeId); - if (!process?.turnedOnAt || !subject?.startDate) return null; - const { activeVersion } = process; - const definition = parseProcessDefinition(activeVersion.schemaVersion, activeVersion.definition); - if (definition.trigger.kind !== "on_record_date") return null; - const triggerDate = dueTriggerDate(definition.trigger, subject.startDate, { - today, - turnedOnDay: dateInLondon(process.turnedOnAt), - }); - const hasRun = - (await processes.findRecordsWithRuns(processId, "employee", [employeeId])).size > 0; - if (!triggerDate || hasRun) return null; - const [run] = await starter.startMany(tx, [ - { - process, - definition, - record: { recordType: "employee", recordId: employeeId }, - subject, - startCause: "started_on_record_date", - startedByEmployeeId: null, - triggerDate, - triggerEvent: null, - startKey: `on_record_date:employee:${employeeId}:${triggerDate}`, - startDay: today, - }, - ]); - return run!; -} - -function errorKind(error: unknown): string | undefined { - return (error as Error | undefined)?.constructor?.name; -} diff --git a/packages/api/src/modules/processes/app/markStepDone.int.test.ts b/packages/api/src/modules/processes/app/markStepDone.int.test.ts deleted file mode 100644 index b4377a0df..000000000 --- a/packages/api/src/modules/processes/app/markStepDone.int.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { - ADMIN, - calendarDate, - employeeServices, - expect, - processService, - signedIn, - taskService, - test, -} from "#test/support"; - -import { WELCOME_NEW_STARTER } from "../domain"; - -test("both assignees receive their tasks and completing both finishes the welcome", async ({ - orm, - seed, -}) => { - const manager = await seed.employee(); - const sam = await seed.employee({ firstName: "Sam" }); - await seed.manager(sam.id, manager.id); - const samCtx = signedIn(orm, { employeeId: sam.id, profiles: ["EMPLOYEE"] }); - const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const tasks = taskService(orm); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: sam.id, - }); - const meetingId = started.steps[0]!.task!.id; - const coffeeId = started.steps[1]!.task!.id; - expect((await tasks.listMyTasks(managerCtx)).needsYou).toMatchObject([{ id: meetingId }]); - expect((await tasks.listMyTasks(samCtx)).needsYou).toMatchObject([{ id: coffeeId }]); - - expect((await processes.markStepDone(managerCtx, meetingId)).run.status).toBe("in_progress"); - const completed = await processes.markStepDone(samCtx, coffeeId); - - expect(completed.run).toMatchObject({ - status: "finished", - finishedAt: expect.any(Date) as unknown, - }); - expect((await processes.readRun(ADMIN, started.run.id)).steps).toMatchObject([ - { status: "step_completed", task: { status: "done" } }, - { status: "step_completed", task: { status: "done" } }, - ]); - expect((await processes.readRun(ADMIN, started.run.id)).run.status).toBe("finished"); - expect(await processes.readStepProgress(managerCtx, meetingId)).toMatchObject({ - status: "finished", - doneSteps: 2, - totalSteps: 2, - }); - expect(await processes.readStepProgress(samCtx, coffeeId)).toMatchObject({ - status: "finished", - doneSteps: 2, - totalSteps: 2, - }); -}); - -test("generic task completion refuses a process task without changing it", async ({ - orm, - seed, -}) => { - const employee = await seed.employee(); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const taskId = started.steps[1]!.task!.id; - - await expect(taskService(orm).markDone(ctx, taskId)).rejects.toMatchObject({ - code: "CONFLICT", - message: "This task belongs to a process.", - }); - expect((await processes.readRun(ADMIN, started.run.id)).steps[1]).toMatchObject({ - status: "step_awaiting_task_completion", - task: { status: "to_do" }, - }); -}); - -test("generic task reopening refuses a completed process task", async ({ orm, seed }) => { - const employee = await seed.employee(); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const taskId = started.steps[1]!.task!.id; - await processes.markStepDone(ctx, taskId); - - await expect(taskService(orm).reopen(ctx, taskId)).rejects.toMatchObject({ - code: "CONFLICT", - message: "This task belongs to a process.", - }); - expect((await processes.readRun(ADMIN, started.run.id)).run.status).toBe("finished"); -}); - -test("reopening a completed step puts a finished run back in progress", async ({ orm, seed }) => { - const employee = await seed.employee(); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const taskId = started.steps[1]!.task!.id; - expect((await processes.markStepDone(ctx, taskId)).run.status).toBe("finished"); - - const reopened = await processes.reopenStep(ctx, taskId); - - expect(reopened.run).toMatchObject({ status: "in_progress", finishedAt: null }); - expect(reopened.task).toMatchObject({ status: "to_do", doneAt: null, doneByEmployeeId: null }); - expect((await processes.readRun(ADMIN, started.run.id)).steps[1]).toMatchObject({ - status: "step_awaiting_task_completion", - finishedAt: null, - }); -}); - -test("refuses reopening a step in a cancelled run", async ({ orm, seed }) => { - const manager = await seed.employee(); - const employee = await seed.employee(); - await seed.manager(employee.id, manager.id); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const taskId = started.steps[1]!.task!.id; - await processes.markStepDone(ctx, taskId); - await processes.cancelRun(ADMIN, started.run.id, "No longer needed"); - - await expect(processes.reopenStep(ctx, taskId)).rejects.toMatchObject({ - code: "CONFLICT", - message: "This process was cancelled.", - }); -}); - -test("refuses reopening a finished welcome after its subject was removed", async ({ - orm, - seed, -}) => { - const manager = await seed.employee(); - const employee = await seed.employee(); - await seed.manager(employee.id, manager.id); - const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); - const employeeCtx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const meetingId = started.steps[0]!.task!.id; - await processes.markStepDone(managerCtx, meetingId); - await processes.markStepDone(employeeCtx, started.steps[1]!.task!.id); - await employeeServices(orm).deleteEmployee(ADMIN, { id: employee.id }); - - await expect(processes.reopenStep(managerCtx, meetingId)).rejects.toMatchObject({ - code: "CONFLICT", - message: "The person this process is about was removed from Broccoli.", - }); - expect(await taskService(orm).readTask(managerCtx, meetingId)).toMatchObject({ status: "done" }); - expect((await processes.readRun(ADMIN, started.run.id)).run.status).toBe("finished"); -}); - -test("refuses reopening an older welcome while a newer one is in progress", async ({ - orm, - seed, -}) => { - const employee = await seed.employee({ firstName: "Sam" }); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const first = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const taskId = first.steps[1]!.task!.id; - await processes.markStepDone(ctx, taskId); - await processes.startRun(ADMIN, { processKey: WELCOME_NEW_STARTER, employeeId: employee.id }); - - await expect(processes.reopenStep(ctx, taskId)).rejects.toMatchObject({ - code: "CONFLICT", - message: "Welcome a new starter was started again for Sam; this run is closed.", - }); - expect((await processes.readRun(ADMIN, first.run.id)).run.status).toBe("finished"); -}); - -test("completing a remaining task persists cancellation when the other assignee was removed", async ({ - orm, - seed, -}) => { - const manager = await seed.employee(); - const employee = await seed.employee({ firstName: "Sam" }); - await seed.manager(employee.id, manager.id); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const taskId = started.steps[1]!.task!.id; - await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); - - const changed = await processes.markStepDone(ctx, taskId); - - expect(changed.run.status).toBe("cancelled"); - expect(changed.run).not.toHaveProperty("cancelReason"); - expect(changed.run).not.toHaveProperty("cancelCause"); - expect(changed.task).toMatchObject({ - status: "cancelled", - cancelReason: "This process was cancelled.", - }); - expect((await processes.readRun(ADMIN, started.run.id)).run).toMatchObject({ - status: "cancelled", - cancelCause: "cancelled_because_step_assignee_was_removed", - cancelReason: "The assignee of 'Have a welcome 1:1 with Sam' was removed from Broccoli.", - }); - expect(await taskService(orm).readTask(ctx, taskId)).toMatchObject({ - status: "cancelled", - cancelReason: "This process was cancelled.", - }); -}); - -test("lazy cancellation records Broccoli as the task history author", async ({ orm, seed }) => { - const manager = await seed.employee(); - const employee = await seed.employee(); - await seed.manager(employee.id, manager.id); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - const taskId = started.steps[1]!.task!.id; - await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); - - await processes.markStepDone(ctx, taskId); - - const history = await taskService(orm).readTaskHistory(ctx, taskId, { first: 10 }); - expect(history.edges[0]!.node).toMatchObject({ - author: { kind: "BROCCOLI_ITSELF" }, - cause: { kind: "PROCESS_RUN", ref: started.run.id }, - changes: expect.arrayContaining([ - { field: "status", oldValue: "to_do", newValue: "cancelled" }, - ]) as unknown, - }); -}); - -test("a completed step whose assignee left does not cancel the remaining work", async ({ - orm, - seed, -}) => { - const manager = await seed.employee(); - const employee = await seed.employee(); - await seed.manager(employee.id, manager.id); - const managerCtx = signedIn(orm, { employeeId: manager.id, profiles: ["EMPLOYEE"] }); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm, { today: () => calendarDate("2026-12-03") }); - const started = await processes.startRun(ADMIN, { - processKey: WELCOME_NEW_STARTER, - employeeId: employee.id, - }); - await processes.markStepDone(managerCtx, started.steps[0]!.task!.id); - await employeeServices(orm).deleteEmployee(ADMIN, { id: manager.id }); - - const completed = await processes.markStepDone(ctx, started.steps[1]!.task!.id); - - expect(completed.run.status).toBe("finished"); - expect((await processes.readRun(ADMIN, started.run.id)).steps[0]).toMatchObject({ - status: "step_completed", - task: null, - }); -}); - -test("refuses a manual task through the process completion commands", async ({ orm, seed }) => { - const employee = await seed.employee(); - const task = await seed.task({ assigneeEmployeeId: employee.id, askedByEmployeeId: employee.id }); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - const processes = processService(orm); - - await expect(processes.markStepDone(ctx, task.id)).rejects.toMatchObject({ - code: "CONFLICT", - message: "This task does not belong to a process.", - }); - await expect(processes.reopenStep(ctx, task.id)).rejects.toMatchObject({ - code: "CONFLICT", - message: "This task does not belong to a process.", - }); -}); diff --git a/packages/api/src/modules/processes/app/runLifecycleService.ts b/packages/api/src/modules/processes/app/runLifecycleService.ts deleted file mode 100644 index f5b5003f4..000000000 --- a/packages/api/src/modules/processes/app/runLifecycleService.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { ApplicationError, invalidInput } from "../../../core/applicationError"; -import type { Orm, Transact, Tx } from "../../../core/db"; -import type { Logger } from "../../../core/logger"; -import { PrincipalSecurityContext, type SecurityContext } from "../../../core/security"; -import type { Task, TaskService } from "../../tasks"; -import { processRepository, type StoredProcess } from "../db"; -import { - type CancelCause, - cancelRun, - completeStep, - PROCESS_CANCELLED, - type ProcessRun as StoredRun, - removalCancellation, - reopenStep, - type RunStep, - settleRun, - SUBJECT_REMOVED, -} from "../domain"; -import type { ProcessRun, ProcessRunDetails, StepChange, StepProgress } from "./processViews"; -import type { RunReader } from "./runReader"; -import type { RunStarter } from "./runStarter"; - -/** - * A run while it runs: its assignees finish and reopen their steps, admins cancel it, and it - * cancels itself once its subject or a waiting step's assignee is gone. - */ -export interface RunLifecycleService { - /** The assignee marks their step's task done; the run finishes when every step is done. */ - markStepDone(ctx: SecurityContext, taskId: string): Promise; - /** The assignee takes their step's done task back to do; a finished run goes back in progress. */ - reopenStep(ctx: SecurityContext, taskId: string): Promise; - /** The run behind a task its assignee or asker reads, as counts. */ - readStepProgress(ctx: SecurityContext, taskId: string): Promise; - /** An admin cancels a run in progress: its open tasks with the reason, done steps stay done. */ - cancelRun(ctx: SecurityContext, runId: string, reason: string): Promise; - /** - * The locked run, cancelled and saved when its subject or a waiting step's assignee is gone; - * else as it was. The run cancels itself, so Broccoli is the author, whoever's command noticed. - */ - cancelIfCannotRun(tx: Tx, run: StoredRun): Promise; -} - -export function createRunLifecycleService({ - orm, - transact, - logger, - tasks, - starter, - reader, -}: { - orm: Orm; - transact: Transact; - logger: Logger; - tasks: TaskService; - starter: RunStarter; - reader: RunReader; -}): RunLifecycleService { - const lifecycle: RunLifecycleService = { - async markStepDone(ctx, taskId) { - const change = await changeStep(stepDeps, ctx, taskId, async (tx, run, step) => { - const task = await tasks.markDoneInTransaction(ctx, tx, taskId); - return { task, step: completeStep(run, step, task.doneAt!) }; - }); - logger.info( - { runId: change.run.id, taskId, runStatus: change.run.status }, - "Process step done", - ); - return change; - }, - - async reopenStep(ctx, taskId) { - const change = await changeStep(stepDeps, ctx, taskId, async (tx, run, step, process) => { - const runs = processRepository(tx); - const newer = await runs.findRunInProgress(run.processId, run.recordType, run.recordId); - const subject = run.subjectEmployeeId - ? await starter.readSubject(tx, run.subjectEmployeeId) - : null; - const reopened = reopenStep(run, step, { - processName: process.name, - subjectFirstName: subject?.firstName ?? null, - isSuperseded: newer !== null && newer.id !== run.id, - }); - return { task: await tasks.reopenInTransaction(ctx, tx, taskId), step: reopened }; - }); - logger.info( - { runId: change.run.id, taskId, runStatus: change.run.status }, - "Process step reopened", - ); - return change; - }, - - async readStepProgress(ctx, taskId) { - const task = await tasks.readTask(ctx, taskId); - const runId = (await reader.requireRun(orm, runIdOf(task))).id; - return transact(async (tx) => { - const runs = processRepository(tx); - // Locked, so a step change committing meanwhile cannot pair its steps with the old status. - const run = (await runs.lockRun(runId))!; - const [process] = await runs.findProcessesByIds([run.processId]); - const steps = (await runs.findSteps(run.id)).filter( - ({ status }) => status !== "step_skipped", - ); - return { - processName: process!.name, - subjectEmployeeId: run.subjectEmployeeId, - status: run.status, - doneSteps: steps.filter(({ status }) => status === "step_completed").length, - totalSteps: steps.length, - }; - }); - }, - - async cancelRun(ctx, runId, reason) { - const trimmed = reason.trim(); - if (!trimmed) throw invalidInput([{ field: "reason", code: "required" }]); - const found = await reader.requireRun(orm, runId); - await ctx.requireInScope("process_run", "update", found.subjectEmployeeId ?? "public"); - const run = await transact(async (tx) => - cancelAndSave(tasks, ctx, tx, (await processRepository(tx).lockRun(found.id))!, { - cause: "cancelled_by_admin", - reason: trimmed, - taskReason: trimmed, - byEmployeeId: ctx.user?.employeeId ?? null, - }), - ); - logger.info({ runId: run.id, cancelCause: run.cancelCause }, "Process run cancelled"); - return reader.readDetails(run); - }, - - async cancelIfCannotRun(tx, run) { - if (run.status !== "in_progress") return run; - const steps = await processRepository(tx).findSteps(run.id); - const subject = run.subjectEmployeeId - ? await starter.readSubject(tx, run.subjectEmployeeId) - : null; - const removal = removalCancellation( - run, - steps, - run.subjectEmployeeId === null || subject !== null, - ); - if (!removal) return run; - const reason = - removal.cause === "cancelled_because_subject_was_removed" - ? SUBJECT_REMOVED - : `The assignee of '${(await starter.stepTitles(tx, run)).get(removal.stepKey!)}' was removed from Broccoli.`; - return cancelAndSave(tasks, PrincipalSecurityContext.system(), tx, run, { - cause: removal.cause, - reason, - taskReason: PROCESS_CANCELLED, - byEmployeeId: null, - }); - }, - }; - - const stepDeps = { orm, transact, tasks, reader, lifecycle }; - return lifecycle; -} - -/** - * Locks the task's process, then its run (the order starts take), cancels the run when it can no - * longer run, else applies the step change and settles the run; one transaction commits either. - */ -async function changeStep( - { orm, transact, tasks, reader, lifecycle }: StepCommandDeps, - ctx: SecurityContext, - taskId: string, - change: StepCommand, -): Promise { - const task = await tasks.readTask(ctx, taskId); - const runId = runIdOf(task); - const [process] = await processRepository(orm).findProcessesByIds([ - (await reader.requireRun(orm, runId)).processId, - ]); - return transact(async (tx) => { - const runs = processRepository(tx); - await runs.lockProcess(process!.id); - const locked = (await runs.lockRun(runId))!; - const checked = await lifecycle.cancelIfCannotRun(tx, locked); - if (checked.status === "cancelled" && locked.status !== "cancelled") { - const [cancelledTask] = await tasks.readTasksInTransaction(tx, [task.id]); - return { run: assigneeView(reader.view(checked, process!)), task: cancelledTask! }; - } - const steps = await runs.findSteps(locked.id); - const step = steps.find(({ taskId: id }) => id === task.id)!; - const changed = await change(tx, locked, step, process!); - await runs.saveSteps([changed.step]); - const settled = settleRun( - locked, - steps.map((s) => (s.stepKey === step.stepKey ? changed.step : s)), - new Date(), - ); - const saved = settled === locked ? locked : await runs.saveRun(settled); - return { run: assigneeView(reader.view(saved, process!)), task: changed.task }; - }); -} - -interface StepCommandDeps { - orm: Orm; - transact: Transact; - tasks: TaskService; - reader: RunReader; - lifecycle: RunLifecycleService; -} - -/** Cancels the locked run: its open tasks with `taskReason`, the run with its cause and reason. */ -async function cancelAndSave( - tasks: TaskService, - ctx: SecurityContext, - tx: Tx, - run: StoredRun, - how: Cancellation, -): Promise { - const runs = processRepository(tx); - const steps = await runs.findSteps(run.id); - const cancelled = cancelRun(run, steps, { ...how, now: new Date() }); - await tasks.cancelInTransaction(ctx, tx, { - taskIds: steps.flatMap(({ status, taskId }) => - status === "step_awaiting_task_completion" && taskId ? [taskId] : [], - ), - reason: how.taskReason, - cause: { kind: "PROCESS_RUN", ref: run.id }, - }); - await runs.saveSteps(cancelled.steps); - return runs.saveRun(cancelled.run); -} - -/** How a run is cancelled: its own cause and reason, and the reason its open tasks get. */ -interface Cancellation { - cause: CancelCause; - reason: string; - taskReason: string; - byEmployeeId: string | null; -} - -/** What a step command does to its task and step, inside the run's transaction. */ -type StepCommand = ( - tx: Tx, - run: StoredRun, - step: RunStep, - process: StoredProcess, -) => Promise<{ task: Task; step: RunStep }>; - -function runIdOf(task: Task): string { - const runId = task.type === "process_step_to_do" ? task.processSource?.processRunId : null; - if (!runId) throw new ApplicationError("CONFLICT", "This task does not belong to a process."); - return runId; -} - -/** The run as an assignee's step change returns it: without why it was cancelled. */ -function assigneeView(run: ProcessRun): StepChange["run"] { - const visible: Partial = { ...run }; - delete visible.cancelReason; - delete visible.cancelCause; - return visible as StepChange["run"]; -} diff --git a/packages/api/src/modules/processes/domain/eventTrigger.ts b/packages/api/src/modules/processes/domain/eventTrigger.ts deleted file mode 100644 index 8e66d8017..000000000 --- a/packages/api/src/modules/processes/domain/eventTrigger.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { DomainEvents } from "../../../core/events"; -import type { EventTrigger, ProcessDefinition } from "./processDefinition"; - -/** - * The events that may start a process, with the record type each is about, the ids of those - * records in its payload (one run per record), and the payload a run keeps for its own record. - */ -// shortcut: the list lives here; move it to the publishing modules' registrations when modules become dynamic. -export const TRIGGERING_EVENTS = { - "employees.added": { - recordType: "employee", - recordIds: ({ employeeIds }: DomainEvents["employees.added"]) => employeeIds, - payloadFor: (_: DomainEvents["employees.added"], employeeId: string) => ({ - employeeIds: [employeeId], - }), - }, - "tasks.task_created": { - recordType: "task", - recordIds: ({ taskId }: DomainEvents["tasks.task_created"]) => [taskId], - payloadFor: (payload: DomainEvents["tasks.task_created"]) => payload, - }, - "tasks.task_status_changed": { - recordType: "task", - recordIds: ({ taskId }: DomainEvents["tasks.task_status_changed"]) => [taskId], - payloadFor: (payload: DomainEvents["tasks.task_status_changed"]) => payload, - }, -} as const; - -export type TriggeringEventType = keyof typeof TRIGGERING_EVENTS; - -/** An event as a trigger reads it. */ -export interface TriggerEvent { - id: string; - type: string; - payload: unknown; -} - -/** One run to start: a process, the event that triggered it and one record the event is about. */ -export interface EventStart { - processId: string; - event: Event; - recordId: string; -} - -/** - * The runs the events start, one per matching process and record, in the order of the events, - * then the processes, then the event's records. - */ -export function matchEventStarts( - processes: readonly { processId: string; trigger: ProcessDefinition["trigger"] }[], - events: readonly Event[], -): EventStart[] { - return events.flatMap((event) => - processes.flatMap(({ processId, trigger }) => - trigger.kind === "on_event" && isTriggeredBy(trigger, event) - ? recordIdsOf(event).map((recordId) => ({ processId, event, recordId })) - : [], - ), - ); -} - -/** The event as it concerns one of its records, as that record's run keeps it. */ -export function eventAboutRecord( - { id, type, payload }: TriggerEvent, - recordId: string, -): TriggerEvent { - return { id, type, payload: entryOf(type).payloadFor(payload, recordId) }; -} - -export function isTriggeringEvent(type: string): type is TriggeringEventType { - return Object.hasOwn(TRIGGERING_EVENTS, type); -} - -function isTriggeredBy(trigger: EventTrigger, { type, payload }: TriggerEvent): boolean { - if (type !== trigger.event) return false; - if (!trigger.when) return true; - const value = (payload as Record)[trigger.when.field] ?? null; - return trigger.when.in.some((expected) => expected === value); -} - -function recordIdsOf({ type, payload }: TriggerEvent): readonly string[] { - return isTriggeringEvent(type) ? entryOf(type).recordIds(payload) : []; -} - -/** An event's entry, typed for any payload: each entry reads its own event's, which `type` names. */ -function entryOf(type: string) { - return TRIGGERING_EVENTS[type as TriggeringEventType] as { - recordIds: (payload: unknown) => string[]; - payloadFor: (payload: unknown, recordId: string) => unknown; - }; -} diff --git a/packages/api/src/modules/processes/domain/processRun.ts b/packages/api/src/modules/processes/domain/processRun.ts deleted file mode 100644 index 2644cfa46..000000000 --- a/packages/api/src/modules/processes/domain/processRun.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { ApplicationError } from "../../../core/applicationError"; -import type { CalendarDate } from "../../../utils"; -import type { TriggerEvent } from "./eventTrigger"; -import type { AssigneeRule } from "./processDefinition"; - -export const RUN_STATUSES = ["in_progress", "finished", "cancelled"] as const; -export type RunStatus = (typeof RUN_STATUSES)[number]; - -export const START_CAUSES = [ - "started_on_record_date", - "started_by_admin", - "started_on_event", -] as const; -export type StartCause = (typeof START_CAUSES)[number]; - -export const CANCEL_CAUSES = [ - "cancelled_by_admin", - "cancelled_because_step_assignee_was_removed", - "cancelled_because_subject_was_removed", -] as const; -export type CancelCause = (typeof CANCEL_CAUSES)[number]; - -export const STEP_STATUSES = [ - "step_awaiting_task_completion", - "step_completed", - "step_skipped", - "step_cancelled", -] as const; -export type StepStatus = (typeof STEP_STATUSES)[number]; - -export const ASSIGNEE_NOTES = ["assignee_is_owner_because_subject_has_no_manager"] as const; -export type AssigneeNote = (typeof ASSIGNEE_NOTES)[number]; - -export const SKIP_REASONS = ["no_assignee_for_step"] as const; -export type SkipReason = (typeof SKIP_REASONS)[number]; - -/** Why a run whose subject was removed stops: on the run for admins, and refusing a reopen. */ -export const SUBJECT_REMOVED = "The person this process is about was removed from Broccoli."; - -/** What the tasks of a run cancelled for a removal tell their assignees; the detail stays on the run. */ -export const PROCESS_CANCELLED = "This process was cancelled."; - -/** One run of a process version for one record. */ -export interface ProcessRun { - id: string; - processId: string; - processVersionId: string; - recordType: string; - recordId: string; - /** The employee the run is about, which permission scopes filter on; null for a record about nobody. */ - subjectEmployeeId: string | null; - startCause: StartCause; - startedByEmployeeId: string | null; - /** The date the trigger fired for; with the process, `startKey` keeps it from starting twice. */ - triggerDate: CalendarDate | null; - startKey: string | null; - /** The event that started the run, for `started_on_event`; steps read who acted from it. */ - triggerEvent: TriggerEvent | null; - /** The day due dates count from. */ - startDay: CalendarDate; - status: RunStatus; - finishedAt: Date | null; - cancelledAt: Date | null; - cancelledByEmployeeId: string | null; - cancelCause: CancelCause | null; - cancelReason: string | null; - createdAt: Date; - updatedAt: Date; -} - -/** Where one step of a run stands; its task, once created, is the work. */ -export interface RunStep { - runId: string; - stepKey: string; - status: StepStatus; - /** Null for a skipped step, and once the task's assignee was removed. */ - taskId: string | null; - assigneeNote: AssigneeNote | null; - skipReason: SkipReason | null; - finishedAt: Date | null; -} - -/** The person a step's task goes to, and why, when it is not the obvious one. */ -export interface ResolvedAssignee { - employeeId: string; - note: AssigneeNote | null; -} - -/** - * Follows a step's assignee rule and its fallbacks to the first kind with a person; null when none - * has one, so the step is skipped. - */ -export function resolveAssignee( - rule: AssigneeRule, - candidates: Readonly>, -): ResolvedAssignee | null { - const employeeId = candidates[rule.kind]; - if (employeeId !== null) { - return { employeeId, note: null }; - } - const fallback = rule.otherwise ? resolveAssignee(rule.otherwise, candidates) : null; - if (fallback && rule.kind === "subjects_manager" && rule.otherwise?.kind === "workspace_owner") { - return { ...fallback, note: "assignee_is_owner_because_subject_has_no_manager" }; - } - return fallback; -} - -/** A run whose steps are all completed or skipped is finished; one with an open step is in progress. */ -export function settleRun(run: ProcessRun, steps: readonly RunStep[], now: Date): ProcessRun { - const allDone = steps.every( - ({ status }) => status === "step_completed" || status === "step_skipped", - ); - if (allDone) { - return run.status === "finished" ? run : { ...run, status: "finished", finishedAt: now }; - } - return { ...run, status: "in_progress", finishedAt: null }; -} - -export function completeStep(run: ProcessRun, step: RunStep, now: Date): RunStep { - refuseUnlessOpen(run); - return { ...step, status: "step_completed", finishedAt: now }; -} - -/** Takes a completed step back to awaiting its task, in a run not cancelled or superseded. */ -export function reopenStep( - run: ProcessRun, - step: RunStep, - { - processName, - subjectFirstName, - isSuperseded, - }: { - processName: string; - /** Null once the person the run is about was removed. */ - subjectFirstName: string | null; - isSuperseded: boolean; - }, -): RunStep { - if (run.status === "cancelled") { - throw new ApplicationError("CONFLICT", PROCESS_CANCELLED); - } - if (run.subjectEmployeeId !== null && subjectFirstName === null) { - throw new ApplicationError("CONFLICT", SUBJECT_REMOVED); - } - if (isSuperseded) { - throw new ApplicationError( - "CONFLICT", - `${processName} was started again for ${subjectFirstName}; this run is closed.`, - ); - } - return { ...step, status: "step_awaiting_task_completion", finishedAt: null }; -} - -/** A run cancelled: open steps cancelled, done steps kept. */ -export function cancelRun( - run: ProcessRun, - steps: readonly RunStep[], - cancellation: { cause: CancelCause; reason: string; byEmployeeId: string | null; now: Date }, -): { run: ProcessRun; steps: RunStep[] } { - if (run.status !== "in_progress") { - throw new ApplicationError("CONFLICT", "Only a run in progress can be cancelled."); - } - return { - run: { - ...run, - status: "cancelled", - cancelledAt: cancellation.now, - cancelledByEmployeeId: cancellation.byEmployeeId, - cancelCause: cancellation.cause, - cancelReason: cancellation.reason, - }, - steps: steps.map((step) => - step.status === "step_awaiting_task_completion" - ? { ...step, status: "step_cancelled", finishedAt: cancellation.now } - : step, - ), - }; -} - -/** - * Why a run in progress can no longer run, if it cannot: its subject is gone, or a step still - * awaiting its task lost it with its assignee. A step done before its assignee left changes nothing. - */ -export function removalCancellation( - run: ProcessRun, - steps: readonly RunStep[], - subjectExists: boolean, -): { cause: CancelCause; stepKey: string | null } | null { - if (run.status !== "in_progress") return null; - if (!subjectExists) return { cause: "cancelled_because_subject_was_removed", stepKey: null }; - const orphan = steps.find( - ({ status, taskId }) => status === "step_awaiting_task_completion" && taskId === null, - ); - return orphan - ? { cause: "cancelled_because_step_assignee_was_removed", stepKey: orphan.stepKey } - : null; -} - -function refuseUnlessOpen(run: ProcessRun): void { - if (run.status === "cancelled") { - throw new ApplicationError("CONFLICT", PROCESS_CANCELLED); - } -} diff --git a/packages/api/src/modules/processes/graphql/schema.graphql b/packages/api/src/modules/processes/graphql/schema.graphql deleted file mode 100644 index 03f2975c5..000000000 --- a/packages/api/src/modules/processes/graphql/schema.graphql +++ /dev/null @@ -1,5 +0,0 @@ -# Processes have no API yet; their runs' changes to tasks show in the tasks' history with this cause. -enum RecordChangeCauseKind { - "A process run made the change on its own, such as creating or cancelling a step's task; the ref is the run." - PROCESS_RUN -} diff --git a/packages/api/src/modules/processes/index.ts b/packages/api/src/modules/processes/index.ts deleted file mode 100644 index e425d43df..000000000 --- a/packages/api/src/modules/processes/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readSdl } from "../../core/graphql"; -import { PrincipalSecurityContext } from "../../core/security"; -import type { ModuleDependencies, ModuleManifest } from "../../core/moduleManifest"; -import { type ProcessService, startDueRunsJob } from "./app"; - -// Public values. -export { createProcessService } from "./app"; - -/** Processes that give people tasks when something happens to a record, and their runs. */ -export function processesModule({ - logger, - processes, -}: Pick & { processes: ProcessService }): ModuleManifest { - return { - name: "processes", - typeDefs: readSdl(import.meta.url, "./graphql/schema.graphql"), - resolvers: {}, - jobs: startDueRunsJob({ processes, logger }), - events: { - // Logged once the change commits, never for runs a rollback dropped. - "processes.runs_started_on_event": (started) => - Promise.resolve( - logger.info(started, "Process runs started with the change that triggered them"), - ), - }, - eventsBeforeCommit: (tx, events) => - processes.startRunsOnEvents(PrincipalSecurityContext.system(), tx, events), - }; -} diff --git a/packages/api/src/modules/processes/module.md b/packages/api/src/modules/processes/module.md deleted file mode 100644 index e41dea42d..000000000 --- a/packages/api/src/modules/processes/module.md +++ /dev/null @@ -1,22 +0,0 @@ -# Processes - -A process gives people tasks when something happens to a record, and follows each run of it to its end. The first one is ready-made: **Welcome a new starter**, which on a person's start date gives their manager "Have a welcome 1:1" and the person "Have coffee calls with 3 colleagues". - -## Rules - -- A process is off until an admin turns it on (`MANAGE_WORKSPACE`); turning it on or off appends a history line for record type `process`, which only those who manage the workspace read. Turning it on starts today's due runs at once, and never anyone whose date was before that day. -- What a process does lives in its published version's definition (JSON, read by `parseProcessDefinition` for the `definition_schema_version` it was written in). A published version never changes; a new release ships a new version. The trigger fires on a record's date plus an offset (`on_record_date`, employees only), or on a domain event (`on_event`); schedule triggers are refused. -- An `on_event` trigger names an event listed in `TRIGGERING_EVENTS` (`employees.added`, `tasks.task_created`, `tasks.task_status_changed`), with an optional `when` on one payload field, and runs `in_same_transaction` with `faultMode` `rollback_transaction`: before the change commits, `startRunsOnEvents` starts one run per event and record the event is about, batched per process, skipping a record whose run is in progress or that is gone. The run keeps the event (`trigger_event`); a run that cannot start fails the change. -- The daily job (`processes.startDueRuns`, 06:00 London) starts a run for each record whose trigger date is today or up to the trigger's `maxDaysLate` days ago, if it never had a run of that process, one transaction each. It also cancels runs that can no longer run. -- An admin starts a run by hand (`create` on `process_run` for the person), whether the process is on or off, unless one is in progress for them. Before the person's start date, the run counts from their start date. -- Steps run in parallel. Each step's task goes to its assignee rule's person on the run's start day, else its fallback, with a note when the owner covers a missing manager; with nobody, the step is skipped. Due dates count calendar days, or business days by the person's own working weeks and bank holidays, refusing a year with no known bank holidays. -- A step's task (`process_step_to_do`) is finished and reopened through `markStepDone` and `reopenStep`, which change the task and the run in one transaction: the run finishes when every step is done or skipped, and a reopened step takes it back in progress, unless the run was cancelled or a newer one is in progress. -- A run cancels itself the next time it runs when its subject was removed, or when a step still waiting lost its task with its assignee; the cancellation commits and is returned. Assignees read "This process was cancelled."; the cause stays on the run for admins. -- Runs are read through `process_run` permissions: admins and the owner read and change every run; others read none. A task's readers see the run's progress as counts, never the other steps. - -## Data model - -- **`processes`:** `key`, `name`, `description`, `active_version_id` (one of its own published versions), `turned_on_at` (null while off). -- **`process_versions`:** `version`, `status` (`draft` or `published`, one draft per process), `definition_schema_version`, `definition`. -- **`process_runs`:** the version it started with, the record it is about (`record_type`, `record_id`: an employee or a task) and its subject (`subject_employee_id`, no foreign key so a removal is noticed; a task's subject, which may be null), how it started, `trigger_date` or `trigger_event` and `start_key` (unique per process, so a trigger starts once), `start_day`, `status` (`in_progress`, `finished`, `cancelled`) with its columns. One run in progress per process and record. -- **`process_run_steps`:** one row per step: `status`, `task_id` (set null when the task goes with its assignee), `assignee_note`, `skip_reason`, `finished_at`. diff --git a/packages/api/src/modules/system/app/storedText.pbt.test.ts b/packages/api/src/modules/system/app/storedText.pbt.test.ts index a4fc83b11..dfe662822 100644 --- a/packages/api/src/modules/system/app/storedText.pbt.test.ts +++ b/packages/api/src/modules/system/app/storedText.pbt.test.ts @@ -23,7 +23,6 @@ test.for( entry: `employee.reason.${operation}`, limit: 500, })), - { entry: "task.cancelReason", limit: 1000 }, { entry: "invite.note", limit: 1000 }, { entry: "setup.inviteNote", limit: 1000 }, { entry: "mail.from", limit: 254 }, diff --git a/packages/api/src/modules/system/app/storedTextExamples.int.test.ts b/packages/api/src/modules/system/app/storedTextExamples.int.test.ts index 3ce854599..eeac0f13f 100644 --- a/packages/api/src/modules/system/app/storedTextExamples.int.test.ts +++ b/packages/api/src/modules/system/app/storedTextExamples.int.test.ts @@ -8,7 +8,6 @@ test.for([ "type.create", "employee.create.firstName", "employee.reason.update", - "task.cancelReason", "invite.note", "setup.inviteNote", ])("%s refuses NUL without changing storage", async (entry, { orm, seed }) => { @@ -29,7 +28,6 @@ test.for([ "type.create", "employee.create.firstName", "employee.reason.update", - "task.cancelReason", "invite.note", "setup.inviteNote", "mail.secret", diff --git a/packages/api/src/modules/tasks/app/cancelTask.int.test.ts b/packages/api/src/modules/tasks/app/cancelTask.int.test.ts deleted file mode 100644 index 4f3b4e0a6..000000000 --- a/packages/api/src/modules/tasks/app/cancelTask.int.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { expect, signedIn, taskService, test } from "#test/support"; - -test("lets the asker cancel a task with a reason visible to both people", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id }); - const assigneeCtx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); - const askerCtx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); - const tasks = taskService(orm); - - const { task: cancelled } = await tasks.cancel(askerCtx, task.id, " No longer needed "); - - expect(cancelled).toMatchObject({ - status: "cancelled", - cancelledAt: expect.any(Date) as unknown, - cancelReason: "No longer needed", - }); - expect(await tasks.listMyTasks(assigneeCtx)).toEqual({ - needsYou: [], - waitingOnOthers: [], - resolvedRecently: [cancelled], - }); - expect(await tasks.listMyTasks(askerCtx)).toEqual({ - needsYou: [], - waitingOnOthers: [], - resolvedRecently: [cancelled], - }); -}); - -test("refuses cancellation by the assignee", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id }); - const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); - const tasks = taskService(orm); - - await expect(tasks.cancel(ctx, task.id, "No longer needed")).rejects.toMatchObject({ - code: "FORBIDDEN", - message: "Only the person who asked can cancel this task.", - }); - expect(await tasks.readTask(ctx, task.id)).toEqual(task); -}); - -test("refuses a blank cancellation reason without changing the task", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id }); - const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); - const tasks = taskService(orm); - - await expect(tasks.cancel(ctx, task.id, " \n\t ")).rejects.toMatchObject({ - code: "INVALID_INPUT", - fieldErrors: [{ field: "reason", code: "required" }], - }); - expect(await tasks.readTask(ctx, task.id)).toEqual(task); -}); - -test("refuses cancellation of a done task", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ - assigneeEmployeeId: assignee.id, - askedByEmployeeId: asker.id, - status: "done", - doneAt: new Date("2026-11-08T12:00:00Z"), - doneByEmployeeId: assignee.id, - }); - const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); - - await expect(taskService(orm).cancel(ctx, task.id, "No longer needed")).rejects.toMatchObject({ - code: "CONFLICT", - message: "This task is already done.", - }); -}); - -test("records cancellation and its trimmed reason in the task's history", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id }); - const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); - - const { historyLine } = await taskService(orm).cancel(ctx, task.id, " No longer needed "); - - expect(historyLine).toMatchObject({ - recordType: "task", - recordId: task.id, - operation: "UPDATED", - reason: "No longer needed", - changes: [{ field: "status", oldValue: "to_do", newValue: "cancelled" }], - }); -}); - -test("hides a task from an unrelated caller trying to cancel it", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const other = await seed.employee(); - const task = await seed.task({ - assigneeEmployeeId: assignee.id, - askedByEmployeeId: asker.id, - subjectEmployeeId: other.id, - }); - const ctx = signedIn(orm, { employeeId: other.id, profiles: ["OWNER"] }); - - await expect(taskService(orm).cancel(ctx, task.id, "No longer needed")).rejects.toMatchObject({ - code: "NOT_FOUND", - message: "No task has this id.", - }); -}); - -test("reports an unknown task as not found when cancelling it", async ({ orm, seed }) => { - const employee = await seed.employee(); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - - await expect( - taskService(orm).cancel(ctx, "00000000-0000-4000-8000-000000000001", "No longer needed"), - ).rejects.toMatchObject({ code: "NOT_FOUND", message: "No task has this id." }); -}); diff --git a/packages/api/src/modules/tasks/app/reopenTask.int.test.ts b/packages/api/src/modules/tasks/app/reopenTask.int.test.ts deleted file mode 100644 index f4f8e54e5..000000000 --- a/packages/api/src/modules/tasks/app/reopenTask.int.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { expect, signedIn, taskService, test } from "#test/support"; - -test("lets the assignee reopen a done task back into needs you", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ - assigneeEmployeeId: assignee.id, - askedByEmployeeId: asker.id, - status: "done", - doneAt: new Date("2026-11-08T12:00:00Z"), - doneByEmployeeId: assignee.id, - }); - const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); - const tasks = taskService(orm); - - const { task: reopened } = await tasks.reopen(ctx, task.id); - - expect(reopened).toMatchObject({ status: "to_do", doneAt: null, doneByEmployeeId: null }); - expect(await tasks.listMyTasks(ctx)).toEqual({ - needsYou: [reopened], - waitingOnOthers: [], - resolvedRecently: [], - }); -}); - -test("refuses reopening by the person who asked", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ - assigneeEmployeeId: assignee.id, - askedByEmployeeId: asker.id, - status: "done", - doneAt: new Date("2026-11-08T12:00:00Z"), - doneByEmployeeId: assignee.id, - }); - const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); - const tasks = taskService(orm); - - await expect(tasks.reopen(ctx, task.id)).rejects.toMatchObject({ - code: "FORBIDDEN", - message: "Only the person who holds this task can reopen it.", - }); - expect(await tasks.readTask(ctx, task.id)).toEqual(task); -}); - -test("refuses reopening a task that is already to do", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ assigneeEmployeeId: assignee.id, askedByEmployeeId: asker.id }); - const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); - - await expect(taskService(orm).reopen(ctx, task.id)).rejects.toMatchObject({ - code: "CONFLICT", - message: "Only a done task can be reopened.", - }); -}); - -test("records reopening in the task's history", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const task = await seed.task({ - assigneeEmployeeId: assignee.id, - askedByEmployeeId: asker.id, - status: "done", - doneAt: new Date("2026-11-08T12:00:00Z"), - doneByEmployeeId: assignee.id, - }); - const ctx = signedIn(orm, { employeeId: assignee.id, profiles: ["EMPLOYEE"] }); - - const { historyLine } = await taskService(orm).reopen(ctx, task.id); - - expect(historyLine).toMatchObject({ - recordType: "task", - recordId: task.id, - operation: "UPDATED", - changes: [{ field: "status", oldValue: "done", newValue: "to_do" }], - }); -}); - -test("hides a task from an unrelated caller trying to reopen it", async ({ orm, seed }) => { - const assignee = await seed.employee(); - const asker = await seed.employee(); - const other = await seed.employee(); - const task = await seed.task({ - assigneeEmployeeId: assignee.id, - askedByEmployeeId: asker.id, - subjectEmployeeId: other.id, - status: "done", - doneAt: new Date("2026-11-08T12:00:00Z"), - doneByEmployeeId: assignee.id, - }); - const ctx = signedIn(orm, { employeeId: other.id, profiles: ["OWNER"] }); - - await expect(taskService(orm).reopen(ctx, task.id)).rejects.toMatchObject({ - code: "NOT_FOUND", - message: "No task has this id.", - }); -}); - -test("reports an unknown task as not found when reopening it", async ({ orm, seed }) => { - const employee = await seed.employee(); - const ctx = signedIn(orm, { employeeId: employee.id, profiles: ["EMPLOYEE"] }); - - await expect( - taskService(orm).reopen(ctx, "00000000-0000-4000-8000-000000000001"), - ).rejects.toMatchObject({ code: "NOT_FOUND", message: "No task has this id." }); -}); diff --git a/packages/api/src/modules/tasks/app/taskRecordType.ts b/packages/api/src/modules/tasks/app/taskRecordType.ts deleted file mode 100644 index 4312e9a87..000000000 --- a/packages/api/src/modules/tasks/app/taskRecordType.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Orm } from "../../../core/db"; -import type { RecordTypeRegistration } from "../../../core/records"; -import { parseUuid } from "../../../utils"; -import { taskRepository } from "../db"; -import { isTaskVisibleTo } from "../domain"; - -/** How core reads a task's history: only its assignee and the person who asked, as for the task. */ -export function taskRecordType(orm: Orm): RecordTypeRegistration { - return { - graphqlType: "Task", - canonicalId: parseUuid, - loadRecords: async (ids) => { - const tasks = await taskRepository(orm).findByIds(ids); - return new Map(tasks.map((task) => [task.id, task])); - }, - readableIds: async (ctx, ids) => { - const employeeId = ctx.user?.employeeId; - if (!employeeId) return new Set(); - const tasks = await taskRepository(orm).findByIds(ids); - return new Set(tasks.filter((task) => isTaskVisibleTo(task, employeeId)).map(({ id }) => id)); - }, - }; -} diff --git a/packages/api/src/modules/tasks/domain/task.test.ts b/packages/api/src/modules/tasks/domain/task.test.ts deleted file mode 100644 index 3629fe9ea..000000000 --- a/packages/api/src/modules/tasks/domain/task.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { expect, test } from "vitest"; - -import { - cancelTask, - markTaskDone, - markTaskWaiting, - reopenTask, - type Task, - type TaskStatus, -} from "./task"; - -test("marks a task to do as done by its assignee", () => { - const task = taskIn("to_do"); - const now = new Date("2026-11-09T12:00:00Z"); - - const done = markTaskDone(task, task.assigneeEmployeeId, now); - - expect(done).toMatchObject({ - status: "done", - doneAt: now, - doneByEmployeeId: task.assigneeEmployeeId, - cancelledAt: null, - cancelReason: null, - }); - expect(task.status).toBe("to_do"); -}); - -test.each([ - ["waiting", "This task is waiting."], - ["done", "This task is already done."], - ["cancelled", "This task was cancelled."], -] as const)("refuses to mark a %s task done", (status, message) => { - const task = taskIn(status); - - expect(() => - markTaskDone(task, task.assigneeEmployeeId, new Date("2026-11-09T12:00:00Z")), - ).toThrowError(expect.objectContaining({ code: "CONFLICT", message })); -}); - -test("reopens a done task and clears who completed it and when", () => { - const task = taskIn("done"); - - const reopened = reopenTask(task); - - expect(reopened).toMatchObject({ status: "to_do", doneAt: null, doneByEmployeeId: null }); - expect(task.status).toBe("done"); -}); - -test.each(["to_do", "waiting", "cancelled"] as const)("refuses to reopen a %s task", (status) => { - expect(() => reopenTask(taskIn(status))).toThrowError( - expect.objectContaining({ - code: "CONFLICT", - message: "Only a done task can be reopened.", - }), - ); -}); - -test.each(["to_do", "waiting"] as const)("cancels a %s task with a reason", (status) => { - const task = taskIn(status); - const now = new Date("2026-11-09T12:00:00Z"); - - const cancelled = cancelTask(task, "No longer needed", now); - - expect(cancelled).toMatchObject({ - status: "cancelled", - cancelledAt: now, - cancelReason: "No longer needed", - doneAt: null, - doneByEmployeeId: null, - }); - expect(task.status).toBe(status); -}); - -test.each([ - ["done", "This task is already done."], - ["cancelled", "This task was cancelled."], -] as const)("refuses to cancel a %s task", (status, message) => { - expect(() => - cancelTask(taskIn(status), "No longer needed", new Date("2026-11-09T12:00:00Z")), - ).toThrowError(expect.objectContaining({ code: "CONFLICT", message })); -}); - -test("puts a task to do into waiting", () => { - const task = taskIn("to_do"); - - expect(markTaskWaiting(task).status).toBe("waiting"); - expect(task.status).toBe("to_do"); -}); - -test.each([ - ["waiting", "Only a task to do can wait."], - ["done", "Only a task to do can wait."], - ["cancelled", "This task was cancelled."], -] as const)("refuses to put a %s task into waiting", (status, message) => { - expect(() => markTaskWaiting(taskIn(status))).toThrowError( - expect.objectContaining({ - code: "CONFLICT", - message, - }), - ); -}); - -function taskIn(status: TaskStatus): Task { - const createdAt = new Date("2026-11-01T12:00:00Z"); - const closedAt = new Date("2026-11-08T12:00:00Z"); - return { - id: "00000000-0000-4000-8000-000000000001", - type: "manual_to_do", - status, - title: "Read the handbook", - description: null, - assigneeEmployeeId: "00000000-0000-4000-8000-000000000002", - askedByEmployeeId: "00000000-0000-4000-8000-000000000003", - subjectEmployeeId: null, - processSource: null, - record: null, - dueDate: null, - doneAt: status === "done" ? closedAt : null, - doneByEmployeeId: status === "done" ? "00000000-0000-4000-8000-000000000002" : null, - cancelledAt: status === "cancelled" ? closedAt : null, - cancelReason: status === "cancelled" ? "No longer needed" : null, - createdAt, - updatedAt: createdAt, - }; -} diff --git a/packages/api/src/modules/tasks/domain/task.ts b/packages/api/src/modules/tasks/domain/task.ts deleted file mode 100644 index 156496255..000000000 --- a/packages/api/src/modules/tasks/domain/task.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { ApplicationError } from "../../../core/applicationError"; -import type { CalendarDate } from "../../../utils"; - -/** - * `manual_to_do` is finished by hand with Mark done; `process_step_to_do` is a process step's to-do, - * finished through processes so its run keeps up. Features add their own types. - */ -export const TASK_TYPES = ["manual_to_do", "process_step_to_do"] as const; -export type TaskType = (typeof TASK_TYPES)[number]; - -export const TASK_STATUSES = ["to_do", "waiting", "done", "cancelled"] as const; -export type TaskStatus = (typeof TASK_STATUSES)[number]; - -/** The process step that created a task, when no person asked for it. */ -export interface TaskProcessSource { - processKey: string; - processRunId: string | null; - stepKey: string; -} - -/** The record a task acts on, as the records registry names it. */ -export interface TaskRecord { - recordType: string; - recordId: string; -} - -/** Work asked of one person, and where it stands. */ -export interface Task { - id: string; - type: TaskType; - status: TaskStatus; - title: string; - description: string | null; - assigneeEmployeeId: string; - askedByEmployeeId: string | null; - subjectEmployeeId: string | null; - processSource: TaskProcessSource | null; - record: TaskRecord | null; - dueDate: CalendarDate | null; - doneAt: Date | null; - doneByEmployeeId: string | null; - cancelledAt: Date | null; - cancelReason: string | null; - createdAt: Date; - updatedAt: Date; -} - -/** Only the assignee and the person who asked see a task; whom it is about does not count. */ -export function isTaskVisibleTo(task: Task, employeeId: string): boolean { - return task.assigneeEmployeeId === employeeId || task.askedByEmployeeId === employeeId; -} - -export function markTaskDone(task: Task, byEmployeeId: string, now: Date): Task { - refuseClosed(task); - if (task.status === "waiting") { - throw new ApplicationError("CONFLICT", "This task is waiting."); - } - return { ...task, status: "done", doneAt: now, doneByEmployeeId: byEmployeeId }; -} - -export function reopenTask(task: Task): Task { - if (task.status !== "done") { - throw new ApplicationError("CONFLICT", "Only a done task can be reopened."); - } - return { ...task, status: "to_do", doneAt: null, doneByEmployeeId: null }; -} - -/** `reason` is the trimmed, non-blank reason the caller checked. */ -export function cancelTask(task: Task, reason: string, now: Date): Task { - refuseClosed(task); - return { ...task, status: "cancelled", cancelledAt: now, cancelReason: reason }; -} - -export function markTaskWaiting(task: Task): Task { - if (task.status === "cancelled") { - throw cancelledConflict(); - } - if (task.status !== "to_do") { - throw new ApplicationError("CONFLICT", "Only a task to do can wait."); - } - return { ...task, status: "waiting" }; -} - -function refuseClosed(task: Task): void { - if (task.status === "done") { - throw new ApplicationError("CONFLICT", "This task is already done."); - } - if (task.status === "cancelled") { - throw cancelledConflict(); - } -} - -function cancelledConflict(): ApplicationError { - return new ApplicationError("CONFLICT", "This task was cancelled."); -} diff --git a/packages/api/src/modules/tasks/graphql/resolvers.ts b/packages/api/src/modules/tasks/graphql/resolvers.ts deleted file mode 100644 index 420204bec..000000000 --- a/packages/api/src/modules/tasks/graphql/resolvers.ts +++ /dev/null @@ -1,75 +0,0 @@ -import DataLoader from "dataloader"; - -import { ApplicationError } from "../../../core/applicationError"; -import { - type GraphqlContext, - mutationPayload, - perRequest, - toEnum, - type Resolvers, - type ResolversTypes, -} from "../../../core/graphql"; -import type { EmployeeReadService } from "../../employees"; -import type { TaskService } from "../app"; - -const EXPECTED_ERRORS = ["INVALID_INPUT", "NOT_FOUND", "CONFLICT"] as const; - -export function createResolvers( - tasks: TaskService, - employees: Pick, -): Resolvers { - // The people a list of tasks names, read in one query with the caller's access. - const loaders = perRequest((context) => ({ - employee: new DataLoader(async (ids: readonly string[]) => { - const found = await employees.readEmployees(context.getActor(), ids); - return ids.map((id) => found.get(id) ?? null); - }), - })); - const loadEmployee = (context: GraphqlContext, id: string | null) => - id === null ? null : loaders(context).employee.load(id); - return { - Query: { - myTasks: (_parent, _args, context) => tasks.listMyTasks(context.getActor()), - task: async (_parent, { id }, context) => { - try { - return await tasks.readTask(context.getActor(), id); - } catch (error) { - if (error instanceof ApplicationError && error.code === "NOT_FOUND") return null; - throw error; - } - }, - }, - Mutation: { - tasks: () => ({}), - }, - TasksMutations: { - markDone: (_parent, { input }, context) => - mutationPayload( - () => tasks.markDone(context.getActor(), input.id), - EXPECTED_ERRORS, - ), - reopen: (_parent, { input }, context) => - mutationPayload( - () => tasks.reopen(context.getActor(), input.id), - EXPECTED_ERRORS, - ), - cancel: (_parent, { input }, context) => - mutationPayload( - () => tasks.cancel(context.getActor(), input.id, input.reason), - EXPECTED_ERRORS, - ), - }, - Task: { - type: ({ type }) => toEnum(type), - status: ({ status }) => toEnum(status), - // The assignee's deletion deletes the task, so the assignee is always there. - assignee: async ({ assigneeEmployeeId }, _args, context) => - (await loadEmployee(context, assigneeEmployeeId))!, - askedBy: ({ askedByEmployeeId }, _args, context) => loadEmployee(context, askedByEmployeeId), - subject: ({ subjectEmployeeId }, _args, context) => loadEmployee(context, subjectEmployeeId), - doneBy: ({ doneByEmployeeId }, _args, context) => loadEmployee(context, doneByEmployeeId), - history: ({ id }, { first, after }, context) => - tasks.readTaskHistory(context.getActor(), id, { first: first ?? undefined, after }), - }, - }; -} diff --git a/packages/api/src/modules/tasks/module.md b/packages/api/src/modules/tasks/module.md deleted file mode 100644 index 4e5eb9011..000000000 --- a/packages/api/src/modules/tasks/module.md +++ /dev/null @@ -1,19 +0,0 @@ -# Tasks - -A task is work asked of one person, its assignee. The person who asked, or the process step that created it, is its source. Only the assignee and the person who asked see a task; anyone else gets "No task has this id.", so a task's existence does not leak. The person a task is about never sees it for that reason alone. - -## Rules - -- A task asks for work and never grants authority. A feature creates its tasks with `TaskService.createTask` in its own transaction and finishes them with its own command, under the caller's permissions. -- Statuses: `to_do`, `waiting`, `done` and `cancelled`. Only the assignee marks a to-do done or reopens it; only the person who asked cancels it, with a reason. A process step's to-do refuses these generic commands ("This task belongs to a process."): processes finishes, reopens and cancels it with the trusted operations that join its transaction. A task created by a process step, with no person who asked, is cancelled only by its feature. -- The first change wins: each change locks the task's row, so a second Mark done gets "This task is already done." -- Every change appends a history line for record type `task`, in its transaction, and returns it. Tasks register their own reading rule with core's records: `recordHistory` and `Task.history` serve a task's lines to its assignee and the person who asked, and refuse anyone else. -- Deleting the assignee or the person who asked deletes the task; deleting the person it is about or who finished it leaves it, without them. Reset setup and Exit demo forget the tasks' history. - -## Data model - -- **`tasks`:** `type` (`manual_to_do`, finished by hand; `process_step_to_do`, a process step's to-do finished through processes), `status`, `title`, `description`, `assignee_employee_id` (the assignee), `asked_by_employee_id`, `subject_employee_id`, the process source (`process_key`, `process_run_id`, `step_key`; deleting the run deletes its tasks, one per step), the record it acts on (`record_type`, `record_id`), `due_date`, and the done and cancelled columns that follow the status. Checks keep a task asked by someone (a person or a process step) and the columns matching its status. - -## My tasks - -`TaskService.listMyTasks` groups the caller's tasks by where the ball is: **Needs you** (tasks they hold to do, by due date), **Waiting on others** (open tasks they asked someone else for, and their own waiting tasks) and **Resolved recently** (done or cancelled in the last 14 days, newest first). diff --git a/packages/api/src/utils/calendarDate.test.ts b/packages/api/src/utils/calendarDate.test.ts index 3229333e3..4fcf53a10 100644 --- a/packages/api/src/utils/calendarDate.test.ts +++ b/packages/api/src/utils/calendarDate.test.ts @@ -1,6 +1,13 @@ import { expect, it } from "vitest"; -import { addDays, type CalendarDate, coversDate, parseCalendarDate, parseDateColumn } from "."; +import { + addDays, + addWorkingDays, + type CalendarDate, + coversDate, + parseCalendarDate, + parseDateColumn, +} from "."; it("returns a real date unchanged", () => { expect(parseCalendarDate("2026-02-28")).toBe("2026-02-28"); @@ -119,3 +126,7 @@ it("should leave 0000-01-01 unchanged when adding zero days", () => { // Assert expect(result).toBe(date); }); + +it("finds no working day when none comes within the search", () => { + expect(addWorkingDays("2026-01-01" as CalendarDate, 1, () => false, 10)).toBeNull(); +}); diff --git a/packages/api/src/utils/calendarDate.ts b/packages/api/src/utils/calendarDate.ts index 1ca66dd5b..cb60e48da 100644 --- a/packages/api/src/utils/calendarDate.ts +++ b/packages/api/src/utils/calendarDate.ts @@ -33,6 +33,26 @@ export function addDays(date: CalendarDate, days: number): CalendarDate { return moved.toISOString().slice(0, 10) as CalendarDate; } +/** + * The `count`-th day after `from` on which `isWorkingDay` holds, or before it when negative; 0 is + * `from` itself. Null when no working day comes within `maxSearchDays`. + */ +export function addWorkingDays( + from: CalendarDate, + count: number, + isWorkingDay: (date: CalendarDate) => boolean, + maxSearchDays: number, +): CalendarDate | null { + const step = Math.sign(count); + let date = from; + for (let left = Math.abs(count), searched = 0; left > 0; searched += 1) { + if (searched === maxSearchDays) return null; + date = addDays(date, step); + if (isWorkingDay(date)) left -= 1; + } + return date; +} + /** The same day `years` later; 29 February becomes 1 March in other years. */ export function addYears(date: CalendarDate, years: number): CalendarDate { const moved = utcMidnight(date); diff --git a/packages/api/src/utils/collectRegistrations.test.ts b/packages/api/src/utils/collectRegistrations.test.ts new file mode 100644 index 000000000..71dac8257 --- /dev/null +++ b/packages/api/src/utils/collectRegistrations.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; + +import { collectRegistrations } from "./collectRegistrations"; + +test("merges distinct registration keys into a frozen catalog", () => { + const registrations = collectRegistrations("task type", [ + { manual: { title: "To do" } }, + { approval: { title: "Approval" } }, + ]); + + expect(registrations).toEqual({ manual: { title: "To do" }, approval: { title: "Approval" } }); + expect(Object.isFrozen(registrations)).toBe(true); +}); + +test("refuses a registration key used by two parts", () => { + expect(() => collectRegistrations("task type", [{ x: "first" }, { x: "second" }])).toThrow( + "task type x is registered twice.", + ); +}); diff --git a/packages/api/src/utils/collectRegistrations.ts b/packages/api/src/utils/collectRegistrations.ts new file mode 100644 index 000000000..562e3766d --- /dev/null +++ b/packages/api/src/utils/collectRegistrations.ts @@ -0,0 +1,17 @@ +/** + * Merges what modules register under one kind, such as triggering events, frozen; a key that two + * parts register stops startup, since the second would silently replace the first. + */ +export function collectRegistrations( + kind: string, + parts: readonly Readonly>[], +): Readonly> { + const collected: Record = {}; + for (const part of parts) { + for (const [key, registration] of Object.entries(part)) { + if (Object.hasOwn(collected, key)) throw new Error(`${kind} ${key} is registered twice.`); + collected[key] = registration; + } + } + return Object.freeze(collected); +} diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 9560d6725..78e950c94 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -1,6 +1,7 @@ // Public values. export { addDays, + addWorkingDays, addYears, coversDate, daysBetween, @@ -21,6 +22,7 @@ export { parseXlsx } from "./xlsx"; export { parseOrigin } from "./origin"; export { readCookie } from "./readCookie"; export { urlUnder } from "./urlUnder"; +export { collectRegistrations } from "./collectRegistrations"; export { parseUuid } from "./uuid"; // Public types. diff --git a/packages/api/test/e2e/tasks.test.ts b/packages/api/test/e2e/tasks.test.ts index 8b54158c6..eb083f2e3 100644 --- a/packages/api/test/e2e/tasks.test.ts +++ b/packages/api/test/e2e/tasks.test.ts @@ -137,6 +137,46 @@ test("refuses my tasks without a session", async ({ env }) => { }); }); +test("the asker deletes a task over GraphQL and it disappears from the assignee's tasks", async ({ + env, +}) => { + await env.signInAsOwner(); + const assigneeCredentials = { email: "assignee@example.com", password: TEST_OWNER.password }; + const askerCredentials = { email: "asker@example.com", password: TEST_OWNER.password }; + const assigneeUser = await env.seed.user(assigneeCredentials); + const askerUser = await env.seed.user(askerCredentials); + const assignee = await env.seed.employee({ userId: assigneeUser.id }); + const asker = await env.seed.employee({ userId: askerUser.id }); + const task = await env.seed.task({ + assigneeEmployeeId: assignee.id, + askedByEmployeeId: asker.id, + }); + const { browser: assigneeBrowser } = await env.signIn(assigneeCredentials); + const before = await assigneeBrowser.graphql("query { myTasks { needsYou { id } } }"); + expect(await before.json()).toEqual({ data: { myTasks: { needsYou: [{ id: task.id }] } } }); + const { browser } = await env.signIn(askerCredentials); + + const deleted = await browser.graphql( + `mutation DeleteTask($id: ID!) { + tasks { delete(input: { id: $id }) { + deletedTaskId + error { __typename ... on NotFoundError { message } } + } } + }`, + { id: task.id }, + ); + + expect(await deleted.json()).toEqual({ + data: { tasks: { delete: { deletedTaskId: task.id, error: null } } }, + }); + const after = await assigneeBrowser.graphql( + "query { myTasks { needsYou { id } waitingOnOthers { id } resolvedRecently { id } } }", + ); + expect(await after.json()).toEqual({ + data: { myTasks: { needsYou: [], waitingOnOthers: [], resolvedRecently: [] } }, + }); +}); + const MARK_DONE_WITH_HISTORY = `mutation MarkDone($id: ID!) { tasks { markDone(input: { id: $id }) { task { id history(first: 5) { edges { node { operation } } } } diff --git a/packages/api/test/support/processService.ts b/packages/api/test/support/processService.ts index 8ee2f28ad..b34221ff7 100644 --- a/packages/api/test/support/processService.ts +++ b/packages/api/test/support/processService.ts @@ -1,13 +1,27 @@ import { createBeforeCommitBus, type Orm, type Transact, transactor } from "../../src/core/db"; import { createEventBus } from "../../src/core/events"; import { createLogger } from "../../src/core/logger"; -import { createProcessService, processesModule } from "../../src/modules/processes"; -import { type CalendarDate, todayInLondon } from "../../src/utils"; +import { + createProcessService, + processesModule, + type TriggeringEventRegistration, +} from "../../src/core/processes"; +import { TASK_TRIGGERING_EVENTS, taskRecordType } from "../../src/core/tasks"; +import { + createPeopleAdapter, + createWorkingCalendarsAdapter, + EMPLOYEE_TRIGGERING_EVENTS, + employeeRecordType, +} from "../../src/modules/employees"; +import { type CalendarDate, collectRegistrations, todayInLondon } from "../../src/utils"; import { bankHolidayService } from "./bankHolidayService"; import { taskService } from "./services"; import { testTransactor } from "./testTransactor"; -/** The process service with real tasks, offline bank holidays and an injectable day, at noon. */ +/** + * The process service with real tasks, people, record types and triggering events, offline bank + * holidays and an injectable day, at noon. + */ export function processService( orm: Orm, { @@ -23,12 +37,24 @@ export function processService( transact, logger: createLogger("silent"), tasks: taskService(orm, { transact }), - bankHolidays: bankHolidayService(), + people: createPeopleAdapter({ orm }), + workingCalendars: createWorkingCalendarsAdapter({ + transact, + bankHolidays: bankHolidayService(), + }), + recordTypes: { employee: employeeRecordType(orm), task: taskRecordType(orm) }, + triggeringEvents: collectRegistrations("triggering event", [ + EMPLOYEE_TRIGGERING_EVENTS, + TASK_TRIGGERING_EVENTS, + ]), now: () => new Date(`${today()}T12:00:00Z`), }); } -/** Transactions whose events start processes before the commit, using the supplied day. */ +/** + * Transactions whose events move processes on before the commit, as in the server: a done step's + * run, and the runs events start, on the supplied day. + */ export function transactWithProcesses( orm: Orm, { today }: { today: () => CalendarDate }, diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts index 655c540e0..c7a61edba 100644 --- a/packages/api/test/support/seedProcess.ts +++ b/packages/api/test/support/seedProcess.ts @@ -1,6 +1,6 @@ import type { Orm } from "../../src/core/db"; -import { processRepository } from "../../src/modules/processes/db"; -import type { ProcessDefinition } from "../../src/modules/processes/domain"; +import { processRepository } from "../../src/core/processes/db"; +import type { ProcessDefinition } from "../../src/core/processes/domain"; import { testTransactor } from "./testTransactor"; /** A process with its first published version active, without invoking use cases or events. */ @@ -41,15 +41,16 @@ export function eventProcess( faultMode: "rollback_transaction", ...(when && { when }), }, - startableByAdmin: false, steps: [ { key: "follow_up", kind: "task", - title: "Follow up", - description: null, - assignee: { kind: "subject" }, - due: { amount: 0, unit: "calendar_days" }, + task: { + title: "Follow up", + description: null, + assignee: { kind: "subject" }, + due: { amount: 0, unit: "calendar_days" }, + }, }, ], }; diff --git a/packages/api/test/support/seedTask.ts b/packages/api/test/support/seedTask.ts index 11f009c8d..09de3eb71 100644 --- a/packages/api/test/support/seedTask.ts +++ b/packages/api/test/support/seedTask.ts @@ -1,5 +1,5 @@ import type { Orm } from "../../src/core/db"; -import { type NewTaskRow, taskRepository } from "../../src/modules/tasks/db"; +import { type NewTaskRow, taskRepository } from "../../src/core/tasks/db"; import { testTransactor } from "./testTransactor"; export function seedTask( @@ -12,9 +12,10 @@ export function seedTask( type: "manual_to_do", status: "to_do", description: null, - askedByEmployeeId: null, + // A manual to-do always has someone who asked: its assignee, unless the test names another. + askedByEmployeeId: + overrides.type && overrides.type !== "manual_to_do" ? null : overrides.assigneeEmployeeId, subjectEmployeeId: null, - processSource: null, record: null, dueDate: null, doneAt: null, diff --git a/packages/api/test/support/storedTextProbe.ts b/packages/api/test/support/storedTextProbe.ts index daf82fd55..0dc3ab2b1 100644 --- a/packages/api/test/support/storedTextProbe.ts +++ b/packages/api/test/support/storedTextProbe.ts @@ -5,14 +5,12 @@ import { employeeServices, employeeInviteService, companyService, - taskService, mailSettings, importServices, systemService, } from "./services"; import { recordCloneService } from "./recordCloneService"; import { recordHistory } from "./statelessServices"; -import { signedIn } from "./signedIn"; import { OWNER } from "./testSecurityContext"; import { TEST_SECRET_BOX } from "./testSecretBox"; import type { TestSeed } from "./testSeed"; @@ -169,21 +167,6 @@ export async function storedTextProbe( read: async () => (await service.getCompany(OWNER)).name, }; } - if (entry === "task.cancelReason") { - const asker = await seed.employee(); - const holder = await seed.employee(); - const task = await seed.task({ - askedByEmployeeId: asker.id, - assigneeEmployeeId: holder.id, - }); - const ctx = signedIn(orm, { employeeId: asker.id, profiles: ["EMPLOYEE"] }); - const service = taskService(orm); - return { - expected, - act: () => service.cancel(ctx, task.id, value), - read: async () => (await service.readTask(ctx, task.id)).cancelReason, - }; - } if (entry === "invite.note") { const employee = await seed.employee(); const owner = await seed.owner({ email: `owner-${employee.id}@example.com` }); diff --git a/packages/api/test/support/taskService.ts b/packages/api/test/support/taskService.ts index 52692b6ba..112f7b576 100644 --- a/packages/api/test/support/taskService.ts +++ b/packages/api/test/support/taskService.ts @@ -1,6 +1,6 @@ import type { Orm, Transact } from "../../src/core/db"; import { createLogger } from "../../src/core/logger"; -import { createTaskService } from "../../src/modules/tasks"; +import { createTaskService } from "../../src/core/tasks"; import { testTransactor } from "./testTransactor"; export function taskService( diff --git a/packages/api/test/support/timeOffService.ts b/packages/api/test/support/timeOffService.ts index 9e4749602..efc1a1f45 100644 --- a/packages/api/test/support/timeOffService.ts +++ b/packages/api/test/support/timeOffService.ts @@ -5,7 +5,7 @@ import { createEventBus } from "../../src/core/events"; import type { SecurityContext } from "../../src/core/security"; import { createLogger } from "../../src/core/logger"; import { createDemoFillService } from "../../src/modules/demo"; -import { createTaskService } from "../../src/modules/tasks"; +import { createTaskService } from "../../src/core/tasks"; import { createBookTimeOffService, createOwnTimeOffRulesService, diff --git a/packages/web/src/features/tasks/TaskPanel/CancelTaskDialog.tsx b/packages/web/src/features/tasks/TaskPanel/CancelTaskDialog.tsx deleted file mode 100644 index 4272524bb..000000000 --- a/packages/web/src/features/tasks/TaskPanel/CancelTaskDialog.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useState } from "react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Input } from "@/components/ui/input"; -import { t } from "@/i18n"; - -type Props = { - open: boolean; - onOpenChange: (open: boolean) => void; - /** The assignee, who reads the reason. */ - assigneeName: string; - isPending: boolean; - /** Cancels with the trimmed reason; resolves true once cancelled. */ - onCancel: (reason: string) => Promise; -}; - -/** Asks the person who asked for a task why they withdraw it; the reason is required. */ -export function CancelTaskDialog({ open, onOpenChange, assigneeName, isPending, onCancel }: Props) { - const [reason, setReason] = useState(""); - const [showRequired, setShowRequired] = useState(false); - - const confirm = async () => { - const trimmed = reason.trim(); - if (!trimmed) { - setShowRequired(true); - return; - } - if (await onCancel(trimmed)) onOpenChange(false); - }; - - return ( - { - if (isPending) return; - if (!nextOpen) { - setReason(""); - setShowRequired(false); - } - onOpenChange(nextOpen); - }} - > - - - {t("tasks.cancelDialog.title")} - - {t("tasks.cancelDialog.description", { name: assigneeName })} - - - setReason(event.target.value)} - /> - {showRequired && ( - - )} - - {t("tasks.cancelDialog.keep")} - void confirm()} - > - {t("tasks.cancelDialog.confirm")} - - - - - ); -} diff --git a/packages/web/src/features/tasks/TaskPanel/DeleteTaskDialog.tsx b/packages/web/src/features/tasks/TaskPanel/DeleteTaskDialog.tsx new file mode 100644 index 000000000..8a75a5175 --- /dev/null +++ b/packages/web/src/features/tasks/TaskPanel/DeleteTaskDialog.tsx @@ -0,0 +1,48 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { t } from "@/i18n"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The assignee, whose tasks it leaves. */ + assigneeName: string; + isPending: boolean; + /** Resolves true once deleted. */ + onDelete: () => Promise; +}; + +/** Asks the person who asked for a task to confirm deleting it. */ +export function DeleteTaskDialog({ open, onOpenChange, assigneeName, isPending, onDelete }: Props) { + return ( + !isPending && onOpenChange(nextOpen)}> + + + {t("tasks.deleteDialog.title")} + + {t("tasks.deleteDialog.description", { name: assigneeName })} + + + + {t("tasks.deleteDialog.keep")} + void onDelete().then((deleted) => deleted && onOpenChange(false))} + > + {t("tasks.deleteDialog.confirm")} + + + + + ); +} diff --git a/packages/web/src/features/tasks/TaskPanel/TaskDetails.tsx b/packages/web/src/features/tasks/TaskPanel/TaskDetails.tsx index 038f87bdc..f733741bd 100644 --- a/packages/web/src/features/tasks/TaskPanel/TaskDetails.tsx +++ b/packages/web/src/features/tasks/TaskPanel/TaskDetails.tsx @@ -18,21 +18,24 @@ import { TASK_DESCRIPTION } from "../taskDescription"; import { TaskStatus } from "../TaskStatus"; import { formatTaskDay, isOpen, personName, taskSource, type TaskRow } from "../taskText"; import { useTaskActions } from "../useTaskActions"; -import { CancelTaskDialog } from "./CancelTaskDialog"; +import { DeleteTaskDialog } from "./DeleteTaskDialog"; import { PanelCloseButton } from "./PanelCloseButton"; -type Props = { task: TaskRow }; +type Props = { + task: TaskRow; + /** Closes the panel once the task is deleted. */ + onDeleted: () => void; +}; /** A loaded task: what is asked and by whom on the left, what acting does and where it came from on the right. */ -export function TaskDetails({ task }: Props) { +export function TaskDetails({ task, onDeleted }: Props) { const { me } = useRouteContext({ from: "/_authenticated" }); const viewerId = me.employee?.id ?? null; const actions = useTaskActions(); - const [cancelling, setCancelling] = useState(false); + const [deleting, setDeleting] = useState(false); const holds = task.assignee.id === viewerId; const asked = task.askedBy?.id === viewerId; - const canReopen = holds && task.status === "DONE"; - const canCancel = asked && isOpen(task); + const canDelete = asked && isOpen(task); return (
@@ -69,7 +72,7 @@ export function TaskDetails({ task }: Props) { {t("tasks.markDone")} )} - {(canReopen || canCancel) && ( + {canDelete && ( } @@ -77,16 +80,9 @@ export function TaskDetails({ task }: Props) { - {canReopen && ( - void actions.reopen(task.id)}> - {t("tasks.reopen")} - - )} - {canCancel && ( - setCancelling(true)}> - {t("tasks.cancel")} - - )} + setDeleting(true)}> + {t("tasks.delete")} + )} @@ -150,12 +146,16 @@ export function TaskDetails({ task }: Props) {
- actions.cancel(task.id, reason)} + onDelete={async () => { + const deleted = await actions.delete(task.id); + if (deleted) onDeleted(); + return deleted; + }} /> ); diff --git a/packages/web/src/features/tasks/TaskPanel/TaskPanel.tsx b/packages/web/src/features/tasks/TaskPanel/TaskPanel.tsx index b31837d65..dc516d367 100644 --- a/packages/web/src/features/tasks/TaskPanel/TaskPanel.tsx +++ b/packages/web/src/features/tasks/TaskPanel/TaskPanel.tsx @@ -23,7 +23,7 @@ export function TaskPanel({ taskId, onClose }: Props) { !open && onClose()}> {data?.task ? ( - + ) : (
diff --git a/packages/web/src/features/tasks/taskDescription.ts b/packages/web/src/features/tasks/taskDescription.ts index 5adff7633..0f96ff01b 100644 --- a/packages/web/src/features/tasks/taskDescription.ts +++ b/packages/web/src/features/tasks/taskDescription.ts @@ -5,8 +5,7 @@ import { t } from "@/i18n"; export const TASK_DESCRIPTION: RecordDescription = { titleField: "title", createdAs: { EDITED_DIRECTLY: "tasks.history.created" }, - changedAs: ({ field, oldValue, newValue }) => - field === "status" ? statusChangeSentence(oldValue, newValue) : null, + changedAs: ({ field, newValue }) => (field === "status" ? statusChangeSentence(newValue) : null), fields: [ { key: "title", @@ -25,12 +24,11 @@ export const TASK_DESCRIPTION: RecordDescription = { ], }; -/** "marked it done", "reopened it", "cancelled it" or "put it on hold". */ -function statusChangeSentence(oldValue: string | null, newValue: string | null): string | null { +/** "marked it done", "cancelled it" or "put it on hold". */ +function statusChangeSentence(newValue: string | null): string | null { if (newValue === "done") return t("tasks.history.markedDone"); if (newValue === "cancelled") return t("tasks.history.cancelled"); if (newValue === "waiting") return t("tasks.history.waiting"); - if (newValue === "to_do" && oldValue === "done") return t("tasks.history.reopened"); return null; } diff --git a/packages/web/src/features/tasks/taskText.test.ts b/packages/web/src/features/tasks/taskText.test.ts index 199151956..6ab8ab19d 100644 --- a/packages/web/src/features/tasks/taskText.test.ts +++ b/packages/web/src/features/tasks/taskText.test.ts @@ -152,12 +152,6 @@ it("does not count a task marked done in place in needs you", () => { expect(countTasksNeedingYou(groups, assignee.id)).toBe(0); }); -it("counts a reopened task still sitting in resolved recently", () => { - const groups = { needsYou: [], waitingOnOthers: [], resolvedRecently: [taskWith()] }; - - expect(countTasksNeedingYou(groups, assignee.id)).toBe(1); -}); - it("does not count a task to do that someone else holds", () => { const task = taskWith({ assignee: asker, askedBy: assignee }); const groups = { needsYou: [], waitingOnOthers: [task], resolvedRecently: [] }; diff --git a/packages/web/src/features/tasks/taskText.ts b/packages/web/src/features/tasks/taskText.ts index 78f4e29a8..d6b8a0300 100644 --- a/packages/web/src/features/tasks/taskText.ts +++ b/packages/web/src/features/tasks/taskText.ts @@ -11,17 +11,13 @@ export function formatTaskDay(value: string): string { return DAY_FORMAT.format(utcDay(day)); } -/** - * The tasks to do the viewer holds, in whichever group the cache has them: a task reopened on this - * page still sits under Resolved recently until Tasks next loads. - */ +/** The tasks to do the viewer holds: one marked done on this page stays listed until Tasks next loads. */ export function countTasksNeedingYou( - groups: { needsYou: TaskRow[]; waitingOnOthers: TaskRow[]; resolvedRecently: TaskRow[] }, + groups: { needsYou: TaskRow[] }, viewerId: string | null, ): number { - return [...groups.needsYou, ...groups.waitingOnOthers, ...groups.resolvedRecently].filter( - (task) => task.status === "TO_DO" && task.assignee.id === viewerId, - ).length; + return groups.needsYou.filter((task) => task.status === "TO_DO" && task.assignee.id === viewerId) + .length; } /** Whether a task still open was due before `today`. */ diff --git a/packages/web/src/features/tasks/tasks.graphql b/packages/web/src/features/tasks/tasks.graphql index fd709ae54..057c7d765 100644 --- a/packages/web/src/features/tasks/tasks.graphql +++ b/packages/web/src/features/tasks/tasks.graphql @@ -78,31 +78,10 @@ mutation MarkTaskDone($input: TaskMarkDoneInput!) { } } -mutation ReopenTask($input: TaskReopenInput!) { +mutation DeleteTask($input: TaskDeleteInput!) { tasks { - reopen(input: $input) { - task { - ...TaskFields @unmask - } - historyLine { - ...RecordHistory_line @unmask - } - error { - ...TaskError @unmask - } - } - } -} - -mutation CancelTask($input: TaskCancelInput!) { - tasks { - cancel(input: $input) { - task { - ...TaskFields @unmask - } - historyLine { - ...RecordHistory_line @unmask - } + delete(input: $input) { + deletedTaskId error { ...TaskError @unmask } diff --git a/packages/web/src/features/tasks/useTaskActions.ts b/packages/web/src/features/tasks/useTaskActions.ts index 5a496c376..b50d6622e 100644 --- a/packages/web/src/features/tasks/useTaskActions.ts +++ b/packages/web/src/features/tasks/useTaskActions.ts @@ -1,9 +1,8 @@ import { toast } from "sonner"; import { - CancelTaskDocument, + DeleteTaskDocument, MarkTaskDoneDocument, - ReopenTaskDocument, type TaskErrorFragment, } from "@/__generated__/graphql"; import { PayloadFailure, useCache, usePayloadMutation } from "@/apollo"; @@ -12,21 +11,16 @@ import { t } from "@/i18n"; import { addTaskHistoryLine, forgetStaleTask } from "./cache"; /** - * Mark done, with Undo for a few seconds, Reopen and Cancel. Each payload returns the task, which - * updates in place; the groups it belongs to change when Tasks next loads. A refusal, such as a - * change someone made first, shows its reason and loads the task again as it now stands. + * Mark done, and Delete for the person who asked. Mark done returns the task, which updates in + * place; the groups it belongs to change when Tasks next loads. A refusal, such as a change someone + * made first, shows its reason and loads the task again as it now stands. */ export function useTaskActions() { const cache = useCache(); const [markDone, markDoneState] = usePayloadMutation(MarkTaskDoneDocument, { update: (cache, { data }) => addTaskHistoryLine(cache, data?.tasks.markDone), }); - const [reopen, reopenState] = usePayloadMutation(ReopenTaskDocument, { - update: (cache, { data }) => addTaskHistoryLine(cache, data?.tasks.reopen), - }); - const [cancel, cancelState] = usePayloadMutation(CancelTaskDocument, { - update: (cache, { data }) => addTaskHistoryLine(cache, data?.tasks.cancel), - }); + const [deleteTask, deleteState] = usePayloadMutation(DeleteTaskDocument); const refused = (id: string) => (failure: unknown) => { const payloadError = @@ -34,24 +28,22 @@ export function useTaskActions() { toast.error(payloadError ? payloadError.message : t("tasks.actionFailed")); if (payloadError) forgetStaleTask(cache, id); }; - const reopenTask = (id: string) => - reopen({ variables: { input: { id } } }).then(() => undefined, refused(id)); return { - isPending: markDoneState.loading || reopenState.loading || cancelState.loading, + isPending: markDoneState.loading || deleteState.loading, markDone: (id: string) => markDone({ variables: { input: { id } } }).then( - () => - void toast(t("tasks.markedDone"), { - action: { label: t("tasks.undo"), onClick: () => void reopenTask(id) }, - }), + () => void toast(t("tasks.markedDone")), refused(id), ), - reopen: reopenTask, - /** Resolves true once cancelled; a refusal shows and resolves false. */ - cancel: (id: string, reason: string) => - cancel({ variables: { input: { id, reason } } }).then( - () => true, + /** Resolves true once deleted, the task gone from the cache; a refusal shows and resolves false. */ + delete: (id: string) => + deleteTask({ variables: { input: { id } } }).then( + () => { + forgetStaleTask(cache, id); + toast(t("tasks.deleted")); + return true; + }, (failure: unknown) => { refused(id)(failure); return false; diff --git a/packages/web/src/i18n/en/tasks.ts b/packages/web/src/i18n/en/tasks.ts index 6bba05f0b..2605b9b83 100644 --- a/packages/web/src/i18n/en/tasks.ts +++ b/packages/web/src/i18n/en/tasks.ts @@ -30,11 +30,10 @@ export const tasksEn = { "tasks.fact.cancelled": "Cancelled: {reason}", "tasks.you": "You", "tasks.markDone": "Mark done", - "tasks.undo": "Undo", "tasks.markedDone": "Marked done", "tasks.actionFailed": "That did not work. Try again.", - "tasks.reopen": "Reopen", - "tasks.cancel": "Cancel task", + "tasks.delete": "Delete task", + "tasks.deleted": "Task deleted", "tasks.more": "More actions", "tasks.panel.close": "Close", "tasks.panel.notFound": "This task is not yours to see, or it is gone.", @@ -59,13 +58,10 @@ export const tasksEn = { "tasks.history.title": "Title", "tasks.history.status": "Status", "tasks.history.markedDone": "marked it done", - "tasks.history.reopened": "reopened it", "tasks.history.cancelled": "cancelled it", "tasks.history.waiting": "put it on hold", - "tasks.cancelDialog.title": "Cancel this task?", - "tasks.cancelDialog.description": "{name} no longer needs to do it and sees why.", - "tasks.cancelDialog.reason": "Why", - "tasks.cancelDialog.reasonRequired": "Say why you are cancelling it.", - "tasks.cancelDialog.confirm": "Cancel task", - "tasks.cancelDialog.keep": "Keep it", + "tasks.deleteDialog.title": "Delete this task?", + "tasks.deleteDialog.description": "It leaves {name}'s tasks, and nobody can see it again.", + "tasks.deleteDialog.confirm": "Delete task", + "tasks.deleteDialog.keep": "Keep it", } as const;