From dd1a542983335a29e53bfd4d6bcd119441018f22 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sat, 14 Nov 2026 15:36:27 +0100 Subject: [PATCH 01/19] refactor(api): move tasks and processes into core --- packages/api/codegen.ts | 2 +- packages/api/importRules.json | 61 ++++++++++++++----- .../processes/app/cancelRun.int.test.ts | 0 .../processes/app/dueRunService.ts | 6 +- .../processes/app/eventRunService.ts | 8 +-- .../{modules => core}/processes/app/index.ts | 0 .../processes/app/markStepDone.int.test.ts | 0 .../processes/app/processRecordType.ts | 4 +- .../processes/app/processService.ts | 14 ++--- .../processes/app/processViews.ts | 0 .../processes/app/readRuns.int.test.ts | 0 .../processes/app/registrations.ts | 6 +- .../processes/app/runLifecycleService.ts | 8 +-- .../processes/app/runReader.ts | 4 +- .../processes/app/runStarter.ts | 8 +-- .../processes/app/startDueRuns.int.test.ts | 0 .../processes/app/startDueRunsJob.ts | 6 +- .../processes/app/startRun.int.test.ts | 0 .../app/startRunsOnEvents.int.test.ts | 0 .../app/startRunsOnEventsAtScale.int.test.ts | 2 +- .../processes/app/turnOnProcess.int.test.ts | 0 .../processes/app/workingCalendars.ts | 10 +-- .../{modules => core}/processes/db/index.ts | 0 .../processes/db/processRepository.ts | 4 +- .../processes/db/processRunsTable.ts | 2 +- .../processes/db/processesTable.ts | 0 .../processes/domain/dayOffset.test.ts | 0 .../processes/domain/dayOffset.ts | 2 +- .../processes/domain/eventTrigger.test.ts | 0 .../processes/domain/eventTrigger.ts | 2 +- .../processes/domain/index.ts | 0 .../domain/processDefinition.test.ts | 0 .../processes/domain/processDefinition.ts | 0 .../processes/domain/processRun.ts | 2 +- .../processes/domain/recordDateTrigger.ts | 0 .../processes/graphql/schema.graphql | 0 .../src/{modules => core}/processes/index.ts | 6 +- .../src/{modules => core}/processes/module.md | 0 .../tasks/app/cancelTask.int.test.ts | 0 .../tasks/app/createTask.int.test.ts | 0 .../tasks/app/createTasks.ts | 8 +-- .../src/{modules => core}/tasks/app/index.ts | 0 .../tasks/app/listMyTasks.int.test.ts | 0 .../tasks/app/markDone.int.test.ts | 0 .../tasks/app/readTask.int.test.ts | 0 .../tasks/app/readTaskHistory.int.test.ts | 0 .../tasks/app/registrations.ts | 4 +- .../tasks/app/reopenTask.int.test.ts | 0 .../tasks/app/taskRecordType.ts | 4 +- .../tasks/app/taskService.ts | 12 ++-- .../{modules => core}/tasks/codegen/index.ts | 4 +- .../src/{modules => core}/tasks/db/index.ts | 0 .../tasks/db/taskRepository.ts | 2 +- .../{modules => core}/tasks/db/tasksTable.ts | 0 .../{modules => core}/tasks/domain/index.ts | 0 .../{modules => core}/tasks/domain/myTasks.ts | 0 .../tasks/domain/task.test.ts | 0 .../{modules => core}/tasks/domain/task.ts | 2 +- .../{modules => core}/tasks/graphql/index.ts | 0 .../tasks/graphql/resolvers.ts | 6 +- .../tasks/graphql/schema.graphql | 0 .../api/src/{modules => core}/tasks/index.ts | 6 +- .../api/src/{modules => core}/tasks/module.md | 0 .../src/modules/demo/app/demoFillService.ts | 2 +- packages/api/src/modules/index.ts | 4 +- packages/api/test/support/processService.ts | 2 +- packages/api/test/support/seedProcess.ts | 4 +- packages/api/test/support/seedTask.ts | 2 +- packages/api/test/support/taskService.ts | 2 +- packages/api/test/support/timeOffService.ts | 2 +- 70 files changed, 126 insertions(+), 97 deletions(-) rename packages/api/src/{modules => core}/processes/app/cancelRun.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/app/dueRunService.ts (97%) rename packages/api/src/{modules => core}/processes/app/eventRunService.ts (96%) rename packages/api/src/{modules => core}/processes/app/index.ts (100%) rename packages/api/src/{modules => core}/processes/app/markStepDone.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/app/processRecordType.ts (84%) rename packages/api/src/{modules => core}/processes/app/processService.ts (96%) rename packages/api/src/{modules => core}/processes/app/processViews.ts (100%) rename packages/api/src/{modules => core}/processes/app/readRuns.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/app/registrations.ts (85%) rename packages/api/src/{modules => core}/processes/app/runLifecycleService.ts (97%) rename packages/api/src/{modules => core}/processes/app/runReader.ts (96%) rename packages/api/src/{modules => core}/processes/app/runStarter.ts (97%) rename packages/api/src/{modules => core}/processes/app/startDueRuns.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/app/startDueRunsJob.ts (86%) rename packages/api/src/{modules => core}/processes/app/startRun.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/app/startRunsOnEvents.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/app/startRunsOnEventsAtScale.int.test.ts (98%) rename packages/api/src/{modules => core}/processes/app/turnOnProcess.int.test.ts (100%) rename packages/api/src/{modules => core}/processes/app/workingCalendars.ts (89%) rename packages/api/src/{modules => core}/processes/db/index.ts (100%) rename packages/api/src/{modules => core}/processes/db/processRepository.ts (99%) rename packages/api/src/{modules => core}/processes/db/processRunsTable.ts (99%) 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 (96%) rename packages/api/src/{modules => core}/processes/domain/eventTrigger.test.ts (100%) rename packages/api/src/{modules => core}/processes/domain/eventTrigger.ts (98%) rename packages/api/src/{modules => core}/processes/domain/index.ts (100%) rename packages/api/src/{modules => core}/processes/domain/processDefinition.test.ts (100%) rename packages/api/src/{modules => core}/processes/domain/processDefinition.ts (100%) rename packages/api/src/{modules => core}/processes/domain/processRun.ts (99%) rename packages/api/src/{modules => core}/processes/domain/recordDateTrigger.ts (100%) rename packages/api/src/{modules => core}/processes/graphql/schema.graphql (100%) rename packages/api/src/{modules => core}/processes/index.ts (82%) rename packages/api/src/{modules => core}/processes/module.md (100%) rename packages/api/src/{modules => core}/tasks/app/cancelTask.int.test.ts (100%) rename packages/api/src/{modules => core}/tasks/app/createTask.int.test.ts (100%) rename packages/api/src/{modules => core}/tasks/app/createTasks.ts (94%) rename packages/api/src/{modules => core}/tasks/app/index.ts (100%) rename packages/api/src/{modules => core}/tasks/app/listMyTasks.int.test.ts (100%) 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 (100%) rename packages/api/src/{modules => core}/tasks/app/readTaskHistory.int.test.ts (100%) rename packages/api/src/{modules => core}/tasks/app/registrations.ts (91%) rename packages/api/src/{modules => core}/tasks/app/reopenTask.int.test.ts (100%) rename packages/api/src/{modules => core}/tasks/app/taskRecordType.ts (87%) rename packages/api/src/{modules => core}/tasks/app/taskService.ts (96%) 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 (98%) rename packages/api/src/{modules => core}/tasks/db/tasksTable.ts (100%) rename packages/api/src/{modules => core}/tasks/domain/index.ts (100%) rename packages/api/src/{modules => core}/tasks/domain/myTasks.ts (100%) rename packages/api/src/{modules => core}/tasks/domain/task.test.ts (100%) rename packages/api/src/{modules => core}/tasks/domain/task.ts (97%) rename packages/api/src/{modules => core}/tasks/graphql/index.ts (100%) rename packages/api/src/{modules => core}/tasks/graphql/resolvers.ts (94%) rename packages/api/src/{modules => core}/tasks/graphql/schema.graphql (100%) rename packages/api/src/{modules => core}/tasks/index.ts (79%) rename packages/api/src/{modules => core}/tasks/module.md (100%) 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/importRules.json b/packages/api/importRules.json index 9447198b0..8c15ebffc 100644 --- a/packages/api/importRules.json +++ b/packages/api/importRules.json @@ -24,9 +24,17 @@ "src/core/instance", "src/core/moduleManifest", "src/core/records", + "src/core/processes", + "src/core/tasks", "src/modules/*" ], - "src/modules/bankHolidays": ["src/core/*", "!src/core/server", "src/utils"], + "src/modules/bankHolidays": [ + "src/core/*", + "!src/core/server", + "!src/core/processes", + "!src/core/tasks", + "src/utils" + ], "src/modules/company": [ "src/core/*", "!src/core/server", @@ -37,13 +45,14 @@ "src/core/*", "!src/core/server", "src/modules/employees", - "src/modules/tasks", "src/modules/timeOff", "src/utils" ], "src/modules/employees": [ "src/core/*", "!src/core/server", + "!src/core/processes", + "!src/core/tasks", "src/integrations/mail", "src/modules/bankHolidays", "src/utils" @@ -63,17 +72,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", @@ -173,16 +173,45 @@ "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/modules/employees" + ], + "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", + "src/modules/bankHolidays", + "src/modules/employees" + ] }, "layers": { "domain": ["helpers"], diff --git a/packages/api/src/modules/processes/app/cancelRun.int.test.ts b/packages/api/src/core/processes/app/cancelRun.int.test.ts similarity index 100% rename from packages/api/src/modules/processes/app/cancelRun.int.test.ts rename to packages/api/src/core/processes/app/cancelRun.int.test.ts diff --git a/packages/api/src/modules/processes/app/dueRunService.ts b/packages/api/src/core/processes/app/dueRunService.ts similarity index 97% rename from packages/api/src/modules/processes/app/dueRunService.ts rename to packages/api/src/core/processes/app/dueRunService.ts index 98c49d546..a7b71d53b 100644 --- a/packages/api/src/modules/processes/app/dueRunService.ts +++ b/packages/api/src/core/processes/app/dueRunService.ts @@ -1,6 +1,6 @@ -import { isUniqueViolation, type Orm, type Transact, type Tx } from "../../../core/db"; -import type { Logger } from "../../../core/logger"; -import type { SecurityContext } from "../../../core/security"; +import { isUniqueViolation, type Orm, type Transact, type Tx } from "../../db"; +import type { Logger } from "../../logger"; +import type { SecurityContext } from "../../security"; import { type CalendarDate, dateInLondon } from "../../../utils"; import { processRepository, type StoredProcess } from "../db"; import { dueTriggerDate, parseProcessDefinition, type ProcessRun } from "../domain"; diff --git a/packages/api/src/modules/processes/app/eventRunService.ts b/packages/api/src/core/processes/app/eventRunService.ts similarity index 96% rename from packages/api/src/modules/processes/app/eventRunService.ts rename to packages/api/src/core/processes/app/eventRunService.ts index 883787d4e..b07af5432 100644 --- a/packages/api/src/modules/processes/app/eventRunService.ts +++ b/packages/api/src/core/processes/app/eventRunService.ts @@ -1,7 +1,7 @@ -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"; diff --git a/packages/api/src/modules/processes/app/index.ts b/packages/api/src/core/processes/app/index.ts similarity index 100% rename from packages/api/src/modules/processes/app/index.ts rename to packages/api/src/core/processes/app/index.ts diff --git a/packages/api/src/modules/processes/app/markStepDone.int.test.ts b/packages/api/src/core/processes/app/markStepDone.int.test.ts similarity index 100% rename from packages/api/src/modules/processes/app/markStepDone.int.test.ts rename to packages/api/src/core/processes/app/markStepDone.int.test.ts 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 96% rename from packages/api/src/modules/processes/app/processService.ts rename to packages/api/src/core/processes/app/processService.ts index 3c28493aa..18b1ea41e 100644 --- a/packages/api/src/modules/processes/app/processService.ts +++ b/packages/api/src/core/processes/app/processService.ts @@ -1,15 +1,15 @@ -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"; +} from "../../records"; +import { PrincipalSecurityContext, type SecurityContext } from "../../security"; import { type CalendarDate, dateInLondon, parseUuid } from "../../../utils"; -import type { BankHolidayService } from "../../bankHolidays"; +import type { BankHolidayService } from "../../../modules/bankHolidays"; import type { TaskService } from "../../tasks"; import { processRepository, type StoredProcess } from "../db"; import { parseProcessDefinition } from "../domain"; diff --git a/packages/api/src/modules/processes/app/processViews.ts b/packages/api/src/core/processes/app/processViews.ts similarity index 100% rename from packages/api/src/modules/processes/app/processViews.ts rename to packages/api/src/core/processes/app/processViews.ts 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 100% rename from packages/api/src/modules/processes/app/readRuns.int.test.ts rename to packages/api/src/core/processes/app/readRuns.int.test.ts diff --git a/packages/api/src/modules/processes/app/registrations.ts b/packages/api/src/core/processes/app/registrations.ts similarity index 85% rename from packages/api/src/modules/processes/app/registrations.ts rename to packages/api/src/core/processes/app/registrations.ts index b6a50488b..b4eb526dc 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,13 +12,13 @@ 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[] }; diff --git a/packages/api/src/modules/processes/app/runLifecycleService.ts b/packages/api/src/core/processes/app/runLifecycleService.ts similarity index 97% rename from packages/api/src/modules/processes/app/runLifecycleService.ts rename to packages/api/src/core/processes/app/runLifecycleService.ts index f5b5003f4..23bcc0464 100644 --- a/packages/api/src/modules/processes/app/runLifecycleService.ts +++ b/packages/api/src/core/processes/app/runLifecycleService.ts @@ -1,7 +1,7 @@ -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 { ApplicationError, invalidInput } from "../../applicationError"; +import type { Orm, Transact, Tx } from "../../db"; +import type { Logger } from "../../logger"; +import { PrincipalSecurityContext, type SecurityContext } from "../../security"; import type { Task, TaskService } from "../../tasks"; import { processRepository, type StoredProcess } from "../db"; import { diff --git a/packages/api/src/modules/processes/app/runReader.ts b/packages/api/src/core/processes/app/runReader.ts similarity index 96% rename from packages/api/src/modules/processes/app/runReader.ts rename to packages/api/src/core/processes/app/runReader.ts index ba4dbf51f..cfdf44e6e 100644 --- a/packages/api/src/modules/processes/app/runReader.ts +++ b/packages/api/src/core/processes/app/runReader.ts @@ -1,5 +1,5 @@ -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"; diff --git a/packages/api/src/modules/processes/app/runStarter.ts b/packages/api/src/core/processes/app/runStarter.ts similarity index 97% rename from packages/api/src/modules/processes/app/runStarter.ts rename to packages/api/src/core/processes/app/runStarter.ts index 3931266b3..f1e9080e3 100644 --- a/packages/api/src/modules/processes/app/runStarter.ts +++ b/packages/api/src/core/processes/app/runStarter.ts @@ -1,13 +1,13 @@ -import type { Orm, Transact, Tx } from "../../../core/db"; -import { PrincipalSecurityContext } from "../../../core/security"; +import type { Orm, Transact, Tx } from "../../db"; +import { PrincipalSecurityContext } from "../../security"; import { addDays, type CalendarDate } from "../../../utils"; -import type { BankHolidayService } from "../../bankHolidays"; +import type { BankHolidayService } from "../../../modules/bankHolidays"; import { createEmployeeReadService, createEmployeeWorkingPatternService, employeeDirectory, type EmployeeReadService, -} from "../../employees"; +} from "../../../modules/employees"; import type { NewTask, TaskService } from "../../tasks"; import { type NewRunRow, processRepository, type StoredProcess } from "../db"; import { 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 100% rename from packages/api/src/modules/processes/app/startDueRuns.int.test.ts rename to packages/api/src/core/processes/app/startDueRuns.int.test.ts 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 100% rename from packages/api/src/modules/processes/app/startRun.int.test.ts rename to packages/api/src/core/processes/app/startRun.int.test.ts 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 100% rename from packages/api/src/modules/processes/app/startRunsOnEvents.int.test.ts rename to packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts 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 98% 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..83355ba78 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 ({ 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/app/workingCalendars.ts b/packages/api/src/core/processes/app/workingCalendars.ts similarity index 89% rename from packages/api/src/modules/processes/app/workingCalendars.ts rename to packages/api/src/core/processes/app/workingCalendars.ts index 9b48e3815..7d52f11f6 100644 --- a/packages/api/src/modules/processes/app/workingCalendars.ts +++ b/packages/api/src/core/processes/app/workingCalendars.ts @@ -1,9 +1,9 @@ -import { ApplicationError } from "../../../core/applicationError"; -import type { Tx } from "../../../core/db"; -import { PrincipalSecurityContext } from "../../../core/security"; +import { ApplicationError } from "../../applicationError"; +import type { Tx } from "../../db"; +import { PrincipalSecurityContext } from "../../security"; import { addDays, type CalendarDate } from "../../../utils"; -import type { BankHolidayService } from "../../bankHolidays"; -import type { EmployeeWorkingPatternService } from "../../employees"; +import type { BankHolidayService } from "../../../modules/bankHolidays"; +import type { EmployeeWorkingPatternService } from "../../../modules/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. */ 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 99% rename from packages/api/src/modules/processes/db/processRepository.ts rename to packages/api/src/core/processes/db/processRepository.ts index 0d8f4cbdd..96f689532 100644 --- a/packages/api/src/modules/processes/db/processRepository.ts +++ b/packages/api/src/core/processes/db/processRepository.ts @@ -1,7 +1,7 @@ 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 { ormOf, type Orm, type OrmTransaction, type Tx } from "../../db"; +import { inRecordScope, type RecordScope } from "../../security"; import { PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessRun, type RunStep } from "../domain"; import { processes, processVersions } from "./processesTable"; import { processRuns, processRunSteps } from "./processRunsTable"; diff --git a/packages/api/src/modules/processes/db/processRunsTable.ts b/packages/api/src/core/processes/db/processRunsTable.ts similarity index 99% rename from packages/api/src/modules/processes/db/processRunsTable.ts rename to packages/api/src/core/processes/db/processRunsTable.ts index d39e4a768..b65d0961c 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, 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 96% rename from packages/api/src/modules/processes/domain/dayOffset.ts rename to packages/api/src/core/processes/domain/dayOffset.ts index 797ba60c5..29749478e 100644 --- a/packages/api/src/modules/processes/domain/dayOffset.ts +++ b/packages/api/src/core/processes/domain/dayOffset.ts @@ -1,4 +1,4 @@ -import { ApplicationError } from "../../../core/applicationError"; +import { ApplicationError } from "../../applicationError"; import { addDays, type CalendarDate, weekdayOf } from "../../../utils"; import type { DayOffset } from "./processDefinition"; diff --git a/packages/api/src/modules/processes/domain/eventTrigger.test.ts b/packages/api/src/core/processes/domain/eventTrigger.test.ts similarity index 100% rename from packages/api/src/modules/processes/domain/eventTrigger.test.ts rename to packages/api/src/core/processes/domain/eventTrigger.test.ts diff --git a/packages/api/src/modules/processes/domain/eventTrigger.ts b/packages/api/src/core/processes/domain/eventTrigger.ts similarity index 98% rename from packages/api/src/modules/processes/domain/eventTrigger.ts rename to packages/api/src/core/processes/domain/eventTrigger.ts index 8e66d8017..d8bdd900d 100644 --- a/packages/api/src/modules/processes/domain/eventTrigger.ts +++ b/packages/api/src/core/processes/domain/eventTrigger.ts @@ -1,4 +1,4 @@ -import type { DomainEvents } from "../../../core/events"; +import type { DomainEvents } from "../../events"; import type { EventTrigger, ProcessDefinition } from "./processDefinition"; /** diff --git a/packages/api/src/modules/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts similarity index 100% rename from packages/api/src/modules/processes/domain/index.ts rename to packages/api/src/core/processes/domain/index.ts diff --git a/packages/api/src/modules/processes/domain/processDefinition.test.ts b/packages/api/src/core/processes/domain/processDefinition.test.ts similarity index 100% rename from packages/api/src/modules/processes/domain/processDefinition.test.ts rename to packages/api/src/core/processes/domain/processDefinition.test.ts diff --git a/packages/api/src/modules/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts similarity index 100% rename from packages/api/src/modules/processes/domain/processDefinition.ts rename to packages/api/src/core/processes/domain/processDefinition.ts diff --git a/packages/api/src/modules/processes/domain/processRun.ts b/packages/api/src/core/processes/domain/processRun.ts similarity index 99% rename from packages/api/src/modules/processes/domain/processRun.ts rename to packages/api/src/core/processes/domain/processRun.ts index 2644cfa46..78b4897e9 100644 --- a/packages/api/src/modules/processes/domain/processRun.ts +++ b/packages/api/src/core/processes/domain/processRun.ts @@ -1,4 +1,4 @@ -import { ApplicationError } from "../../../core/applicationError"; +import { ApplicationError } from "../../applicationError"; import type { CalendarDate } from "../../../utils"; import type { TriggerEvent } from "./eventTrigger"; import type { AssigneeRule } from "./processDefinition"; 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/processes/graphql/schema.graphql b/packages/api/src/core/processes/graphql/schema.graphql similarity index 100% rename from packages/api/src/modules/processes/graphql/schema.graphql rename to packages/api/src/core/processes/graphql/schema.graphql diff --git a/packages/api/src/modules/processes/index.ts b/packages/api/src/core/processes/index.ts similarity index 82% rename from packages/api/src/modules/processes/index.ts rename to packages/api/src/core/processes/index.ts index e425d43df..47132ee68 100644 --- a/packages/api/src/modules/processes/index.ts +++ b/packages/api/src/core/processes/index.ts @@ -1,6 +1,6 @@ -import { readSdl } from "../../core/graphql"; -import { PrincipalSecurityContext } from "../../core/security"; -import type { ModuleDependencies, ModuleManifest } from "../../core/moduleManifest"; +import { readSdl } from "../graphql"; +import { PrincipalSecurityContext } from "../security"; +import type { ModuleDependencies, ModuleManifest } from "../moduleManifest"; import { type ProcessService, startDueRunsJob } from "./app"; // Public values. diff --git a/packages/api/src/modules/processes/module.md b/packages/api/src/core/processes/module.md similarity index 100% rename from packages/api/src/modules/processes/module.md rename to packages/api/src/core/processes/module.md diff --git a/packages/api/src/modules/tasks/app/cancelTask.int.test.ts b/packages/api/src/core/tasks/app/cancelTask.int.test.ts similarity index 100% rename from packages/api/src/modules/tasks/app/cancelTask.int.test.ts rename to packages/api/src/core/tasks/app/cancelTask.int.test.ts 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 100% rename from packages/api/src/modules/tasks/app/createTask.int.test.ts rename to packages/api/src/core/tasks/app/createTask.int.test.ts diff --git a/packages/api/src/modules/tasks/app/createTasks.ts b/packages/api/src/core/tasks/app/createTasks.ts similarity index 94% rename from packages/api/src/modules/tasks/app/createTasks.ts rename to packages/api/src/core/tasks/app/createTasks.ts index 45b40441b..b0db33767 100644 --- a/packages/api/src/modules/tasks/app/createTasks.ts +++ b/packages/api/src/core/tasks/app/createTasks.ts @@ -1,7 +1,7 @@ -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"; diff --git a/packages/api/src/modules/tasks/app/index.ts b/packages/api/src/core/tasks/app/index.ts similarity index 100% rename from packages/api/src/modules/tasks/app/index.ts rename to packages/api/src/core/tasks/app/index.ts 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 100% rename from packages/api/src/modules/tasks/app/listMyTasks.int.test.ts rename to packages/api/src/core/tasks/app/listMyTasks.int.test.ts 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 100% rename from packages/api/src/modules/tasks/app/readTask.int.test.ts rename to packages/api/src/core/tasks/app/readTask.int.test.ts 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 91% rename from packages/api/src/modules/tasks/app/registrations.ts rename to packages/api/src/core/tasks/app/registrations.ts index 0dd16ef44..d35d15fbe 100644 --- a/packages/api/src/modules/tasks/app/registrations.ts +++ b/packages/api/src/core/tasks/app/registrations.ts @@ -1,14 +1,14 @@ // 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": { diff --git a/packages/api/src/modules/tasks/app/reopenTask.int.test.ts b/packages/api/src/core/tasks/app/reopenTask.int.test.ts similarity index 100% rename from packages/api/src/modules/tasks/app/reopenTask.int.test.ts rename to packages/api/src/core/tasks/app/reopenTask.int.test.ts diff --git a/packages/api/src/modules/tasks/app/taskRecordType.ts b/packages/api/src/core/tasks/app/taskRecordType.ts similarity index 87% rename from packages/api/src/modules/tasks/app/taskRecordType.ts rename to packages/api/src/core/tasks/app/taskRecordType.ts index 4312e9a87..26760776e 100644 --- a/packages/api/src/modules/tasks/app/taskRecordType.ts +++ b/packages/api/src/core/tasks/app/taskRecordType.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 { taskRepository } from "../db"; import { isTaskVisibleTo } from "../domain"; diff --git a/packages/api/src/modules/tasks/app/taskService.ts b/packages/api/src/core/tasks/app/taskService.ts similarity index 96% rename from packages/api/src/modules/tasks/app/taskService.ts rename to packages/api/src/core/tasks/app/taskService.ts index 23ec9b2c1..3b6cf9a14 100644 --- a/packages/api/src/modules/tasks/app/taskService.ts +++ b/packages/api/src/core/tasks/app/taskService.ts @@ -1,13 +1,13 @@ -import { ApplicationError } from "../../../core/applicationError"; -import type { Transact, Tx } from "../../../core/db"; -import type { ModuleDependencies } from "../../../core/moduleManifest"; +import { ApplicationError } from "../../applicationError"; +import type { Transact, 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"; 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 98% rename from packages/api/src/modules/tasks/db/taskRepository.ts rename to packages/api/src/core/tasks/db/taskRepository.ts index e2029c373..2eb3e5c6f 100644 --- a/packages/api/src/modules/tasks/db/taskRepository.ts +++ b/packages/api/src/core/tasks/db/taskRepository.ts @@ -1,6 +1,6 @@ import { and, eq, gte, inArray, or } from "drizzle-orm"; -import { ormOf, type Orm, type Tx } from "../../../core/db"; +import { ormOf, type Orm, type Tx } from "../../db"; import type { Task } from "../domain"; import { tasks } from "./tasksTable"; diff --git a/packages/api/src/modules/tasks/db/tasksTable.ts b/packages/api/src/core/tasks/db/tasksTable.ts similarity index 100% rename from packages/api/src/modules/tasks/db/tasksTable.ts rename to packages/api/src/core/tasks/db/tasksTable.ts diff --git a/packages/api/src/modules/tasks/domain/index.ts b/packages/api/src/core/tasks/domain/index.ts similarity index 100% rename from packages/api/src/modules/tasks/domain/index.ts rename to packages/api/src/core/tasks/domain/index.ts 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/modules/tasks/domain/task.test.ts b/packages/api/src/core/tasks/domain/task.test.ts similarity index 100% rename from packages/api/src/modules/tasks/domain/task.test.ts rename to packages/api/src/core/tasks/domain/task.test.ts diff --git a/packages/api/src/modules/tasks/domain/task.ts b/packages/api/src/core/tasks/domain/task.ts similarity index 97% rename from packages/api/src/modules/tasks/domain/task.ts rename to packages/api/src/core/tasks/domain/task.ts index 156496255..64a7a8e99 100644 --- a/packages/api/src/modules/tasks/domain/task.ts +++ b/packages/api/src/core/tasks/domain/task.ts @@ -1,4 +1,4 @@ -import { ApplicationError } from "../../../core/applicationError"; +import { ApplicationError } from "../../applicationError"; import type { CalendarDate } from "../../../utils"; /** diff --git a/packages/api/src/modules/tasks/graphql/index.ts b/packages/api/src/core/tasks/graphql/index.ts similarity index 100% rename from packages/api/src/modules/tasks/graphql/index.ts rename to packages/api/src/core/tasks/graphql/index.ts diff --git a/packages/api/src/modules/tasks/graphql/resolvers.ts b/packages/api/src/core/tasks/graphql/resolvers.ts similarity index 94% rename from packages/api/src/modules/tasks/graphql/resolvers.ts rename to packages/api/src/core/tasks/graphql/resolvers.ts index 420204bec..b44443a95 100644 --- a/packages/api/src/modules/tasks/graphql/resolvers.ts +++ b/packages/api/src/core/tasks/graphql/resolvers.ts @@ -1,6 +1,6 @@ import DataLoader from "dataloader"; -import { ApplicationError } from "../../../core/applicationError"; +import { ApplicationError } from "../../applicationError"; import { type GraphqlContext, mutationPayload, @@ -8,8 +8,8 @@ import { toEnum, type Resolvers, type ResolversTypes, -} from "../../../core/graphql"; -import type { EmployeeReadService } from "../../employees"; +} from "../../graphql"; +import type { EmployeeReadService } from "../../../modules/employees"; import type { TaskService } from "../app"; const EXPECTED_ERRORS = ["INVALID_INPUT", "NOT_FOUND", "CONFLICT"] as const; diff --git a/packages/api/src/modules/tasks/graphql/schema.graphql b/packages/api/src/core/tasks/graphql/schema.graphql similarity index 100% rename from packages/api/src/modules/tasks/graphql/schema.graphql rename to packages/api/src/core/tasks/graphql/schema.graphql diff --git a/packages/api/src/modules/tasks/index.ts b/packages/api/src/core/tasks/index.ts similarity index 79% rename from packages/api/src/modules/tasks/index.ts rename to packages/api/src/core/tasks/index.ts index 4e669f397..9df118739 100644 --- a/packages/api/src/modules/tasks/index.ts +++ b/packages/api/src/core/tasks/index.ts @@ -1,6 +1,6 @@ -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 type { EmployeeReadService } from "../../modules/employees"; import { taskRecordType, type TaskService } from "./app"; import { createResolvers } from "./graphql"; diff --git a/packages/api/src/modules/tasks/module.md b/packages/api/src/core/tasks/module.md similarity index 100% rename from packages/api/src/modules/tasks/module.md rename to packages/api/src/core/tasks/module.md 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/index.ts b/packages/api/src/modules/index.ts index b08de2ff6..9c14b7121 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -19,10 +19,10 @@ import { import { externalAgentsModule } from "./externalAgents"; import { createImportServices, importsModule } from "./imports"; import { mcpAgentsModule } from "./mcpAgents"; -import { createProcessService, processesModule } from "./processes"; +import { createProcessService, processesModule } from "../core/processes"; import { createSlack, slackModule, type SlackUrls } from "./slack"; import { systemModule } from "./system"; -import { createTaskService, tasksModule } from "./tasks"; +import { createTaskService, tasksModule } from "../core/tasks"; import { createTimeOffSetupService, createTimeOffService, timeOffModule } from "./timeOff"; const employeeSetup = createEmployeeSetupService(); diff --git a/packages/api/test/support/processService.ts b/packages/api/test/support/processService.ts index 8ee2f28ad..010778c6b 100644 --- a/packages/api/test/support/processService.ts +++ b/packages/api/test/support/processService.ts @@ -1,7 +1,7 @@ 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 { createProcessService, processesModule } from "../../src/core/processes"; import { type CalendarDate, todayInLondon } from "../../src/utils"; import { bankHolidayService } from "./bankHolidayService"; import { taskService } from "./services"; diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts index 655c540e0..7865f2e4d 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. */ diff --git a/packages/api/test/support/seedTask.ts b/packages/api/test/support/seedTask.ts index 11f009c8d..f04d454b6 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( 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, From ae860dcda332b025b731b9e1fa8a66eeba4ad38b Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sat, 14 Nov 2026 15:56:50 +0100 Subject: [PATCH 02/19] refactor(api): processes follow tasks through events and plug features in through registrations Tasks and processes import no module. Processes reads people and working calendars through ports the employees module fills, record dates and subjects through record type registrations, and triggering events from the modules that publish them. A process step's task is marked done with the ordinary task command; processes completes the step before the commit. Reopen and cancelling a task by hand are gone; the person who asked deletes an open task instead. --- docs/architecture.md | 8 +- docs/qa/tasks.md | 9 +- packages/api/drizzle/0036_tasks.sql | 7 +- packages/api/drizzle/0038_processes.sql | 6 +- packages/api/drizzle/meta/0036_snapshot.json | 24 +- packages/api/drizzle/meta/0037_snapshot.json | 24 +- packages/api/drizzle/meta/0038_snapshot.json | 51 +-- packages/api/importRules.json | 21 +- .../core/processes/app/cancelRun.int.test.ts | 100 ------ .../src/core/processes/app/dueRunService.ts | 122 +++++--- .../src/core/processes/app/eventRunService.ts | 78 ++--- .../core/processes/app/finishStep.int.test.ts | 218 +++++++++++++ packages/api/src/core/processes/app/index.ts | 3 + .../processes/app/markStepDone.int.test.ts | 290 ------------------ packages/api/src/core/processes/app/ports.ts | 36 +++ .../src/core/processes/app/processService.ts | 148 +++++---- .../src/core/processes/app/processViews.ts | 9 +- .../core/processes/app/readRuns.int.test.ts | 37 +-- .../src/core/processes/app/registrations.ts | 2 + .../core/processes/app/runLifecycleService.ts | 187 +++-------- .../api/src/core/processes/app/runStarter.ts | 135 ++++---- .../processes/app/startDueRuns.int.test.ts | 9 +- .../core/processes/app/startRun.int.test.ts | 48 ++- .../app/startRunsOnEvents.int.test.ts | 6 +- .../app/startRunsOnEventsAtScale.int.test.ts | 8 +- .../processes/app/taskTriggeringEvents.ts | 15 + .../core/processes/app/workingCalendars.ts | 76 ----- .../core/processes/db/processRepository.ts | 29 ++ .../src/core/processes/db/processRunsTable.ts | 2 +- .../processes/domain/eventTrigger.test.ts | 64 +++- .../src/core/processes/domain/eventTrigger.ts | 68 ++-- .../api/src/core/processes/domain/index.ts | 6 +- .../domain/processDefinition.test.ts | 73 +++-- .../processes/domain/processDefinition.ts | 62 +++- .../src/core/processes/domain/processRun.ts | 33 +- .../api/src/core/processes/graphql/index.ts | 1 + .../src/core/processes/graphql/resolvers.ts | 19 ++ .../src/core/processes/graphql/schema.graphql | 16 + packages/api/src/core/processes/index.ts | 19 +- packages/api/src/core/processes/module.md | 13 +- packages/api/src/core/records/app/index.ts | 1 + .../records/app/recordHistoryReaderService.ts | 16 + packages/api/src/core/records/index.ts | 10 +- .../src/core/tasks/app/cancelTask.int.test.ts | 117 ------- .../src/core/tasks/app/createTask.int.test.ts | 26 +- .../api/src/core/tasks/app/createTasks.ts | 30 +- .../src/core/tasks/app/deleteTask.int.test.ts | 135 ++++++++ .../core/tasks/app/listMyTasks.int.test.ts | 2 +- .../src/core/tasks/app/readTask.int.test.ts | 2 +- .../api/src/core/tasks/app/registrations.ts | 6 +- .../src/core/tasks/app/reopenTask.int.test.ts | 107 ------- .../api/src/core/tasks/app/taskRecordType.ts | 12 +- .../api/src/core/tasks/app/taskService.ts | 93 +++--- .../api/src/core/tasks/db/taskRepository.ts | 21 +- packages/api/src/core/tasks/db/tasksTable.ts | 33 +- packages/api/src/core/tasks/domain/index.ts | 6 +- .../api/src/core/tasks/domain/task.test.ts | 28 +- packages/api/src/core/tasks/domain/task.ts | 29 +- .../api/src/core/tasks/graphql/resolvers.ts | 45 +-- .../api/src/core/tasks/graphql/schema.graphql | 47 +-- packages/api/src/core/tasks/index.ts | 12 +- packages/api/src/core/tasks/module.md | 10 +- .../employees/app/employeeReadService.ts | 20 -- .../employees/app/employeeRecordType.ts | 16 +- .../api/src/modules/employees/app/index.ts | 5 + .../modules/employees/app/processAdapters.ts | 104 +++++++ .../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 | 4 + packages/api/src/modules/index.ts | 27 +- .../modules/system/app/storedText.pbt.test.ts | 1 - .../system/app/storedTextExamples.int.test.ts | 2 - .../src/utils/collectRegistrations.test.ts | 19 ++ .../api/src/utils/collectRegistrations.ts | 17 + packages/api/src/utils/index.ts | 1 + packages/api/test/e2e/tasks.test.ts | 40 +++ packages/api/test/support/processService.ts | 36 ++- packages/api/test/support/seedTask.ts | 5 +- packages/api/test/support/storedTextProbe.ts | 17 - .../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 +- 90 files changed, 1598 insertions(+), 1900 deletions(-) delete mode 100644 packages/api/src/core/processes/app/cancelRun.int.test.ts create mode 100644 packages/api/src/core/processes/app/finishStep.int.test.ts delete mode 100644 packages/api/src/core/processes/app/markStepDone.int.test.ts create mode 100644 packages/api/src/core/processes/app/ports.ts create mode 100644 packages/api/src/core/processes/app/taskTriggeringEvents.ts delete mode 100644 packages/api/src/core/processes/app/workingCalendars.ts create mode 100644 packages/api/src/core/processes/graphql/index.ts create mode 100644 packages/api/src/core/processes/graphql/resolvers.ts delete mode 100644 packages/api/src/core/tasks/app/cancelTask.int.test.ts create mode 100644 packages/api/src/core/tasks/app/deleteTask.int.test.ts delete mode 100644 packages/api/src/core/tasks/app/reopenTask.int.test.ts create mode 100644 packages/api/src/modules/employees/app/processAdapters.ts 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..3590d1d71 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 @@ -82,8 +82,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/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/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..11b435786 100644 --- a/packages/api/drizzle/0038_processes.sql +++ b/packages/api/drizzle/0038_processes.sql @@ -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) @@ -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 -- The ready-made Welcome a new starter, off, with its first published version active. INSERT INTO "processes" ("key", "name", "description") VALUES ( 'welcome_new_starter', 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..2fce33730 100644 --- a/packages/api/drizzle/meta/0038_snapshot.json +++ b/packages/api/drizzle/meta/0038_snapshot.json @@ -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 8c15ebffc..350299464 100644 --- a/packages/api/importRules.json +++ b/packages/api/importRules.json @@ -26,15 +26,10 @@ "src/core/records", "src/core/processes", "src/core/tasks", - "src/modules/*" - ], - "src/modules/bankHolidays": [ - "src/core/*", - "!src/core/server", - "!src/core/processes", - "!src/core/tasks", + "src/modules/*", "src/utils" ], + "src/modules/bankHolidays": ["src/core/*", "!src/core/server", "src/utils"], "src/modules/company": [ "src/core/*", "!src/core/server", @@ -51,8 +46,6 @@ "src/modules/employees": [ "src/core/*", "!src/core/server", - "!src/core/processes", - "!src/core/tasks", "src/integrations/mail", "src/modules/bankHolidays", "src/utils" @@ -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"], @@ -193,8 +187,7 @@ "src/core/pagination", "src/core/records", "src/core/security", - "src/utils", - "src/modules/employees" + "src/utils" ], "src/core/processes": [ "src/core/applicationError", @@ -208,9 +201,7 @@ "src/core/records", "src/core/security", "src/core/tasks", - "src/utils", - "src/modules/bankHolidays", - "src/modules/employees" + "src/utils" ] }, "layers": { diff --git a/packages/api/src/core/processes/app/cancelRun.int.test.ts b/packages/api/src/core/processes/app/cancelRun.int.test.ts deleted file mode 100644 index db33d6b2c..000000000 --- a/packages/api/src/core/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/core/processes/app/dueRunService.ts b/packages/api/src/core/processes/app/dueRunService.ts index a7b71d53b..e783ddedb 100644 --- a/packages/api/src/core/processes/app/dueRunService.ts +++ b/packages/api/src/core/processes/app/dueRunService.ts @@ -1,11 +1,12 @@ import { isUniqueViolation, type Orm, type Transact, type Tx } from "../../db"; import type { Logger } from "../../logger"; import type { SecurityContext } from "../../security"; -import { type CalendarDate, dateInLondon } from "../../../utils"; +import { addDays, type CalendarDate, dateInLondon } from "../../../utils"; import { processRepository, type StoredProcess } from "../db"; -import { dueTriggerDate, parseProcessDefinition, type ProcessRun } from "../domain"; +import type { TaskService } from "../../tasks"; +import { dueTriggerDate, type ProcessRun, type RecordDateTrigger } from "../domain"; import type { RunLifecycleService } from "./runLifecycleService"; -import type { RunStarter } from "./runStarter"; +import type { DefinitionReader, ProcessRecordTypes, RunStarter } from "./runStarter"; /** Broccoli's daily pass over processes. */ export interface DueRunService { @@ -19,53 +20,45 @@ export interface DueRunService { ): Promise<{ runsStarted: number; runsCancelled: number }>; } -export function createDueRunService({ - orm, - transact, - logger, - starter, - lifecycle, -}: { +interface DueRunDependencies { orm: Orm; transact: Transact; logger: Logger; + tasks: TaskService; starter: RunStarter; lifecycle: RunLifecycleService; -}): DueRunService { + 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 { activeVersion } = process; - const { trigger } = parseProcessDefinition( - activeVersion.schemaVersion, - activeVersion.definition, - ); + 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, - "employee", - due.map(({ employeeId }) => employeeId), + trigger.recordType, + due.map(({ recordId }) => recordId), ); - for (const { employeeId } of due) { - if ( - !withRuns.has(employeeId) && - (await startOne({ transact, logger, starter }, process, employeeId, today)) - ) + 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(async (tx) => - lifecycle.cancelIfCannotRun(tx, (await processRepository(tx).lockRun(run.id))!), - ); - if (checked.status !== "cancelled") continue; + 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) { @@ -77,16 +70,38 @@ export function createDueRunService({ }; } +/** + * 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 openTaskIds = async () => + (await runs.findSteps(runId)).flatMap(({ status, taskId }) => + status === "step_awaiting_task_completion" && taskId ? [taskId] : [], + ); + const locked = await openTaskIds(); + await tasks.lockInTransaction(tx, locked); + const run = (await runs.lockRun(runId))!; + if (String(await 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( - { transact, logger, starter }: { transact: Transact; logger: Logger; starter: RunStarter }, + dependencies: DueRunDependencies, process: StoredProcess, - employeeId: string, + recordId: string, today: CalendarDate, ): Promise { + const { transact, logger } = dependencies; try { const run = await transact((tx) => - startIfStillDue(starter, tx, { processId: process.id, employeeId, today }), + startIfStillDue(dependencies, tx, { processId: process.id, recordId, today }), ); if (run) { logger.info( @@ -99,7 +114,7 @@ async function startOne( const reason = isUniqueViolation(error, "process_runs_start_key_unique") ? "already_started" : errorKind(error); - logger.error({ processKey: process.key, employeeId, reason }, "Process run not started"); + logger.error({ processKey: process.key, recordId, reason }, "Process run not started"); return null; } } @@ -109,42 +124,61 @@ async function startOne( * still due and without a run: either may have changed since the pass looked. */ async function startIfStillDue( - starter: RunStarter, + { starter, recordTypes, definitionOf }: DueRunDependencies, tx: Tx, - { processId, employeeId, today }: { processId: string; employeeId: string; today: CalendarDate }, + { processId, recordId, today }: { processId: string; recordId: 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, { + 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, "employee", [employeeId])).size > 0; - if (!triggerDate || hasRun) return null; + (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: "employee", recordId: employeeId }, - subject, + record: { recordType: trigger.recordType, recordId }, + subject: subjects.get(recordId)!, startCause: "started_on_record_date", startedByEmployeeId: null, triggerDate, triggerEvent: null, - startKey: `on_record_date:employee:${employeeId}:${triggerDate}`, + 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/core/processes/app/eventRunService.ts b/packages/api/src/core/processes/app/eventRunService.ts index b07af5432..e2ba524dd 100644 --- a/packages/api/src/core/processes/app/eventRunService.ts +++ b/packages/api/src/core/processes/app/eventRunService.ts @@ -3,17 +3,16 @@ 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..7d6f43722 --- /dev/null +++ b/packages/api/src/core/processes/app/finishStep.int.test.ts @@ -0,0 +1,218 @@ +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("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/core/processes/app/index.ts b/packages/api/src/core/processes/app/index.ts index d18fc800b..646b61e94 100644 --- a/packages/api/src/core/processes/app/index.ts +++ b/packages/api/src/core/processes/app/index.ts @@ -1,2 +1,5 @@ export { createProcessService, type ProcessService } from "./processService"; +export type { PeoplePort, RunSubject, WorkingCalendarsPort } from "./ports"; +export { PROCESS_STEP_TO_DO } from "./runStarter"; export { startDueRunsJob } from "./startDueRunsJob"; +export { TASK_TRIGGERING_EVENTS } from "./taskTriggeringEvents"; diff --git a/packages/api/src/core/processes/app/markStepDone.int.test.ts b/packages/api/src/core/processes/app/markStepDone.int.test.ts deleted file mode 100644 index b4377a0df..000000000 --- a/packages/api/src/core/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/core/processes/app/ports.ts b/packages/api/src/core/processes/app/ports.ts new file mode 100644 index 000000000..c9673e6fd --- /dev/null +++ b/packages/api/src/core/processes/app/ports.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/processes/app/processService.ts b/packages/api/src/core/processes/app/processService.ts index 18b1ea41e..abcb09769 100644 --- a/packages/api/src/core/processes/app/processService.ts +++ b/packages/api/src/core/processes/app/processService.ts @@ -6,30 +6,25 @@ import { createRecordHistoryReaderService, createRecordHistoryService, type HistoryLine, + type RecordTypeRegistry, } from "../../records"; import { PrincipalSecurityContext, type SecurityContext } from "../../security"; -import { type CalendarDate, dateInLondon, parseUuid } from "../../../utils"; -import type { BankHolidayService } from "../../../modules/bankHolidays"; +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,31 @@ 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") { + const definition = definitionOf(process.activeVersion); + const { recordType } = definition.subject; + if (!definition.startableByAdmin) { throw new ApplicationError("FORBIDDEN", "This process starts only by itself."); } 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)) { + const subjects = await starter.readRecordSubjects(tx, recordType, [recordId]); + if (!subjects.has(recordId)) throw recordNotFound(); + const subject = subjects.get(recordId)!; + await ctx.requireInScope("process_run", "create", subject?.id ?? "public"); + if (await processRepository(tx).findRunInProgress(process.id, recordType, recordId)) { 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 }, subject, startCause: "started_by_admin", startedByEmployeeId: ctx.user?.employeeId ?? null, @@ -173,8 +191,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 +204,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 +215,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 +276,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/core/processes/app/processViews.ts b/packages/api/src/core/processes/app/processViews.ts index 1a3635cb1..b5310a6b8 100644 --- a/packages/api/src/core/processes/app/processViews.ts +++ b/packages/api/src/core/processes/app/processViews.ts @@ -1,5 +1,5 @@ import type { CalendarDate } from "../../../utils"; -import type { Task, TaskStatus } from "../../tasks"; +import type { TaskStatus } from "../../tasks"; import type { ProcessRun 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/core/processes/app/readRuns.int.test.ts b/packages/api/src/core/processes/app/readRuns.int.test.ts index e125835e1..17b22bd47 100644 --- a/packages/api/src/core/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/core/processes/app/registrations.ts b/packages/api/src/core/processes/app/registrations.ts index b4eb526dc..5e66e7712 100644 --- a/packages/api/src/core/processes/app/registrations.ts +++ b/packages/api/src/core/processes/app/registrations.ts @@ -22,6 +22,8 @@ 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 index 23bcc0464..8494df2e1 100644 --- a/packages/api/src/core/processes/app/runLifecycleService.ts +++ b/packages/api/src/core/processes/app/runLifecycleService.ts @@ -1,9 +1,9 @@ -import { ApplicationError, invalidInput } from "../../applicationError"; +import { ApplicationError } from "../../applicationError"; import type { Orm, Transact, Tx } from "../../db"; -import type { Logger } from "../../logger"; +import type { PublishedEvent } from "../../events"; import { PrincipalSecurityContext, type SecurityContext } from "../../security"; -import type { Task, TaskService } from "../../tasks"; -import { processRepository, type StoredProcess } from "../db"; +import type { TaskService } from "../../tasks"; +import { processRepository } from "../db"; import { type CancelCause, cancelRun, @@ -11,31 +11,29 @@ import { 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 { StepProgress } from "./processViews"; 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. + * 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 { - /** 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; + /** + * 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; - /** 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. + * 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: StoredRun): Promise; } @@ -43,59 +41,54 @@ export interface RunLifecycleService { 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 { + async completeStepsOfDoneTasks(tx, events) { + const doneTaskIds = new Set( + events.flatMap(({ type, payload }) => + type === "tasks.task_status_changed" && payload.to === "done" ? [payload.taskId] : [], + ), ); - 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", + if (doneTaskIds.size === 0) return; + const runs = processRepository(tx); + const awaiting = (await runs.findStepsByTaskIds([...doneTaskIds])).filter( + ({ status }) => status === "step_awaiting_task_completion", ); - return change; + const now = new Date(); + for (const runId of [...new Set(awaiting.map((step) => step.runId))].sort()) { + const run = (await runs.lockRun(runId))!; + const steps = (await runs.findSteps(runId)).map((step) => + step.status === "step_awaiting_task_completion" && doneTaskIds.has(step.taskId!) + ? completeStep(run, step, now) + : step, + ); + const completed = steps.filter(({ taskId }) => taskId && doneTaskIds.has(taskId)); + await runs.saveSteps(completed); + const settled = settleRun(run, steps, now); + if (settled !== run) await runs.saveRun(settled); + tx.publish("processes.steps_completed", { + runId, + taskIds: completed.map(({ taskId }) => taskId!), + runStatus: settled.status, + }); + } }, async readStepProgress(ctx, taskId) { const task = await tasks.readTask(ctx, taskId); - const runId = (await reader.requireRun(orm, runIdOf(task))).id; + 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(runId))!; + const run = (await runs.lockRun(step.runId))!; const [process] = await runs.findProcessesByIds([run.processId]); const steps = (await runs.findSteps(run.id)).filter( ({ status }) => status !== "step_skipped", @@ -110,23 +103,6 @@ export function createRunLifecycleService({ }); }, - 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); @@ -151,55 +127,6 @@ export function createRunLifecycleService({ }); }, }; - - 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. */ @@ -231,25 +158,3 @@ interface Cancellation { 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/core/processes/app/runStarter.ts b/packages/api/src/core/processes/app/runStarter.ts index f1e9080e3..7a5db7e2d 100644 --- a/packages/api/src/core/processes/app/runStarter.ts +++ b/packages/api/src/core/processes/app/runStarter.ts @@ -1,13 +1,7 @@ -import type { Orm, Transact, Tx } from "../../db"; +import type { Tx } from "../../db"; +import type { RecordProcessCapabilities } from "../../records"; import { PrincipalSecurityContext } from "../../security"; import { addDays, type CalendarDate } from "../../../utils"; -import type { BankHolidayService } from "../../../modules/bankHolidays"; -import { - createEmployeeReadService, - createEmployeeWorkingPatternService, - employeeDirectory, - type EmployeeReadService, -} from "../../../modules/employees"; import type { NewTask, TaskService } from "../../tasks"; import { type NewRunRow, processRepository, type StoredProcess } from "../db"; import { @@ -15,7 +9,6 @@ import { type DayOffset, dueTriggerDate, type IsWorkingDay, - parseProcessDefinition, type ProcessDefinition, type ProcessRun, type RecordDateTrigger, @@ -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; @@ -55,52 +52,69 @@ export interface RunStart { 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. */ + /** 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>; } 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, + (await readPeople(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,23 +138,24 @@ 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)])); }, @@ -156,39 +171,19 @@ interface RunPlan { 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 = { @@ -238,11 +233,11 @@ function newTasksOf({ run, start, assignees, isWorkingDay }: RunPlan): NewTask[] if (!assignee) return []; return [ { - type: "process_step_to_do", + type: PROCESS_STEP_TO_DO, + sourceKey: start.process.key, 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), diff --git a/packages/api/src/core/processes/app/startDueRuns.int.test.ts b/packages/api/src/core/processes/app/startDueRuns.int.test.ts index ca7f4e3e0..962c006c9 100644 --- a/packages/api/src/core/processes/app/startDueRuns.int.test.ts +++ b/packages/api/src/core/processes/app/startDueRuns.int.test.ts @@ -61,12 +61,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 +107,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"); diff --git a/packages/api/src/core/processes/app/startRun.int.test.ts b/packages/api/src/core/processes/app/startRun.int.test.ts index be18171d3..cc1fd1d4c 100644 --- a/packages/api/src/core/processes/app/startRun.int.test.ts +++ b/packages/api/src/core/processes/app/startRun.int.test.ts @@ -1,6 +1,8 @@ import { ADMIN, EMPLOYEE, + SYSTEM, + employeeServices, calendarDate, expect, processService, @@ -13,7 +15,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 +28,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 +61,12 @@ 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", - }, }); }); @@ -90,7 +82,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 +108,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 +127,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 +148,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 +164,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 +176,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 +204,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,7 +217,7 @@ 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" }); }); @@ -235,7 +231,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 +252,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 +265,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/core/processes/app/startRunsOnEvents.int.test.ts b/packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts index 2ca81ea4a..513f5b2f7 100644 --- a/packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts +++ b/packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts @@ -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", }, ]); }); @@ -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 }, }, }, ]); diff --git a/packages/api/src/core/processes/app/startRunsOnEventsAtScale.int.test.ts b/packages/api/src/core/processes/app/startRunsOnEventsAtScale.int.test.ts index 83355ba78..82fe975b0 100644 --- a/packages/api/src/core/processes/app/startRunsOnEventsAtScale.int.test.ts +++ b/packages/api/src/core/processes/app/startRunsOnEventsAtScale.int.test.ts @@ -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/core/processes/app/taskTriggeringEvents.ts b/packages/api/src/core/processes/app/taskTriggeringEvents.ts new file mode 100644 index 000000000..3abeac884 --- /dev/null +++ b/packages/api/src/core/processes/app/taskTriggeringEvents.ts @@ -0,0 +1,15 @@ +import { triggeringEvent, type TriggeringEvents } from "../domain"; + +/** Tasks' events that may start a process on the task, each run keeping the event as it came. */ +export const TASK_TRIGGERING_EVENTS: TriggeringEvents = { + "tasks.task_created": triggeringEvent<"tasks.task_created">({ + recordType: "task", + recordIds: ({ taskId }) => [taskId], + payloadFor: (payload) => payload, + }), + "tasks.task_status_changed": triggeringEvent<"tasks.task_status_changed">({ + recordType: "task", + recordIds: ({ taskId }) => [taskId], + payloadFor: (payload) => payload, + }), +}; diff --git a/packages/api/src/core/processes/app/workingCalendars.ts b/packages/api/src/core/processes/app/workingCalendars.ts deleted file mode 100644 index 7d52f11f6..000000000 --- a/packages/api/src/core/processes/app/workingCalendars.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ApplicationError } from "../../applicationError"; -import type { Tx } from "../../db"; -import { PrincipalSecurityContext } from "../../security"; -import { addDays, type CalendarDate } from "../../../utils"; -import type { BankHolidayService } from "../../../modules/bankHolidays"; -import type { EmployeeWorkingPatternService } from "../../../modules/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/core/processes/db/processRepository.ts b/packages/api/src/core/processes/db/processRepository.ts index 96f689532..4b74d4465 100644 --- a/packages/api/src/core/processes/db/processRepository.ts +++ b/packages/api/src/core/processes/db/processRepository.ts @@ -58,6 +58,12 @@ export interface ProcessRepository { saveSteps(steps: readonly RunStep[]): 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; /** Every run in progress, of every process. */ @@ -230,6 +236,29 @@ export function processRepository(db: Orm | Tx): ProcessRepository { return orm.select().from(processRunSteps).where(eq(processRunSteps.runId, runId)); }, + async findStepsByTaskIds(taskIds) { + if (taskIds.length === 0) return []; + return orm + .select() + .from(processRunSteps) + .where(inArray(processRunSteps.taskId, [...taskIds])); + }, + + async findTaskSources(taskIds) { + if (taskIds.length === 0) return new Map(); + const rows = await orm + .select({ + taskId: processRunSteps.taskId, + processKey: processes.key, + stepKey: processRunSteps.stepKey, + }) + .from(processRunSteps) + .innerJoin(processRuns, eq(processRuns.id, processRunSteps.runId)) + .innerJoin(processes, eq(processes.id, processRuns.processId)) + .where(inArray(processRunSteps.taskId, [...taskIds])); + return new Map(rows.map(({ taskId, ...source }) => [taskId!, source])); + }, + async listRuns(processId, scope) { return await orm .select() diff --git a/packages/api/src/core/processes/db/processRunsTable.ts b/packages/api/src/core/processes/db/processRunsTable.ts index b65d0961c..61cf3338d 100644 --- a/packages/api/src/core/processes/db/processRunsTable.ts +++ b/packages/api/src/core/processes/db/processRunsTable.ts @@ -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", diff --git a/packages/api/src/core/processes/domain/eventTrigger.test.ts b/packages/api/src/core/processes/domain/eventTrigger.test.ts index 7a9c57913..dc0a196cf 100644 --- a/packages/api/src/core/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 index d8bdd900d..dda953340 100644 --- a/packages/api/src/core/processes/domain/eventTrigger.ts +++ b/packages/api/src/core/processes/domain/eventTrigger.ts @@ -2,31 +2,27 @@ import type { DomainEvents } from "../../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. + * 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. */ -// 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 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>; -export type TriggeringEventType = keyof typeof TRIGGERING_EVENTS; +/** A registration for one event, typed by its payload. */ +export function triggeringEvent(registration: { + recordType: string; + recordIds(payload: DomainEvents[Type]): readonly string[]; + payloadFor(payload: DomainEvents[Type], recordId: string): unknown; +}): TriggeringEventRegistration { + return registration; +} /** An event as a trigger reads it. */ export interface TriggerEvent { @@ -49,11 +45,16 @@ export interface EventStart { 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) - ? recordIdsOf(event).map((recordId) => ({ processId, event, recordId })) + ? (triggeringEvents[event.type]?.recordIds(event.payload) ?? []).map((recordId) => ({ + processId, + event, + recordId, + })) : [], ), ); @@ -63,12 +64,9 @@ export function matchEventStarts( export function eventAboutRecord( { id, type, payload }: TriggerEvent, recordId: string, + triggeringEvents: TriggeringEvents, ): TriggerEvent { - return { id, type, payload: entryOf(type).payloadFor(payload, recordId) }; -} - -export function isTriggeringEvent(type: string): type is TriggeringEventType { - return Object.hasOwn(TRIGGERING_EVENTS, type); + return { id, type, payload: triggeringEvents[type]!.payloadFor(payload, recordId) }; } function isTriggeredBy(trigger: EventTrigger, { type, payload }: TriggerEvent): boolean { @@ -77,15 +75,3 @@ function isTriggeredBy(trigger: EventTrigger, { type, payload }: TriggerEvent): 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/core/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts index ee029cd2e..a7427a455 100644 --- a/packages/api/src/core/processes/domain/index.ts +++ b/packages/api/src/core/processes/domain/index.ts @@ -3,13 +3,16 @@ export { dueTriggerDate } from "./recordDateTrigger"; export { eventAboutRecord, type EventStart, - isTriggeringEvent, matchEventStarts, type TriggerEvent, + triggeringEvent, + type TriggeringEventRegistration, + type TriggeringEvents, } from "./eventTrigger"; export { type DayOffset, parseProcessDefinition, + type ProcessCatalog, PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessDefinition, type RecordDateTrigger, @@ -25,7 +28,6 @@ export { PROCESS_CANCELLED, type ProcessRun, removalCancellation, - reopenStep, resolveAssignee, type ResolvedAssignee, RUN_STATUSES, diff --git a/packages/api/src/core/processes/domain/processDefinition.test.ts b/packages/api/src/core/processes/domain/processDefinition.test.ts index 3bcd050d6..0e748c2b7 100644 --- a/packages/api/src/core/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,7 +56,7 @@ 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.", ); }); @@ -65,7 +65,7 @@ test("refuses a task due more than 366 calendar days before the start", () => { const definition = welcomeDefinition(); definition.steps[0]!.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,7 +74,7 @@ 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.", ); }); @@ -83,7 +83,7 @@ test("refuses a task due more than 260 business days after the start", () => { const definition = welcomeDefinition(); definition.steps[1]!.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.", ); }); @@ -94,28 +94,32 @@ test("accepts offsets at the calendar and business day limits", () => { definition.steps[0]!.due.amount = 366; definition.steps[1]!.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"; - 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.", ); }); @@ -219,3 +227,12 @@ function welcomeDefinition() { ], }; } + +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/core/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts index 112e492ca..f7f9978df 100644 --- a/packages/api/src/core/processes/domain/processDefinition.ts +++ b/packages/api/src/core/processes/domain/processDefinition.ts @@ -1,7 +1,5 @@ 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; @@ -35,8 +33,9 @@ export interface TaskStep { /** 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,7 +47,8 @@ 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"; @@ -58,23 +58,33 @@ export interface EventTrigger { /** 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" }; + subject: { recordType: string }; trigger: RecordDateTrigger | EventTrigger; startableByAdmin: boolean; steps: TaskStep[]; } -/** Reads a stored definition, refusing a shape, trigger or step this code cannot run. */ +/** 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>; +} + +/** 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)]) { @@ -85,20 +95,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( @@ -138,14 +166,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,7 +186,7 @@ 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 diff --git a/packages/api/src/core/processes/domain/processRun.ts b/packages/api/src/core/processes/domain/processRun.ts index 78b4897e9..b96a34cb4 100644 --- a/packages/api/src/core/processes/domain/processRun.ts +++ b/packages/api/src/core/processes/domain/processRun.ts @@ -14,7 +14,6 @@ export const START_CAUSES = [ 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; @@ -34,7 +33,7 @@ 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. */ +/** 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. */ @@ -121,36 +120,6 @@ export function completeStep(run: ProcessRun, step: RunStep, now: Date): RunStep 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, diff --git a/packages/api/src/core/processes/graphql/index.ts b/packages/api/src/core/processes/graphql/index.ts new file mode 100644 index 000000000..fa52daef8 --- /dev/null +++ b/packages/api/src/core/processes/graphql/index.ts @@ -0,0 +1 @@ +export { createResolvers } from "./resolvers"; 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 index 03f2975c5..cd0892f86 100644 --- a/packages/api/src/core/processes/graphql/schema.graphql +++ b/packages/api/src/core/processes/graphql/schema.graphql @@ -3,3 +3,19 @@ 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 index 47132ee68..d9ee18f38 100644 --- a/packages/api/src/core/processes/index.ts +++ b/packages/api/src/core/processes/index.ts @@ -2,9 +2,15 @@ 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 } from "./app"; +export { createProcessService, PROCESS_STEP_TO_DO, TASK_TRIGGERING_EVENTS } from "./app"; +export { triggeringEvent, type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "./domain"; + +// Public types. +export type { PeoplePort, ProcessService, RunSubject, WorkingCalendarsPort } from "./app"; +export type { TriggeringEventRegistration, TriggeringEvents } from "./domain"; /** Processes that give people tasks when something happens to a record, and their runs. */ export function processesModule({ @@ -14,7 +20,7 @@ export function processesModule({ return { name: "processes", typeDefs: readSdl(import.meta.url, "./graphql/schema.graphql"), - resolvers: {}, + resolvers: createResolvers(processes), jobs: startDueRunsJob({ processes, logger }), events: { // Logged once the change commits, never for runs a rollback dropped. @@ -22,8 +28,13 @@ export function processesModule({ 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); }, - eventsBeforeCommit: (tx, events) => - processes.startRunsOnEvents(PrincipalSecurityContext.system(), tx, events), }; } diff --git a/packages/api/src/core/processes/module.md b/packages/api/src/core/processes/module.md index e41dea42d..d5904c3c5 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -5,18 +5,19 @@ 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 (`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. +- 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 (`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. +- 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 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. +- 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`, which the employees module fills, 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`: 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_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_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/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/core/tasks/app/cancelTask.int.test.ts b/packages/api/src/core/tasks/app/cancelTask.int.test.ts deleted file mode 100644 index 4f3b4e0a6..000000000 --- a/packages/api/src/core/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/core/tasks/app/createTask.int.test.ts b/packages/api/src/core/tasks/app/createTask.int.test.ts index 950f3a28f..e0ba14e54 100644 --- a/packages/api/src/core/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/core/tasks/app/createTasks.ts b/packages/api/src/core/tasks/app/createTasks.ts index b0db33767..8e2f07546 100644 --- a/packages/api/src/core/tasks/app/createTasks.ts +++ b/packages/api/src/core/tasks/app/createTasks.ts @@ -4,17 +4,17 @@ 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/core/tasks/app/listMyTasks.int.test.ts b/packages/api/src/core/tasks/app/listMyTasks.int.test.ts index f5fd252b5..afd7d27dd 100644 --- a/packages/api/src/core/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/core/tasks/app/readTask.int.test.ts b/packages/api/src/core/tasks/app/readTask.int.test.ts index 414b765eb..aca01495c 100644 --- a/packages/api/src/core/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/core/tasks/app/registrations.ts b/packages/api/src/core/tasks/app/registrations.ts index d35d15fbe..5be91a963 100644 --- a/packages/api/src/core/tasks/app/registrations.ts +++ b/packages/api/src/core/tasks/app/registrations.ts @@ -13,11 +13,13 @@ declare module "../../events" { /** 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/reopenTask.int.test.ts b/packages/api/src/core/tasks/app/reopenTask.int.test.ts deleted file mode 100644 index f4f8e54e5..000000000 --- a/packages/api/src/core/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/core/tasks/app/taskRecordType.ts b/packages/api/src/core/tasks/app/taskRecordType.ts index 26760776e..8fa15316b 100644 --- a/packages/api/src/core/tasks/app/taskRecordType.ts +++ b/packages/api/src/core/tasks/app/taskRecordType.ts @@ -4,7 +4,10 @@ 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. */ +/** + * 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", @@ -19,5 +22,12 @@ export function taskRecordType(orm: Orm): RecordTypeRegistration { const tasks = await taskRepository(orm).findByIds(ids); return new Set(tasks.filter((task) => isTaskVisibleTo(task, 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); + return new Map(tasks.map(({ id, subjectEmployeeId }) => [id, subjectEmployeeId])); + }, + }, }; } diff --git a/packages/api/src/core/tasks/app/taskService.ts b/packages/api/src/core/tasks/app/taskService.ts index 3b6cf9a14..b73d75b46 100644 --- a/packages/api/src/core/tasks/app/taskService.ts +++ b/packages/api/src/core/tasks/app/taskService.ts @@ -17,8 +17,8 @@ import { isTaskVisibleTo, markTaskDone, markTaskWaiting, + refuseClosed, type MyTasks, - reopenTask, resolvedRecentlySince, type Task, } from "../domain"; @@ -35,16 +35,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 +58,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,22 +99,6 @@ 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)) { @@ -124,6 +110,10 @@ export function createTaskService({ } }, + async lockInTransaction(tx, taskIds) { + await taskRepository(tx).lockByIds(taskIds); + }, + async markWaiting(ctx, tx, taskId) { const tasks = taskRepository(tx); const task = await tasks.lockById(taskId); @@ -156,7 +146,6 @@ export function createTaskService({ 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()); }); @@ -164,29 +153,37 @@ export function createTaskService({ 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); - }); - 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); + async deleteTask(ctx, taskId) { + const deleted = await transact(async (tx) => { + const tasks = taskRepository(tx); + const id = parseUuid(taskId); + const { task, employeeId } = requireVisible(ctx, id ? await tasks.lockById(id) : null); if (task.askedByEmployeeId !== employeeId) { throw new ApplicationError( "FORBIDDEN", - "Only the person who asked can cancel this task.", + "Only the person who asked can delete this task.", ); } - return cancelTask(task, requireText(reason, "reason"), new Date()); + refuseClosed(task); + await tasks.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: 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) { @@ -272,13 +269,6 @@ function refuseUnlessAssignee(task: Task, employeeId: string, message: string): } } -/** 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."); } @@ -286,4 +276,3 @@ function notFound(): ApplicationError { 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/db/taskRepository.ts b/packages/api/src/core/tasks/db/taskRepository.ts index 2eb3e5c6f..cabae5e28 100644 --- a/packages/api/src/core/tasks/db/taskRepository.ts +++ b/packages/api/src/core/tasks/db/taskRepository.ts @@ -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 { +function toTask({ recordType, recordId, ...row }: TaskRow): Task { return { ...row, - processSource: processKey && stepKey ? { processKey, processRunId, stepKey } : null, record: recordType && recordId ? { recordType, recordId } : null, }; } diff --git a/packages/api/src/core/tasks/db/tasksTable.ts b/packages/api/src/core/tasks/db/tasksTable.ts index 0392cb589..256aecea7 100644 --- a/packages/api/src/core/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/core/tasks/domain/index.ts b/packages/api/src/core/tasks/domain/index.ts index 515c2f297..3456e807d 100644 --- a/packages/api/src/core/tasks/domain/index.ts +++ b/packages/api/src/core/tasks/domain/index.ts @@ -3,13 +3,11 @@ export { isTaskVisibleTo, markTaskDone, markTaskWaiting, - reopenTask, TASK_STATUSES, - TASK_TYPES, + MANUAL_TO_DO, + refuseClosed, type Task, - type TaskProcessSource, type TaskStatus, type TaskRecord, - type TaskType, } from "./task"; export { groupMyTasks, type MyTasks, resolvedRecentlySince } from "./myTasks"; diff --git a/packages/api/src/core/tasks/domain/task.test.ts b/packages/api/src/core/tasks/domain/task.test.ts index 3629fe9ea..f00737dda 100644 --- a/packages/api/src/core/tasks/domain/task.test.ts +++ b/packages/api/src/core/tasks/domain/task.test.ts @@ -1,13 +1,6 @@ import { expect, test } from "vitest"; -import { - cancelTask, - markTaskDone, - markTaskWaiting, - reopenTask, - type Task, - type TaskStatus, -} from "./task"; +import { cancelTask, markTaskDone, markTaskWaiting, type Task, type TaskStatus } from "./task"; test("marks a task to do as done by its assignee", () => { const task = taskIn("to_do"); @@ -37,24 +30,6 @@ test.each([ ).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"); @@ -112,7 +87,6 @@ function taskIn(status: TaskStatus): Task { 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, diff --git a/packages/api/src/core/tasks/domain/task.ts b/packages/api/src/core/tasks/domain/task.ts index 64a7a8e99..d47b6e781 100644 --- a/packages/api/src/core/tasks/domain/task.ts +++ b/packages/api/src/core/tasks/domain/task.ts @@ -1,23 +1,12 @@ import { ApplicationError } from "../../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]; +/** 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 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; @@ -27,14 +16,14 @@ export interface TaskRecord { /** Work asked of one person, and where it stands. */ export interface Task { id: string; - type: TaskType; + /** `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; - processSource: TaskProcessSource | null; record: TaskRecord | null; dueDate: CalendarDate | null; doneAt: Date | null; @@ -58,13 +47,6 @@ export function markTaskDone(task: Task, byEmployeeId: string, now: Date): Task 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); @@ -81,7 +63,8 @@ export function markTaskWaiting(task: Task): Task { return { ...task, status: "waiting" }; } -function refuseClosed(task: Task): void { +/** Done and cancelled are final: nothing changes such a task again. */ +export function refuseClosed(task: Task): void { if (task.status === "done") { throw new ApplicationError("CONFLICT", "This task is already done."); } diff --git a/packages/api/src/core/tasks/graphql/resolvers.ts b/packages/api/src/core/tasks/graphql/resolvers.ts index b44443a95..c3f80d7cb 100644 --- a/packages/api/src/core/tasks/graphql/resolvers.ts +++ b/packages/api/src/core/tasks/graphql/resolvers.ts @@ -1,32 +1,10 @@ -import DataLoader from "dataloader"; - import { ApplicationError } from "../../applicationError"; -import { - type GraphqlContext, - mutationPayload, - perRequest, - toEnum, - type Resolvers, - type ResolversTypes, -} from "../../graphql"; -import type { EmployeeReadService } from "../../../modules/employees"; +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, - 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); +export function createResolvers(tasks: TaskService): Resolvers { return { Query: { myTasks: (_parent, _args, context) => tasks.listMyTasks(context.getActor()), @@ -48,26 +26,15 @@ export function createResolvers( () => 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), + delete: (_parent, { input }, context) => + mutationPayload( + () => tasks.deleteTask(context.getActor(), input.id), EXPECTED_ERRORS, ), }, Task: { - type: ({ type }) => toEnum(type), + type: ({ type }) => toEnum(type) as ResolversTypes["TaskType"], 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/core/tasks/graphql/schema.graphql b/packages/api/src/core/tasks/graphql/schema.graphql index 0d6886d8a..a6c7bda5a 100644 --- a/packages/api/src/core/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/core/tasks/index.ts b/packages/api/src/core/tasks/index.ts index 9df118739..b8c379262 100644 --- a/packages/api/src/core/tasks/index.ts +++ b/packages/api/src/core/tasks/index.ts @@ -1,29 +1,25 @@ import { readSdl } from "../graphql"; import type { ModuleDependencies, ModuleManifest } from "../moduleManifest"; -import type { EmployeeReadService } from "../../modules/employees"; import { taskRecordType, type TaskService } from "./app"; import { createResolvers } from "./graphql"; // Public values. -export { createTaskService } from "./app"; +export { createTaskService, 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 index 4e5eb9011..cb3bdea29 100644 --- a/packages/api/src/core/tasks/module.md +++ b/packages/api/src/core/tasks/module.md @@ -1,18 +1,18 @@ # 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. +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.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. +- 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. +- 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`, 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. +- **`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 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/index.ts b/packages/api/src/modules/employees/app/index.ts index 639db251e..3849412c6 100644 --- a/packages/api/src/modules/employees/app/index.ts +++ b/packages/api/src/modules/employees/app/index.ts @@ -42,6 +42,11 @@ export { type OwnWorkingWeekService, } from "./employmentTerms/ownWorkingWeekService"; export { employeeRecordType } from "./employeeRecordType"; +export { + createPeopleAdapter, + createWorkingCalendarsAdapter, + EMPLOYEE_TRIGGERING_EVENTS, +} from "./processAdapters"; export { type CopyTermsParts, createTermsService, diff --git a/packages/api/src/modules/employees/app/processAdapters.ts b/packages/api/src/modules/employees/app/processAdapters.ts new file mode 100644 index 000000000..a64b63fa3 --- /dev/null +++ b/packages/api/src/modules/employees/app/processAdapters.ts @@ -0,0 +1,104 @@ +import { ApplicationError } from "../../../core/applicationError"; +import type { ModuleDependencies } from "../../../core/moduleManifest"; +import { + type IsWorkingDay, + MAX_SEARCH_DAYS, + type PeoplePort, + triggeringEvent, + type TriggeringEvents, + type WorkingCalendarsPort, + workingDaysOf, +} from "../../../core/processes"; +import { PrincipalSecurityContext } from "../../../core/security"; +import { addDays, type CalendarDate } from "../../../utils"; +import type { BankHolidayService } from "../../bankHolidays"; +import { employmentRepository } from "../db"; +import { employeeDirectory } from "./employeeDirectory"; +import { readEmployeesByName } from "./employeeReadService"; +import { createEmployeeWorkingPatternService } from "./employmentTerms/employeeWorkingPatternService"; + +/** Processes read people through this: whom a run is about, their managers and the owner. */ +export function createPeopleAdapter(): PeoplePort { + return { + async readPeople(tx, employeeIds) { + const found = await readEmployeesByName(tx, employeeIds); + const periods = await employmentRepository(tx).findPeriods(found.map(({ id }) => id)); + return new Map( + found.map(({ id, firstName }) => [ + id, + { id, firstName, startDate: periods.get(id)?.[0]?.startDate ?? null }, + ]), + ); + }, + readManagersOn: (tx, { employeeIds, date }) => + employmentRepository(tx).findManagerIds(employeeIds, date), + readOwner: (tx) => employeeDirectory(tx).ownersEmployeeId(), + }; +} + +/** Processes count business days through this: people's working weeks and bank holidays. */ +export function createWorkingCalendarsAdapter({ + transact, + bankHolidays, +}: Pick & { + bankHolidays: BankHolidayService; +}): WorkingCalendarsPort { + const workingPatterns = createEmployeeWorkingPatternService({ transact }); + 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): [string, IsWorkingDay | null] => { + const weeks = weeksByEmployee.get(employeeId) ?? []; + return [employeeId, workingDaysOf(weeks, isBankHolidayIn(weeks, holidays))]; + }), + ); + }, + }; +} + +/** Employees' events that may start a process: one run per employee added. */ +export const EMPLOYEE_TRIGGERING_EVENTS: TriggeringEvents = { + "employees.added": triggeringEvent<"employees.added">({ + recordType: "employee", + recordIds: ({ employeeIds }) => employeeIds, + payloadFor: (_, employeeId) => ({ employeeIds: [employeeId] }), + }), +}; + +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/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..d2d105f25 100644 --- a/packages/api/src/modules/employees/index.ts +++ b/packages/api/src/modules/employees/index.ts @@ -39,9 +39,13 @@ export { createEmployeeWorkingPatternService, createLocationService, createOwnWorkingWeekService, + createPeopleAdapter, createTermsService, + createWorkingCalendarsAdapter, + EMPLOYEE_TRIGGERING_EVENTS, employeeDirectory, employeeNotFound, + employeeRecordType, termsRecordType, } from "./app"; export { diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts index 9c14b7121..d804273b9 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -11,18 +11,22 @@ 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 "../core/processes"; +import { createProcessService, processesModule, TASK_TRIGGERING_EVENTS } from "../core/processes"; import { createSlack, slackModule, type SlackUrls } from "./slack"; import { systemModule } from "./system"; -import { createTaskService, tasksModule } from "../core/tasks"; +import { createTaskService, taskRecordType, tasksModule } from "../core/tasks"; +import { collectRegistrations } from "../utils"; import { createTimeOffSetupService, createTimeOffService, timeOffModule } from "./timeOff"; const employeeSetup = createEmployeeSetupService(); @@ -56,12 +60,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(), + 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 +137,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/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/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..bb9e43fde 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -21,6 +21,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 010778c6b..8bbc00528 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/core/processes"; -import { type CalendarDate, todayInLondon } from "../../src/utils"; +import { + createProcessService, + processesModule, + TASK_TRIGGERING_EVENTS, +} from "../../src/core/processes"; +import { 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(), + 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/seedTask.ts b/packages/api/test/support/seedTask.ts index f04d454b6..09de3eb71 100644 --- a/packages/api/test/support/seedTask.ts +++ b/packages/api/test/support/seedTask.ts @@ -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/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; From 09da3e25626c7b6edec7d0e8a1da8444161b551e Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sat, 14 Nov 2026 16:07:14 +0100 Subject: [PATCH 03/19] fix(processes): leave a run whose record is gone for the daily check A run whose subject record or person was removed no longer finishes when its last task is marked done, and the daily check cancels a run whose record, such as a deleted task, is gone. Manual starts accept the record id in any case. Tasks register their own triggering events. --- .../core/processes/app/finishStep.int.test.ts | 31 +++++++++++++ packages/api/src/core/processes/app/index.ts | 1 - .../src/core/processes/app/processService.ts | 11 ++--- .../core/processes/app/runLifecycleService.ts | 29 ++++++++---- .../processes/app/startDueRuns.int.test.ts | 45 +++++++++++++++++++ .../core/processes/app/startRun.int.test.ts | 15 +++++++ .../processes/app/taskTriggeringEvents.ts | 15 ------- .../src/core/processes/domain/eventTrigger.ts | 10 ----- .../api/src/core/processes/domain/index.ts | 1 - packages/api/src/core/processes/index.ts | 4 +- packages/api/src/core/tasks/app/index.ts | 1 + .../core/tasks/app/taskTriggeringEvents.ts | 18 ++++++++ packages/api/src/core/tasks/index.ts | 2 +- .../modules/employees/app/processAdapters.ts | 19 ++++---- packages/api/src/modules/index.ts | 15 +++++-- packages/api/test/support/processService.ts | 6 +-- 16 files changed, 165 insertions(+), 58 deletions(-) delete mode 100644 packages/api/src/core/processes/app/taskTriggeringEvents.ts create mode 100644 packages/api/src/core/tasks/app/taskTriggeringEvents.ts diff --git a/packages/api/src/core/processes/app/finishStep.int.test.ts b/packages/api/src/core/processes/app/finishStep.int.test.ts index 7d6f43722..5cc009cd8 100644 --- a/packages/api/src/core/processes/app/finishStep.int.test.ts +++ b/packages/api/src/core/processes/app/finishStep.int.test.ts @@ -133,6 +133,37 @@ test("completing a task after the subject was removed leaves the run for the dai 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, diff --git a/packages/api/src/core/processes/app/index.ts b/packages/api/src/core/processes/app/index.ts index 646b61e94..5c24f6228 100644 --- a/packages/api/src/core/processes/app/index.ts +++ b/packages/api/src/core/processes/app/index.ts @@ -2,4 +2,3 @@ export { createProcessService, type ProcessService } from "./processService"; export type { PeoplePort, RunSubject, WorkingCalendarsPort } from "./ports"; export { PROCESS_STEP_TO_DO } from "./runStarter"; export { startDueRunsJob } from "./startDueRunsJob"; -export { TASK_TRIGGERING_EVENTS } from "./taskTriggeringEvents"; diff --git a/packages/api/src/core/processes/app/processService.ts b/packages/api/src/core/processes/app/processService.ts index abcb09769..5011c6b8f 100644 --- a/packages/api/src/core/processes/app/processService.ts +++ b/packages/api/src/core/processes/app/processService.ts @@ -160,11 +160,12 @@ export function createProcessService({ } const run = await transact(async (tx) => { await processRepository(tx).lockProcess(process.id); - const subjects = await starter.readRecordSubjects(tx, recordType, [recordId]); - if (!subjects.has(recordId)) throw recordNotFound(); - const subject = subjects.get(recordId)!; + // 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, recordId)) { + if (await processRepository(tx).findRunInProgress(process.id, recordType, canonicalId)) { throw new ApplicationError( "CONFLICT", `${process.name} is already running for ${subject?.firstName ?? "this record"}.`, @@ -175,7 +176,7 @@ export function createProcessService({ { process, definition, - record: { recordType, recordId }, + record: { recordType, recordId: canonicalId }, subject, startCause: "started_by_admin", startedByEmployeeId: ctx.user?.employeeId ?? null, diff --git a/packages/api/src/core/processes/app/runLifecycleService.ts b/packages/api/src/core/processes/app/runLifecycleService.ts index 8494df2e1..6601ed3f5 100644 --- a/packages/api/src/core/processes/app/runLifecycleService.ts +++ b/packages/api/src/core/processes/app/runLifecycleService.ts @@ -11,6 +11,7 @@ import { PROCESS_CANCELLED, type ProcessRun as StoredRun, removalCancellation, + type RunStep, settleRun, SUBJECT_REMOVED, } from "../domain"; @@ -71,7 +72,10 @@ export function createRunLifecycleService({ ); const completed = steps.filter(({ taskId }) => taskId && doneTaskIds.has(taskId)); await runs.saveSteps(completed); - const settled = settleRun(run, steps, now); + // A run that can no longer run stays in progress for the daily check, which cancels it. + const settled = (await removalOf(starter, tx, run, steps)) + ? run + : settleRun(run, steps, now); if (settled !== run) await runs.saveRun(settled); tx.publish("processes.steps_completed", { runId, @@ -105,14 +109,11 @@ export function createRunLifecycleService({ 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( + const removal = await removalOf( + starter, + tx, run, - steps, - run.subjectEmployeeId === null || subject !== null, + await processRepository(tx).findSteps(run.id), ); if (!removal) return run; const reason = @@ -129,6 +130,18 @@ export function createRunLifecycleService({ }; } +/** + * 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: StoredRun, steps: readonly RunStep[]) { + 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 removalCancellation(run, steps, subjectExists); +} + /** Cancels the locked run: its open tasks with `taskReason`, the run with its cause and reason. */ async function cancelAndSave( tasks: TaskService, diff --git a/packages/api/src/core/processes/app/startDueRuns.int.test.ts b/packages/api/src/core/processes/app/startDueRuns.int.test.ts index 962c006c9..2c7f41851 100644 --- a/packages/api/src/core/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"; @@ -122,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/core/processes/app/startRun.int.test.ts b/packages/api/src/core/processes/app/startRun.int.test.ts index cc1fd1d4c..ae0f56ea8 100644 --- a/packages/api/src/core/processes/app/startRun.int.test.ts +++ b/packages/api/src/core/processes/app/startRun.int.test.ts @@ -70,6 +70,21 @@ test("an admin starts two parallel tasks with their assignees and due dates", as }); }); +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 }, + }); +}); + test("tasks from an admin's welcome are authored by Broccoli with the run as cause", async ({ orm, seed, diff --git a/packages/api/src/core/processes/app/taskTriggeringEvents.ts b/packages/api/src/core/processes/app/taskTriggeringEvents.ts deleted file mode 100644 index 3abeac884..000000000 --- a/packages/api/src/core/processes/app/taskTriggeringEvents.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { triggeringEvent, type TriggeringEvents } from "../domain"; - -/** Tasks' events that may start a process on the task, each run keeping the event as it came. */ -export const TASK_TRIGGERING_EVENTS: TriggeringEvents = { - "tasks.task_created": triggeringEvent<"tasks.task_created">({ - recordType: "task", - recordIds: ({ taskId }) => [taskId], - payloadFor: (payload) => payload, - }), - "tasks.task_status_changed": triggeringEvent<"tasks.task_status_changed">({ - recordType: "task", - recordIds: ({ taskId }) => [taskId], - payloadFor: (payload) => payload, - }), -}; diff --git a/packages/api/src/core/processes/domain/eventTrigger.ts b/packages/api/src/core/processes/domain/eventTrigger.ts index dda953340..37ff2a7c7 100644 --- a/packages/api/src/core/processes/domain/eventTrigger.ts +++ b/packages/api/src/core/processes/domain/eventTrigger.ts @@ -1,4 +1,3 @@ -import type { DomainEvents } from "../../events"; import type { EventTrigger, ProcessDefinition } from "./processDefinition"; /** @@ -15,15 +14,6 @@ export interface TriggeringEventRegistration { /** The events that may start a process, by type. */ export type TriggeringEvents = Readonly>; -/** A registration for one event, typed by its payload. */ -export function triggeringEvent(registration: { - recordType: string; - recordIds(payload: DomainEvents[Type]): readonly string[]; - payloadFor(payload: DomainEvents[Type], recordId: string): unknown; -}): TriggeringEventRegistration { - return registration; -} - /** An event as a trigger reads it. */ export interface TriggerEvent { id: string; diff --git a/packages/api/src/core/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts index a7427a455..1e8d915d8 100644 --- a/packages/api/src/core/processes/domain/index.ts +++ b/packages/api/src/core/processes/domain/index.ts @@ -5,7 +5,6 @@ export { type EventStart, matchEventStarts, type TriggerEvent, - triggeringEvent, type TriggeringEventRegistration, type TriggeringEvents, } from "./eventTrigger"; diff --git a/packages/api/src/core/processes/index.ts b/packages/api/src/core/processes/index.ts index d9ee18f38..a8c682ea9 100644 --- a/packages/api/src/core/processes/index.ts +++ b/packages/api/src/core/processes/index.ts @@ -5,8 +5,8 @@ import { type ProcessService, startDueRunsJob } from "./app"; import { createResolvers } from "./graphql"; // Public values. -export { createProcessService, PROCESS_STEP_TO_DO, TASK_TRIGGERING_EVENTS } from "./app"; -export { triggeringEvent, type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "./domain"; +export { createProcessService, PROCESS_STEP_TO_DO } from "./app"; +export { type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "./domain"; // Public types. export type { PeoplePort, ProcessService, RunSubject, WorkingCalendarsPort } from "./app"; diff --git a/packages/api/src/core/tasks/app/index.ts b/packages/api/src/core/tasks/app/index.ts index dc4fbf1c9..d163a305f 100644 --- a/packages/api/src/core/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/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/core/tasks/index.ts b/packages/api/src/core/tasks/index.ts index b8c379262..0ea9c8d82 100644 --- a/packages/api/src/core/tasks/index.ts +++ b/packages/api/src/core/tasks/index.ts @@ -4,7 +4,7 @@ import { taskRecordType, type TaskService } from "./app"; import { createResolvers } from "./graphql"; // Public values. -export { createTaskService, taskRecordType } from "./app"; +export { createTaskService, TASK_TRIGGERING_EVENTS, taskRecordType } from "./app"; // Public types. export type { NewTask, TaskService } from "./app"; diff --git a/packages/api/src/modules/employees/app/processAdapters.ts b/packages/api/src/modules/employees/app/processAdapters.ts index a64b63fa3..73b96d703 100644 --- a/packages/api/src/modules/employees/app/processAdapters.ts +++ b/packages/api/src/modules/employees/app/processAdapters.ts @@ -1,11 +1,10 @@ import { ApplicationError } from "../../../core/applicationError"; +import type { DomainEvents } from "../../../core/events"; import type { ModuleDependencies } from "../../../core/moduleManifest"; import { type IsWorkingDay, MAX_SEARCH_DAYS, type PeoplePort, - triggeringEvent, - type TriggeringEvents, type WorkingCalendarsPort, workingDaysOf, } from "../../../core/processes"; @@ -22,11 +21,11 @@ export function createPeopleAdapter(): PeoplePort { return { async readPeople(tx, employeeIds) { const found = await readEmployeesByName(tx, employeeIds); - const periods = await employmentRepository(tx).findPeriods(found.map(({ id }) => id)); + const startDates = await employmentRepository(tx).findStartDates(found.map(({ id }) => id)); return new Map( found.map(({ id, firstName }) => [ id, - { id, firstName, startDate: periods.get(id)?.[0]?.startDate ?? null }, + { id, firstName, startDate: startDates.get(id) ?? null }, ]), ); }, @@ -78,12 +77,14 @@ export function createWorkingCalendarsAdapter({ } /** Employees' events that may start a process: one run per employee added. */ -export const EMPLOYEE_TRIGGERING_EVENTS: TriggeringEvents = { - "employees.added": triggeringEvent<"employees.added">({ +export const EMPLOYEE_TRIGGERING_EVENTS = { + "employees.added": { recordType: "employee", - recordIds: ({ employeeIds }) => employeeIds, - payloadFor: (_, employeeId) => ({ employeeIds: [employeeId] }), - }), + recordIds: ({ employeeIds }: DomainEvents["employees.added"]) => employeeIds, + payloadFor: (_: DomainEvents["employees.added"], employeeId: string) => ({ + employeeIds: [employeeId], + }), + }, }; function isBankHolidayIn( diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts index d804273b9..d5def0b65 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -22,10 +22,19 @@ import { import { externalAgentsModule } from "./externalAgents"; import { createImportServices, importsModule } from "./imports"; import { mcpAgentsModule } from "./mcpAgents"; -import { createProcessService, processesModule, TASK_TRIGGERING_EVENTS } from "../core/processes"; +import { + createProcessService, + processesModule, + type TriggeringEventRegistration, +} from "../core/processes"; import { createSlack, slackModule, type SlackUrls } from "./slack"; import { systemModule } from "./system"; -import { createTaskService, taskRecordType, tasksModule } from "../core/tasks"; +import { + createTaskService, + TASK_TRIGGERING_EVENTS, + taskRecordType, + tasksModule, +} from "../core/tasks"; import { collectRegistrations } from "../utils"; import { createTimeOffSetupService, createTimeOffService, timeOffModule } from "./timeOff"; @@ -67,7 +76,7 @@ export function createModules( 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", [ + triggeringEvents: collectRegistrations("triggering event", [ EMPLOYEE_TRIGGERING_EVENTS, TASK_TRIGGERING_EVENTS, ]), diff --git a/packages/api/test/support/processService.ts b/packages/api/test/support/processService.ts index 8bbc00528..24b5aec0a 100644 --- a/packages/api/test/support/processService.ts +++ b/packages/api/test/support/processService.ts @@ -4,9 +4,9 @@ import { createLogger } from "../../src/core/logger"; import { createProcessService, processesModule, - TASK_TRIGGERING_EVENTS, + type TriggeringEventRegistration, } from "../../src/core/processes"; -import { taskRecordType } from "../../src/core/tasks"; +import { TASK_TRIGGERING_EVENTS, taskRecordType } from "../../src/core/tasks"; import { createPeopleAdapter, createWorkingCalendarsAdapter, @@ -43,7 +43,7 @@ export function processService( bankHolidays: bankHolidayService(), }), recordTypes: { employee: employeeRecordType(orm), task: taskRecordType(orm) }, - triggeringEvents: collectRegistrations("triggering event", [ + triggeringEvents: collectRegistrations("triggering event", [ EMPLOYEE_TRIGGERING_EVENTS, TASK_TRIGGERING_EVENTS, ]), From 600a324ee0b8c54240067b5c93242062b95f2af6 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sat, 14 Nov 2026 16:13:26 +0100 Subject: [PATCH 04/19] fix(processes): refuse a malformed task record id as not found Also drop the unused single-person subject read and inline mark done's task change. --- .../api/src/core/processes/app/runStarter.ts | 5 --- .../core/processes/app/startRun.int.test.ts | 12 ++++++ .../api/src/core/tasks/app/taskRecordType.ts | 2 +- .../api/src/core/tasks/app/taskService.ts | 38 +++++++------------ 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/api/src/core/processes/app/runStarter.ts b/packages/api/src/core/processes/app/runStarter.ts index 7a5db7e2d..7fe72c1c0 100644 --- a/packages/api/src/core/processes/app/runStarter.ts +++ b/packages/api/src/core/processes/app/runStarter.ts @@ -50,8 +50,6 @@ 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 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. @@ -94,9 +92,6 @@ export function createRunStarter({ ids.length === 0 ? Promise.resolve(new Map()) : people.readPeople(tx, ids); return { - readSubject: async (tx, employeeId) => - (await readPeople(tx, [employeeId])).get(employeeId) ?? null, - async readRecordSubjects(tx, recordType, recordIds) { const subjectIds = await recordTypes[recordType]!.readSubjects(tx, [...new Set(recordIds)]); const found = await readPeople( diff --git a/packages/api/src/core/processes/app/startRun.int.test.ts b/packages/api/src/core/processes/app/startRun.int.test.ts index ae0f56ea8..b43ca59f9 100644 --- a/packages/api/src/core/processes/app/startRun.int.test.ts +++ b/packages/api/src/core/processes/app/startRun.int.test.ts @@ -4,9 +4,11 @@ import { SYSTEM, employeeServices, calendarDate, + eventProcess, expect, processService, seedOwnWeek, + seedProcess, seedWorkingPattern, signedIn, taskService, @@ -237,6 +239,16 @@ test("refuses a manual welcome without a create grant", async ({ orm, seed }) => ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); +test("refuses a malformed task record id as not found", async ({ orm }) => { + const definition = eventProcess("tasks.task_created"); + definition.startableByAdmin = true; + const { key } = await seedProcess(orm, { key: "follow_up", definition }); + + 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, diff --git a/packages/api/src/core/tasks/app/taskRecordType.ts b/packages/api/src/core/tasks/app/taskRecordType.ts index 8fa15316b..813ba1029 100644 --- a/packages/api/src/core/tasks/app/taskRecordType.ts +++ b/packages/api/src/core/tasks/app/taskRecordType.ts @@ -25,7 +25,7 @@ export function taskRecordType(orm: Orm): RecordTypeRegistration { // 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); + 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/core/tasks/app/taskService.ts b/packages/api/src/core/tasks/app/taskService.ts index b73d75b46..1e62071e9 100644 --- a/packages/api/src/core/tasks/app/taskService.ts +++ b/packages/api/src/core/tasks/app/taskService.ts @@ -1,5 +1,5 @@ import { ApplicationError } from "../../applicationError"; -import type { Transact, Tx } from "../../db"; +import type { Tx } from "../../db"; import type { ModuleDependencies } from "../../moduleManifest"; import { createRecordHistoryReaderService, @@ -145,9 +145,13 @@ 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) => { + const done = await transact(async (tx) => { + const { task, employeeId } = await lockVisible(ctx, tx, taskId); refuseUnlessAssignee(task, employeeId, MARK_DONE_REFUSAL); - return markTaskDone(task, employeeId, new Date()); + const saved = await taskRepository(tx).saveStatus( + markTaskDone(task, 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; @@ -155,9 +159,7 @@ export function createTaskService({ async deleteTask(ctx, taskId) { const deleted = await transact(async (tx) => { - const tasks = taskRepository(tx); - const id = parseUuid(taskId); - const { task, employeeId } = requireVisible(ctx, id ? await tasks.lockById(id) : null); + const { task, employeeId } = await lockVisible(ctx, tx, taskId); if (task.askedByEmployeeId !== employeeId) { throw new ApplicationError( "FORBIDDEN", @@ -165,7 +167,7 @@ export function createTaskService({ ); } refuseClosed(task); - await tasks.delete(task.id); + await taskRepository(tx).delete(task.id); await history.append(ctx, tx, { recordType: "task", operation: "DELETED", @@ -198,28 +200,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. */ From 2585d66808f2152638a5541d1113fd12ecaf0aaa Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sat, 14 Nov 2026 16:45:46 +0100 Subject: [PATCH 05/19] refactor(tasks): a task is an aggregate that owns its rules Task becomes a class: who sees it, who marks it done or deletes it, and that done and cancelled are final live on it; every change returns a new task. --- .../api/src/core/tasks/app/taskRecordType.ts | 3 +- .../api/src/core/tasks/app/taskService.ts | 39 +----- .../api/src/core/tasks/db/taskRepository.ts | 8 +- packages/api/src/core/tasks/domain/index.ts | 8 +- .../api/src/core/tasks/domain/task.test.ts | 101 +++++++++++++--- packages/api/src/core/tasks/domain/task.ts | 111 +++++++++++++----- 6 files changed, 180 insertions(+), 90 deletions(-) diff --git a/packages/api/src/core/tasks/app/taskRecordType.ts b/packages/api/src/core/tasks/app/taskRecordType.ts index 813ba1029..0a9180402 100644 --- a/packages/api/src/core/tasks/app/taskRecordType.ts +++ b/packages/api/src/core/tasks/app/taskRecordType.ts @@ -2,7 +2,6 @@ import type { Orm } from "../../db"; import type { RecordTypeRegistration } from "../../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; @@ -20,7 +19,7 @@ export function taskRecordType(orm: Orm): RecordTypeRegistration { 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)); + 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: { diff --git a/packages/api/src/core/tasks/app/taskService.ts b/packages/api/src/core/tasks/app/taskService.ts index 1e62071e9..22ad07ce5 100644 --- a/packages/api/src/core/tasks/app/taskService.ts +++ b/packages/api/src/core/tasks/app/taskService.ts @@ -11,17 +11,7 @@ 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, - refuseClosed, - type MyTasks, - resolvedRecentlySince, - type Task, -} from "../domain"; +import { groupMyTasks, type MyTasks, resolvedRecentlySince, type Task } from "../domain"; import { taskRecordType } from "./taskRecordType"; /** @@ -104,7 +94,7 @@ export function createTaskService({ 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); } @@ -120,7 +110,7 @@ export function createTaskService({ 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; }, @@ -147,10 +137,7 @@ export function createTaskService({ async markDone(ctx, taskId) { const done = await transact(async (tx) => { const { task, employeeId } = await lockVisible(ctx, tx, taskId); - refuseUnlessAssignee(task, employeeId, MARK_DONE_REFUSAL); - const saved = await taskRepository(tx).saveStatus( - markTaskDone(task, employeeId, new Date()), - ); + 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"); @@ -160,13 +147,7 @@ export function createTaskService({ async deleteTask(ctx, taskId) { const deleted = await transact(async (tx) => { const { task, employeeId } = await lockVisible(ctx, tx, taskId); - if (task.askedByEmployeeId !== employeeId) { - throw new ApplicationError( - "FORBIDDEN", - "Only the person who asked can delete this task.", - ); - } - refuseClosed(task); + task.requireDeletableBy(employeeId); await taskRepository(tx).delete(task.id); await history.append(ctx, tx, { recordType: "task", @@ -245,22 +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); - } -} - 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."; diff --git a/packages/api/src/core/tasks/db/taskRepository.ts b/packages/api/src/core/tasks/db/taskRepository.ts index cabae5e28..231227e59 100644 --- a/packages/api/src/core/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 "../../db"; -import type { Task } from "../domain"; +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 { @@ -122,8 +122,8 @@ function toRow({ record, ...task }: NewTaskRow) { } function toTask({ recordType, recordId, ...row }: TaskRow): Task { - return { + return Task.restore({ ...row, record: recordType && recordId ? { recordType, recordId } : null, - }; + }); } diff --git a/packages/api/src/core/tasks/domain/index.ts b/packages/api/src/core/tasks/domain/index.ts index 3456e807d..91110f417 100644 --- a/packages/api/src/core/tasks/domain/index.ts +++ b/packages/api/src/core/tasks/domain/index.ts @@ -1,12 +1,8 @@ export { - cancelTask, - isTaskVisibleTo, - markTaskDone, - markTaskWaiting, TASK_STATUSES, MANUAL_TO_DO, - refuseClosed, - type Task, + Task, + type TaskFields, type TaskStatus, type TaskRecord, } from "./task"; diff --git a/packages/api/src/core/tasks/domain/task.test.ts b/packages/api/src/core/tasks/domain/task.test.ts index f00737dda..85bff1c5f 100644 --- a/packages/api/src/core/tasks/domain/task.test.ts +++ b/packages/api/src/core/tasks/domain/task.test.ts @@ -1,13 +1,15 @@ import { expect, test } from "vitest"; -import { cancelTask, markTaskDone, markTaskWaiting, type Task, type TaskStatus } from "./task"; +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 = markTaskDone(task, task.assigneeEmployeeId, now); + const done = task.markDone(task.assigneeEmployeeId, now); + expect(done).toBeInstanceOf(Task); expect(done).toMatchObject({ status: "done", doneAt: now, @@ -15,7 +17,20 @@ test("marks a task to do as done by its assignee", () => { cancelledAt: null, cancelReason: null, }); - expect(task.status).toBe("to_do"); + 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([ @@ -26,16 +41,18 @@ test.each([ const task = taskIn(status); expect(() => - markTaskDone(task, task.assigneeEmployeeId, new Date("2026-11-09T12:00:00Z")), + 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 = cancelTask(task, "No longer needed", now); + const cancelled = task.cancel("No longer needed", now); + expect(cancelled).toBeInstanceOf(Task); expect(cancelled).toMatchObject({ status: "cancelled", cancelledAt: now, @@ -43,23 +60,29 @@ test.each(["to_do", "waiting"] as const)("cancels a %s task with a reason", (sta doneAt: null, doneByEmployeeId: null, }); - expect(task.status).toBe(status); + 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) => { - expect(() => - cancelTask(taskIn(status), "No longer needed", new Date("2026-11-09T12:00:00Z")), - ).toThrowError(expect.objectContaining({ code: "CONFLICT", 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(markTaskWaiting(task).status).toBe("waiting"); - expect(task.status).toBe("to_do"); + expect(waiting).toBeInstanceOf(Task); + expect(waiting.status).toBe("waiting"); + expect(task).toEqual(original); }); test.each([ @@ -67,7 +90,9 @@ test.each([ ["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( + const task = taskIn(status); + + expect(() => task.markWaiting()).toThrowError( expect.objectContaining({ code: "CONFLICT", message, @@ -75,10 +100,56 @@ test.each([ ); }); +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 { + return Task.restore({ id: "00000000-0000-4000-8000-000000000001", type: "manual_to_do", status, @@ -86,7 +157,7 @@ function taskIn(status: TaskStatus): Task { description: null, assigneeEmployeeId: "00000000-0000-4000-8000-000000000002", askedByEmployeeId: "00000000-0000-4000-8000-000000000003", - subjectEmployeeId: null, + subjectEmployeeId: "00000000-0000-4000-8000-000000000004", record: null, dueDate: null, doneAt: status === "done" ? closedAt : null, @@ -95,5 +166,5 @@ function taskIn(status: TaskStatus): Task { 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 index d47b6e781..6bf8ece19 100644 --- a/packages/api/src/core/tasks/domain/task.ts +++ b/packages/api/src/core/tasks/domain/task.ts @@ -13,8 +13,8 @@ export interface TaskRecord { recordId: string; } -/** Work asked of one person, and where it stands. */ -export interface Task { +/** 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; @@ -34,42 +34,93 @@ export interface Task { 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; -} +/** + * 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; -export function markTaskDone(task: Task, byEmployeeId: string, now: Date): Task { - refuseClosed(task); - if (task.status === "waiting") { - throw new ApplicationError("CONFLICT", "This task is waiting."); + private constructor(fields: TaskFields) { + Object.assign(this, fields); } - return { ...task, status: "done", doneAt: now, doneByEmployeeId: byEmployeeId }; -} -/** `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 }; -} + /** The task as stored. */ + static restore(fields: TaskFields): Task { + return new Task(fields); + } -export function markTaskWaiting(task: Task): Task { - if (task.status === "cancelled") { - throw cancelledConflict(); + /** 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; } - if (task.status !== "to_do") { - throw new ApplicationError("CONFLICT", "Only a task to do can wait."); + + /** 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 }); } - return { ...task, status: "waiting" }; -} -/** Done and cancelled are final: nothing changes such a task again. */ -export function refuseClosed(task: Task): void { - if (task.status === "done") { - throw new ApplicationError("CONFLICT", "This task is already done."); + /** `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 }); } - if (task.status === "cancelled") { - throw cancelledConflict(); + + 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 }); } } From 0cdb0e7846bc6dd20847cd3c632b459896c487eb Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sat, 14 Nov 2026 16:51:52 +0100 Subject: [PATCH 06/19] refactor(processes): a run is an aggregate that holds its steps ProcessRun becomes a class with its steps: completing a step's tasks, finishing, the removal check and cancelling live on it. Changes load the run with lockRun and write it back with saveRun. --- .../src/core/processes/app/dueRunService.ts | 21 +- .../src/core/processes/app/processViews.ts | 2 +- .../core/processes/app/runLifecycleService.ts | 88 ++---- .../api/src/core/processes/app/runReader.ts | 4 +- .../api/src/core/processes/app/runStarter.ts | 8 +- .../core/processes/db/processRepository.ts | 42 +-- .../api/src/core/processes/domain/index.ts | 7 +- .../core/processes/domain/processRun.test.ts | 251 ++++++++++++++++++ .../src/core/processes/domain/processRun.ts | 163 ++++++++---- 9 files changed, 418 insertions(+), 168 deletions(-) create mode 100644 packages/api/src/core/processes/domain/processRun.test.ts diff --git a/packages/api/src/core/processes/app/dueRunService.ts b/packages/api/src/core/processes/app/dueRunService.ts index e783ddedb..635f4cfe6 100644 --- a/packages/api/src/core/processes/app/dueRunService.ts +++ b/packages/api/src/core/processes/app/dueRunService.ts @@ -4,7 +4,12 @@ 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, type ProcessRun, type RecordDateTrigger } from "../domain"; +import { + dueTriggerDate, + type ProcessRun, + type ProcessRunFields, + type RecordDateTrigger, +} from "../domain"; import type { RunLifecycleService } from "./runLifecycleService"; import type { DefinitionReader, ProcessRecordTypes, RunStarter } from "./runStarter"; @@ -80,14 +85,12 @@ async function checkRun( runId: string, ): Promise { const runs = processRepository(tx); - const openTaskIds = async () => - (await runs.findSteps(runId)).flatMap(({ status, taskId }) => - status === "step_awaiting_task_completion" && taskId ? [taskId] : [], - ); - const locked = await openTaskIds(); + const locked = (await runs.findSteps(runId)).flatMap(({ status, taskId }) => + status === "step_awaiting_task_completion" && taskId ? [taskId] : [], + ); await tasks.lockInTransaction(tx, locked); const run = (await runs.lockRun(runId))!; - if (String(await openTaskIds()) !== String(locked)) return null; + if (String(run.openTaskIds()) !== String(locked)) return null; return lifecycle.cancelIfCannotRun(tx, run); } @@ -97,7 +100,7 @@ async function startOne( process: StoredProcess, recordId: string, today: CalendarDate, -): Promise { +): Promise { const { transact, logger } = dependencies; try { const run = await transact((tx) => @@ -127,7 +130,7 @@ async function startIfStillDue( { starter, recordTypes, definitionOf }: DueRunDependencies, tx: Tx, { processId, recordId, today }: { processId: string; recordId: string; today: CalendarDate }, -): Promise { +): Promise { const processes = processRepository(tx); await processes.lockProcess(processId); const [process] = await processes.findProcessesByIds([processId]); diff --git a/packages/api/src/core/processes/app/processViews.ts b/packages/api/src/core/processes/app/processViews.ts index b5310a6b8..9d823caad 100644 --- a/packages/api/src/core/processes/app/processViews.ts +++ b/packages/api/src/core/processes/app/processViews.ts @@ -1,6 +1,6 @@ import type { CalendarDate } from "../../../utils"; import type { TaskStatus } from "../../tasks"; -import type { ProcessRun as StoredRun, RunStatus, RunStep } from "../domain"; +import type { ProcessRunFields as StoredRun, RunStatus, RunStep } from "../domain"; // The shapes the process service returns. diff --git a/packages/api/src/core/processes/app/runLifecycleService.ts b/packages/api/src/core/processes/app/runLifecycleService.ts index 6601ed3f5..a33d3d1a7 100644 --- a/packages/api/src/core/processes/app/runLifecycleService.ts +++ b/packages/api/src/core/processes/app/runLifecycleService.ts @@ -4,17 +4,7 @@ import type { PublishedEvent } from "../../events"; import { PrincipalSecurityContext, type SecurityContext } from "../../security"; import type { TaskService } from "../../tasks"; import { processRepository } from "../db"; -import { - type CancelCause, - cancelRun, - completeStep, - PROCESS_CANCELLED, - type ProcessRun as StoredRun, - removalCancellation, - type RunStep, - settleRun, - SUBJECT_REMOVED, -} from "../domain"; +import { PROCESS_CANCELLED, type ProcessRun, SUBJECT_REMOVED } from "../domain"; import type { StepProgress } from "./processViews"; import type { RunStarter } from "./runStarter"; @@ -36,7 +26,7 @@ export interface RunLifecycleService { * 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: StoredRun): Promise; + cancelIfCannotRun(tx: Tx, run: ProcessRun): Promise; } export function createRunLifecycleService({ @@ -65,22 +55,14 @@ export function createRunLifecycleService({ const now = new Date(); for (const runId of [...new Set(awaiting.map((step) => step.runId))].sort()) { const run = (await runs.lockRun(runId))!; - const steps = (await runs.findSteps(runId)).map((step) => - step.status === "step_awaiting_task_completion" && doneTaskIds.has(step.taskId!) - ? completeStep(run, step, now) - : step, - ); - const completed = steps.filter(({ taskId }) => taskId && doneTaskIds.has(taskId)); - await runs.saveSteps(completed); // A run that can no longer run stays in progress for the daily check, which cancels it. - const settled = (await removalOf(starter, tx, run, steps)) - ? run - : settleRun(run, steps, now); - if (settled !== run) await runs.saveRun(settled); + 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: completed.map(({ taskId }) => taskId!), - runStatus: settled.status, + taskIds: run.openTaskIds().filter((taskId) => doneTaskIds.has(taskId)), + runStatus: changed.status, }); } }, @@ -94,9 +76,7 @@ export function createRunLifecycleService({ // 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 = (await runs.findSteps(run.id)).filter( - ({ status }) => status !== "step_skipped", - ); + const steps = run.steps.filter(({ status }) => status !== "step_skipped"); return { processName: process!.name, subjectEmployeeId: run.subjectEmployeeId, @@ -109,23 +89,25 @@ export function createRunLifecycleService({ async cancelIfCannotRun(tx, run) { if (run.status !== "in_progress") return run; - const removal = await removalOf( - starter, - tx, - run, - await processRepository(tx).findSteps(run.id), - ); + 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.`; - return cancelAndSave(tasks, PrincipalSecurityContext.system(), tx, run, { + const cancelled = run.cancel({ cause: removal.cause, reason, - taskReason: PROCESS_CANCELLED, 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; }, }; } @@ -134,40 +116,10 @@ export function createRunLifecycleService({ * 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: StoredRun, steps: readonly RunStep[]) { +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 removalCancellation(run, steps, subjectExists); -} - -/** 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; + return run.removal(subjectExists); } diff --git a/packages/api/src/core/processes/app/runReader.ts b/packages/api/src/core/processes/app/runReader.ts index cfdf44e6e..562301a03 100644 --- a/packages/api/src/core/processes/app/runReader.ts +++ b/packages/api/src/core/processes/app/runReader.ts @@ -3,7 +3,7 @@ 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/core/processes/app/runStarter.ts b/packages/api/src/core/processes/app/runStarter.ts index 7fe72c1c0..e4a7f60fe 100644 --- a/packages/api/src/core/processes/app/runStarter.ts +++ b/packages/api/src/core/processes/app/runStarter.ts @@ -10,7 +10,7 @@ import { dueTriggerDate, type IsWorkingDay, type ProcessDefinition, - type ProcessRun, + type ProcessRunFields, type RecordDateTrigger, resolveAssignee, type ResolvedAssignee, @@ -64,7 +64,7 @@ export interface RunStarter { * 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; + startMany(tx: Tx, starts: readonly RunStart[]): Promise; /** The records whose date triggers a run today, with the date it triggered on. */ findDue( tx: Tx, @@ -72,7 +72,7 @@ export interface RunStarter { days: { today: CalendarDate; turnedOnDay: 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({ @@ -159,7 +159,7 @@ export function createRunStarter({ /** 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)[]; diff --git a/packages/api/src/core/processes/db/processRepository.ts b/packages/api/src/core/processes/db/processRepository.ts index 4b74d4465..1041e7792 100644 --- a/packages/api/src/core/processes/db/processRepository.ts +++ b/packages/api/src/core/processes/db/processRepository.ts @@ -2,7 +2,12 @@ import { and, desc, eq, inArray, isNotNull, type SQL, sql } from "drizzle-orm"; import { ormOf, type Orm, type OrmTransaction, type Tx } from "../../db"; import { inRecordScope, type RecordScope } from "../../security"; -import { PROCESS_DEFINITION_SCHEMA_VERSION, type ProcessRun, type RunStep } from "../domain"; +import { + PROCESS_DEFINITION_SCHEMA_VERSION, + ProcessRun, + type ProcessRunFields, + type RunStep, +} from "../domain"; import { processes, processVersions } from "./processesTable"; import { processRuns, processRunSteps } from "./processRunsTable"; @@ -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,13 +54,13 @@ 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. */ + 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. */ @@ -65,9 +70,9 @@ export interface ProcessRepository { 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, @@ -85,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; } @@ -186,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())); @@ -211,19 +216,16 @@ 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(processRunSteps).where(eq(processRunSteps.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) { + .where(eq(processRuns.id, run.id)); for (const { runId, stepKey, ...step } of steps) { await orm .update(processRunSteps) diff --git a/packages/api/src/core/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts index 1e8d915d8..c451d5c99 100644 --- a/packages/api/src/core/processes/domain/index.ts +++ b/packages/api/src/core/processes/domain/index.ts @@ -22,17 +22,14 @@ export { ASSIGNEE_NOTES, CANCEL_CAUSES, type CancelCause, - cancelRun, - completeStep, PROCESS_CANCELLED, - type ProcessRun, - removalCancellation, + 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/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 index b96a34cb4..ff526d93c 100644 --- a/packages/api/src/core/processes/domain/processRun.ts +++ b/packages/api/src/core/processes/domain/processRun.ts @@ -39,8 +39,8 @@ export const SUBJECT_REMOVED = "The person this process is about was removed fro /** 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 { +/** One run of a process version for one record, as stored. */ +export interface ProcessRunFields { id: string; processId: string; processVersionId: string; @@ -104,69 +104,114 @@ export function resolveAssignee( 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 }; +/** + * 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; } - 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 }; -} + /** The run and its steps, as stored. */ + static restore(fields: ProcessRunFields, steps: readonly RunStep[]): ProcessRun { + return new ProcessRun(fields, steps); + } -/** 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."); + /** The tasks of steps still awaiting them. */ + openTaskIds(): string[] { + return this.steps.flatMap(({ status, taskId }) => + status === "step_awaiting_task_completion" && taskId ? [taskId] : [], + ); } - 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 } + + /** + * 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. - */ -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; -} + /** + * 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, + ), + ); + } -function refuseUnlessOpen(run: ProcessRun): void { - if (run.status === "cancelled") { - throw new ApplicationError("CONFLICT", PROCESS_CANCELLED); + private with(changes: Partial, steps: readonly RunStep[]): ProcessRun { + return new ProcessRun({ ...this, ...changes }, steps); } } From 1a741f1c2a53fb13825a2ba5f8339186ae17c107 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sat, 14 Nov 2026 16:56:35 +0100 Subject: [PATCH 07/19] fix(processes): save only a step's status, leaving its task reference to the database Saving a run rewrote every step's task id, which could restore a task id the database had just cleared and fail a concurrent mark done. --- packages/api/src/core/processes/app/dueRunService.ts | 5 ++--- .../api/src/core/processes/db/processRepository.ts | 7 ++++--- packages/api/src/core/processes/domain/index.ts | 1 + packages/api/src/core/processes/domain/processRun.ts | 11 ++++++++--- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/api/src/core/processes/app/dueRunService.ts b/packages/api/src/core/processes/app/dueRunService.ts index 635f4cfe6..cd012793d 100644 --- a/packages/api/src/core/processes/app/dueRunService.ts +++ b/packages/api/src/core/processes/app/dueRunService.ts @@ -6,6 +6,7 @@ import { processRepository, type StoredProcess } from "../db"; import type { TaskService } from "../../tasks"; import { dueTriggerDate, + openTaskIdsOf, type ProcessRun, type ProcessRunFields, type RecordDateTrigger, @@ -85,9 +86,7 @@ async function checkRun( runId: string, ): Promise { const runs = processRepository(tx); - const locked = (await runs.findSteps(runId)).flatMap(({ status, taskId }) => - status === "step_awaiting_task_completion" && taskId ? [taskId] : [], - ); + 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; diff --git a/packages/api/src/core/processes/db/processRepository.ts b/packages/api/src/core/processes/db/processRepository.ts index 1041e7792..ad5023119 100644 --- a/packages/api/src/core/processes/db/processRepository.ts +++ b/packages/api/src/core/processes/db/processRepository.ts @@ -59,7 +59,7 @@ export interface ProcessRepository { findRun(id: string): Promise; /** The run with its steps, locked until the transaction ends, so changes to it take turns. */ lockRun(id: string): Promise; - /** Writes the run and its steps. */ + /** Writes the run and its steps' status. */ saveRun(run: ProcessRun): Promise; /** The run's steps, in no particular order. */ findSteps(runId: string): Promise; @@ -226,10 +226,11 @@ export function processRepository(db: Orm | Tx): ProcessRepository { .update(processRuns) .set({ ...run, updatedAt: new Date() }) .where(eq(processRuns.id, run.id)); - for (const { runId, stepKey, ...step } of steps) { + // 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) + .set({ status, finishedAt }) .where(and(eq(processRunSteps.runId, runId), eq(processRunSteps.stepKey, stepKey))); } }, diff --git a/packages/api/src/core/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts index c451d5c99..3f614bd21 100644 --- a/packages/api/src/core/processes/domain/index.ts +++ b/packages/api/src/core/processes/domain/index.ts @@ -23,6 +23,7 @@ export { CANCEL_CAUSES, type CancelCause, PROCESS_CANCELLED, + openTaskIdsOf, ProcessRun, type ProcessRunFields, resolveAssignee, diff --git a/packages/api/src/core/processes/domain/processRun.ts b/packages/api/src/core/processes/domain/processRun.ts index ff526d93c..cb14cb950 100644 --- a/packages/api/src/core/processes/domain/processRun.ts +++ b/packages/api/src/core/processes/domain/processRun.ts @@ -145,9 +145,7 @@ export class ProcessRun implements Readonly { /** The tasks of steps still awaiting them. */ openTaskIds(): string[] { - return this.steps.flatMap(({ status, taskId }) => - status === "step_awaiting_task_completion" && taskId ? [taskId] : [], - ); + return openTaskIdsOf(this.steps); } /** @@ -215,3 +213,10 @@ export class ProcessRun implements Readonly { 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] : [], + ); +} From d211297eb53b5e62688dec401b5b2bfb8d339a7e Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 06:48:39 +0100 Subject: [PATCH 08/19] refactor(api): ports and adapters get their own folders Processes' ports move to processes/ports and the employees module's implementations of them to employees/adapters, both enforced as layers, so a module's outside dependencies show in its folders. --- docs/architecture.md | 4 ++- docs/code-quality.md | 2 +- packages/api/importRules.json | 4 ++- .../src/core/processes/app/eventRunService.ts | 2 +- packages/api/src/core/processes/app/index.ts | 1 - .../src/core/processes/app/processService.ts | 2 +- .../api/src/core/processes/app/runStarter.ts | 2 +- packages/api/src/core/processes/index.ts | 3 ++- packages/api/src/core/processes/module.md | 2 +- .../{app/ports.ts => ports/index.ts} | 0 .../src/modules/employees/adapters/index.ts | 1 + .../{app => adapters}/processAdapters.ts | 25 ++++++------------- .../employees/app/employeeTriggeringEvents.ts | 12 +++++++++ .../api/src/modules/employees/app/index.ts | 6 +---- packages/api/src/modules/employees/index.ts | 3 +-- packages/api/src/modules/index.ts | 2 +- packages/api/test/support/processService.ts | 2 +- 17 files changed, 38 insertions(+), 35 deletions(-) rename packages/api/src/core/processes/{app/ports.ts => ports/index.ts} (100%) create mode 100644 packages/api/src/modules/employees/adapters/index.ts rename packages/api/src/modules/employees/{app => adapters}/processAdapters.ts (80%) create mode 100644 packages/api/src/modules/employees/app/employeeTriggeringEvents.ts diff --git a/docs/architecture.md b/docs/architecture.md index 3590d1d71..812078914 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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)). diff --git a/docs/code-quality.md b/docs/code-quality.md index 3c93f948e..370825595 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. diff --git a/packages/api/importRules.json b/packages/api/importRules.json index 350299464..836fc1514 100644 --- a/packages/api/importRules.json +++ b/packages/api/importRules.json @@ -208,7 +208,9 @@ "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/eventRunService.ts b/packages/api/src/core/processes/app/eventRunService.ts index e2ba524dd..f62dae314 100644 --- a/packages/api/src/core/processes/app/eventRunService.ts +++ b/packages/api/src/core/processes/app/eventRunService.ts @@ -11,7 +11,7 @@ import { type ProcessDefinition, type TriggeringEvents, } from "../domain"; -import type { RunSubject } from "./ports"; +import type { RunSubject } from "../ports"; import type { DefinitionReader, RunStarter } from "./runStarter"; /** Starts the runs that a transaction's events trigger, inside that transaction. */ diff --git a/packages/api/src/core/processes/app/index.ts b/packages/api/src/core/processes/app/index.ts index 5c24f6228..f1d438230 100644 --- a/packages/api/src/core/processes/app/index.ts +++ b/packages/api/src/core/processes/app/index.ts @@ -1,4 +1,3 @@ export { createProcessService, type ProcessService } from "./processService"; -export type { PeoplePort, RunSubject, WorkingCalendarsPort } from "./ports"; export { PROCESS_STEP_TO_DO } from "./runStarter"; export { startDueRunsJob } from "./startDueRunsJob"; diff --git a/packages/api/src/core/processes/app/processService.ts b/packages/api/src/core/processes/app/processService.ts index 5011c6b8f..e973c7ef0 100644 --- a/packages/api/src/core/processes/app/processService.ts +++ b/packages/api/src/core/processes/app/processService.ts @@ -16,7 +16,7 @@ import { parseProcessDefinition, type TriggeringEvents } from "../domain"; import { createDueRunService } from "./dueRunService"; import { createEventRunService, type EventRunService } from "./eventRunService"; import { processRecordType } from "./processRecordType"; -import type { PeoplePort, WorkingCalendarsPort } from "./ports"; +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"; diff --git a/packages/api/src/core/processes/app/runStarter.ts b/packages/api/src/core/processes/app/runStarter.ts index e4a7f60fe..21561d72e 100644 --- a/packages/api/src/core/processes/app/runStarter.ts +++ b/packages/api/src/core/processes/app/runStarter.ts @@ -18,7 +18,7 @@ import { type StartCause, type TriggerEvent, } from "../domain"; -import type { PeoplePort, RunSubject, WorkingCalendarsPort } from "./ports"; +import type { PeoplePort, RunSubject, WorkingCalendarsPort } from "../ports"; /** 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"; diff --git a/packages/api/src/core/processes/index.ts b/packages/api/src/core/processes/index.ts index a8c682ea9..a38641cd3 100644 --- a/packages/api/src/core/processes/index.ts +++ b/packages/api/src/core/processes/index.ts @@ -9,7 +9,8 @@ export { createProcessService, PROCESS_STEP_TO_DO } from "./app"; export { type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from "./domain"; // Public types. -export type { PeoplePort, ProcessService, RunSubject, WorkingCalendarsPort } from "./app"; +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. */ diff --git a/packages/api/src/core/processes/module.md b/packages/api/src/core/processes/module.md index d5904c3c5..1fd249816 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -12,7 +12,7 @@ A process gives people tasks when something happens to a record, and follows eac - 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`, which the employees module fills, and the records it runs on through their record types' registered process capabilities. It imports no module. +- 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 diff --git a/packages/api/src/core/processes/app/ports.ts b/packages/api/src/core/processes/ports/index.ts similarity index 100% rename from packages/api/src/core/processes/app/ports.ts rename to packages/api/src/core/processes/ports/index.ts 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/employees/app/processAdapters.ts b/packages/api/src/modules/employees/adapters/processAdapters.ts similarity index 80% rename from packages/api/src/modules/employees/app/processAdapters.ts rename to packages/api/src/modules/employees/adapters/processAdapters.ts index 73b96d703..fff463363 100644 --- a/packages/api/src/modules/employees/app/processAdapters.ts +++ b/packages/api/src/modules/employees/adapters/processAdapters.ts @@ -1,5 +1,4 @@ import { ApplicationError } from "../../../core/applicationError"; -import type { DomainEvents } from "../../../core/events"; import type { ModuleDependencies } from "../../../core/moduleManifest"; import { type IsWorkingDay, @@ -11,16 +10,19 @@ import { import { PrincipalSecurityContext } from "../../../core/security"; import { addDays, type CalendarDate } from "../../../utils"; import type { BankHolidayService } from "../../bankHolidays"; +import { + createEmployeeReadService, + createEmployeeWorkingPatternService, + employeeDirectory, +} from "../app"; import { employmentRepository } from "../db"; -import { employeeDirectory } from "./employeeDirectory"; -import { readEmployeesByName } from "./employeeReadService"; -import { createEmployeeWorkingPatternService } from "./employmentTerms/employeeWorkingPatternService"; /** Processes read people through this: whom a run is about, their managers and the owner. */ -export function createPeopleAdapter(): PeoplePort { +export function createPeopleAdapter({ orm }: Pick): PeoplePort { + const employeeReads = createEmployeeReadService({ orm }); return { async readPeople(tx, employeeIds) { - const found = await readEmployeesByName(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 }) => [ @@ -76,17 +78,6 @@ export function createWorkingCalendarsAdapter({ }; } -/** 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], - }), - }, -}; - function isBankHolidayIn( weeks: readonly { bankHolidayCalendar: string | null }[], holidays: ReadonlyMap, 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 3849412c6..d42033b72 100644 --- a/packages/api/src/modules/employees/app/index.ts +++ b/packages/api/src/modules/employees/app/index.ts @@ -42,11 +42,7 @@ export { type OwnWorkingWeekService, } from "./employmentTerms/ownWorkingWeekService"; export { employeeRecordType } from "./employeeRecordType"; -export { - createPeopleAdapter, - createWorkingCalendarsAdapter, - EMPLOYEE_TRIGGERING_EVENTS, -} from "./processAdapters"; +export { EMPLOYEE_TRIGGERING_EVENTS } from "./employeeTriggeringEvents"; export { type CopyTermsParts, createTermsService, diff --git a/packages/api/src/modules/employees/index.ts b/packages/api/src/modules/employees/index.ts index d2d105f25..6ea4e20e4 100644 --- a/packages/api/src/modules/employees/index.ts +++ b/packages/api/src/modules/employees/index.ts @@ -39,15 +39,14 @@ export { createEmployeeWorkingPatternService, createLocationService, createOwnWorkingWeekService, - createPeopleAdapter, createTermsService, - createWorkingCalendarsAdapter, 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 d5def0b65..70f8f9285 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -72,7 +72,7 @@ export function createModules( const processes = createProcessService({ ...core, tasks, - people: createPeopleAdapter(), + 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) }, diff --git a/packages/api/test/support/processService.ts b/packages/api/test/support/processService.ts index 24b5aec0a..b34221ff7 100644 --- a/packages/api/test/support/processService.ts +++ b/packages/api/test/support/processService.ts @@ -37,7 +37,7 @@ export function processService( transact, logger: createLogger("silent"), tasks: taskService(orm, { transact }), - people: createPeopleAdapter(), + people: createPeopleAdapter({ orm }), workingCalendars: createWorkingCalendarsAdapter({ transact, bankHolidays: bankHolidayService(), From 81bb34c9e4354c2a5af49bb71d0c7624c6c6917f Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 06:52:26 +0100 Subject: [PATCH 09/19] refactor(utils): counting working days moves to utils Processes keeps what a day offset means, its search limit and the refusal; utils counts working days for any caller. --- .../src/core/processes/domain/dayOffset.ts | 16 ++++----------- packages/api/src/utils/calendarDate.test.ts | 13 +++++++++++- packages/api/src/utils/calendarDate.ts | 20 +++++++++++++++++++ packages/api/src/utils/index.ts | 1 + 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/api/src/core/processes/domain/dayOffset.ts b/packages/api/src/core/processes/domain/dayOffset.ts index 29749478e..c5e209e70 100644 --- a/packages/api/src/core/processes/domain/dayOffset.ts +++ b/packages/api/src/core/processes/domain/dayOffset.ts @@ -1,5 +1,5 @@ import { ApplicationError } from "../../applicationError"; -import { addDays, type CalendarDate, weekdayOf } from "../../../utils"; +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/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/index.ts b/packages/api/src/utils/index.ts index bb9e43fde..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, From 22e71676a3d91c67ae56d42f15d8e9e7ad7e3e64 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:00:10 +0100 Subject: [PATCH 10/19] refactor(processes): run steps table becomes process_run_step_progress The steps live in the version's definition; the table holds a run's progress on each of them. --- packages/api/drizzle/0038_processes.sql | 22 +++++----- packages/api/drizzle/meta/0038_snapshot.json | 42 +++++++++---------- .../core/processes/db/processRepository.ts | 26 ++++++------ .../src/core/processes/db/processRunsTable.ts | 16 +++---- packages/api/src/core/processes/module.md | 2 +- 5 files changed, 54 insertions(+), 54 deletions(-) diff --git a/packages/api/drizzle/0038_processes.sql b/packages/api/drizzle/0038_processes.sql index 11b435786..cdbd628a7 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" ( @@ -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 @@ -89,7 +89,7 @@ CREATE INDEX "process_runs_record_idx" ON "process_runs" USING btree ("process_i CREATE INDEX "process_runs_subject_idx" ON "process_runs" USING btree ("subject_employee_id");--> 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 "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', diff --git a/packages/api/drizzle/meta/0038_snapshot.json b/packages/api/drizzle/meta/0038_snapshot.json index 2fce33730..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 diff --git a/packages/api/src/core/processes/db/processRepository.ts b/packages/api/src/core/processes/db/processRepository.ts index ad5023119..cc969973b 100644 --- a/packages/api/src/core/processes/db/processRepository.ts +++ b/packages/api/src/core/processes/db/processRepository.ts @@ -9,7 +9,7 @@ import { type RunStep, } from "../domain"; import { processes, processVersions } from "./processesTable"; -import { processRuns, processRunSteps } from "./processRunsTable"; +import { processRuns, processRunStepProgress } from "./processRunsTable"; /** A process with the stored definition of its active version, not yet parsed. */ export interface StoredProcess { @@ -201,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(processRunStepProgress).values(steps.slice(start, start + INSERT_BATCH_SIZE)); } }, @@ -217,7 +217,7 @@ export function processRepository(db: Orm | Tx): ProcessRepository { .where(eq(processRuns.id, id)) .for("update"); if (!row) return null; - const steps = await orm.select().from(processRunSteps).where(eq(processRunSteps.runId, id)); + const steps = await orm.select().from(processRunStepProgress).where(eq(processRunStepProgress.runId, id)); return ProcessRun.restore(row, steps); }, @@ -229,36 +229,36 @@ export function processRepository(db: Orm | Tx): ProcessRepository { // 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) + .update(processRunStepProgress) .set({ status, finishedAt }) - .where(and(eq(processRunSteps.runId, runId), eq(processRunSteps.stepKey, stepKey))); + .where(and(eq(processRunStepProgress.runId, runId), eq(processRunStepProgress.stepKey, stepKey))); } }, async findSteps(runId) { - return orm.select().from(processRunSteps).where(eq(processRunSteps.runId, runId)); + return orm.select().from(processRunStepProgress).where(eq(processRunStepProgress.runId, runId)); }, async findStepsByTaskIds(taskIds) { if (taskIds.length === 0) return []; return orm .select() - .from(processRunSteps) - .where(inArray(processRunSteps.taskId, [...taskIds])); + .from(processRunStepProgress) + .where(inArray(processRunStepProgress.taskId, [...taskIds])); }, async findTaskSources(taskIds) { if (taskIds.length === 0) return new Map(); const rows = await orm .select({ - taskId: processRunSteps.taskId, + taskId: processRunStepProgress.taskId, processKey: processes.key, - stepKey: processRunSteps.stepKey, + stepKey: processRunStepProgress.stepKey, }) - .from(processRunSteps) - .innerJoin(processRuns, eq(processRuns.id, processRunSteps.runId)) + .from(processRunStepProgress) + .innerJoin(processRuns, eq(processRuns.id, processRunStepProgress.runId)) .innerJoin(processes, eq(processes.id, processRuns.processId)) - .where(inArray(processRunSteps.taskId, [...taskIds])); + .where(inArray(processRunStepProgress.taskId, [...taskIds])); return new Map(rows.map(({ taskId, ...source }) => [taskId!, source])); }, diff --git a/packages/api/src/core/processes/db/processRunsTable.ts b/packages/api/src/core/processes/db/processRunsTable.ts index 61cf3338d..025d0b98d 100644 --- a/packages/api/src/core/processes/db/processRunsTable.ts +++ b/packages/api/src/core/processes/db/processRunsTable.ts @@ -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 processRunStepProgress = 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/core/processes/module.md b/packages/api/src/core/processes/module.md index 1fd249816..5f3c86d1b 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -20,4 +20,4 @@ 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`: 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_steps`:** one row per step: `status`, `task_id` (set null when the task goes with its assignee), `assignee_note`, `skip_reason`, `finished_at`. +- **`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`. From b246b23ad52190391f157f50678a3e64eb6a2229 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:00:10 +0100 Subject: [PATCH 11/19] refactor(processes): process definition types read top-down --- .../processes/domain/processDefinition.ts | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/api/src/core/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts index f7f9978df..28045926a 100644 --- a/packages/api/src/core/processes/domain/processDefinition.ts +++ b/packages/api/src/core/processes/domain/processDefinition.ts @@ -6,28 +6,21 @@ 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; + startableByAdmin: boolean; + 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. */ @@ -55,23 +48,28 @@ export interface EventTrigger { 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: string }; - trigger: RecordDateTrigger | EventTrigger; - startableByAdmin: boolean; - steps: TaskStep[]; +/** 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>; +/** Days before (negative) or after (positive) a date. */ +export interface DayOffset { + amount: number; + unit: keyof typeof OFFSET_LIMITS; } +/** 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, @@ -151,6 +149,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"]), From 9c0fa7a2088ca9e144b603308b1269a79493aaf6 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:00:54 +0100 Subject: [PATCH 12/19] docs(processes): show a run from a change and from a date in JSON --- packages/api/src/core/processes/module.md | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/api/src/core/processes/module.md b/packages/api/src/core/processes/module.md index 5f3c86d1b..c03b7eaae 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -15,6 +15,87 @@ A process gives people tasks when something happens to a record, and follows eac - 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. +## 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" }, + "startableByAdmin": false, + "steps": [ + { "key": "order_laptop", "kind": "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. + ## Data model - **`processes`:** `key`, `name`, `description`, `active_version_id` (one of its own published versions), `turned_on_at` (null while off). From 9a7a48e874cd9a1e0533b36bd08bc3d6ace33f3e Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:01:08 +0100 Subject: [PATCH 13/19] docs(processes): format the run examples --- packages/api/src/core/processes/module.md | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/api/src/core/processes/module.md b/packages/api/src/core/processes/module.md index c03b7eaae..17bf83481 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -26,14 +26,22 @@ Ids are shortened; columns are shown as rows are stored. ```json { "subject": { "recordType": "employee" }, - "trigger": { "kind": "on_event", "event": "employees.added", - "run": "in_same_transaction", "faultMode": "rollback_transaction" }, + "trigger": { + "kind": "on_event", + "event": "employees.added", + "run": "in_same_transaction", + "faultMode": "rollback_transaction" + }, "startableByAdmin": false, "steps": [ - { "key": "order_laptop", "kind": "task", + { + "key": "order_laptop", + "kind": "task", "assignee": { "kind": "subjects_manager", "otherwise": { "kind": "workspace_owner" } }, - "title": "Order a laptop for {firstName}", "description": null, - "due": { "amount": 3, "unit": "business_days" } } + "title": "Order a laptop for {firstName}", + "description": null, + "due": { "amount": 3, "unit": "business_days" } + } ] } ``` @@ -64,8 +72,10 @@ Ids are shortened; columns are shown as rows are stored. 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" } } + { + "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: From e412f5300d591bedad46e30d0e0314ed890b9d4f Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:03:26 +0100 Subject: [PATCH 14/19] refactor(processes): name the step progress table runStepProgress in code --- .../core/processes/db/processRepository.ts | 26 +++++++++---------- .../src/core/processes/db/processRunsTable.ts | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/api/src/core/processes/db/processRepository.ts b/packages/api/src/core/processes/db/processRepository.ts index cc969973b..a3caed7bb 100644 --- a/packages/api/src/core/processes/db/processRepository.ts +++ b/packages/api/src/core/processes/db/processRepository.ts @@ -9,7 +9,7 @@ import { type RunStep, } from "../domain"; import { processes, processVersions } from "./processesTable"; -import { processRuns, processRunStepProgress } from "./processRunsTable"; +import { processRuns, runStepProgress } from "./processRunsTable"; /** A process with the stored definition of its active version, not yet parsed. */ export interface StoredProcess { @@ -201,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(processRunStepProgress).values(steps.slice(start, start + INSERT_BATCH_SIZE)); + await orm.insert(runStepProgress).values(steps.slice(start, start + INSERT_BATCH_SIZE)); } }, @@ -217,7 +217,7 @@ export function processRepository(db: Orm | Tx): ProcessRepository { .where(eq(processRuns.id, id)) .for("update"); if (!row) return null; - const steps = await orm.select().from(processRunStepProgress).where(eq(processRunStepProgress.runId, id)); + const steps = await orm.select().from(runStepProgress).where(eq(runStepProgress.runId, id)); return ProcessRun.restore(row, steps); }, @@ -229,36 +229,36 @@ export function processRepository(db: Orm | Tx): ProcessRepository { // 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(processRunStepProgress) + .update(runStepProgress) .set({ status, finishedAt }) - .where(and(eq(processRunStepProgress.runId, runId), eq(processRunStepProgress.stepKey, stepKey))); + .where(and(eq(runStepProgress.runId, runId), eq(runStepProgress.stepKey, stepKey))); } }, async findSteps(runId) { - return orm.select().from(processRunStepProgress).where(eq(processRunStepProgress.runId, runId)); + return orm.select().from(runStepProgress).where(eq(runStepProgress.runId, runId)); }, async findStepsByTaskIds(taskIds) { if (taskIds.length === 0) return []; return orm .select() - .from(processRunStepProgress) - .where(inArray(processRunStepProgress.taskId, [...taskIds])); + .from(runStepProgress) + .where(inArray(runStepProgress.taskId, [...taskIds])); }, async findTaskSources(taskIds) { if (taskIds.length === 0) return new Map(); const rows = await orm .select({ - taskId: processRunStepProgress.taskId, + taskId: runStepProgress.taskId, processKey: processes.key, - stepKey: processRunStepProgress.stepKey, + stepKey: runStepProgress.stepKey, }) - .from(processRunStepProgress) - .innerJoin(processRuns, eq(processRuns.id, processRunStepProgress.runId)) + .from(runStepProgress) + .innerJoin(processRuns, eq(processRuns.id, runStepProgress.runId)) .innerJoin(processes, eq(processes.id, processRuns.processId)) - .where(inArray(processRunStepProgress.taskId, [...taskIds])); + .where(inArray(runStepProgress.taskId, [...taskIds])); return new Map(rows.map(({ taskId, ...source }) => [taskId!, source])); }, diff --git a/packages/api/src/core/processes/db/processRunsTable.ts b/packages/api/src/core/processes/db/processRunsTable.ts index 025d0b98d..2c62bd923 100644 --- a/packages/api/src/core/processes/db/processRunsTable.ts +++ b/packages/api/src/core/processes/db/processRunsTable.ts @@ -105,7 +105,7 @@ export const processRuns = pgTable( ); /** A run's progress on each step of its version's definition, by `step_key`; created with the run. */ -export const processRunStepProgress = pgTable( +export const runStepProgress = pgTable( "process_run_step_progress", { runId: uuid("run_id").notNull(), From 9cc0c4caa7f4a63fdb0a21098774e6c83c3257f2 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:03:26 +0100 Subject: [PATCH 15/19] refactor(processes): drop startableByAdmin; an admin may start any process by hand Starting by hand stays behind create on process_run for the subject. --- packages/api/drizzle/0038_processes.sql | 1 - packages/api/src/core/processes/app/processService.ts | 3 --- packages/api/src/core/processes/app/startRun.int.test.ts | 7 ++++--- .../src/core/processes/domain/processDefinition.test.ts | 1 - .../api/src/core/processes/domain/processDefinition.ts | 2 -- packages/api/src/core/processes/module.md | 1 - packages/api/test/support/seedProcess.ts | 1 - 7 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/api/drizzle/0038_processes.sql b/packages/api/drizzle/0038_processes.sql index cdbd628a7..f7829d7d1 100644 --- a/packages/api/drizzle/0038_processes.sql +++ b/packages/api/drizzle/0038_processes.sql @@ -101,7 +101,6 @@ 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", "assignee": { "kind": "subjects_manager", "otherwise": { "kind": "workspace_owner" } }, diff --git a/packages/api/src/core/processes/app/processService.ts b/packages/api/src/core/processes/app/processService.ts index e973c7ef0..c92f78d19 100644 --- a/packages/api/src/core/processes/app/processService.ts +++ b/packages/api/src/core/processes/app/processService.ts @@ -155,9 +155,6 @@ export function createProcessService({ const process = await reader.requireProcess(orm, processKey); const definition = definitionOf(process.activeVersion); const { recordType } = definition.subject; - if (!definition.startableByAdmin) { - throw new ApplicationError("FORBIDDEN", "This process starts only by itself."); - } const run = await transact(async (tx) => { await processRepository(tx).lockProcess(process.id); // Keyed by the record's own id, such as an employee's lowercase uuid. diff --git a/packages/api/src/core/processes/app/startRun.int.test.ts b/packages/api/src/core/processes/app/startRun.int.test.ts index b43ca59f9..28288c698 100644 --- a/packages/api/src/core/processes/app/startRun.int.test.ts +++ b/packages/api/src/core/processes/app/startRun.int.test.ts @@ -240,9 +240,10 @@ test("refuses a manual welcome without a create grant", async ({ orm, seed }) => }); test("refuses a malformed task record id as not found", async ({ orm }) => { - const definition = eventProcess("tasks.task_created"); - definition.startableByAdmin = true; - const { key } = await seedProcess(orm, { key: "follow_up", definition }); + 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" }), diff --git a/packages/api/src/core/processes/domain/processDefinition.test.ts b/packages/api/src/core/processes/domain/processDefinition.test.ts index 0e748c2b7..2fe59c9e8 100644 --- a/packages/api/src/core/processes/domain/processDefinition.test.ts +++ b/packages/api/src/core/processes/domain/processDefinition.test.ts @@ -206,7 +206,6 @@ function welcomeDefinition() { offset: { amount: 0, unit: "calendar_days" }, maxDaysLate: 7, }, - startableByAdmin: true, steps: [ { key: "welcome_one_to_one", diff --git a/packages/api/src/core/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts index 28045926a..d9d6fcccc 100644 --- a/packages/api/src/core/processes/domain/processDefinition.ts +++ b/packages/api/src/core/processes/domain/processDefinition.ts @@ -11,7 +11,6 @@ export interface ProcessDefinition { /** The record a run is about, the trigger's record type. */ subject: { recordType: string }; trigger: RecordDateTrigger | EventTrigger; - startableByAdmin: boolean; steps: TaskStep[]; } @@ -188,7 +187,6 @@ const triggerSchema = z.discriminatedUnion("kind", [ const definitionSchema = z.object({ subject: z.object({ recordType: z.string().min(1) }), trigger: triggerSchema, - startableByAdmin: z.boolean(), steps: z .array( z.object({ diff --git a/packages/api/src/core/processes/module.md b/packages/api/src/core/processes/module.md index 17bf83481..e5faff965 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -32,7 +32,6 @@ Ids are shortened; columns are shown as rows are stored. "run": "in_same_transaction", "faultMode": "rollback_transaction" }, - "startableByAdmin": false, "steps": [ { "key": "order_laptop", diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts index 7865f2e4d..66cf2b202 100644 --- a/packages/api/test/support/seedProcess.ts +++ b/packages/api/test/support/seedProcess.ts @@ -41,7 +41,6 @@ export function eventProcess( faultMode: "rollback_transaction", ...(when && { when }), }, - startableByAdmin: false, steps: [ { key: "follow_up", From 3d81a69db9e0568713daab210de9212ee61aff88 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:03:45 +0100 Subject: [PATCH 16/19] docs: module.md opens with JSON examples of its main flow (RULE77) --- docs/code-quality.md | 1 + packages/api/src/core/processes/module.md | 26 +++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/code-quality.md b/docs/code-quality.md index 370825595..3f83074ac 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -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/packages/api/src/core/processes/module.md b/packages/api/src/core/processes/module.md index e5faff965..40dc0a000 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -2,19 +2,6 @@ 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`, 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. - ## How a run works Ids are shortened; columns are shown as rows are stored. @@ -105,6 +92,19 @@ The daily job finds people whose `startDate` is today, or up to `maxDaysLate` (7 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). From ee61468168d19e03499a6bb43970aad0246037af Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:07:32 +0100 Subject: [PATCH 17/19] refactor(processes): a step keeps key and kind at its root and its kind's fields under it A task step's assignee, title, description and due move under `task`, so another kind of step brings its own fields without touching the root. --- packages/api/drizzle/0038_processes.sql | 8 ++--- .../api/src/core/processes/app/runStarter.ts | 15 ++++++---- .../app/startRunsOnEvents.int.test.ts | 6 ++-- .../domain/processDefinition.test.ts | 30 +++++++++++-------- .../processes/domain/processDefinition.ts | 19 ++++++++---- packages/api/src/core/processes/module.md | 10 ++++--- packages/api/test/support/seedProcess.ts | 10 ++++--- 7 files changed, 58 insertions(+), 40 deletions(-) diff --git a/packages/api/drizzle/0038_processes.sql b/packages/api/drizzle/0038_processes.sql index f7829d7d1..206af60fc 100644 --- a/packages/api/drizzle/0038_processes.sql +++ b/packages/api/drizzle/0038_processes.sql @@ -102,16 +102,16 @@ INSERT INTO "process_versions" ("process_id", "version", "status", "definition_s "trigger": { "kind": "on_record_date", "recordType": "employee", "dateField": "startDate", "offset": { "amount": 0, "unit": "calendar_days" }, "maxDaysLate": 7 }, "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/src/core/processes/app/runStarter.ts b/packages/api/src/core/processes/app/runStarter.ts index 21561d72e..d2d5a9e11 100644 --- a/packages/api/src/core/processes/app/runStarter.ts +++ b/packages/api/src/core/processes/app/runStarter.ts @@ -152,7 +152,7 @@ export function createRunStarter({ const subject = run.subjectEmployeeId ? ((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)])); }, }; } @@ -186,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)]; } @@ -230,12 +232,13 @@ function newTasksOf({ run, start, assignees, isWorkingDay }: RunPlan): NewTask[] { type: PROCESS_STEP_TO_DO, sourceKey: start.process.key, - title: filled(step.title, start.subject), - description: step.description === null ? null : filled(step.description, start.subject), + title: filled(step.task.title, start.subject), + description: + step.task.description === null ? null : filled(step.task.description, start.subject), assigneeEmployeeId: assignee.employeeId, 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/core/processes/app/startRunsOnEvents.int.test.ts b/packages/api/src/core/processes/app/startRunsOnEvents.int.test.ts index 513f5b2f7..e5b7a799a 100644 --- a/packages/api/src/core/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 }); @@ -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 }); @@ -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/core/processes/domain/processDefinition.test.ts b/packages/api/src/core/processes/domain/processDefinition.test.ts index 2fe59c9e8..7b4b4c996 100644 --- a/packages/api/src/core/processes/domain/processDefinition.test.ts +++ b/packages/api/src/core/processes/domain/processDefinition.test.ts @@ -63,7 +63,7 @@ test("refuses a trigger more than 366 calendar days after its date", () => { 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, catalog)).toThrow( "An offset of -367 calendar_days is beyond the limit of 366.", @@ -81,7 +81,7 @@ test("refuses a trigger more than 260 business days before its date", () => { 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, catalog)).toThrow( "An offset of 261 business_days is beyond the limit of 260.", @@ -91,15 +91,15 @@ 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, 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, catalog)).toThrow(); }); @@ -210,18 +210,22 @@ function welcomeDefinition() { { 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" }, + }, }, ], }; diff --git a/packages/api/src/core/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts index d9d6fcccc..42f74c739 100644 --- a/packages/api/src/core/processes/domain/processDefinition.ts +++ b/packages/api/src/core/processes/domain/processDefinition.ts @@ -47,10 +47,15 @@ export interface EventTrigger { faultMode: "rollback_transaction"; } -/** A step that gives one person a task; `title` and `description` may name `{firstName}`. */ +/** 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; @@ -84,7 +89,7 @@ export function parseProcessDefinition( 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; @@ -192,10 +197,12 @@ const definitionSchema = z.object({ 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/module.md b/packages/api/src/core/processes/module.md index 40dc0a000..c219a6412 100644 --- a/packages/api/src/core/processes/module.md +++ b/packages/api/src/core/processes/module.md @@ -23,10 +23,12 @@ Ids are shortened; columns are shown as rows are stored. { "key": "order_laptop", "kind": "task", - "assignee": { "kind": "subjects_manager", "otherwise": { "kind": "workspace_owner" } }, - "title": "Order a laptop for {firstName}", - "description": null, - "due": { "amount": 3, "unit": "business_days" } + "task": { + "assignee": { "kind": "subjects_manager", "otherwise": { "kind": "workspace_owner" } }, + "title": "Order a laptop for {firstName}", + "description": null, + "due": { "amount": 3, "unit": "business_days" } + } } ] } diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts index 66cf2b202..c7a61edba 100644 --- a/packages/api/test/support/seedProcess.ts +++ b/packages/api/test/support/seedProcess.ts @@ -45,10 +45,12 @@ export function eventProcess( { 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" }, + }, }, ], }; From fc537bc800e304205e874b437fd23fefde6d0b5b Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:11:47 +0100 Subject: [PATCH 18/19] refactor(processes): an event trigger is a class that knows which events start it parseProcessDefinition restores the stored on_event trigger as an EventTrigger; isTriggeredBy moves onto it. --- .../processes/domain/eventTrigger.test.ts | 6 +-- .../src/core/processes/domain/eventTrigger.ts | 51 +++++++++++++++---- .../api/src/core/processes/domain/index.ts | 2 + .../processes/domain/processDefinition.ts | 22 +++----- packages/api/test/support/seedProcess.ts | 6 +-- 5 files changed, 57 insertions(+), 30 deletions(-) diff --git a/packages/api/src/core/processes/domain/eventTrigger.test.ts b/packages/api/src/core/processes/domain/eventTrigger.test.ts index dc0a196cf..143cdff0f 100644 --- a/packages/api/src/core/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 { eventAboutRecord, matchEventStarts } from "./eventTrigger"; +import { eventAboutRecord, EventTrigger, matchEventStarts } from "./eventTrigger"; import type { ProcessDefinition } from "./processDefinition"; test("starts each added person in event, process and record order", () => { @@ -121,13 +121,13 @@ function onEvent( event: "employees.added" | "tasks.task_created" | "tasks.task_status_changed", when?: { field: string; in: (string | null)[] }, ): ProcessDefinition["trigger"] { - return { + return EventTrigger.restore({ kind: "on_event", event, run: "in_same_transaction", faultMode: "rollback_transaction", ...(when && { when }), - }; + }); } const triggeringEvents = { diff --git a/packages/api/src/core/processes/domain/eventTrigger.ts b/packages/api/src/core/processes/domain/eventTrigger.ts index 37ff2a7c7..1b8addc47 100644 --- a/packages/api/src/core/processes/domain/eventTrigger.ts +++ b/packages/api/src/core/processes/domain/eventTrigger.ts @@ -1,4 +1,4 @@ -import type { EventTrigger, ProcessDefinition } from "./processDefinition"; +import type { ProcessDefinition } from "./processDefinition"; /** * What an event that may start a process means to processes, registered by the module that @@ -21,6 +21,46 @@ export interface TriggerEvent { payload: unknown; } +/** The `on_event` trigger as a definition stores it. */ +export interface EventTriggerFields { + kind: "on_event"; + /** 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"; +} + +/** + * 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 class EventTrigger implements Readonly { + declare readonly kind: "on_event"; + declare readonly event: string; + declare readonly when?: { field: string; in: (string | null)[] }; + declare readonly run: "in_same_transaction"; + declare readonly faultMode: "rollback_transaction"; + + private constructor(fields: EventTriggerFields) { + Object.assign(this, fields); + } + + /** The trigger as its definition stores it. */ + static restore(fields: EventTriggerFields): EventTrigger { + return new EventTrigger(fields); + } + + /** Whether the event is this trigger's, with its `when` field holding one of the values. */ + isTriggeredBy({ type, payload }: TriggerEvent): boolean { + if (type !== this.event) return false; + if (!this.when) return true; + const value = (payload as Record)[this.when.field] ?? null; + return this.when.in.some((expected) => expected === value); + } +} + /** One run to start: a process, the event that triggered it and one record the event is about. */ export interface EventStart { processId: string; @@ -39,7 +79,7 @@ export function matchEventStarts( ): EventStart[] { return events.flatMap((event) => processes.flatMap(({ processId, trigger }) => - trigger.kind === "on_event" && isTriggeredBy(trigger, event) + trigger.kind === "on_event" && trigger.isTriggeredBy(event) ? (triggeringEvents[event.type]?.recordIds(event.payload) ?? []).map((recordId) => ({ processId, event, @@ -58,10 +98,3 @@ export function eventAboutRecord( ): 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/core/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts index 3f614bd21..1840fffcc 100644 --- a/packages/api/src/core/processes/domain/index.ts +++ b/packages/api/src/core/processes/domain/index.ts @@ -2,6 +2,8 @@ export { addDayOffset, type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from export { dueTriggerDate } from "./recordDateTrigger"; export { eventAboutRecord, + EventTrigger, + type EventTriggerFields, type EventStart, matchEventStarts, type TriggerEvent, diff --git a/packages/api/src/core/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts index 42f74c739..5e1d51fe4 100644 --- a/packages/api/src/core/processes/domain/processDefinition.ts +++ b/packages/api/src/core/processes/domain/processDefinition.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { EventTrigger } from "./eventTrigger"; + /** The shape of `process_versions.definition` this code reads; a migration moves older shapes on. */ export const PROCESS_DEFINITION_SCHEMA_VERSION = 1; @@ -33,20 +35,6 @@ 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"; - /** 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"; -} - /** A step that gives one person a task. */ export interface TaskStep { key: string; @@ -84,7 +72,11 @@ export function parseProcessDefinition( throw new Error(`Process definition schema version ${schemaVersion} is not known.`); } refuseUnbuiltTrigger(definition, catalog); - const parsed: ProcessDefinition = definitionSchema.parse(definition); + const { trigger, ...rest } = definitionSchema.parse(definition); + const parsed: ProcessDefinition = { + ...rest, + trigger: trigger.kind === "on_event" ? EventTrigger.restore(trigger) : trigger, + }; refuseUnregisteredRecord(parsed, catalog); refuseSubjectOtherThanTrigger(parsed, catalog); refuseRepeatedStepKeys(parsed.steps); diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts index c7a61edba..8a7aea7ed 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/core/processes/db"; -import type { ProcessDefinition } from "../../src/core/processes/domain"; +import { EventTrigger, 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. */ @@ -34,13 +34,13 @@ export function eventProcess( ): ProcessDefinition { return { subject: { recordType: event === "employees.added" ? "employee" : "task" }, - trigger: { + trigger: EventTrigger.restore({ kind: "on_event", event, run: "in_same_transaction", faultMode: "rollback_transaction", ...(when && { when }), - }, + }), steps: [ { key: "follow_up", From 5e476ba0a75620b435250fd2c7068222cc925e0d Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Sun, 15 Nov 2026 07:13:50 +0100 Subject: [PATCH 19/19] Revert "refactor(processes): an event trigger is a class that knows which events start it" This reverts commit fc537bc800e304205e874b437fd23fefde6d0b5b. --- .../processes/domain/eventTrigger.test.ts | 6 +-- .../src/core/processes/domain/eventTrigger.ts | 51 ++++--------------- .../api/src/core/processes/domain/index.ts | 2 - .../processes/domain/processDefinition.ts | 22 +++++--- packages/api/test/support/seedProcess.ts | 6 +-- 5 files changed, 30 insertions(+), 57 deletions(-) diff --git a/packages/api/src/core/processes/domain/eventTrigger.test.ts b/packages/api/src/core/processes/domain/eventTrigger.test.ts index 143cdff0f..dc0a196cf 100644 --- a/packages/api/src/core/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 { eventAboutRecord, EventTrigger, matchEventStarts } from "./eventTrigger"; +import { eventAboutRecord, matchEventStarts } from "./eventTrigger"; import type { ProcessDefinition } from "./processDefinition"; test("starts each added person in event, process and record order", () => { @@ -121,13 +121,13 @@ function onEvent( event: "employees.added" | "tasks.task_created" | "tasks.task_status_changed", when?: { field: string; in: (string | null)[] }, ): ProcessDefinition["trigger"] { - return EventTrigger.restore({ + return { kind: "on_event", event, run: "in_same_transaction", faultMode: "rollback_transaction", ...(when && { when }), - }); + }; } const triggeringEvents = { diff --git a/packages/api/src/core/processes/domain/eventTrigger.ts b/packages/api/src/core/processes/domain/eventTrigger.ts index 1b8addc47..37ff2a7c7 100644 --- a/packages/api/src/core/processes/domain/eventTrigger.ts +++ b/packages/api/src/core/processes/domain/eventTrigger.ts @@ -1,4 +1,4 @@ -import type { ProcessDefinition } from "./processDefinition"; +import type { EventTrigger, ProcessDefinition } from "./processDefinition"; /** * What an event that may start a process means to processes, registered by the module that @@ -21,46 +21,6 @@ export interface TriggerEvent { payload: unknown; } -/** The `on_event` trigger as a definition stores it. */ -export interface EventTriggerFields { - kind: "on_event"; - /** 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"; -} - -/** - * 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 class EventTrigger implements Readonly { - declare readonly kind: "on_event"; - declare readonly event: string; - declare readonly when?: { field: string; in: (string | null)[] }; - declare readonly run: "in_same_transaction"; - declare readonly faultMode: "rollback_transaction"; - - private constructor(fields: EventTriggerFields) { - Object.assign(this, fields); - } - - /** The trigger as its definition stores it. */ - static restore(fields: EventTriggerFields): EventTrigger { - return new EventTrigger(fields); - } - - /** Whether the event is this trigger's, with its `when` field holding one of the values. */ - isTriggeredBy({ type, payload }: TriggerEvent): boolean { - if (type !== this.event) return false; - if (!this.when) return true; - const value = (payload as Record)[this.when.field] ?? null; - return this.when.in.some((expected) => expected === value); - } -} - /** One run to start: a process, the event that triggered it and one record the event is about. */ export interface EventStart { processId: string; @@ -79,7 +39,7 @@ export function matchEventStarts( ): EventStart[] { return events.flatMap((event) => processes.flatMap(({ processId, trigger }) => - trigger.kind === "on_event" && trigger.isTriggeredBy(event) + trigger.kind === "on_event" && isTriggeredBy(trigger, event) ? (triggeringEvents[event.type]?.recordIds(event.payload) ?? []).map((recordId) => ({ processId, event, @@ -98,3 +58,10 @@ export function eventAboutRecord( ): 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/core/processes/domain/index.ts b/packages/api/src/core/processes/domain/index.ts index 1840fffcc..3f614bd21 100644 --- a/packages/api/src/core/processes/domain/index.ts +++ b/packages/api/src/core/processes/domain/index.ts @@ -2,8 +2,6 @@ export { addDayOffset, type IsWorkingDay, MAX_SEARCH_DAYS, workingDaysOf } from export { dueTriggerDate } from "./recordDateTrigger"; export { eventAboutRecord, - EventTrigger, - type EventTriggerFields, type EventStart, matchEventStarts, type TriggerEvent, diff --git a/packages/api/src/core/processes/domain/processDefinition.ts b/packages/api/src/core/processes/domain/processDefinition.ts index 5e1d51fe4..42f74c739 100644 --- a/packages/api/src/core/processes/domain/processDefinition.ts +++ b/packages/api/src/core/processes/domain/processDefinition.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import { EventTrigger } from "./eventTrigger"; - /** The shape of `process_versions.definition` this code reads; a migration moves older shapes on. */ export const PROCESS_DEFINITION_SCHEMA_VERSION = 1; @@ -35,6 +33,20 @@ 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"; + /** 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"; +} + /** A step that gives one person a task. */ export interface TaskStep { key: string; @@ -72,11 +84,7 @@ export function parseProcessDefinition( throw new Error(`Process definition schema version ${schemaVersion} is not known.`); } refuseUnbuiltTrigger(definition, catalog); - const { trigger, ...rest } = definitionSchema.parse(definition); - const parsed: ProcessDefinition = { - ...rest, - trigger: trigger.kind === "on_event" ? EventTrigger.restore(trigger) : trigger, - }; + const parsed: ProcessDefinition = definitionSchema.parse(definition); refuseUnregisteredRecord(parsed, catalog); refuseSubjectOtherThanTrigger(parsed, catalog); refuseRepeatedStepKeys(parsed.steps); diff --git a/packages/api/test/support/seedProcess.ts b/packages/api/test/support/seedProcess.ts index 8a7aea7ed..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/core/processes/db"; -import { EventTrigger, type ProcessDefinition } from "../../src/core/processes/domain"; +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. */ @@ -34,13 +34,13 @@ export function eventProcess( ): ProcessDefinition { return { subject: { recordType: event === "employees.added" ? "employee" : "task" }, - trigger: EventTrigger.restore({ + trigger: { kind: "on_event", event, run: "in_same_transaction", faultMode: "rollback_transaction", ...(when && { when }), - }), + }, steps: [ { key: "follow_up",