diff --git a/AGENTS.md b/AGENTS.md index ff0476c..a0d5bec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,6 +272,11 @@ 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. +Floor and room setup use `project-floor.insert` and `project-room.insert` with +complete stable snapshots. Their inverses remove only unchanged records and +reject floors with assigned rooms or rooms referenced by device rows or +retained upgrade data. Both POST routes require `expectedRevision` and return +the updated history state. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 7f68099..ea952cb 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -190,6 +190,23 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. structure; Redo restores the same ids - stale revisions return `409 PROJECT_REVISION_CONFLICT` +### Project Floors and Rooms + +- `POST /projects/:projectId/floors` + - body: `{ "name": "EG", "expectedRevision": 13 }` + - executes `project-floor.insert` with a stable floor id + - response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }` +- `POST /projects/:projectId/rooms` + - body: + `{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }` + - executes `project-room.insert` with a stable room id; `floorId` is optional + and must belong to the project when present + - response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }` +- Persistent Undo removes only unchanged, unreferenced records. A floor with + assigned rooms and a room referenced by device rows or retained upgrade data + are rejected instead of silently clearing foreign keys. +- Stale revisions return `409 PROJECT_REVISION_CONFLICT`. + ### Tree Endpoint - `GET /projects/:projectId/circuit-lists/:circuitListId/tree` diff --git a/docs/current-architecture.md b/docs/current-architecture.md index c0c3f63..8efd85f 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -70,7 +70,8 @@ Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten -Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen +Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- und +Projekteinstellungsänderungen verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando, Revision und Historienstapel gemeinsam committen beziehungsweise zurückrollen. @@ -198,6 +199,14 @@ Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. vollständig unveränderten, weiterhin stromkreislosen Block. Controller und Projektseite übergeben die erwartete Projektrevision; der frühere direkte Controller-Schreibweg ist entfernt. +`project-floor.insert` und `project-room.insert` versionieren die Anlage von +Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden +jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur +entfernt, solange ihm kein Raum zugeordnet wurde. Ein Raum wird nur entfernt, +solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf +ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den +aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg +mehr. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index d15cd03..8cfc985 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -270,11 +270,18 @@ Completed foundation: 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 +- `project-floor.insert` and `project-room.insert` persist complete location + records with stable ids; their inverses reject changed or referenced records + so Undo never clears room assignments or device-row links implicitly +- the floor and room POST routes require the current expected revision and the + project page tracks the returned history state Remaining constraints before completing project version history: -- extend project-scoped commands only when further project mutations are - deliberately added to history; the generic versioned envelope is complete +- all current normal project-scoped runtime writes are behind the revision + boundary; extend it whenever further project mutations are introduced +- project creation remains initialization at revision zero, while global device + templates remain outside any single project history - 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 1e204f7..c6571b7 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -451,6 +451,9 @@ Implemented foundation: - 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 +- floor and room setup use `project-floor.insert` and `project-room.insert` + with stable ids; their inverses refuse assigned or referenced records and the + former direct repository create methods are 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 43e001b..568f6a8 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/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", + "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-location-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-location-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 20d14a9..e9d7660 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -204,14 +204,19 @@ export default function ProjectDetailPage() { async function handleCreateFloor(event: FormEvent) { event.preventDefault(); - if (!projectId || !floorName.trim()) { + if (!projectId || !project || !floorName.trim()) { return; } setIsSaving(true); setError(null); try { - const created = await createFloor(projectId, { name: floorName.trim() }); - setFloors((current) => [...current, created]); + const result = await createFloor( + projectId, + { name: floorName.trim() }, + project.currentRevision + ); + setFloors((current) => [...current, result.floor]); + applyProjectRevision(result.history.currentRevision); setFloorName(""); } catch (err) { setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); @@ -222,18 +227,28 @@ export default function ProjectDetailPage() { async function handleCreateRoom(event: FormEvent) { event.preventDefault(); - if (!projectId || !roomNumber.trim() || !roomName.trim()) { + if ( + !projectId || + !project || + !roomNumber.trim() || + !roomName.trim() + ) { return; } setIsSaving(true); setError(null); try { - const created = await createRoom(projectId, { - floorId: roomFloorId || undefined, - roomNumber: roomNumber.trim(), - roomName: roomName.trim(), - }); - setRooms((current) => [...current, created]); + const result = await createRoom( + projectId, + { + floorId: roomFloorId || undefined, + roomNumber: roomNumber.trim(), + roomName: roomName.trim(), + }, + project.currentRevision + ); + setRooms((current) => [...current, result.room]); + applyProjectRevision(result.history.currentRevision); setRoomNumber(""); setRoomName(""); setRoomFloorId(""); diff --git a/src/db/repositories/floor.repository.ts b/src/db/repositories/floor.repository.ts index 23751a2..3bd0b98 100644 --- a/src/db/repositories/floor.repository.ts +++ b/src/db/repositories/floor.repository.ts @@ -1,5 +1,4 @@ -import crypto from "node:crypto"; -import { and, asc, eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { db } from "../client.js"; import { floors } from "../schema/floors.js"; @@ -11,26 +10,4 @@ export class FloorRepository { .where(eq(floors.projectId, projectId)) .orderBy(asc(floors.sortOrder), asc(floors.name)); } - - async create(projectId: string, name: string) { - const id = crypto.randomUUID(); - const existing = await this.listByProject(projectId); - const floor = { - id, - projectId, - name, - sortOrder: existing.length, - }; - await db.insert(floors).values(floor); - return floor; - } - - async existsInProject(projectId: string, floorId: string) { - const [row] = await db - .select({ id: floors.id }) - .from(floors) - .where(and(eq(floors.projectId, projectId), eq(floors.id, floorId))) - .limit(1); - return Boolean(row); - } } diff --git a/src/db/repositories/project-location-structure-project-command.repository.ts b/src/db/repositories/project-location-structure-project-command.repository.ts new file mode 100644 index 0000000..aec04b3 --- /dev/null +++ b/src/db/repositories/project-location-structure-project-command.repository.ts @@ -0,0 +1,276 @@ +import { and, eq } from "drizzle-orm"; +import { + assertProjectFloorDeleteProjectCommand, + assertProjectFloorInsertProjectCommand, + assertProjectRoomDeleteProjectCommand, + assertProjectRoomInsertProjectCommand, + createProjectFloorDeleteProjectCommand, + createProjectFloorInsertProjectCommand, + createProjectRoomDeleteProjectCommand, + createProjectRoomInsertProjectCommand, + projectFloorDeleteCommandType, + projectFloorInsertCommandType, + projectRoomDeleteCommandType, + projectRoomInsertCommandType, + type ProjectFloorSnapshot, + type ProjectLocationStructureProjectCommand, + type ProjectRoomSnapshot, +} from "../../domain/models/project-location-structure-project-command.model.js"; +import type { + ExecuteProjectLocationStructureCommandInput, + ProjectLocationStructureProjectCommandStore, +} from "../../domain/ports/project-location-structure-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { consumers } from "../schema/consumers.js"; +import { floors } from "../schema/floors.js"; +import { projects } from "../schema/projects.js"; +import { rooms } from "../schema/rooms.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +export class ProjectLocationStructureProjectCommandRepository + implements ProjectLocationStructureProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteProjectLocationStructureCommandInput) { + 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: ProjectLocationStructureProjectCommand + ): ProjectLocationStructureProjectCommand { + switch (command.type) { + case projectFloorInsertCommandType: + assertProjectFloorInsertProjectCommand(command); + return this.insertFloor( + database, + projectId, + command.payload.floor + ); + case projectFloorDeleteCommandType: + assertProjectFloorDeleteProjectCommand(command); + return this.deleteFloor( + database, + projectId, + command.payload.floor + ); + case projectRoomInsertCommandType: + assertProjectRoomInsertProjectCommand(command); + return this.insertRoom( + database, + projectId, + command.payload.room + ); + case projectRoomDeleteCommandType: + assertProjectRoomDeleteProjectCommand(command); + return this.deleteRoom( + database, + projectId, + command.payload.room + ); + } + } + + private insertFloor( + database: AppDatabase, + projectId: string, + floor: ProjectFloorSnapshot + ) { + this.assertProjectExists(database, projectId); + if (floor.projectId !== projectId) { + throw new Error("Project-floor snapshot does not belong to project."); + } + const existing = database + .select({ id: floors.id }) + .from(floors) + .where(eq(floors.id, floor.id)) + .get(); + if (existing) { + throw new Error("Project-floor id already exists."); + } + database.insert(floors).values(floor).run(); + return createProjectFloorDeleteProjectCommand(floor); + } + + private deleteFloor( + database: AppDatabase, + projectId: string, + expected: ProjectFloorSnapshot + ) { + const persisted = database + .select() + .from(floors) + .where( + and( + eq(floors.id, expected.id), + eq(floors.projectId, projectId) + ) + ) + .get(); + if (!persisted || !sameRecord(expected, persisted)) { + throw new Error("Project floor changed before deletion."); + } + const referencedRoom = database + .select({ id: rooms.id }) + .from(rooms) + .where(eq(rooms.floorId, expected.id)) + .limit(1) + .get(); + if (referencedRoom) { + throw new Error( + "A project floor with assigned rooms cannot be removed by history." + ); + } + const deleted = database + .delete(floors) + .where( + and( + eq(floors.id, expected.id), + eq(floors.projectId, projectId) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error("Project floor could not be deleted."); + } + return createProjectFloorInsertProjectCommand(expected); + } + + private insertRoom( + database: AppDatabase, + projectId: string, + room: ProjectRoomSnapshot + ) { + this.assertProjectExists(database, projectId); + if (room.projectId !== projectId) { + throw new Error("Project-room snapshot does not belong to project."); + } + if (room.floorId) { + const floor = database + .select({ id: floors.id }) + .from(floors) + .where( + and( + eq(floors.id, room.floorId), + eq(floors.projectId, projectId) + ) + ) + .get(); + if (!floor) { + throw new Error("Project room references a foreign floor."); + } + } + const existing = database + .select({ id: rooms.id }) + .from(rooms) + .where(eq(rooms.id, room.id)) + .get(); + if (existing) { + throw new Error("Project-room id already exists."); + } + database.insert(rooms).values(room).run(); + return createProjectRoomDeleteProjectCommand(room); + } + + private deleteRoom( + database: AppDatabase, + projectId: string, + expected: ProjectRoomSnapshot + ) { + const persisted = database + .select() + .from(rooms) + .where( + and( + eq(rooms.id, expected.id), + eq(rooms.projectId, projectId) + ) + ) + .get(); + if (!persisted || !sameRecord(expected, persisted)) { + throw new Error("Project room changed before deletion."); + } + const referencedDeviceRow = database + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.roomId, expected.id)) + .limit(1) + .get(); + const referencedLegacyConsumer = database + .select({ id: consumers.id }) + .from(consumers) + .where(eq(consumers.roomId, expected.id)) + .limit(1) + .get(); + if (referencedDeviceRow || referencedLegacyConsumer) { + throw new Error( + "A referenced project room cannot be removed by history." + ); + } + const deleted = database + .delete(rooms) + .where( + and( + eq(rooms.id, expected.id), + eq(rooms.projectId, projectId) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error("Project room could not be deleted."); + } + return createProjectRoomInsertProjectCommand(expected); + } + + private assertProjectExists( + database: AppDatabase, + projectId: string + ) { + const project = database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + throw new Error("Project does not exist."); + } + } +} + +function sameRecord( + expected: object, + actual: object +) { + const expectedEntries = Object.entries(expected); + const actualRecord = actual as Record; + return ( + expectedEntries.length === Object.keys(actual).length && + expectedEntries.every(([key, value]) => actualRecord[key] === value) + ); +} diff --git a/src/db/repositories/room.repository.ts b/src/db/repositories/room.repository.ts index ed0f104..ce8c3be 100644 --- a/src/db/repositories/room.repository.ts +++ b/src/db/repositories/room.repository.ts @@ -1,8 +1,6 @@ -import crypto from "node:crypto"; -import { and, asc, eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { db } from "../client.js"; import { rooms } from "../schema/rooms.js"; -import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js"; export class RoomRepository { async listByProject(projectId: string) { @@ -12,31 +10,4 @@ export class RoomRepository { .where(eq(rooms.projectId, projectId)) .orderBy(asc(rooms.roomNumber), asc(rooms.roomName)); } - - async create(projectId: string, input: CreateRoomInput) { - const id = crypto.randomUUID(); - const room = { - id, - projectId, - floorId: input.floorId ?? null, - roomNumber: input.roomNumber, - roomName: input.roomName, - }; - await db.insert(rooms).values(room); - return room; - } - - async existsInProject(projectId: string, roomId: string) { - const [row] = await db - .select({ id: rooms.id }) - .from(rooms) - .where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId))) - .limit(1); - return Boolean(row); - } - - async findById(roomId: string) { - const [row] = await db.select().from(rooms).where(eq(rooms.id, roomId)).limit(1); - return row ?? null; - } } diff --git a/src/domain/models/project-location-structure-project-command.model.ts b/src/domain/models/project-location-structure-project-command.model.ts new file mode 100644 index 0000000..32f1a0a --- /dev/null +++ b/src/domain/models/project-location-structure-project-command.model.ts @@ -0,0 +1,259 @@ +import crypto from "node:crypto"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const projectFloorInsertCommandType = "project-floor.insert" as const; +export const projectFloorDeleteCommandType = "project-floor.delete" as const; +export const projectRoomInsertCommandType = "project-room.insert" as const; +export const projectRoomDeleteCommandType = "project-room.delete" as const; +export const projectLocationStructureCommandSchemaVersion = 1 as const; + +export interface ProjectFloorSnapshot { + id: string; + projectId: string; + name: string; + sortOrder: number; +} + +export interface ProjectRoomSnapshot { + id: string; + projectId: string; + floorId: string | null; + roomNumber: string; + roomName: string; +} + +interface ProjectFloorStructureCommandPayload { + floor: ProjectFloorSnapshot; +} + +interface ProjectRoomStructureCommandPayload { + room: ProjectRoomSnapshot; +} + +export interface ProjectFloorInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectFloorInsertCommandType; +} + +export interface ProjectFloorDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectFloorDeleteCommandType; +} + +export interface ProjectRoomInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectRoomInsertCommandType; +} + +export interface ProjectRoomDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectRoomDeleteCommandType; +} + +export type ProjectLocationStructureProjectCommand = + | ProjectFloorInsertProjectCommand + | ProjectFloorDeleteProjectCommand + | ProjectRoomInsertProjectCommand + | ProjectRoomDeleteProjectCommand; + +export function createProjectFloorSnapshot( + projectId: string, + name: string, + sortOrder: number +): ProjectFloorSnapshot { + const floor: ProjectFloorSnapshot = { + id: crypto.randomUUID(), + projectId, + name: name.trim(), + sortOrder, + }; + assertProjectFloorSnapshot(floor); + return floor; +} + +export function createProjectRoomSnapshot( + projectId: string, + input: { + floorId?: string; + roomNumber: string; + roomName: string; + } +): ProjectRoomSnapshot { + const room: ProjectRoomSnapshot = { + id: crypto.randomUUID(), + projectId, + floorId: input.floorId?.trim() || null, + roomNumber: input.roomNumber.trim(), + roomName: input.roomName.trim(), + }; + assertProjectRoomSnapshot(room); + return room; +} + +export function createProjectFloorInsertProjectCommand( + floor: ProjectFloorSnapshot +): ProjectFloorInsertProjectCommand { + assertProjectFloorSnapshot(floor); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectFloorInsertCommandType, + payload: { floor }, + }; +} + +export function createProjectFloorDeleteProjectCommand( + floor: ProjectFloorSnapshot +): ProjectFloorDeleteProjectCommand { + assertProjectFloorSnapshot(floor); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectFloorDeleteCommandType, + payload: { floor }, + }; +} + +export function createProjectRoomInsertProjectCommand( + room: ProjectRoomSnapshot +): ProjectRoomInsertProjectCommand { + assertProjectRoomSnapshot(room); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectRoomInsertCommandType, + payload: { room }, + }; +} + +export function createProjectRoomDeleteProjectCommand( + room: ProjectRoomSnapshot +): ProjectRoomDeleteProjectCommand { + assertProjectRoomSnapshot(room); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectRoomDeleteCommandType, + payload: { room }, + }; +} + +export function assertProjectFloorInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectFloorInsertProjectCommand { + assertProjectFloorStructureProjectCommand( + command, + projectFloorInsertCommandType + ); +} + +export function assertProjectFloorDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectFloorDeleteProjectCommand { + assertProjectFloorStructureProjectCommand( + command, + projectFloorDeleteCommandType + ); +} + +export function assertProjectRoomInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectRoomInsertProjectCommand { + assertProjectRoomStructureProjectCommand( + command, + projectRoomInsertCommandType + ); +} + +export function assertProjectRoomDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectRoomDeleteProjectCommand { + assertProjectRoomStructureProjectCommand( + command, + projectRoomDeleteCommandType + ); +} + +export function assertProjectFloorSnapshot( + floor: unknown +): asserts floor is ProjectFloorSnapshot { + if (!isPlainObject(floor) || Object.keys(floor).length !== 4) { + throw new Error("Project-floor snapshot is invalid."); + } + assertNormalizedNonEmptyString(floor.id, "floor.id"); + assertNormalizedNonEmptyString(floor.projectId, "floor.projectId"); + assertNormalizedNonEmptyString(floor.name, "floor.name"); + if ( + typeof floor.sortOrder !== "number" || + !Number.isSafeInteger(floor.sortOrder) || + floor.sortOrder < 0 + ) { + throw new Error("floor.sortOrder must be a non-negative integer."); + } +} + +export function assertProjectRoomSnapshot( + room: unknown +): asserts room is ProjectRoomSnapshot { + if (!isPlainObject(room) || Object.keys(room).length !== 5) { + throw new Error("Project-room snapshot is invalid."); + } + assertNormalizedNonEmptyString(room.id, "room.id"); + assertNormalizedNonEmptyString(room.projectId, "room.projectId"); + if (room.floorId !== null) { + assertNormalizedNonEmptyString(room.floorId, "room.floorId"); + } + assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber"); + assertNormalizedNonEmptyString(room.roomName, "room.roomName"); +} + +function assertProjectFloorStructureProjectCommand( + command: SerializedProjectCommand, + expectedType: + | typeof projectFloorInsertCommandType + | typeof projectFloorDeleteCommandType +) { + if ( + command.schemaVersion !== projectLocationStructureCommandSchemaVersion || + command.type !== expectedType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported project-floor structure command."); + } + assertProjectFloorSnapshot(command.payload.floor); +} + +function assertProjectRoomStructureProjectCommand( + command: SerializedProjectCommand, + expectedType: + | typeof projectRoomInsertCommandType + | typeof projectRoomDeleteCommandType +) { + if ( + command.schemaVersion !== projectLocationStructureCommandSchemaVersion || + command.type !== expectedType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported project-room structure command."); + } + assertProjectRoomSnapshot(command.payload.room); +} + +function assertNormalizedNonEmptyString( + value: unknown, + field: string +): asserts value is string { + if ( + typeof value !== "string" || + !value || + value !== value.trim() + ) { + throw new Error(`${field} must be a normalized 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/project-location-structure-project-command.store.ts b/src/domain/ports/project-location-structure-project-command.store.ts new file mode 100644 index 0000000..0644d67 --- /dev/null +++ b/src/domain/ports/project-location-structure-project-command.store.ts @@ -0,0 +1,26 @@ +import type { ProjectLocationStructureProjectCommand } from "../models/project-location-structure-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteProjectLocationStructureCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ProjectLocationStructureProjectCommand; +} + +export interface ExecutedProjectLocationStructureCommand { + revision: AppendedProjectRevision; + inverse: ProjectLocationStructureProjectCommand; +} + +export interface ProjectLocationStructureProjectCommandStore { + execute( + input: ExecuteProjectLocationStructureCommandInput + ): ExecutedProjectLocationStructureCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index c7575fc..0e7bcdc 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -47,6 +47,16 @@ import { assertSerializedProjectCommand, type SerializedProjectCommand, } from "../models/project-command.model.js"; +import { + assertProjectFloorDeleteProjectCommand, + assertProjectFloorInsertProjectCommand, + assertProjectRoomDeleteProjectCommand, + assertProjectRoomInsertProjectCommand, + projectFloorDeleteCommandType, + projectFloorInsertCommandType, + projectRoomDeleteCommandType, + projectRoomInsertCommandType, +} from "../models/project-location-structure-project-command.model.js"; import { assertProjectStateRestoreCommand, projectStateRestoreCommandType, @@ -87,6 +97,7 @@ import type { ProjectHistoryDirection, ProjectHistoryStore, } from "../ports/project-history.store.js"; +import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js"; import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js"; import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js"; import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js"; @@ -112,6 +123,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly distributionBoardStructureStore: DistributionBoardStructureProjectCommandStore, + private readonly projectLocationStructureStore: ProjectLocationStructureProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore, @@ -273,6 +285,34 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectFloorInsertCommandType: { + assertProjectFloorInsertProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case projectFloorDeleteCommandType: { + assertProjectFloorDeleteProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case projectRoomInsertCommandType: { + assertProjectRoomInsertProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case projectRoomDeleteCommandType: { + assertProjectRoomDeleteProjectCommand(input.command); + return this.projectLocationStructureStore.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 66cd884..0eac099 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -106,6 +106,11 @@ export interface FloorDto { sortOrder: number; } +export interface ProjectFloorCommandResultDto + extends ProjectCommandResultDto { + floor: FloorDto; +} + export interface RoomDto { id: string; projectId: string; @@ -146,6 +151,11 @@ export interface ProjectDeviceDto { voltageV: number | null; } +export interface ProjectRoomCommandResultDto + extends ProjectCommandResultDto { + room: RoomDto; +} + export interface ProjectDeviceCommandResultDto extends ProjectCommandResultDto { projectDevice: ProjectDeviceDto; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 1850a3e..a3622aa 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -12,11 +12,13 @@ import type { ProjectCommandResultDto, ProjectDeviceCommandResultDto, ProjectDeviceDto, + ProjectFloorCommandResultDto, ProjectHistoryStateDto, ProjectRevisionPageDto, ProjectSnapshotMetadataDto, ProjectSnapshotRestoreResultDto, ProjectSettingsCommandResultDto, + ProjectRoomCommandResultDto, ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncPreviewDto, ProjectDto, @@ -454,22 +456,36 @@ export function listFloors(projectId: string) { return request(`/api/projects/${projectId}/floors`); } -export function createFloor(projectId: string, input: CreateFloorInput) { - return request(`/api/projects/${projectId}/floors`, { - method: "POST", - body: JSON.stringify(input), - }); +export function createFloor( + projectId: string, + input: CreateFloorInput, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/floors`, + { + method: "POST", + body: JSON.stringify({ ...input, expectedRevision }), + } + ); } export function listRooms(projectId: string) { return request(`/api/projects/${projectId}/rooms`); } -export function createRoom(projectId: string, input: CreateRoomInput) { - return request(`/api/projects/${projectId}/rooms`, { - method: "POST", - body: JSON.stringify(input), - }); +export function createRoom( + projectId: string, + input: CreateRoomInput, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/rooms`, + { + method: "POST", + body: JSON.stringify({ ...input, expectedRevision }), + } + ); } export function listGlobalDevices() { diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 0b7b69a..c6054fa 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -32,6 +32,10 @@ const commandTypeLabels: Record = { "project.update-settings": "Projekteinstellungen bearbeitet", "distribution-board.insert": "Verteilung angelegt", "distribution-board.delete": "Verteilung entfernt", + "project-floor.insert": "Geschoss angelegt", + "project-floor.delete": "Geschoss entfernt", + "project-room.insert": "Raum angelegt", + "project-room.delete": "Raum 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 2371bce..6b506b3 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -8,6 +8,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../../db/reposit 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 { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.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"; import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js"; @@ -26,6 +27,8 @@ export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); export const distributionBoardStructureProjectCommandStore = new DistributionBoardStructureProjectCommandRepository(db); +export const projectLocationStructureProjectCommandStore = + new ProjectLocationStructureProjectCommandRepository(db); export const circuitSectionReorderProjectCommandStore = new CircuitSectionReorderProjectCommandRepository(db); export const circuitSectionRenumberProjectCommandStore = @@ -48,6 +51,7 @@ export const projectCommandService = new ProjectCommandService( circuitDeviceRowMoveProjectCommandStore, circuitStructureProjectCommandStore, distributionBoardStructureProjectCommandStore, + projectLocationStructureProjectCommandStore, circuitSectionReorderProjectCommandStore, circuitSectionRenumberProjectCommandStore, projectDeviceStructureProjectCommandStore, diff --git a/src/server/controllers/floor.controller.ts b/src/server/controllers/floor.controller.ts index fbda8b3..adaed42 100644 --- a/src/server/controllers/floor.controller.ts +++ b/src/server/controllers/floor.controller.ts @@ -1,6 +1,12 @@ import type { Request, Response } from "express"; import { FloorRepository } from "../../db/repositories/floor.repository.js"; +import { + createProjectFloorInsertProjectCommand, + createProjectFloorSnapshot, +} from "../../domain/models/project-location-structure-project-command.model.js"; import { createFloorSchema } from "../../shared/validation/project-structure.schemas.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; const floorRepository = new FloorRepository(); @@ -25,6 +31,24 @@ export async function createFloor(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - const floor = await floorRepository.create(projectId, parsed.data.name); - return res.status(201).json(floor); + const existingFloors = await floorRepository.listByProject(projectId); + const nextSortOrder = existingFloors.length + ? Math.max(...existingFloors.map((floor) => floor.sortOrder)) + 1 + : 0; + const floor = createProjectFloorSnapshot( + projectId, + parsed.data.name, + nextSortOrder + ); + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Geschoss anlegen", + command: createProjectFloorInsertProjectCommand(floor), + }); + return res.status(201).json({ ...result, floor }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } } diff --git a/src/server/controllers/room.controller.ts b/src/server/controllers/room.controller.ts index 654eb77..b7d6035 100644 --- a/src/server/controllers/room.controller.ts +++ b/src/server/controllers/room.controller.ts @@ -1,9 +1,13 @@ import type { Request, Response } from "express"; -import { FloorRepository } from "../../db/repositories/floor.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js"; +import { + createProjectRoomInsertProjectCommand, + createProjectRoomSnapshot, +} from "../../domain/models/project-location-structure-project-command.model.js"; import { createRoomSchema } from "../../shared/validation/project-structure.schemas.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; -const floorRepository = new FloorRepository(); const roomRepository = new RoomRepository(); export async function listRoomsByProject(req: Request, res: Response) { @@ -27,13 +31,16 @@ export async function createRoom(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - if (parsed.data.floorId) { - const hasValidFloor = await floorRepository.existsInProject(projectId, parsed.data.floorId); - if (!hasValidFloor) { - return res.status(400).json({ error: "Floor does not belong to the provided project." }); - } + const room = createProjectRoomSnapshot(projectId, parsed.data); + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Raum anlegen", + command: createProjectRoomInsertProjectCommand(room), + }); + return res.status(201).json({ ...result, room }); + } catch (error) { + return respondWithProjectCommandError(error, res); } - - const room = await roomRepository.create(projectId, parsed.data); - return res.status(201).json(room); } diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index ceb6a7f..8cf20a9 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -22,15 +22,21 @@ export const createDistributionBoardSchema = z }) .strict(); -export const createFloorSchema = z.object({ - name: z.string().min(1), -}); +export const createFloorSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1), + }) + .strict(); -export const createRoomSchema = z.object({ - floorId: z.string().min(1).optional(), - roomNumber: z.string().min(1), - roomName: z.string().min(1), -}); +export const createRoomSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + floorId: z.string().trim().min(1).optional(), + roomNumber: z.string().trim().min(1), + roomName: z.string().trim().min(1), + }) + .strict(); export type CreateProjectInput = z.infer; export type UpdateProjectSettingsInput = z.infer< @@ -39,5 +45,3 @@ export type UpdateProjectSettingsInput = z.infer< export type CreateDistributionBoardInput = z.infer< typeof createDistributionBoardSchema >; -export type CreateFloorInput = z.infer; -export type CreateRoomInput = z.infer; diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 88a0606..a91b2a1 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -17,6 +17,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories 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 { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-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"; @@ -29,6 +30,8 @@ import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; +import { floors } from "../src/db/schema/floors.js"; +import { rooms } from "../src/db/schema/rooms.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"; @@ -46,6 +49,12 @@ import { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, } from "../src/domain/models/distribution-board-structure-project-command.model.js"; +import { + createProjectFloorInsertProjectCommand, + createProjectFloorSnapshot, + createProjectRoomInsertProjectCommand, + createProjectRoomSnapshot, +} from "../src/domain/models/project-location-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"; @@ -105,6 +114,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db), + new ProjectLocationStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db), @@ -1036,6 +1046,68 @@ describe("project command service", () => { } }); + it("dispatches floor and room setup with their persisted inverses", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const floor = createProjectFloorSnapshot( + "project-1", + "EG", + 0 + ); + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createProjectFloorInsertProjectCommand(floor), + }); + const room = createProjectRoomSnapshot("project-1", { + floorId: floor.id, + roomNumber: "001", + roomName: "Technik", + }); + const created = service.executeUser({ + projectId: "project-1", + expectedRevision: 1, + command: createProjectRoomInsertProjectCommand(room), + }); + assert.equal(created.history.currentRevision, 2); + assert.ok( + context.db + .select() + .from(rooms) + .where(eq(rooms.id, room.id)) + .get() + ); + + createService(context).undo({ + projectId: "project-1", + expectedRevision: 2, + }); + assert.equal( + context.db + .select() + .from(rooms) + .where(eq(rooms.id, room.id)) + .get(), + undefined + ); + createService(context).undo({ + projectId: "project-1", + expectedRevision: 3, + }); + assert.equal( + context.db + .select() + .from(floors) + .where(eq(floors.id, floor.id)) + .get(), + undefined + ); + } finally { + context.close(); + } + }); + it("rejects unsupported and malformed commands before domain writes", () => { const context = createTestDatabase(); try { diff --git a/tests/project-location-structure-project-command.repository.test.ts b/tests/project-location-structure-project-command.repository.test.ts new file mode 100644 index 0000000..41c39b6 --- /dev/null +++ b/tests/project-location-structure-project-command.repository.test.ts @@ -0,0 +1,464 @@ +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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; +import { consumers } from "../src/db/schema/consumers.js"; +import { floors } from "../src/db/schema/floors.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { rooms } from "../src/db/schema/rooms.js"; +import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { + createProjectFloorInsertProjectCommand, + createProjectFloorSnapshot, + createProjectRoomInsertProjectCommand, + createProjectRoomSnapshot, +} from "../src/domain/models/project-location-structure-project-command.model.js"; +import { createFloor, createRoom } from "../src/frontend/utils/api.js"; +import { + createFloorSchema, + createRoomSchema, +} from "../src/shared/validation/project-structure.schemas.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 getCounts(context: DatabaseContext) { + return { + floors: context.db.select().from(floors).all().length, + rooms: context.db.select().from(rooms).all().length, + revisions: context.db.select().from(projectRevisions).all().length, + }; +} + +describe("project-location structure project command", () => { + it("normalizes snapshots and sends optimistic frontend revisions", async () => { + const floor = createProjectFloorSnapshot("project-1", " EG ", 0); + const room = createProjectRoomSnapshot("project-1", { + floorId: floor.id, + roomNumber: " 001 ", + roomName: " Technik ", + }); + assert.equal(floor.name, "EG"); + assert.equal(room.roomNumber, "001"); + assert.equal(room.roomName, "Technik"); + assert.throws( + () => + createProjectFloorInsertProjectCommand({ + ...floor, + name: " EG ", + }), + /normalized/ + ); + assert.equal( + createFloorSchema.safeParse({ name: "EG" }).success, + false + ); + assert.equal( + createRoomSchema.safeParse({ + expectedRevision: 0, + roomNumber: "001", + roomName: "Technik", + unknown: true, + }).success, + false + ); + + 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 createFloor("project-1", { name: "EG" }, 7); + await createRoom( + "project-1", + { + floorId: "floor-1", + roomNumber: "001", + roomName: "Technik", + }, + 8 + ); + } finally { + globalThis.fetch = originalFetch; + } + assert.deepEqual(requests, [ + { + url: "/api/projects/project-1/floors", + body: { name: "EG", expectedRevision: 7 }, + }, + { + url: "/api/projects/project-1/rooms", + body: { + floorId: "floor-1", + roomNumber: "001", + roomName: "Technik", + expectedRevision: 8, + }, + }, + ]); + }); + + it("creates a stable floor and supports persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const repository = + new ProjectLocationStructureProjectCommandRepository(context.db); + const history = new ProjectHistoryRepository(context.db); + const floor = createProjectFloorSnapshot("project-1", "EG", 0); + const command = createProjectFloorInsertProjectCommand(floor); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual(getCounts(context), { + floors: 1, + rooms: 0, + 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: inserted.inverse, + }); + assert.deepEqual(getCounts(context), { + floors: 0, + rooms: 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( + context.db + .select() + .from(floors) + .where(eq(floors.id, floor.id)) + .get(), + floor + ); + } finally { + context.close(); + } + }); + + it("creates a stable room and supports persisted undo and redo", () => { + const context = createTestDatabase(); + try { + context.db + .insert(floors) + .values({ + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 0, + }) + .run(); + const repository = + new ProjectLocationStructureProjectCommandRepository(context.db); + const history = new ProjectHistoryRepository(context.db); + const room = createProjectRoomSnapshot("project-1", { + floorId: "floor-1", + roomNumber: "001", + roomName: "Technik", + }); + const command = createProjectRoomInsertProjectCommand(room); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + const undoStep = history.getNextCommand("project-1", "undo"); + assert.ok(undoStep); + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: undoStep.changeSetId, + command: inserted.inverse, + }); + assert.equal( + context.db + .select() + .from(rooms) + .where(eq(rooms.id, room.id)) + .get(), + undefined + ); + + 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( + context.db + .select() + .from(rooms) + .where(eq(rooms.id, room.id)) + .get(), + room + ); + } finally { + context.close(); + } + }); + + it("rejects foreign, stale and changed locations without partial writes", () => { + const context = createTestDatabase(); + try { + context.db + .insert(floors) + .values({ + id: "foreign-floor", + projectId: "project-2", + name: "Fremd", + sortOrder: 0, + }) + .run(); + const repository = + new ProjectLocationStructureProjectCommandRepository(context.db); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectRoomInsertProjectCommand( + createProjectRoomSnapshot("project-1", { + floorId: "foreign-floor", + roomNumber: "001", + roomName: "Fremdraum", + }) + ), + }), + /foreign floor/ + ); + const floor = createProjectFloorSnapshot("project-1", "EG", 0); + const command = createProjectFloorInsertProjectCommand(floor); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command, + }), + ProjectRevisionConflictError + ); + assert.deepEqual(getCounts(context), { + floors: 1, + rooms: 0, + revisions: 0, + }); + + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + context.db + .update(floors) + .set({ name: "Direkt geändert" }) + .where(eq(floors.id, floor.id)) + .run(); + const undoStep = new ProjectHistoryRepository( + context.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, + }), + /changed before deletion/ + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 1 + ); + } finally { + context.close(); + } + }); + + it("refuses referenced deletes and rolls back late history failures", () => { + const floorContext = createTestDatabase(); + try { + const repository = + new ProjectLocationStructureProjectCommandRepository( + floorContext.db + ); + const floor = createProjectFloorSnapshot("project-1", "EG", 0); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectFloorInsertProjectCommand(floor), + }); + floorContext.db + .insert(rooms) + .values({ + id: "room-1", + projectId: "project-1", + floorId: floor.id, + roomNumber: "001", + roomName: "Technik", + }) + .run(); + const undoStep = new ProjectHistoryRepository( + floorContext.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, + }), + /assigned rooms/ + ); + } finally { + floorContext.close(); + } + + const roomContext = createTestDatabase(); + try { + const repository = + new ProjectLocationStructureProjectCommandRepository( + roomContext.db + ); + const room = createProjectRoomSnapshot("project-1", { + roomNumber: "001", + roomName: "Technik", + }); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectRoomInsertProjectCommand(room), + }); + roomContext.db + .insert(consumers) + .values({ + id: "legacy-consumer-1", + projectId: "project-1", + roomId: room.id, + name: "Bestand", + quantity: 1, + installedPowerPerUnitKw: 1, + demandFactor: 1, + }) + .run(); + const undoStep = new ProjectHistoryRepository( + roomContext.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, + }), + /referenced project room/ + ); + } finally { + roomContext.close(); + } + + const rollbackContext = createTestDatabase(); + try { + rollbackContext.sqlite.exec(` + CREATE TRIGGER fail_project_location_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced project-location history failure'); + END; + `); + const repository = + new ProjectLocationStructureProjectCommandRepository( + rollbackContext.db + ); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectFloorInsertProjectCommand( + createProjectFloorSnapshot( + "project-1", + "Rollback", + 0 + ) + ), + }), + /forced project-location history failure/ + ); + assert.deepEqual(getCounts(rollbackContext), { + floors: 0, + rooms: 0, + revisions: 0, + }); + } finally { + rollbackContext.close(); + } + }); +}); diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index c7048b5..d842ff3 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -20,6 +20,7 @@ import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/pr 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"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; @@ -134,6 +135,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), new DistributionBoardStructureProjectCommandRepository(context.db), + new ProjectLocationStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db),