From 1eed19ef6bcfc03470815336c38002e3492c0c57 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 31 Jul 2026 14:46:06 +0200 Subject: [PATCH] Add editable project floors and rooms --- AGENTS.md | 12 +- docs/circuit-list-editor-api.md | 19 +- docs/current-architecture.md | 42 ++-- src/app/projects/[projectId]/page.tsx | 196 +++++++++++++++--- src/db/repositories/floor.repository.ts | 10 +- ...on-structure-project-command.repository.ts | 82 +++++++- src/db/repositories/room.repository.ts | 10 +- ...ocation-structure-project-command.model.ts | 111 +++++++++- .../services/project-command.service.ts | 12 ++ src/frontend/utils/api.ts | 38 ++++ src/server/controllers/floor.controller.ts | 61 +++++- src/server/controllers/room.controller.ts | 66 +++++- src/server/routes/project.routes.ts | 8 +- .../validation/project-structure.schemas.ts | 17 ++ ...ructure-project-command.repository.test.ts | 139 ++++++++++++- 15 files changed, 743 insertions(+), 80 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 18896c9..f3bdd53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,7 @@ Immutable revision metadata is available through the paginated stored command payloads. Named logical snapshots can be created and listed through project-scoped API endpoints. Their schema-versioned payload contains the complete supported -project runtime state and a SHA-256, excludes global/upgrade-only data and does +project runtime state and a SHA-256, excludes global data outside the project and does not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete @@ -335,15 +335,15 @@ UUID in foreign-key-safe order. The circuit-tree read model exposes fixed `headerComponents`, group-owned `components`, `footerComponents`, optional group category/number and optional one-to-one protection DTOs for circuits and group components. These are -additive to the retained flat circuit protection fields during the transition. +the only supported circuit-protection source; no flat compatibility fields remain. Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. -Floor and room setup use `project-floor.insert` and `project-room.insert` with -complete stable snapshots. Their inverses remove only unchanged records and +Floor and room setup uses persistent insert, update and delete commands with +complete stable snapshots. Their inverses remove or restore only unchanged records and reject floors with assigned rooms/distribution boards or rooms referenced by -device rows or retained upgrade data. Both POST routes require `expectedRevision` and return -the updated history state. +device rows. All write 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 7e3d2d3..8f225a0 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -253,16 +253,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. - body: `{ "name": "EG", "expectedRevision": 13 }` - executes `project-floor.insert` with a stable floor id - response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }` +- `PUT /projects/:projectId/floors/:floorId` + - body: `{ "name": "1. OG", "expectedRevision": 14 }` + - executes an exact persistent floor update +- `DELETE /projects/:projectId/floors/:floorId` + - body: `{ "expectedRevision": 15 }` + - rejects floors with assigned rooms or distribution boards - `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 or distribution boards and a room referenced by device rows - or retained upgrade data are rejected instead of silently clearing foreign - keys. +- `PUT /projects/:projectId/rooms/:roomId` + - body: + `{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }` + - updates number, name and optional floor as one persistent command +- `DELETE /projects/:projectId/rooms/:roomId` + - body: `{ "expectedRevision": 17 }` + - rejects rooms referenced by device rows +- Persistent Undo/Redo restores exact previous and target records without + silently clearing foreign keys. - Stale revisions return `409 PROJECT_REVISION_CONFLICT`. ### Tree Endpoint diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 076178f..dc8a3f8 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -258,8 +258,8 @@ läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-, Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt -die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only- -Fachliche Verknüpfungen innerhalb des unterstützten Laufzeitmodells bleiben +die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen +innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die Projektübersicht verwendet dafür den separaten Collection-Endpunkt `POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und @@ -414,34 +414,26 @@ wieder her. verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. -Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert; -Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft. -Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie -gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung -hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als -Projektauswahl hochgestuft. -Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer, -Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture, +Das aktuelle Baseline-Snapshot-Schema enthält den Gleichzeitigkeitsfaktor, +Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten +1:1-Schutzgerätedaten. Capture, benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide -JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter -bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch -Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer -und bestehende flache Schutzangaben unverändert erhalten. -`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 +JSON-Importmodi verwenden denselben vollständigen Zustand. Vorherige +Entwicklungsschemas werden nicht mehr eingelesen. +Persistente Insert-, Update- und Delete-Commands versionieren Anlage, +Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und +exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur entfernt, solange ihm weder ein Raum noch eine Verteilung 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. +Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle +Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten +Historienstand. ## Projektgeräte -`ProjectDevice` verwendet ausschließlich die kanonischen Circuit-First-Felder: -`phaseType`, `powerPerUnit`, `simultaneityFactor`, `cosPhi`, `remark` sowie -optionale technische und kategorisierende Felder. +Die Projektgerätekategorie `lighting`, `single_phase` oder `three_phase` ist die +fachliche Klassifikation und Zielgruppe. `phaseType` ist keine separate +Benutzereingabe, sondern wird daraus für Spannung, Verknüpfung und spätere +Dimensionierung abgeleitet. Beim Einfügen entsteht eine verknüpfte `CircuitDeviceRow`. Der Anzeigename wird kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index a4933fd..185feb4 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -11,7 +11,9 @@ import { createFloor, createProjectDevice, createRoom, + deleteFloor, deleteProjectDevice, + deleteRoom, deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, @@ -26,8 +28,10 @@ import { importProjectTransfer, synchronizeProjectDeviceRows, updateDistributionBoard, + updateFloor, updateProjectDevice, updateProjectSettings, + updateRoom, } from "../../../frontend/utils/api"; import type { CircuitListDto, @@ -95,9 +99,11 @@ export default function ProjectDetailPage() { ] = useState("1"); const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [floorName, setFloorName] = useState(""); + const [editingFloor, setEditingFloor] = useState(null); const [roomNumber, setRoomNumber] = useState(""); const [roomName, setRoomName] = useState(""); const [roomFloorId, setRoomFloorId] = useState(""); + const [editingRoom, setEditingRoom] = useState(null); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [structureModal, setStructureModal] = useState< "board" | "floor" | "room" | null @@ -239,7 +245,7 @@ export default function ProjectDetailPage() { } } - async function handleCreateFloor(event: FormEvent) { + async function handleSaveFloor(event: FormEvent) { event.preventDefault(); if (!projectId || !project || !floorName.trim()) { return; @@ -247,23 +253,37 @@ export default function ProjectDetailPage() { setIsSaving(true); setError(null); try { - const result = await createFloor( - projectId, - { name: floorName.trim() }, - project.currentRevision + const result = editingFloor + ? await updateFloor( + projectId, + editingFloor.id, + { name: floorName.trim() }, + project.currentRevision + ) + : await createFloor( + projectId, + { name: floorName.trim() }, + project.currentRevision + ); + setFloors((current) => + editingFloor + ? current.map((floor) => + floor.id === result.floor.id ? result.floor : floor + ) + : [...current, result.floor] ); - setFloors((current) => [...current, result.floor]); applyProjectRevision(result.history.currentRevision); setFloorName(""); + setEditingFloor(null); setStructureModal(null); } catch (err) { - setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); + setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden."); } finally { setIsSaving(false); } } - async function handleCreateRoom(event: FormEvent) { + async function handleSaveRoom(event: FormEvent) { event.preventDefault(); if ( !projectId || @@ -276,23 +296,34 @@ export default function ProjectDetailPage() { setIsSaving(true); setError(null); try { - const result = await createRoom( - projectId, - { - floorId: roomFloorId || undefined, - roomNumber: roomNumber.trim(), - roomName: roomName.trim(), - }, - project.currentRevision + const input = { + floorId: roomFloorId || undefined, + roomNumber: roomNumber.trim(), + roomName: roomName.trim(), + }; + const result = editingRoom + ? await updateRoom( + projectId, + editingRoom.id, + input, + project.currentRevision + ) + : await createRoom(projectId, input, project.currentRevision); + setRooms((current) => + editingRoom + ? current.map((room) => + room.id === result.room.id ? result.room : room + ) + : [...current, result.room] ); - setRooms((current) => [...current, result.room]); applyProjectRevision(result.history.currentRevision); setRoomNumber(""); setRoomName(""); setRoomFloorId(""); + setEditingRoom(null); setStructureModal(null); } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden."); + setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert werden."); } finally { setIsSaving(false); } @@ -467,6 +498,74 @@ export default function ProjectDetailPage() { } } + function openCreateFloor() { + setEditingFloor(null); + setFloorName(""); + setStructureModal("floor"); + } + + function openEditFloor(floor: FloorDto) { + setEditingFloor(floor); + setFloorName(floor.name); + setStructureModal("floor"); + } + + function openCreateRoom() { + setEditingRoom(null); + setRoomNumber(""); + setRoomName(""); + setRoomFloorId(""); + setStructureModal("room"); + } + + function openEditRoom(room: RoomDto) { + setEditingRoom(room); + setRoomNumber(room.roomNumber); + setRoomName(room.roomName); + setRoomFloorId(room.floorId ?? ""); + setStructureModal("room"); + } + + async function handleDeleteFloor() { + if (!projectId || !project || !editingFloor) return; + if (!window.confirm(`Etage „${editingFloor.name}“ wirklich löschen?`)) return; + setIsSaving(true); + setError(null); + try { + const result = await deleteFloor(projectId, editingFloor.id, project.currentRevision); + setFloors((current) => current.filter((floor) => floor.id !== editingFloor.id)); + applyProjectRevision(result.history.currentRevision); + setEditingFloor(null); + setFloorName(""); + setStructureModal(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Etage konnte nicht gelöscht werden."); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteRoom() { + if (!projectId || !project || !editingRoom) return; + if (!window.confirm(`Raum „${editingRoom.roomNumber} ${editingRoom.roomName}“ wirklich löschen?`)) return; + setIsSaving(true); + setError(null); + try { + const result = await deleteRoom(projectId, editingRoom.id, project.currentRevision); + setRooms((current) => current.filter((room) => room.id !== editingRoom.id)); + applyProjectRevision(result.history.currentRevision); + setEditingRoom(null); + setRoomNumber(""); + setRoomName(""); + setRoomFloorId(""); + setStructureModal(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden."); + } finally { + setIsSaving(false); + } + } + async function handleCopyBoard() { if ( !projectId || @@ -907,7 +1006,7 @@ export default function ProjectDetailPage() { ))} {!floors.length ? ( @@ -935,7 +1037,7 @@ export default function ProjectDetailPage() { + ))} {!rooms.length ? ( - + Noch keine Räume vorhanden. @@ -1432,11 +1540,14 @@ export default function ProjectDetailPage() { {structureModal === "floor" ? ( setStructureModal(null)} - onSubmit={handleCreateFloor} + onClose={() => { + setStructureModal(null); + setEditingFloor(null); + }} + onSubmit={handleSaveFloor} submitDisabled={!floorName.trim()} - submitLabel="Etage hinzufügen" - title="Etage hinzufügen" + submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"} + title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"} > ) : null} {structureModal === "room" ? ( setStructureModal(null)} - onSubmit={handleCreateRoom} + onClose={() => { + setStructureModal(null); + setEditingRoom(null); + }} + onSubmit={handleSaveRoom} submitDisabled={!roomNumber.trim() || !roomName.trim()} - submitLabel="Raum anlegen" - title="Raum hinzufügen" + submitLabel={editingRoom ? "Änderungen speichern" : "Raum anlegen"} + title={editingRoom ? "Raum bearbeiten" : "Raum hinzufügen"} >
@@ -1507,6 +1631,16 @@ export default function ProjectDetailPage() {
+ {editingRoom ? ( +
+

+ In Stromkreisen verwendete Räume können nicht gelöscht werden. +

+ +
+ ) : null}
) : null} diff --git a/src/db/repositories/floor.repository.ts b/src/db/repositories/floor.repository.ts index caaabd3..a0c9645 100644 --- a/src/db/repositories/floor.repository.ts +++ b/src/db/repositories/floor.repository.ts @@ -1,4 +1,4 @@ -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import type { AppDatabase } from "../database-context.js"; import { floors } from "../schema/floors.js"; @@ -13,4 +13,12 @@ export class FloorRepository { .where(eq(floors.projectId, projectId)) .orderBy(asc(floors.sortOrder), asc(floors.name)); } + + async findById(projectId: string, floorId: string) { + return this.database + .select() + .from(floors) + .where(and(eq(floors.projectId, projectId), eq(floors.id, floorId))) + .get() ?? null; + } } diff --git a/src/db/repositories/project-location-structure-project-command.repository.ts b/src/db/repositories/project-location-structure-project-command.repository.ts index 2a3466a..7cbadde 100644 --- a/src/db/repositories/project-location-structure-project-command.repository.ts +++ b/src/db/repositories/project-location-structure-project-command.repository.ts @@ -2,16 +2,22 @@ import { and, eq } from "drizzle-orm"; import { assertProjectFloorDeleteProjectCommand, assertProjectFloorInsertProjectCommand, + assertProjectFloorUpdateProjectCommand, assertProjectRoomDeleteProjectCommand, assertProjectRoomInsertProjectCommand, + assertProjectRoomUpdateProjectCommand, createProjectFloorDeleteProjectCommand, createProjectFloorInsertProjectCommand, + createProjectFloorUpdateProjectCommand, createProjectRoomDeleteProjectCommand, createProjectRoomInsertProjectCommand, + createProjectRoomUpdateProjectCommand, projectFloorDeleteCommandType, projectFloorInsertCommandType, + projectFloorUpdateCommandType, projectRoomDeleteCommandType, projectRoomInsertCommandType, + projectRoomUpdateCommandType, type ProjectFloorSnapshot, type ProjectLocationStructureProjectCommand, type ProjectRoomSnapshot, @@ -62,6 +68,9 @@ export class ProjectLocationStructureProjectCommandRepository projectId, command.payload.floor ); + case projectFloorUpdateCommandType: + assertProjectFloorUpdateProjectCommand(command); + return this.updateFloor(database, projectId, command.payload.expected, command.payload.target); case projectRoomInsertCommandType: assertProjectRoomInsertProjectCommand(command); return this.insertRoom( @@ -76,6 +85,9 @@ export class ProjectLocationStructureProjectCommandRepository projectId, command.payload.room ); + case projectRoomUpdateCommandType: + assertProjectRoomUpdateProjectCommand(command); + return this.updateRoom(database, projectId, command.payload.expected, command.payload.target); } } @@ -126,7 +138,7 @@ export class ProjectLocationStructureProjectCommandRepository .get(); if (referencedRoom) { throw new Error( - "A project floor with assigned rooms cannot be removed by history." + "Die Etage kann nicht gelöscht werden, solange Räume zugeordnet sind." ); } const referencedDistributionBoard = database @@ -137,7 +149,7 @@ export class ProjectLocationStructureProjectCommandRepository .get(); if (referencedDistributionBoard) { throw new Error( - "A project floor assigned to a distribution board cannot be removed by history." + "Die Etage kann nicht gelöscht werden, solange eine Verteilung zugeordnet ist." ); } const deleted = database @@ -191,6 +203,70 @@ export class ProjectLocationStructureProjectCommandRepository return createProjectRoomDeleteProjectCommand(room); } + private updateFloor( + database: AppDatabase, + projectId: string, + expected: ProjectFloorSnapshot, + target: 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 update."); + } + const updated = database + .update(floors) + .set({ name: target.name }) + .where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId))) + .run(); + if (updated.changes !== 1) { + throw new Error("Die Etage konnte nicht aktualisiert werden."); + } + return createProjectFloorUpdateProjectCommand(target, expected); + } + + private updateRoom( + database: AppDatabase, + projectId: string, + expected: ProjectRoomSnapshot, + target: 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 update."); + } + if (target.floorId) { + const targetFloor = database + .select({ id: floors.id }) + .from(floors) + .where(and(eq(floors.id, target.floorId), eq(floors.projectId, projectId))) + .get(); + if (!targetFloor) { + throw new Error("Project room references a foreign floor."); + } + } + const updated = database + .update(rooms) + .set({ + floorId: target.floorId, + roomNumber: target.roomNumber, + roomName: target.roomName, + }) + .where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId))) + .run(); + if (updated.changes !== 1) { + throw new Error("Der Raum konnte nicht aktualisiert werden."); + } + return createProjectRoomUpdateProjectCommand(target, expected); + } + private deleteRoom( database: AppDatabase, projectId: string, @@ -217,7 +293,7 @@ export class ProjectLocationStructureProjectCommandRepository .get(); if (referencedDeviceRow) { throw new Error( - "A referenced project room cannot be removed by history." + "Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird." ); } const deleted = database diff --git a/src/db/repositories/room.repository.ts b/src/db/repositories/room.repository.ts index 85e21cc..2a291f3 100644 --- a/src/db/repositories/room.repository.ts +++ b/src/db/repositories/room.repository.ts @@ -1,4 +1,4 @@ -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import type { AppDatabase } from "../database-context.js"; import { rooms } from "../schema/rooms.js"; @@ -13,4 +13,12 @@ export class RoomRepository { .where(eq(rooms.projectId, projectId)) .orderBy(asc(rooms.roomNumber), asc(rooms.roomName)); } + + async findById(projectId: string, roomId: string) { + return this.database + .select() + .from(rooms) + .where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId))) + .get() ?? 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 index 32f1a0a..616add9 100644 --- a/src/domain/models/project-location-structure-project-command.model.ts +++ b/src/domain/models/project-location-structure-project-command.model.ts @@ -3,8 +3,10 @@ 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 projectFloorUpdateCommandType = "project-floor.update" as const; export const projectRoomInsertCommandType = "project-room.insert" as const; export const projectRoomDeleteCommandType = "project-room.delete" as const; +export const projectRoomUpdateCommandType = "project-room.update" as const; export const projectLocationStructureCommandSchemaVersion = 1 as const; export interface ProjectFloorSnapshot { @@ -30,6 +32,16 @@ interface ProjectRoomStructureCommandPayload { room: ProjectRoomSnapshot; } +interface ProjectFloorUpdateCommandPayload { + expected: ProjectFloorSnapshot; + target: ProjectFloorSnapshot; +} + +interface ProjectRoomUpdateCommandPayload { + expected: ProjectRoomSnapshot; + target: ProjectRoomSnapshot; +} + export interface ProjectFloorInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; @@ -42,6 +54,12 @@ export interface ProjectFloorDeleteProjectCommand type: typeof projectFloorDeleteCommandType; } +export interface ProjectFloorUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectFloorUpdateCommandType; +} + export interface ProjectRoomInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; @@ -54,11 +72,19 @@ export interface ProjectRoomDeleteProjectCommand type: typeof projectRoomDeleteCommandType; } +export interface ProjectRoomUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectRoomUpdateCommandType; +} + export type ProjectLocationStructureProjectCommand = | ProjectFloorInsertProjectCommand | ProjectFloorDeleteProjectCommand + | ProjectFloorUpdateProjectCommand | ProjectRoomInsertProjectCommand - | ProjectRoomDeleteProjectCommand; + | ProjectRoomDeleteProjectCommand + | ProjectRoomUpdateProjectCommand; export function createProjectFloorSnapshot( projectId: string, @@ -138,6 +164,30 @@ export function createProjectRoomDeleteProjectCommand( }; } +export function createProjectFloorUpdateProjectCommand( + expected: ProjectFloorSnapshot, + target: ProjectFloorSnapshot +): ProjectFloorUpdateProjectCommand { + assertMatchingFloorUpdate(expected, target); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectFloorUpdateCommandType, + payload: { expected, target }, + }; +} + +export function createProjectRoomUpdateProjectCommand( + expected: ProjectRoomSnapshot, + target: ProjectRoomSnapshot +): ProjectRoomUpdateProjectCommand { + assertMatchingRoomUpdate(expected, target); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectRoomUpdateCommandType, + payload: { expected, target }, + }; +} + export function assertProjectFloorInsertProjectCommand( command: SerializedProjectCommand ): asserts command is ProjectFloorInsertProjectCommand { @@ -174,6 +224,59 @@ export function assertProjectRoomDeleteProjectCommand( ); } +export function assertProjectFloorUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectFloorUpdateProjectCommand { + if ( + command.schemaVersion !== projectLocationStructureCommandSchemaVersion || + command.type !== projectFloorUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error("Unsupported project-floor update command."); + } + assertMatchingFloorUpdate(command.payload.expected, command.payload.target); +} + +export function assertProjectRoomUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectRoomUpdateProjectCommand { + if ( + command.schemaVersion !== projectLocationStructureCommandSchemaVersion || + command.type !== projectRoomUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error("Unsupported project-room update command."); + } + assertMatchingRoomUpdate(command.payload.expected, command.payload.target); +} + +function assertMatchingFloorUpdate(expected: unknown, target: unknown) { + assertProjectFloorSnapshot(expected); + assertProjectFloorSnapshot(target); + if ( + expected.id !== target.id || + expected.projectId !== target.projectId || + expected.sortOrder !== target.sortOrder || + expected.name === target.name + ) { + throw new Error("Project-floor update must change only the name."); + } +} + +function assertMatchingRoomUpdate(expected: unknown, target: unknown) { + assertProjectRoomSnapshot(expected); + assertProjectRoomSnapshot(target); + if ( + expected.id !== target.id || + expected.projectId !== target.projectId || + sameSnapshot(expected, target) + ) { + throw new Error("Project-room update must change room values."); + } +} + export function assertProjectFloorSnapshot( floor: unknown ): asserts floor is ProjectFloorSnapshot { @@ -257,3 +360,9 @@ function assertNormalizedNonEmptyString( function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } + +function sameSnapshot(left: object, right: object) { + return Object.entries(left).every( + ([key, value]) => (right as Record)[key] === value + ); +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 2f2ba16..65e8059 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -72,12 +72,16 @@ import { import { assertProjectFloorDeleteProjectCommand, assertProjectFloorInsertProjectCommand, + assertProjectFloorUpdateProjectCommand, assertProjectRoomDeleteProjectCommand, assertProjectRoomInsertProjectCommand, + assertProjectRoomUpdateProjectCommand, projectFloorDeleteCommandType, projectFloorInsertCommandType, + projectFloorUpdateCommandType, projectRoomDeleteCommandType, projectRoomInsertCommandType, + projectRoomUpdateCommandType, } from "../models/project-location-structure-project-command.model.js"; import { assertProjectStateRestoreCommand, @@ -470,6 +474,10 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectFloorUpdateCommandType: { + assertProjectFloorUpdateProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; + } case projectRoomInsertCommandType: { assertProjectRoomInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ @@ -484,6 +492,10 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectRoomUpdateCommandType: { + assertProjectRoomUpdateProjectCommand(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/utils/api.ts b/src/frontend/utils/api.ts index 5b6b624..460725d 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -786,6 +786,25 @@ export function createFloor( ); } +export function updateFloor( + projectId: string, + floorId: string, + input: CreateFloorInput, + expectedRevision: number +) { + return request(`/api/projects/${projectId}/floors/${floorId}`, { + method: "PUT", + body: JSON.stringify({ ...input, expectedRevision }), + }); +} + +export function deleteFloor(projectId: string, floorId: string, expectedRevision: number) { + return request(`/api/projects/${projectId}/floors/${floorId}`, { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + }); +} + export function listRooms(projectId: string) { return request(`/api/projects/${projectId}/rooms`); } @@ -804,6 +823,25 @@ export function createRoom( ); } +export function updateRoom( + projectId: string, + roomId: string, + input: CreateRoomInput, + expectedRevision: number +) { + return request(`/api/projects/${projectId}/rooms/${roomId}`, { + method: "PUT", + body: JSON.stringify({ ...input, floorId: input.floorId ?? null, expectedRevision }), + }); +} + +export function deleteRoom(projectId: string, roomId: string, expectedRevision: number) { + return request(`/api/projects/${projectId}/rooms/${roomId}`, { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + }); +} + export function listGlobalDevices() { return request("/api/global-devices"); } diff --git a/src/server/controllers/floor.controller.ts b/src/server/controllers/floor.controller.ts index 677e728..085aa10 100644 --- a/src/server/controllers/floor.controller.ts +++ b/src/server/controllers/floor.controller.ts @@ -1,9 +1,15 @@ import type { Request, Response } from "express"; import { + createProjectFloorDeleteProjectCommand, createProjectFloorInsertProjectCommand, createProjectFloorSnapshot, + createProjectFloorUpdateProjectCommand, } from "../../domain/models/project-location-structure-project-command.model.js"; -import { createFloorSchema } from "../../shared/validation/project-structure.schemas.js"; +import { + createFloorSchema, + deleteProjectLocationSchema, + updateFloorSchema, +} from "../../shared/validation/project-structure.schemas.js"; import { projectCommandService } from "../composition/project-command-stores.js"; import { floorRepository } from "../composition/application-repositories.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; @@ -50,3 +56,56 @@ export async function createFloor(req: Request, res: Response) { return respondWithProjectCommandError(error, res); } } + +export async function updateFloor(req: Request, res: Response) { + const { projectId, floorId } = req.params; + if (typeof projectId !== "string" || typeof floorId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + const parsed = updateFloorSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = await floorRepository.findById(projectId, floorId); + if (!current) { + return res.status(404).json({ error: "Floor not found" }); + } + const target = { ...current, name: parsed.data.name }; + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Geschoss bearbeiten", + command: createProjectFloorUpdateProjectCommand(current, target), + }); + return res.json({ ...result, floor: target }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export async function deleteFloor(req: Request, res: Response) { + const { projectId, floorId } = req.params; + if (typeof projectId !== "string" || typeof floorId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + const parsed = deleteProjectLocationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = await floorRepository.findById(projectId, floorId); + if (!current) { + return res.status(404).json({ error: "Floor not found" }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Geschoss löschen", + command: createProjectFloorDeleteProjectCommand(current), + }); + return res.json(result); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} diff --git a/src/server/controllers/room.controller.ts b/src/server/controllers/room.controller.ts index 330a538..3e78628 100644 --- a/src/server/controllers/room.controller.ts +++ b/src/server/controllers/room.controller.ts @@ -1,9 +1,15 @@ import type { Request, Response } from "express"; import { + createProjectRoomDeleteProjectCommand, createProjectRoomInsertProjectCommand, createProjectRoomSnapshot, + createProjectRoomUpdateProjectCommand, } from "../../domain/models/project-location-structure-project-command.model.js"; -import { createRoomSchema } from "../../shared/validation/project-structure.schemas.js"; +import { + createRoomSchema, + deleteProjectLocationSchema, + updateRoomSchema, +} from "../../shared/validation/project-structure.schemas.js"; import { projectCommandService } from "../composition/project-command-stores.js"; import { roomRepository } from "../composition/application-repositories.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; @@ -42,3 +48,61 @@ export async function createRoom(req: Request, res: Response) { return respondWithProjectCommandError(error, res); } } + +export async function updateRoom(req: Request, res: Response) { + const { projectId, roomId } = req.params; + if (typeof projectId !== "string" || typeof roomId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + const parsed = updateRoomSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = await roomRepository.findById(projectId, roomId); + if (!current) { + return res.status(404).json({ error: "Room not found" }); + } + const target = { + ...current, + floorId: parsed.data.floorId, + roomNumber: parsed.data.roomNumber, + roomName: parsed.data.roomName, + }; + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Raum bearbeiten", + command: createProjectRoomUpdateProjectCommand(current, target), + }); + return res.json({ ...result, room: target }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export async function deleteRoom(req: Request, res: Response) { + const { projectId, roomId } = req.params; + if (typeof projectId !== "string" || typeof roomId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + const parsed = deleteProjectLocationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = await roomRepository.findById(projectId, roomId); + if (!current) { + return res.status(404).json({ error: "Room not found" }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Raum löschen", + command: createProjectRoomDeleteProjectCommand(current), + }); + return res.json(result); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 8bdf909..b0bd39f 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -13,8 +13,8 @@ import { updateDistributionBoard, } from "../controllers/distribution-board.controller.js"; import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js"; -import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js"; -import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; +import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js"; +import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getProjectHistory, @@ -74,5 +74,9 @@ projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject); projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree); projectRouter.get("/:projectId/floors", listFloorsByProject); projectRouter.post("/:projectId/floors", createFloor); +projectRouter.put("/:projectId/floors/:floorId", updateFloor); +projectRouter.delete("/:projectId/floors/:floorId", deleteFloor); projectRouter.get("/:projectId/rooms", listRoomsByProject); projectRouter.post("/:projectId/rooms", createRoom); +projectRouter.put("/:projectId/rooms/:roomId", updateRoom); +projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom); diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index cb666e2..bf747da 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -69,6 +69,14 @@ export const createFloorSchema = z }) .strict(); +export const updateFloorSchema = createFloorSchema; + +export const deleteProjectLocationSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + }) + .strict(); + export const createRoomSchema = z .object({ expectedRevision: expectedProjectRevisionSchema, @@ -78,6 +86,15 @@ export const createRoomSchema = z }) .strict(); +export const updateRoomSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + floorId: z.string().trim().min(1).nullable(), + roomNumber: z.string().trim().min(1), + roomName: z.string().trim().min(1), + }) + .strict(); + export type CreateProjectInput = z.infer; export type UpdateProjectSettingsInput = z.infer< typeof updateProjectSettingsSchema diff --git a/tests/project-location-structure-project-command.repository.test.ts b/tests/project-location-structure-project-command.repository.test.ts index 19f278b..6378965 100644 --- a/tests/project-location-structure-project-command.repository.test.ts +++ b/tests/project-location-structure-project-command.repository.test.ts @@ -18,10 +18,19 @@ import { ProjectRevisionConflictError } from "../src/domain/errors/project-revis import { createProjectFloorInsertProjectCommand, createProjectFloorSnapshot, + createProjectFloorUpdateProjectCommand, createProjectRoomInsertProjectCommand, createProjectRoomSnapshot, + createProjectRoomUpdateProjectCommand, } from "../src/domain/models/project-location-structure-project-command.model.js"; -import { createFloor, createRoom } from "../src/frontend/utils/api.js"; +import { + createFloor, + createRoom, + deleteFloor, + deleteRoom, + updateFloor, + updateRoom, +} from "../src/frontend/utils/api.js"; import { createFloorSchema, createRoomSchema, @@ -83,11 +92,12 @@ describe("project-location structure project command", () => { false ); - const requests: Array<{ url: string; body: unknown }> = []; + const requests: Array<{ url: string; method: string; body: unknown }> = []; const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { requests.push({ url: String(input), + method: init?.method ?? "GET", body: JSON.parse(String(init?.body)), }); return new Response(JSON.stringify({}), { @@ -106,16 +116,27 @@ describe("project-location structure project command", () => { }, 8 ); + await updateFloor("project-1", "floor-1", { name: "1. OG" }, 9); + await updateRoom( + "project-1", + "room-1", + { roomNumber: "101", roomName: "Büro" }, + 10 + ); + await deleteFloor("project-1", "floor-1", 11); + await deleteRoom("project-1", "room-1", 12); } finally { globalThis.fetch = originalFetch; } assert.deepEqual(requests, [ { url: "/api/projects/project-1/floors", + method: "POST", body: { name: "EG", expectedRevision: 7 }, }, { url: "/api/projects/project-1/rooms", + method: "POST", body: { floorId: "floor-1", roomNumber: "001", @@ -123,9 +144,119 @@ describe("project-location structure project command", () => { expectedRevision: 8, }, }, + { + url: "/api/projects/project-1/floors/floor-1", + method: "PUT", + body: { name: "1. OG", expectedRevision: 9 }, + }, + { + url: "/api/projects/project-1/rooms/room-1", + method: "PUT", + body: { + roomNumber: "101", + roomName: "Büro", + floorId: null, + expectedRevision: 10, + }, + }, + { + url: "/api/projects/project-1/floors/floor-1", + method: "DELETE", + body: { expectedRevision: 11 }, + }, + { + url: "/api/projects/project-1/rooms/room-1", + method: "DELETE", + body: { expectedRevision: 12 }, + }, ]); }); + it("updates floors and rooms with exact 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 room = createProjectRoomSnapshot("project-1", { + floorId: floor.id, + roomNumber: "001", + roomName: "Technik", + }); + context.db.insert(floors).values(floor).run(); + context.db.insert(rooms).values(room).run(); + + const updatedFloor = { ...floor, name: "Erdgeschoss" }; + const floorCommand = createProjectFloorUpdateProjectCommand(floor, updatedFloor); + const floorResult = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: floorCommand, + }); + const updatedRoom = { + ...room, + floorId: null, + roomNumber: "002", + roomName: "Lager", + }; + const roomCommand = createProjectRoomUpdateProjectCommand(room, updatedRoom); + const roomResult = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: roomCommand, + }); + assert.equal(context.db.select().from(floors).where(eq(floors.id, floor.id)).get()?.name, "Erdgeschoss"); + assert.deepEqual(context.db.select().from(rooms).where(eq(rooms.id, room.id)).get(), updatedRoom); + + const undoRoom = history.getNextCommand("project-1", "undo"); + assert.ok(undoRoom); + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "undo", + historyTargetChangeSetId: undoRoom.changeSetId, + command: roomResult.inverse, + }); + assert.deepEqual(context.db.select().from(rooms).where(eq(rooms.id, room.id)).get(), room); + + const undoFloor = history.getNextCommand("project-1", "undo"); + assert.ok(undoFloor); + repository.execute({ + projectId: "project-1", + expectedRevision: 3, + source: "undo", + historyTargetChangeSetId: undoFloor.changeSetId, + command: floorResult.inverse, + }); + assert.deepEqual(context.db.select().from(floors).where(eq(floors.id, floor.id)).get(), floor); + + const redoFloor = history.getNextCommand("project-1", "redo"); + assert.ok(redoFloor); + repository.execute({ + projectId: "project-1", + expectedRevision: 4, + source: "redo", + historyTargetChangeSetId: redoFloor.changeSetId, + command: floorCommand, + }); + const redoRoom = history.getNextCommand("project-1", "redo"); + assert.ok(redoRoom); + repository.execute({ + projectId: "project-1", + expectedRevision: 5, + source: "redo", + historyTargetChangeSetId: redoRoom.changeSetId, + command: roomCommand, + }); + assert.deepEqual(context.db.select().from(floors).where(eq(floors.id, floor.id)).get(), updatedFloor); + assert.deepEqual(context.db.select().from(rooms).where(eq(rooms.id, room.id)).get(), updatedRoom); + } finally { + context.close(); + } + }); + it("creates a stable floor and supports persisted undo and redo", () => { const context = createTestDatabase(); try { @@ -218,7 +349,7 @@ describe("project-location structure project command", () => { historyTargetChangeSetId: undoStep.changeSetId, command: inserted.inverse, }), - /assigned to a distribution board/ + /Verteilung zugeordnet/ ); assert.equal( context.db.select().from(projectRevisions).all().length, @@ -416,7 +547,7 @@ describe("project-location structure project command", () => { historyTargetChangeSetId: undoStep.changeSetId, command: inserted.inverse, }), - /assigned rooms/ + /Räume zugeordnet/ ); } finally { floorContext.close();