From 1cf26a932e81b425371ce732822f9e4ed8ba5fd8 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 25 Jul 2026 23:41:27 +0200 Subject: [PATCH] Persist distribution board setup --- AGENTS.md | 4 + docs/circuit-list-editor-api.md | 9 +- docs/current-architecture.md | 7 + ...history-and-external-model-architecture.md | 4 + docs/spec/07-implementation-phases-todo.md | 3 + package.json | 4 +- src/app/projects/[projectId]/page.tsx | 14 +- .../circuit-section.repository.ts | 8 +- ...rd-structure-project-command.repository.ts | 238 ++++++++++++ ...n-board-structure-project-command.model.ts | 243 ++++++++++++ ...n-board-structure-project-command.store.ts | 26 ++ .../services/project-command.service.ts | 22 ++ src/frontend/types.ts | 5 + src/frontend/utils/api.ts | 18 +- src/frontend/utils/project-version-history.ts | 2 + .../composition/project-command-stores.ts | 4 + .../distribution-board.controller.ts | 24 +- .../validation/project-structure.schemas.ts | 9 +- ...ructure-project-command.repository.test.ts | 346 ++++++++++++++++++ tests/project-command.service.test.ts | 57 +++ ...t-state-restore-command.repository.test.ts | 2 + 21 files changed, 1027 insertions(+), 22 deletions(-) create mode 100644 src/db/repositories/distribution-board-structure-project-command.repository.ts create mode 100644 src/domain/models/distribution-board-structure-project-command.model.ts create mode 100644 src/domain/ports/distribution-board-structure-project-command.store.ts create mode 100644 tests/distribution-board-structure-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index e05ddf6..ff0476c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,6 +268,10 @@ paths do the same; their undo action uses the project-wide history endpoint. Project voltage settings use the persistent `project.update-settings` command. Both voltage defaults change in one revision and Undo/Redo restores them together; the project PUT route requires `expectedRevision`. +Distribution-board setup uses `distribution-board.insert` with a complete +stable snapshot of the board, circuit list and four default sections. Its +inverse removes only the same unchanged and still-empty structure; the POST +route requires `expectedRevision` and returns the updated history state. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 1caf6e8..7f68099 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -181,7 +181,14 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. ### Distribution Board Setup - `POST /projects/:projectId/distribution-boards` - - atomically creates the distribution board, its circuit list and all default circuit sections + - body: `{ "name": "UV-02", "expectedRevision": 12 }` + - executes `distribution-board.insert` with stable ids for the distribution + board, its circuit list and all four default circuit sections + - response: + `{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }` + - persistent Undo removes only the unchanged and still-empty generated + structure; Redo restores the same ids + - stale revisions return `409 PROJECT_REVISION_CONFLICT` ### Tree Endpoint diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 5532123..c0c3f63 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -191,6 +191,13 @@ gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. +`distribution-board.insert` versioniert die Anlage einer Verteilung als einen +vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen. +Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. +`distribution-board.delete` ist die persistierte Inverse und entfernt nur den +vollständig unveränderten, weiterhin stromkreislosen Block. Controller und +Projektseite übergeben die erwartete Projektrevision; der frühere direkte +Controller-Schreibweg ist entfernt. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index d0624b2..d15cd03 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -266,6 +266,10 @@ Completed foundation: - `project.update-settings` persists both project voltage defaults, its exact inverse, revision and history transition atomically; the project page and existing PUT route require the current expected revision +- `distribution-board.insert` persists the complete generated board, circuit + list and default-section structure with stable ids; its delete inverse + refuses changed or populated structures and the project-page POST tracks the + returned revision Remaining constraints before completing project version history: diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 26397bf..1e204f7 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -448,6 +448,9 @@ Implemented foundation: - project voltage settings use `project.update-settings`; both values, inverse, revision and history transition commit atomically and the former direct settings write is removed +- distribution-board setup uses `distribution-board.insert` with a complete + stable board/list/default-section snapshot; Undo removes only an unchanged + empty setup and the former direct controller write is removed - 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 2240ada..43e001b 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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 6b8cab4..20d14a9 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -177,15 +177,23 @@ export default function ProjectDetailPage() { async function handleCreateBoard(event: FormEvent) { event.preventDefault(); - if (!projectId || !boardName.trim()) { + if (!projectId || !project || !boardName.trim()) { return; } setIsSaving(true); setError(null); try { - const created = await createDistributionBoard(projectId, boardName.trim()); - setBoards((current) => [...current, created]); + const result = await createDistributionBoard( + projectId, + boardName.trim(), + project.currentRevision + ); + setBoards((current) => [ + ...current, + result.distributionBoard, + ]); setCircuitLists(await listCircuitLists(projectId)); + applyProjectRevision(result.history.currentRevision); setBoardName(""); } catch (err) { setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden."); diff --git a/src/db/repositories/circuit-section.repository.ts b/src/db/repositories/circuit-section.repository.ts index 7c3f1f0..7590624 100644 --- a/src/db/repositories/circuit-section.repository.ts +++ b/src/db/repositories/circuit-section.repository.ts @@ -1,15 +1,11 @@ import crypto from "node:crypto"; import { and, asc, eq } from "drizzle-orm"; +import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js"; import { db } from "../client.js"; import { circuitSections } from "../schema/circuit-sections.js"; export function createDefaultCircuitSectionValues(circuitListId: string) { - return [ - { key: "lighting", displayName: "Lighting", prefix: "-1F", sortOrder: 10 }, - { key: "single_phase", displayName: "Single-phase circuits", prefix: "-2F", sortOrder: 20 }, - { key: "three_phase", displayName: "Three-phase circuits", prefix: "-3F", sortOrder: 30 }, - { key: "unassigned", displayName: "Unassigned", prefix: "-UF", sortOrder: 90 }, - ].map((entry) => ({ + return defaultCircuitSectionDefinitions.map((entry) => ({ id: crypto.randomUUID(), circuitListId, ...entry, diff --git a/src/db/repositories/distribution-board-structure-project-command.repository.ts b/src/db/repositories/distribution-board-structure-project-command.repository.ts new file mode 100644 index 0000000..017a82e --- /dev/null +++ b/src/db/repositories/distribution-board-structure-project-command.repository.ts @@ -0,0 +1,238 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { + assertDistributionBoardDeleteProjectCommand, + assertDistributionBoardInsertProjectCommand, + createDistributionBoardDeleteProjectCommand, + createDistributionBoardInsertProjectCommand, + distributionBoardDeleteCommandType, + distributionBoardInsertCommandType, + type DistributionBoardStructureProjectCommand, + type DistributionBoardStructureSnapshot, +} from "../../domain/models/distribution-board-structure-project-command.model.js"; +import type { + DistributionBoardStructureProjectCommandStore, + ExecuteDistributionBoardStructureCommandInput, +} from "../../domain/ports/distribution-board-structure-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitSections } from "../schema/circuit-sections.js"; +import { circuits } from "../schema/circuits.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { projects } from "../schema/projects.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +export class DistributionBoardStructureProjectCommandRepository + implements DistributionBoardStructureProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteDistributionBoardStructureCommandInput) { + return this.database.transaction((tx) => { + const inverse = this.applyCommand( + tx, + input.projectId, + input.command + ); + const revision = appendProjectRevision(tx, { + projectId: input.projectId, + expectedRevision: input.expectedRevision, + source: input.source, + description: input.description, + actorId: input.actorId, + forward: input.command, + inverse, + }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); + return { revision, inverse }; + }); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: DistributionBoardStructureProjectCommand + ): DistributionBoardStructureProjectCommand { + if (command.type === distributionBoardInsertCommandType) { + assertDistributionBoardInsertProjectCommand(command); + return this.insert( + database, + projectId, + command.payload.structure + ); + } + if (command.type === distributionBoardDeleteCommandType) { + assertDistributionBoardDeleteProjectCommand(command); + return this.delete( + database, + projectId, + command.payload.structure + ); + } + throw new Error("Unsupported distribution-board structure command."); + } + + private insert( + database: AppDatabase, + projectId: string, + structure: DistributionBoardStructureSnapshot + ) { + if ( + structure.distributionBoard.projectId !== projectId || + structure.circuitList.projectId !== projectId + ) { + throw new Error( + "Distribution-board structure does not belong to project." + ); + } + const project = database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + throw new Error("Project not found."); + } + const existingBoard = database + .select({ id: distributionBoards.id }) + .from(distributionBoards) + .where(eq(distributionBoards.id, structure.distributionBoard.id)) + .get(); + const existingList = database + .select({ id: circuitLists.id }) + .from(circuitLists) + .where(eq(circuitLists.id, structure.circuitList.id)) + .get(); + const existingSection = database + .select({ id: circuitSections.id }) + .from(circuitSections) + .where( + inArray( + circuitSections.id, + structure.sections.map((section) => section.id) + ) + ) + .limit(1) + .get(); + if (existingBoard || existingList || existingSection) { + throw new Error("Distribution-board structure id already exists."); + } + + database + .insert(distributionBoards) + .values(structure.distributionBoard) + .run(); + database.insert(circuitLists).values(structure.circuitList).run(); + database.insert(circuitSections).values(structure.sections).run(); + return createDistributionBoardDeleteProjectCommand(structure); + } + + private delete( + database: AppDatabase, + projectId: string, + structure: DistributionBoardStructureSnapshot + ) { + const board = database + .select() + .from(distributionBoards) + .where(eq(distributionBoards.id, structure.distributionBoard.id)) + .get(); + const list = database + .select() + .from(circuitLists) + .where(eq(circuitLists.id, structure.circuitList.id)) + .get(); + const sections = database + .select() + .from(circuitSections) + .where( + eq(circuitSections.circuitListId, structure.circuitList.id) + ) + .orderBy( + asc(circuitSections.sortOrder), + asc(circuitSections.id) + ) + .all(); + if ( + !board || + !list || + board.projectId !== projectId || + !structureMatches(structure, { board, list, sections }) + ) { + throw new Error( + "Distribution-board structure changed before deletion." + ); + } + const populatedCircuit = database + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.circuitListId, structure.circuitList.id)) + .limit(1) + .get(); + if (populatedCircuit) { + throw new Error( + "A populated distribution board cannot be removed by history." + ); + } + + const deleted = database + .delete(distributionBoards) + .where( + and( + eq( + distributionBoards.id, + structure.distributionBoard.id + ), + eq(distributionBoards.projectId, projectId) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error("Distribution board could not be deleted."); + } + return createDistributionBoardInsertProjectCommand(structure); + } +} + +function structureMatches( + expected: DistributionBoardStructureSnapshot, + actual: { + board: typeof distributionBoards.$inferSelect; + list: typeof circuitLists.$inferSelect; + sections: Array; + } +) { + if ( + !sameRecord(expected.distributionBoard, actual.board) || + !sameRecord(expected.circuitList, actual.list) || + expected.sections.length !== actual.sections.length + ) { + return false; + } + const expectedSections = [...expected.sections].sort( + (left, right) => + left.sortOrder - right.sortOrder || + left.id.localeCompare(right.id) + ); + return expectedSections.every((section, index) => + sameRecord(section, actual.sections[index]) + ); +} + +function sameRecord( + expected: Record, + actual: Record +) { + const expectedEntries = Object.entries(expected); + return ( + expectedEntries.length === Object.keys(actual).length && + expectedEntries.every( + ([key, value]) => actual[key] === value + ) + ); +} diff --git a/src/domain/models/distribution-board-structure-project-command.model.ts b/src/domain/models/distribution-board-structure-project-command.model.ts new file mode 100644 index 0000000..715003c --- /dev/null +++ b/src/domain/models/distribution-board-structure-project-command.model.ts @@ -0,0 +1,243 @@ +import crypto from "node:crypto"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const distributionBoardInsertCommandType = + "distribution-board.insert" as const; +export const distributionBoardDeleteCommandType = + "distribution-board.delete" as const; +export const distributionBoardStructureCommandSchemaVersion = 1 as const; + +export const defaultCircuitSectionDefinitions = [ + { + key: "lighting", + displayName: "Lighting", + prefix: "-1F", + sortOrder: 10, + }, + { + key: "single_phase", + displayName: "Single-phase circuits", + prefix: "-2F", + sortOrder: 20, + }, + { + key: "three_phase", + displayName: "Three-phase circuits", + prefix: "-3F", + sortOrder: 30, + }, + { + key: "unassigned", + displayName: "Unassigned", + prefix: "-UF", + sortOrder: 90, + }, +] as const; + +export interface DistributionBoardStructureSnapshot { + distributionBoard: { + id: string; + projectId: string; + name: string; + }; + circuitList: { + id: string; + projectId: string; + distributionBoardId: string; + name: string; + }; + sections: Array<{ + id: string; + circuitListId: string; + key: string; + displayName: string; + prefix: string; + sortOrder: number; + }>; +} + +interface DistributionBoardStructureCommandPayload { + structure: DistributionBoardStructureSnapshot; +} + +export interface DistributionBoardInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof distributionBoardStructureCommandSchemaVersion; + type: typeof distributionBoardInsertCommandType; +} + +export interface DistributionBoardDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof distributionBoardStructureCommandSchemaVersion; + type: typeof distributionBoardDeleteCommandType; +} + +export type DistributionBoardStructureProjectCommand = + | DistributionBoardInsertProjectCommand + | DistributionBoardDeleteProjectCommand; + +export function createDistributionBoardStructureSnapshot( + projectId: string, + name: string +): DistributionBoardStructureSnapshot { + const normalizedName = name.trim(); + assertNonEmptyString(projectId, "projectId"); + assertNonEmptyString(normalizedName, "name"); + const structureId = crypto.randomUUID(); + const structure: DistributionBoardStructureSnapshot = { + distributionBoard: { + id: structureId, + projectId, + name: normalizedName, + }, + circuitList: { + id: structureId, + projectId, + distributionBoardId: structureId, + name: `${normalizedName} Stromkreisliste`, + }, + sections: defaultCircuitSectionDefinitions.map((section) => ({ + id: crypto.randomUUID(), + circuitListId: structureId, + ...section, + })), + }; + assertDistributionBoardStructureSnapshot(structure); + return structure; +} + +export function createDistributionBoardInsertProjectCommand( + structure: DistributionBoardStructureSnapshot +): DistributionBoardInsertProjectCommand { + const command: DistributionBoardInsertProjectCommand = { + schemaVersion: distributionBoardStructureCommandSchemaVersion, + type: distributionBoardInsertCommandType, + payload: { structure }, + }; + assertDistributionBoardInsertProjectCommand(command); + return command; +} + +export function createDistributionBoardDeleteProjectCommand( + structure: DistributionBoardStructureSnapshot +): DistributionBoardDeleteProjectCommand { + const command: DistributionBoardDeleteProjectCommand = { + schemaVersion: distributionBoardStructureCommandSchemaVersion, + type: distributionBoardDeleteCommandType, + payload: { structure }, + }; + assertDistributionBoardDeleteProjectCommand(command); + return command; +} + +export function assertDistributionBoardInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is DistributionBoardInsertProjectCommand { + assertDistributionBoardStructureProjectCommand( + command, + distributionBoardInsertCommandType + ); +} + +export function assertDistributionBoardDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is DistributionBoardDeleteProjectCommand { + assertDistributionBoardStructureProjectCommand( + command, + distributionBoardDeleteCommandType + ); +} + +export function assertDistributionBoardStructureSnapshot( + structure: unknown +): asserts structure is DistributionBoardStructureSnapshot { + if (!isPlainObject(structure) || Object.keys(structure).length !== 3) { + throw new Error("Distribution-board structure is invalid."); + } + const { distributionBoard, circuitList, sections } = structure; + if ( + !isPlainObject(distributionBoard) || + Object.keys(distributionBoard).length !== 3 || + !isPlainObject(circuitList) || + Object.keys(circuitList).length !== 4 || + !Array.isArray(sections) || + sections.length !== defaultCircuitSectionDefinitions.length + ) { + throw new Error("Distribution-board structure is incomplete."); + } + for (const [field, value] of Object.entries(distributionBoard)) { + assertNonEmptyString(value, `distributionBoard.${field}`); + } + for (const [field, value] of Object.entries(circuitList)) { + assertNonEmptyString(value, `circuitList.${field}`); + } + if ( + circuitList.projectId !== distributionBoard.projectId || + circuitList.distributionBoardId !== distributionBoard.id || + circuitList.name !== + `${distributionBoard.name} Stromkreisliste` + ) { + throw new Error( + "Distribution board and circuit list are inconsistent." + ); + } + + const sectionIds = new Set(); + for (let index = 0; index < sections.length; index += 1) { + const section = sections[index]; + const expected = defaultCircuitSectionDefinitions[index]; + if ( + !isPlainObject(section) || + Object.keys(section).length !== 6 + ) { + throw new Error("Default circuit section is invalid."); + } + assertNonEmptyString(section.id, "section.id"); + if (sectionIds.has(section.id)) { + throw new Error("Default circuit-section ids must be unique."); + } + sectionIds.add(section.id); + if ( + section.circuitListId !== circuitList.id || + section.key !== expected.key || + section.displayName !== expected.displayName || + section.prefix !== expected.prefix || + section.sortOrder !== expected.sortOrder + ) { + throw new Error( + "Distribution-board structure has invalid default sections." + ); + } + } +} + +function assertDistributionBoardStructureProjectCommand( + command: SerializedProjectCommand, + expectedType: + | typeof distributionBoardInsertCommandType + | typeof distributionBoardDeleteCommandType +) { + if ( + command.schemaVersion !== + distributionBoardStructureCommandSchemaVersion || + command.type !== expectedType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported distribution-board structure command."); + } + assertDistributionBoardStructureSnapshot(command.payload.structure); +} + +function assertNonEmptyString( + value: unknown, + field: string +): asserts value is string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/distribution-board-structure-project-command.store.ts b/src/domain/ports/distribution-board-structure-project-command.store.ts new file mode 100644 index 0000000..8c81dff --- /dev/null +++ b/src/domain/ports/distribution-board-structure-project-command.store.ts @@ -0,0 +1,26 @@ +import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteDistributionBoardStructureCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: DistributionBoardStructureProjectCommand; +} + +export interface ExecutedDistributionBoardStructureCommand { + revision: AppendedProjectRevision; + inverse: DistributionBoardStructureProjectCommand; +} + +export interface DistributionBoardStructureProjectCommandStore { + execute( + input: ExecuteDistributionBoardStructureCommandInput + ): ExecutedDistributionBoardStructureCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index ddfa32d..c7575fc 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -37,6 +37,12 @@ import { circuitDeleteCommandType, circuitInsertCommandType, } from "../models/circuit-structure-project-command.model.js"; +import { + assertDistributionBoardDeleteProjectCommand, + assertDistributionBoardInsertProjectCommand, + distributionBoardDeleteCommandType, + distributionBoardInsertCommandType, +} from "../models/distribution-board-structure-project-command.model.js"; import { assertSerializedProjectCommand, type SerializedProjectCommand, @@ -70,6 +76,7 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js"; import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js"; import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js"; +import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js"; import type { ExecuteProjectHistoryCommandInput, ExecutedProjectCommand, @@ -104,6 +111,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore, private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore, + private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore, @@ -251,6 +259,20 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case distributionBoardInsertCommandType: { + assertDistributionBoardInsertProjectCommand(input.command); + return this.distributionBoardStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case distributionBoardDeleteCommandType: { + assertDistributionBoardDeleteProjectCommand(input.command); + return this.distributionBoardStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } case circuitSectionReorderCommandType: { assertCircuitSectionReorderProjectCommand(input.command); return this.circuitSectionReorderStore.execute({ diff --git a/src/frontend/types.ts b/src/frontend/types.ts index bb75085..66cd884 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -87,6 +87,11 @@ export interface DistributionBoardDto { name: string; } +export interface DistributionBoardCommandResultDto + extends ProjectCommandResultDto { + distributionBoard: DistributionBoardDto; +} + export interface CircuitListDto { id: string; projectId: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 657a027..1850a3e 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -5,6 +5,7 @@ import type { CreateRoomInput, CreateGlobalDeviceInput, DistributionBoardDto, + DistributionBoardCommandResultDto, FloorDto, GlobalDeviceDto, ProjectCommandDto, @@ -222,11 +223,18 @@ export function listDistributionBoards(projectId: string) { return request(`/api/projects/${projectId}/distribution-boards`); } -export function createDistributionBoard(projectId: string, name: string) { - return request(`/api/projects/${projectId}/distribution-boards`, { - method: "POST", - body: JSON.stringify({ name }), - }); +export function createDistributionBoard( + projectId: string, + name: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/distribution-boards`, + { + method: "POST", + body: JSON.stringify({ name, expectedRevision }), + } + ); } export function listCircuitLists(projectId: string) { diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 2493c7a..0b7b69a 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -30,6 +30,8 @@ const commandTypeLabels: Record = { "project-device.delete": "Projektgerät gelöscht", "project-device.sync-rows": "Projektgerät-Verknüpfungen geändert", "project.update-settings": "Projekteinstellungen bearbeitet", + "distribution-board.insert": "Verteilung angelegt", + "distribution-board.delete": "Verteilung entfernt", "project.restore-state": "Projektstand wiederhergestellt", }; diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 3773ee6..2371bce 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -6,6 +6,7 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; +import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js"; @@ -23,6 +24,8 @@ export const circuitDeviceRowMoveProjectCommandStore = new CircuitDeviceRowMoveProjectCommandRepository(db); export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); +export const distributionBoardStructureProjectCommandStore = + new DistributionBoardStructureProjectCommandRepository(db); export const circuitSectionReorderProjectCommandStore = new CircuitSectionReorderProjectCommandRepository(db); export const circuitSectionRenumberProjectCommandStore = @@ -44,6 +47,7 @@ export const projectCommandService = new ProjectCommandService( circuitDeviceRowStructureProjectCommandStore, circuitDeviceRowMoveProjectCommandStore, circuitStructureProjectCommandStore, + distributionBoardStructureProjectCommandStore, circuitSectionReorderProjectCommandStore, circuitSectionRenumberProjectCommandStore, projectDeviceStructureProjectCommandStore, diff --git a/src/server/controllers/distribution-board.controller.ts b/src/server/controllers/distribution-board.controller.ts index dcd76c8..f59ba8b 100644 --- a/src/server/controllers/distribution-board.controller.ts +++ b/src/server/controllers/distribution-board.controller.ts @@ -1,7 +1,13 @@ import type { Request, Response } from "express"; import { db } from "../../db/client.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; +import { + createDistributionBoardInsertProjectCommand, + createDistributionBoardStructureSnapshot, +} from "../../domain/models/distribution-board-structure-project-command.model.js"; import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; const distributionBoardRepository = new DistributionBoardRepository(db); @@ -26,9 +32,23 @@ export async function createDistributionBoard(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - const board = distributionBoardRepository.createWithCircuitListAndDefaultSections( + const structure = createDistributionBoardStructureSnapshot( projectId, parsed.data.name ); - return res.status(201).json(board); + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Verteilung anlegen", + command: + createDistributionBoardInsertProjectCommand(structure), + }); + return res.status(201).json({ + ...result, + distributionBoard: structure.distributionBoard, + }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } } diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index 323e42a..ceb6a7f 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -15,9 +15,12 @@ export const updateProjectSettingsSchema = z }) .strict(); -export const createDistributionBoardSchema = z.object({ - name: z.string().min(1), -}); +export const createDistributionBoardSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1), + }) + .strict(); export const createFloorSchema = z.object({ name: z.string().min(1), diff --git a/tests/distribution-board-structure-project-command.repository.test.ts b/tests/distribution-board-structure-project-command.repository.test.ts new file mode 100644 index 0000000..5de7591 --- /dev/null +++ b/tests/distribution-board-structure-project-command.repository.test.ts @@ -0,0 +1,346 @@ +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 { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { + createDistributionBoardInsertProjectCommand, + createDistributionBoardStructureSnapshot, +} from "../src/domain/models/distribution-board-structure-project-command.model.js"; +import { createDistributionBoard } from "../src/frontend/utils/api.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" }, + { id: "project-2", name: "Foreign project" }, + ]) + .run(); + return context; +} + +function getStructureCounts(context: DatabaseContext) { + return { + boards: context.db.select().from(distributionBoards).all().length, + lists: context.db.select().from(circuitLists).all().length, + sections: context.db.select().from(circuitSections).all().length, + revisions: context.db.select().from(projectRevisions).all().length, + }; +} + +describe("distribution-board structure project command", () => { + it("builds the fixed setup and sends the expected frontend revision", async () => { + const structure = createDistributionBoardStructureSnapshot( + "project-1", + " UV-02 " + ); + assert.equal(structure.distributionBoard.name, "UV-02"); + assert.equal( + structure.circuitList.name, + "UV-02 Stromkreisliste" + ); + assert.deepEqual( + structure.sections.map((section) => [ + section.key, + section.prefix, + ]), + [ + ["lighting", "-1F"], + ["single_phase", "-2F"], + ["three_phase", "-3F"], + ["unassigned", "-UF"], + ] + ); + assert.throws( + () => + createDistributionBoardInsertProjectCommand({ + ...structure, + sections: structure.sections.slice(0, 3), + }), + /incomplete/ + ); + + const requests: Array<{ url: string; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body)), + }); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await createDistributionBoard("project-1", "UV-02", 7); + } finally { + globalThis.fetch = originalFetch; + } + assert.deepEqual(requests, [ + { + url: "/api/projects/project-1/distribution-boards", + body: { name: "UV-02", expectedRevision: 7 }, + }, + ]); + }); + + it("creates stable structure and supports persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const repository = + new DistributionBoardStructureProjectCommandRepository( + context.db + ); + const history = new ProjectHistoryRepository(context.db); + const structure = createDistributionBoardStructureSnapshot( + "project-1", + "UV-02" + ); + const command = + createDistributionBoardInsertProjectCommand(structure); + const forward = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual(getStructureCounts(context), { + boards: 1, + lists: 1, + sections: 4, + revisions: 1, + }); + + const undoStep = history.getNextCommand("project-1", "undo"); + assert.ok(undoStep); + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: undoStep.changeSetId, + command: forward.inverse, + }); + assert.deepEqual(getStructureCounts(context), { + boards: 0, + lists: 0, + sections: 0, + revisions: 2, + }); + + const redoStep = history.getNextCommand("project-1", "redo"); + assert.ok(redoStep); + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: redoStep.changeSetId, + command, + }); + assert.deepEqual(getStructureCounts(context), { + boards: 1, + lists: 1, + sections: 4, + revisions: 3, + }); + assert.ok( + context.db + .select() + .from(distributionBoards) + .where( + eq( + distributionBoards.id, + structure.distributionBoard.id + ) + ) + .get() + ); + } finally { + context.close(); + } + }); + + it("rejects foreign, stale and changed structures without partial writes", () => { + const context = createTestDatabase(); + try { + const repository = + new DistributionBoardStructureProjectCommandRepository( + context.db + ); + const foreign = createDistributionBoardInsertProjectCommand( + createDistributionBoardStructureSnapshot( + "project-2", + "UV fremd" + ) + ); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: foreign, + }), + /does not belong/ + ); + const structure = createDistributionBoardStructureSnapshot( + "project-1", + "UV-02" + ); + const command = + createDistributionBoardInsertProjectCommand(structure); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command, + }), + ProjectRevisionConflictError + ); + assert.deepEqual(getStructureCounts(context), { + boards: 0, + lists: 0, + sections: 0, + revisions: 0, + }); + + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + context.db + .update(distributionBoards) + .set({ name: "Direkt geändert" }) + .where(eq(distributionBoards.id, structure.distributionBoard.id)) + .run(); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: + new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: inserted.inverse, + }), + /changed before deletion/ + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 1 + ); + } finally { + context.close(); + } + }); + + it("refuses populated deletion and rolls back late history failures", () => { + const populatedContext = createTestDatabase(); + try { + const repository = + new DistributionBoardStructureProjectCommandRepository( + populatedContext.db + ); + const structure = createDistributionBoardStructureSnapshot( + "project-1", + "UV-02" + ); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardInsertProjectCommand(structure), + }); + populatedContext.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: structure.circuitList.id, + sectionId: structure.sections[0].id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + }) + .run(); + const undoStep = new ProjectHistoryRepository( + populatedContext.db + ).getNextCommand("project-1", "undo"); + assert.ok(undoStep); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: undoStep.changeSetId, + command: inserted.inverse, + }), + /populated/ + ); + } finally { + populatedContext.close(); + } + + const rollbackContext = createTestDatabase(); + try { + rollbackContext.sqlite.exec(` + CREATE TRIGGER fail_distribution_board_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced distribution-board history failure'); + END; + `); + const repository = + new DistributionBoardStructureProjectCommandRepository( + rollbackContext.db + ); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createDistributionBoardInsertProjectCommand( + createDistributionBoardStructureSnapshot( + "project-1", + "UV rollback" + ) + ), + }), + /forced distribution-board history failure/ + ); + assert.deepEqual(getStructureCounts(rollbackContext), { + boards: 0, + lists: 0, + sections: 0, + revisions: 0, + }); + } finally { + rollbackContext.close(); + } + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 7e68022..88a0606 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -15,6 +15,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../src/db/reposit import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; @@ -41,6 +42,10 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/ import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js"; import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; +import { + createDistributionBoardInsertProjectCommand, + createDistributionBoardStructureSnapshot, +} from "../src/domain/models/distribution-board-structure-project-command.model.js"; import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js"; import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js"; @@ -99,6 +104,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowStructureProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), + new DistributionBoardStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db), @@ -979,6 +985,57 @@ describe("project command service", () => { } }); + it("dispatches distribution-board setup and its persisted inverse", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const structure = createDistributionBoardStructureSnapshot( + "project-1", + "UV-02" + ); + const created = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: + createDistributionBoardInsertProjectCommand(structure), + }); + assert.equal(created.history.currentRevision, 1); + assert.ok( + context.db + .select() + .from(circuitSections) + .where( + eq( + circuitSections.circuitListId, + structure.circuitList.id + ) + ) + .get() + ); + + const undone = createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.history.currentRevision, 2); + assert.equal( + context.db + .select() + .from(circuitSections) + .where( + eq( + circuitSections.circuitListId, + structure.circuitList.id + ) + ) + .all().length, + 0 + ); + } finally { + context.close(); + } + }); + it("rejects unsupported and malformed commands before domain writes", () => { const context = createTestDatabase(); try { diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index 7bbda57..c7048b5 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -15,6 +15,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; @@ -132,6 +133,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowStructureProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), + new DistributionBoardStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db),