From e4c7cf06e915886ecdd70e7fddfd2ebd26724eea Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 21:56:13 +0200 Subject: [PATCH] Add project command API --- AGENTS.md | 5 +- docs/circuit-list-editor-api.md | 42 ++- docs/current-architecture.md | 9 +- ...history-and-external-model-architecture.md | 12 +- docs/spec/07-implementation-phases-todo.md | 5 + package.json | 4 +- .../project-history.repository.ts | 53 +++- ...ect-history-operation-unavailable.error.ts | 13 + src/domain/ports/project-command.executor.ts | 33 +++ src/domain/ports/project-history.store.ts | 13 + .../services/project-command.service.ts | 138 +++++++++ src/frontend/types.ts | 17 ++ src/frontend/utils/api.ts | 57 ++++ .../composition/project-command-stores.ts | 8 + .../controllers/project-command.controller.ts | 99 +++++++ .../controllers/project-history.controller.ts | 7 +- src/server/routes/project.routes.ts | 8 + .../validation/project-command.schemas.ts | 17 ++ tests/project-command.service.test.ts | 262 ++++++++++++++++++ 19 files changed, 781 insertions(+), 21 deletions(-) create mode 100644 src/domain/errors/project-history-operation-unavailable.error.ts create mode 100644 src/domain/ports/project-command.executor.ts create mode 100644 src/domain/services/project-command.service.ts create mode 100644 src/server/controllers/project-command.controller.ts create mode 100644 src/shared/validation/project-command.schemas.ts create mode 100644 tests/project-command.service.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1ee944a..46fdb40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,8 +222,9 @@ After saving, the row becomes linked to the new project device. Session-local UI undo/redo exists for structural and destructive operations. The server already persists immutable revisions and project-wide undo/redo -eligibility for the first internal commands. Public command execution, -server-side undo/redo endpoints and the UI cutover remain future work. +eligibility for the first commands. Public command, undo and redo endpoints +exist for Circuit and CircuitDeviceRow field updates. Complete command coverage +and the UI cutover remain future work. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 392930b..1b0f0f5 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -6,15 +6,49 @@ The circuit-first editor uses tree, circuit, row and project-device endpoints. All paths below are mounted below `/api`. There is no Consumer application API. Project responses from `GET /projects` and `GET /projects/:projectId` include -`currentRevision`. It currently exposes the persisted revision foundation only; -no public undo or redo command exists yet. +`currentRevision`. Versioned project commands use this value for optimistic +concurrency checks. -### Project History State +### Project Commands and History - `GET /projects/:projectId/history` - returns `currentRevision`, `undoDepth`, `redoDepth` and the current top change-set id for both persistent stacks - - is read-only; the editor does not consume it yet +- `POST /projects/:projectId/commands` + - executes a supported versioned command as a new user revision + - body: `{ "expectedRevision": 0, "command": { ... } }` +- `POST /projects/:projectId/history/undo` +- `POST /projects/:projectId/history/redo` + - body: `{ "expectedRevision": 1 }` + - executes the eligible inverse or forward command as a new auditable + revision + +The public dispatcher currently supports `circuit.update` and +`circuit-device-row.update`, both with schema version `1`. Other command types +are rejected. The editor does not consume these endpoints yet. + +Example: + +```json +{ + "expectedRevision": 0, + "description": "Rename circuit", + "command": { + "schemaVersion": 1, + "type": "circuit.update", + "payload": { + "circuitId": "cir_1", + "changes": [ + { "field": "displayName", "value": "Sockets East" } + ] + } + } +} +``` + +A stale `expectedRevision` returns HTTP `409` with +`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry +returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. ## Circuit-First Endpoints diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 90c911c..b74eca1 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -76,9 +76,12 @@ Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite, persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über -`GET /api/projects/:projectId/history` lesbar. Die schreibenden Command-Stores -sind noch nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo -ist daher noch nicht verfügbar. +`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt +die unterstützten Typen `circuit.update` und `circuit-device-row.update` über +öffentliche Command-, Undo- und Redo-Endpunkte aus. Das Grid verwendet diese +Endpunkte noch nicht; seine sichtbare Historie bleibt daher sitzungslokal. +Struktur-, Synchronisierungs- und Löschoperationen müssen vor der +Frontend-Umstellung ebenfalls als persistente Commands modelliert werden. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index ccdfe3d..8c442f0 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -167,7 +167,12 @@ Completed foundation: - domain writes, audit revisions and stack transitions share one transaction; a forced stack failure rolls all of them back - `GET /api/projects/:projectId/history` exposes current revision, stack depths - and top change-set ids without enabling mutation endpoints + and top change-set ids +- a central application dispatcher executes the supported `circuit.update` + and `circuit-device-row.update` envelopes without coupling the domain service + to SQLite +- public command, undo and redo endpoints require an expected revision; undo + and redo reconstruct the eligible persisted inverse or forward command Remaining constraints before completing persistent history: @@ -176,10 +181,9 @@ Remaining constraints before completing persistent history: command envelope is complete - route the remaining structural, synchronization and destructive domain writes through the shared command, revision and stack transaction boundary -- add public command execution plus undo/redo endpoints; read-only revision and - stack eligibility state is already exposed - replace the session-local frontend command stack only after server-side - command execution and inverse application are complete + command coverage is complete for structural, destructive and synchronization + operations - do not model IFCGUID as an overloaded circuit equipment identifier - keep database backup/restore checks separate from project history tests diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index dd22d1e..b0d4ed9 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -362,6 +362,8 @@ Tasks: - define concrete serializable domain command descriptions and executors outside React - [x] persist undo/redo eligibility on the server +- [x] expose public command, undo and redo endpoints for the first supported + update command types - add named and periodic logical project snapshots - restore a historical snapshot as a new revision - keep database backups separate from logical history @@ -385,6 +387,9 @@ Implemented foundation: late-failure rollback coverage - a read-only project history endpoint exposes revision, stack depths and top change-set ids +- a central application dispatcher reconstructs persisted commands and exposes + optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow + field updates - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index d5e9f20..56648ee 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/project-history.repository.ts b/src/db/repositories/project-history.repository.ts index 1a97a15..7c6024c 100644 --- a/src/db/repositories/project-history.repository.ts +++ b/src/db/repositories/project-history.repository.ts @@ -1,10 +1,15 @@ -import { asc, eq } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; +import { deserializeProjectCommand } from "../../domain/models/project-command.model.js"; import type { + ProjectHistoryCommand, + ProjectHistoryDirection, ProjectHistoryState, ProjectHistoryStore, } from "../../domain/ports/project-history.store.js"; import type { AppDatabase } from "../database-context.js"; +import { projectChangeSets } from "../schema/project-change-sets.js"; import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js"; +import { projectRevisions } from "../schema/project-revisions.js"; import { projects } from "../schema/projects.js"; export class ProjectHistoryRepository implements ProjectHistoryStore { @@ -41,4 +46,50 @@ export class ProjectHistoryRepository implements ProjectHistoryStore { redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null, }; } + + getNextCommand( + projectId: string, + direction: ProjectHistoryDirection + ): ProjectHistoryCommand | null { + const entry = this.database + .select({ + changeSetId: projectHistoryStackEntries.changeSetId, + forwardPayloadJson: projectChangeSets.forwardPayloadJson, + inversePayloadJson: projectChangeSets.inversePayloadJson, + }) + .from(projectHistoryStackEntries) + .innerJoin( + projectChangeSets, + eq( + projectChangeSets.id, + projectHistoryStackEntries.changeSetId + ) + ) + .innerJoin( + projectRevisions, + eq(projectRevisions.id, projectChangeSets.projectRevisionId) + ) + .where( + and( + eq(projectHistoryStackEntries.projectId, projectId), + eq(projectHistoryStackEntries.stack, direction), + eq(projectRevisions.projectId, projectId) + ) + ) + .orderBy(desc(projectHistoryStackEntries.position)) + .limit(1) + .get(); + if (!entry) { + return null; + } + + return { + changeSetId: entry.changeSetId, + command: deserializeProjectCommand( + direction === "undo" + ? entry.inversePayloadJson + : entry.forwardPayloadJson + ), + }; + } } diff --git a/src/domain/errors/project-history-operation-unavailable.error.ts b/src/domain/errors/project-history-operation-unavailable.error.ts new file mode 100644 index 0000000..f835f11 --- /dev/null +++ b/src/domain/errors/project-history-operation-unavailable.error.ts @@ -0,0 +1,13 @@ +import type { ProjectHistoryDirection } from "../ports/project-history.store.js"; + +export class ProjectHistoryOperationUnavailableError extends Error { + constructor( + readonly projectId: string, + readonly direction: ProjectHistoryDirection + ) { + super( + `Project ${projectId} has no ${direction} operation available.` + ); + this.name = "ProjectHistoryOperationUnavailableError"; + } +} diff --git a/src/domain/ports/project-command.executor.ts b/src/domain/ports/project-command.executor.ts new file mode 100644 index 0000000..48125d7 --- /dev/null +++ b/src/domain/ports/project-command.executor.ts @@ -0,0 +1,33 @@ +import type { AppendedProjectRevision } from "./project-revision.store.js"; +import type { ProjectHistoryState } from "./project-history.store.js"; + +export interface ExecuteUserProjectCommandInput { + projectId: string; + expectedRevision: number; + description?: string; + actorId?: string; + command: unknown; +} + +export interface ExecuteProjectHistoryCommandInput { + projectId: string; + expectedRevision: number; + actorId?: string; +} + +export interface ExecutedProjectCommand { + revision: AppendedProjectRevision; + history: ProjectHistoryState; +} + +export interface ProjectCommandExecutor { + executeUser( + input: ExecuteUserProjectCommandInput + ): ExecutedProjectCommand; + undo( + input: ExecuteProjectHistoryCommandInput + ): ExecutedProjectCommand; + redo( + input: ExecuteProjectHistoryCommandInput + ): ExecutedProjectCommand; +} diff --git a/src/domain/ports/project-history.store.ts b/src/domain/ports/project-history.store.ts index 7003104..98a40b3 100644 --- a/src/domain/ports/project-history.store.ts +++ b/src/domain/ports/project-history.store.ts @@ -1,3 +1,7 @@ +import type { SerializedProjectCommand } from "../models/project-command.model.js"; + +export type ProjectHistoryDirection = "undo" | "redo"; + export interface ProjectHistoryState { projectId: string; currentRevision: number; @@ -7,6 +11,15 @@ export interface ProjectHistoryState { redoChangeSetId: string | null; } +export interface ProjectHistoryCommand { + changeSetId: string; + command: SerializedProjectCommand; +} + export interface ProjectHistoryStore { getState(projectId: string): ProjectHistoryState | null; + getNextCommand( + projectId: string, + direction: ProjectHistoryDirection + ): ProjectHistoryCommand | null; } diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts new file mode 100644 index 0000000..cd61c4d --- /dev/null +++ b/src/domain/services/project-command.service.ts @@ -0,0 +1,138 @@ +import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js"; +import { + assertCircuitDeviceRowUpdateProjectCommand, + circuitDeviceRowUpdateCommandType, +} from "../models/circuit-device-row-project-command.model.js"; +import { + assertCircuitUpdateProjectCommand, + circuitUpdateCommandType, +} from "../models/circuit-project-command.model.js"; +import { + assertSerializedProjectCommand, + type SerializedProjectCommand, +} from "../models/project-command.model.js"; +import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js"; +import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js"; +import type { + ExecuteProjectHistoryCommandInput, + ExecutedProjectCommand, + ExecuteUserProjectCommandInput, + ProjectCommandExecutor, +} from "../ports/project-command.executor.js"; +import type { + ProjectHistoryDirection, + ProjectHistoryStore, +} from "../ports/project-history.store.js"; +import type { ProjectRevisionSource } from "../ports/project-revision.store.js"; + +interface DispatchProjectCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: SerializedProjectCommand; +} + +export class ProjectCommandService implements ProjectCommandExecutor { + constructor( + private readonly circuitStore: CircuitProjectCommandStore, + private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore, + private readonly historyStore: ProjectHistoryStore + ) {} + + executeUser( + input: ExecuteUserProjectCommandInput + ): ExecutedProjectCommand { + assertExpectedRevision(input.expectedRevision); + assertSerializedProjectCommand(input.command); + return this.dispatch({ + ...input, + source: "user", + command: input.command, + }); + } + + undo( + input: ExecuteProjectHistoryCommandInput + ): ExecutedProjectCommand { + return this.executeHistory(input, "undo"); + } + + redo( + input: ExecuteProjectHistoryCommandInput + ): ExecutedProjectCommand { + return this.executeHistory(input, "redo"); + } + + private executeHistory( + input: ExecuteProjectHistoryCommandInput, + direction: ProjectHistoryDirection + ) { + assertExpectedRevision(input.expectedRevision); + const step = this.historyStore.getNextCommand( + input.projectId, + direction + ); + if (!step) { + throw new ProjectHistoryOperationUnavailableError( + input.projectId, + direction + ); + } + + return this.dispatch({ + ...input, + source: direction, + description: `${direction === "undo" ? "Undo" : "Redo"} ${step.command.type}`, + historyTargetChangeSetId: step.changeSetId, + command: step.command, + }); + } + + private dispatch( + input: DispatchProjectCommandInput + ): ExecutedProjectCommand { + const revision = this.executeTypedCommand(input); + const history = this.historyStore.getState(input.projectId); + if (!history) { + throw new Error("Project history disappeared after command execution."); + } + return { revision, history }; + } + + private executeTypedCommand(input: DispatchProjectCommandInput) { + switch (input.command.type) { + case circuitUpdateCommandType: { + assertCircuitUpdateProjectCommand(input.command); + return this.circuitStore.executeUpdate({ + ...input, + command: input.command, + }).revision; + } + case circuitDeviceRowUpdateCommandType: { + assertCircuitDeviceRowUpdateProjectCommand(input.command); + return this.deviceRowStore.executeUpdate({ + ...input, + command: input.command, + }).revision; + } + default: + throw new Error( + `Unsupported project command type: ${input.command.type}.` + ); + } + } +} + +function assertExpectedRevision(expectedRevision: number) { + if ( + !Number.isSafeInteger(expectedRevision) || + expectedRevision < 0 + ) { + throw new Error( + "Expected project revision must be a non-negative integer." + ); + } +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index d13178d..b197b21 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -15,6 +15,23 @@ export interface ProjectHistoryStateDto { redoChangeSetId: string | null; } +export interface ProjectCommandDto { + schemaVersion: number; + type: string; + payload: unknown; +} + +export interface ProjectCommandResultDto { + revision: { + revisionId: string; + changeSetId: string; + projectId: string; + revisionNumber: number; + createdAtIso: string; + }; + history: ProjectHistoryStateDto; +} + export interface DistributionBoardDto { id: string; projectId: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 11e82f0..777180c 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -7,6 +7,8 @@ import type { DistributionBoardDto, FloorDto, GlobalDeviceDto, + ProjectCommandDto, + ProjectCommandResultDto, ProjectDeviceDto, ProjectHistoryStateDto, ProjectDeviceDisconnectResultDto, @@ -61,6 +63,61 @@ export function getProjectHistory(projectId: string) { ); } +export function executeProjectCommand( + projectId: string, + expectedRevision: number, + command: ProjectCommandDto, + description?: string +) { + return request( + `/api/projects/${projectId}/commands`, + { + method: "POST", + body: JSON.stringify({ + expectedRevision, + command, + ...(description ? { description } : {}), + }), + } + ); +} + +export function undoProjectCommand( + projectId: string, + expectedRevision: number +) { + return executeProjectHistoryCommand( + projectId, + expectedRevision, + "undo" + ); +} + +export function redoProjectCommand( + projectId: string, + expectedRevision: number +) { + return executeProjectHistoryCommand( + projectId, + expectedRevision, + "redo" + ); +} + +function executeProjectHistoryCommand( + projectId: string, + expectedRevision: number, + direction: "undo" | "redo" +) { + return request( + `/api/projects/${projectId}/history/${direction}`, + { + method: "POST", + body: JSON.stringify({ expectedRevision }), + } + ); +} + export function createProject(name: string) { return request("/api/projects", { method: "POST", diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index e64135a..ff19b5d 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -1,7 +1,15 @@ import { db } from "../../db/client.js"; import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; +import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; +import { ProjectCommandService } from "../../domain/services/project-command.service.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = new CircuitDeviceRowProjectCommandRepository(db); +export const projectHistoryStore = new ProjectHistoryRepository(db); +export const projectCommandService = new ProjectCommandService( + circuitProjectCommandStore, + circuitDeviceRowProjectCommandStore, + projectHistoryStore +); diff --git a/src/server/controllers/project-command.controller.ts b/src/server/controllers/project-command.controller.ts new file mode 100644 index 0000000..aaf4983 --- /dev/null +++ b/src/server/controllers/project-command.controller.ts @@ -0,0 +1,99 @@ +import type { Request, Response } from "express"; +import { ProjectHistoryOperationUnavailableError } from "../../domain/errors/project-history-operation-unavailable.error.js"; +import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; +import { + executeProjectCommandSchema, + executeProjectHistoryCommandSchema, +} from "../../shared/validation/project-command.schemas.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; + +export function executeProjectCommand(req: Request, res: Response) { + const projectId = getProjectId(req, res); + if (!projectId) { + return; + } + const parsed = executeProjectCommandSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + return res.json( + projectCommandService.executeUser({ + projectId, + ...parsed.data, + }) + ); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export function undoProjectCommand(req: Request, res: Response) { + return executeHistoryCommand(req, res, "undo"); +} + +export function redoProjectCommand(req: Request, res: Response) { + return executeHistoryCommand(req, res, "redo"); +} + +function executeHistoryCommand( + req: Request, + res: Response, + direction: "undo" | "redo" +) { + const projectId = getProjectId(req, res); + if (!projectId) { + return; + } + const parsed = executeProjectHistoryCommandSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + const result = projectCommandService[direction]({ + projectId, + ...parsed.data, + }); + return res.json(result); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +function getProjectId(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string" || !projectId.trim()) { + res.status(400).json({ error: "Invalid projectId" }); + return null; + } + return projectId; +} + +export function respondWithProjectCommandError( + error: unknown, + res: Response +) { + if (error instanceof ProjectRevisionConflictError) { + return res.status(409).json({ + error: error.message, + code: "PROJECT_REVISION_CONFLICT", + expectedRevision: error.expectedRevision, + currentRevision: error.actualRevision, + }); + } + if (error instanceof ProjectHistoryOperationUnavailableError) { + return res.status(409).json({ + error: error.message, + code: "PROJECT_HISTORY_OPERATION_UNAVAILABLE", + direction: error.direction, + }); + } + return res.status(400).json({ + error: + error instanceof Error + ? error.message + : "Project command could not be executed.", + }); +} diff --git a/src/server/controllers/project-history.controller.ts b/src/server/controllers/project-history.controller.ts index 060ffd4..5e8df18 100644 --- a/src/server/controllers/project-history.controller.ts +++ b/src/server/controllers/project-history.controller.ts @@ -1,8 +1,5 @@ import type { Request, Response } from "express"; -import { db } from "../../db/client.js"; -import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; - -const projectHistoryRepository = new ProjectHistoryRepository(db); +import { projectHistoryStore } from "../composition/project-command-stores.js"; export function getProjectHistory(req: Request, res: Response) { const { projectId } = req.params; @@ -10,7 +7,7 @@ export function getProjectHistory(req: Request, res: Response) { return res.status(400).json({ error: "Invalid projectId" }); } - const state = projectHistoryRepository.getState(projectId); + const state = projectHistoryStore.getState(projectId); if (!state) { return res.status(404).json({ error: "Project not found" }); } diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 37a7fa9..f9083b3 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -14,6 +14,11 @@ import { createFloor, listFloorsByProject } from "../controllers/floor.controlle import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getProjectHistory } from "../controllers/project-history.controller.js"; +import { + executeProjectCommand, + redoProjectCommand, + undoProjectCommand, +} from "../controllers/project-command.controller.js"; export const projectRouter = Router(); @@ -21,6 +26,9 @@ projectRouter.get("/", listProjects); projectRouter.post("/", createProject); projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId/history", getProjectHistory); +projectRouter.post("/:projectId/commands", executeProjectCommand); +projectRouter.post("/:projectId/history/undo", undoProjectCommand); +projectRouter.post("/:projectId/history/redo", redoProjectCommand); projectRouter.put("/:projectId", updateProjectSettings); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); diff --git a/src/shared/validation/project-command.schemas.ts b/src/shared/validation/project-command.schemas.ts new file mode 100644 index 0000000..e371d19 --- /dev/null +++ b/src/shared/validation/project-command.schemas.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +const projectCommandEnvelopeSchema = z.object({ + schemaVersion: z.number().int().positive(), + type: z.string().trim().min(1), + payload: z.unknown(), +}); + +export const executeProjectCommandSchema = z.object({ + expectedRevision: z.number().int().nonnegative(), + description: z.string().trim().min(1).max(500).optional(), + command: projectCommandEnvelopeSchema, +}); + +export const executeProjectHistoryCommandSchema = z.object({ + expectedRevision: z.number().int().nonnegative(), +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts new file mode 100644 index 0000000..d337237 --- /dev/null +++ b/tests/project-command.service.test.ts @@ -0,0 +1,262 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; +import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; +import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; +import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; +import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values({ id: "project-1", name: "Test project" }) + .run(); + const board = new DistributionBoardRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .get(); + assert.ok(section); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + displayName: "Bestand", + sortOrder: 10, + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }) + .run(); + return context; +} + +function createService(context: DatabaseContext) { + return new ProjectCommandService( + new CircuitProjectCommandRepository(context.db), + new CircuitDeviceRowProjectCommandRepository(context.db), + new ProjectHistoryRepository(context.db) + ); +} + +function getCircuitName(context: DatabaseContext) { + return context.db + .select({ displayName: circuits.displayName }) + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get()?.displayName; +} + +function getRowQuantity(context: DatabaseContext) { + return context.db + .select({ quantity: circuitDeviceRows.quantity }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get()?.quantity; +} + +describe("project command service", () => { + it("dispatches supported commands and persists undo/redo across service instances", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const circuitResult = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Neu", + }), + }); + assert.equal(circuitResult.revision.revisionNumber, 1); + assert.equal(circuitResult.history.undoDepth, 1); + assert.equal(getCircuitName(context), "Neu"); + + const rowResult = service.executeUser({ + projectId: "project-1", + expectedRevision: 1, + command: createCircuitDeviceRowUpdateProjectCommand("row-1", { + quantity: 2, + }), + }); + assert.equal(rowResult.revision.revisionNumber, 2); + assert.equal(rowResult.history.undoDepth, 2); + assert.equal(getRowQuantity(context), 2); + + const reloadedService = createService(context); + const firstUndo = reloadedService.undo({ + projectId: "project-1", + expectedRevision: 2, + }); + assert.equal(firstUndo.revision.revisionNumber, 3); + assert.equal(firstUndo.history.undoDepth, 1); + assert.equal(firstUndo.history.redoDepth, 1); + assert.equal(getRowQuantity(context), 1); + + const secondUndo = reloadedService.undo({ + projectId: "project-1", + expectedRevision: 3, + }); + assert.equal(secondUndo.revision.revisionNumber, 4); + assert.equal(secondUndo.history.undoDepth, 0); + assert.equal(secondUndo.history.redoDepth, 2); + assert.equal(getCircuitName(context), "Bestand"); + + const redo = reloadedService.redo({ + projectId: "project-1", + expectedRevision: 4, + }); + assert.equal(redo.revision.revisionNumber, 5); + assert.equal(redo.history.undoDepth, 1); + assert.equal(redo.history.redoDepth, 1); + assert.equal(getCircuitName(context), "Neu"); + assert.deepEqual( + context.db + .select({ source: projectRevisions.source }) + .from(projectRevisions) + .all() + .map((row) => row.source), + ["user", "user", "undo", "undo", "redo"] + ); + } finally { + context.close(); + } + }); + + it("rejects stale undo and preserves the domain value and stack", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const changed = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitUpdateProjectCommand("circuit-1", { + displayName: "Neu", + }), + }); + + assert.throws( + () => + service.undo({ + projectId: "project-1", + expectedRevision: 0, + }), + (error) => + error instanceof ProjectRevisionConflictError && + error.actualRevision === 1 + ); + assert.equal(getCircuitName(context), "Neu"); + assert.deepEqual( + new ProjectHistoryRepository(context.db).getState("project-1"), + changed.history + ); + assert.equal(context.db.select().from(projectRevisions).all().length, 1); + assert.equal(context.db.select().from(projectChangeSets).all().length, 1); + } finally { + context.close(); + } + }); + + it("rejects unavailable history directions without writing a revision", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + assert.throws( + () => + service.undo({ + projectId: "project-1", + expectedRevision: 0, + }), + (error) => + error instanceof ProjectHistoryOperationUnavailableError && + error.direction === "undo" + ); + assert.throws( + () => + service.redo({ + projectId: "project-1", + expectedRevision: 0, + }), + (error) => + error instanceof ProjectHistoryOperationUnavailableError && + error.direction === "redo" + ); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); + + it("rejects unsupported and malformed commands before domain writes", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + assert.throws( + () => + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: { + schemaVersion: 1, + type: "unknown.command", + payload: {}, + }, + }), + /Unsupported project command type/ + ); + assert.throws( + () => + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: { + schemaVersion: 1, + type: "circuit.update", + payload: { circuitId: "circuit-1", changes: [] }, + }, + }), + /at least one change/ + ); + assert.equal(getCircuitName(context), "Bestand"); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); +});