Add editable project floors and rooms

This commit is contained in:
2026-07-31 14:46:06 +02:00
parent 1e3c721cfa
commit 1eed19ef6b
15 changed files with 743 additions and 80 deletions
+6 -6
View File
@@ -245,7 +245,7 @@ Immutable revision metadata is available through the paginated
stored command payloads. stored command payloads.
Named logical snapshots can be created and listed through project-scoped API Named logical snapshots can be created and listed through project-scoped API
endpoints. Their schema-versioned payload contains the complete supported 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 not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete 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 The circuit-tree read model exposes fixed `headerComponents`, group-owned
`components`, `footerComponents`, optional group category/number and optional `components`, `footerComponents`, optional group category/number and optional
one-to-one protection DTOs for circuits and group components. These are 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 floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one `distribution-board.update`; both values are snapshot/export fields and one
persistent undo step. persistent undo step.
Floor and room setup use `project-floor.insert` and `project-room.insert` with Floor and room setup uses persistent insert, update and delete commands with
complete stable snapshots. Their inverses remove only unchanged records and complete stable snapshots. Their inverses remove or restore only unchanged records and
reject floors with assigned rooms/distribution boards or rooms referenced by reject floors with assigned rooms/distribution boards or rooms referenced by
device rows or retained upgrade data. Both POST routes require `expectedRevision` and return device rows. All write routes require `expectedRevision` and return the updated
the updated history state. history state.
Required operations: Required operations:
+15 -4
View File
@@ -253,16 +253,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
- body: `{ "name": "EG", "expectedRevision": 13 }` - body: `{ "name": "EG", "expectedRevision": 13 }`
- executes `project-floor.insert` with a stable floor id - executes `project-floor.insert` with a stable floor id
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }` - 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` - `POST /projects/:projectId/rooms`
- body: - body:
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }` `{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
- executes `project-room.insert` with a stable room id; `floorId` is optional - executes `project-room.insert` with a stable room id; `floorId` is optional
and must belong to the project when present and must belong to the project when present
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }` - response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
- Persistent Undo removes only unchanged, unreferenced records. A floor with - `PUT /projects/:projectId/rooms/:roomId`
assigned rooms or distribution boards and a room referenced by device rows - body:
or retained upgrade data are rejected instead of silently clearing foreign `{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }`
keys. - 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`. - Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
### Tree Endpoint ### Tree Endpoint
+17 -25
View File
@@ -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-, rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only- die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
Fachliche Verknüpfungen innerhalb des unterstützten Laufzeitmodells bleiben innerhalb des unterstützten Laufzeitmodells bleiben
erhalten. Die erhalten. Die
Projektübersicht verwendet dafür den separaten Collection-Endpunkt Projektübersicht verwendet dafür den separaten Collection-Endpunkt
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und `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 verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert; Das aktuelle Baseline-Snapshot-Schema enthält den Gleichzeitigkeitsfaktor,
Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft. Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie 1:1-Schutzgerätedaten. Capture,
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,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter JSON-Importmodi verwenden denselben vollständigen Zustand. Vorherige
bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch Entwicklungsschemas werden nicht mehr eingelesen.
Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
und bestehende flache Schutzangaben unverändert erhalten. Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
`project-floor.insert` und `project-room.insert` versionieren die Anlage von exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur
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 weder ein Raum noch eine Verteilung zugeordnet wurde. Ein entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
Raum wird nur entfernt, Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den Historienstand.
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
mehr.
## Projektgeräte ## Projektgeräte
`ProjectDevice` verwendet ausschließlich die kanonischen Circuit-First-Felder: Die Projektgerätekategorie `lighting`, `single_phase` oder `three_phase` ist die
`phaseType`, `powerPerUnit`, `simultaneityFactor`, `cosPhi`, `remark` sowie fachliche Klassifikation und Zielgruppe. `phaseType` ist keine separate
optionale technische und kategorisierende Felder. 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 Beim Einfügen entsteht eine verknüpfte `CircuitDeviceRow`. Der Anzeigename wird
kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät
+165 -31
View File
@@ -11,7 +11,9 @@ import {
createFloor, createFloor,
createProjectDevice, createProjectDevice,
createRoom, createRoom,
deleteFloor,
deleteProjectDevice, deleteProjectDevice,
deleteRoom,
deleteDistributionBoard, deleteDistributionBoard,
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
exportProjectTransfer, exportProjectTransfer,
@@ -26,8 +28,10 @@ import {
importProjectTransfer, importProjectTransfer,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
updateDistributionBoard, updateDistributionBoard,
updateFloor,
updateProjectDevice, updateProjectDevice,
updateProjectSettings, updateProjectSettings,
updateRoom,
} from "../../../frontend/utils/api"; } from "../../../frontend/utils/api";
import type { import type {
CircuitListDto, CircuitListDto,
@@ -95,9 +99,11 @@ export default function ProjectDetailPage() {
] = useState("1"); ] = useState("1");
const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [floorName, setFloorName] = useState(""); const [floorName, setFloorName] = useState("");
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
const [roomNumber, setRoomNumber] = useState(""); const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState(""); const [roomName, setRoomName] = useState("");
const [roomFloorId, setRoomFloorId] = useState(""); const [roomFloorId, setRoomFloorId] = useState("");
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [structureModal, setStructureModal] = useState< const [structureModal, setStructureModal] = useState<
"board" | "floor" | "room" | null "board" | "floor" | "room" | null
@@ -239,7 +245,7 @@ export default function ProjectDetailPage() {
} }
} }
async function handleCreateFloor(event: FormEvent<HTMLFormElement>) { async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!projectId || !project || !floorName.trim()) { if (!projectId || !project || !floorName.trim()) {
return; return;
@@ -247,23 +253,37 @@ export default function ProjectDetailPage() {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const result = await createFloor( const result = editingFloor
projectId, ? await updateFloor(
{ name: floorName.trim() }, projectId,
project.currentRevision 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); applyProjectRevision(result.history.currentRevision);
setFloorName(""); setFloorName("");
setEditingFloor(null);
setStructureModal(null); setStructureModal(null);
} catch (err) { } 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 { } finally {
setIsSaving(false); setIsSaving(false);
} }
} }
async function handleCreateRoom(event: FormEvent<HTMLFormElement>) { async function handleSaveRoom(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if ( if (
!projectId || !projectId ||
@@ -276,23 +296,34 @@ export default function ProjectDetailPage() {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const result = await createRoom( const input = {
projectId, floorId: roomFloorId || undefined,
{ roomNumber: roomNumber.trim(),
floorId: roomFloorId || undefined, roomName: roomName.trim(),
roomNumber: roomNumber.trim(), };
roomName: roomName.trim(), const result = editingRoom
}, ? await updateRoom(
project.currentRevision 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); applyProjectRevision(result.history.currentRevision);
setRoomNumber(""); setRoomNumber("");
setRoomName(""); setRoomName("");
setRoomFloorId(""); setRoomFloorId("");
setEditingRoom(null);
setStructureModal(null); setStructureModal(null);
} catch (err) { } 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 { } finally {
setIsSaving(false); 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() { async function handleCopyBoard() {
if ( if (
!projectId || !projectId ||
@@ -907,7 +1006,7 @@ export default function ProjectDetailPage() {
</div> </div>
<button <button
className="btn btn-sm btn-outline-primary" className="btn btn-sm btn-outline-primary"
onClick={() => setStructureModal("floor")} onClick={openCreateFloor}
type="button" type="button"
> >
Hinzufügen Hinzufügen
@@ -915,8 +1014,11 @@ export default function ProjectDetailPage() {
</div> </div>
<ul className="list-group list-group-flush"> <ul className="list-group list-group-flush">
{floors.map((floor) => ( {floors.map((floor) => (
<li className="list-group-item" key={floor.id}> <li className="list-group-item d-flex justify-content-between align-items-center gap-2" key={floor.id}>
{floor.name} <span>{floor.name}</span>
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditFloor(floor)} type="button">
Bearbeiten
</button>
</li> </li>
))} ))}
{!floors.length ? ( {!floors.length ? (
@@ -935,7 +1037,7 @@ export default function ProjectDetailPage() {
</div> </div>
<button <button
className="btn btn-sm btn-outline-primary" className="btn btn-sm btn-outline-primary"
onClick={() => setStructureModal("room")} onClick={openCreateRoom}
type="button" type="button"
> >
Hinzufügen Hinzufügen
@@ -948,6 +1050,7 @@ export default function ProjectDetailPage() {
<th>Raumnummer</th> <th>Raumnummer</th>
<th>Raumname</th> <th>Raumname</th>
<th>Etage</th> <th>Etage</th>
<th className="text-end">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -956,11 +1059,16 @@ export default function ProjectDetailPage() {
<td>{room.roomNumber}</td> <td>{room.roomNumber}</td>
<td>{room.roomName}</td> <td>{room.roomName}</td>
<td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td> <td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td>
<td className="text-end">
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditRoom(room)} type="button">
Bearbeiten
</button>
</td>
</tr> </tr>
))} ))}
{!rooms.length ? ( {!rooms.length ? (
<tr> <tr>
<td colSpan={3} className="text-center text-secondary py-4"> <td colSpan={4} className="text-center text-secondary py-4">
Noch keine Räume vorhanden. Noch keine Räume vorhanden.
</td> </td>
</tr> </tr>
@@ -1432,11 +1540,14 @@ export default function ProjectDetailPage() {
{structureModal === "floor" ? ( {structureModal === "floor" ? (
<FormModal <FormModal
isSaving={isSaving} isSaving={isSaving}
onClose={() => setStructureModal(null)} onClose={() => {
onSubmit={handleCreateFloor} setStructureModal(null);
setEditingFloor(null);
}}
onSubmit={handleSaveFloor}
submitDisabled={!floorName.trim()} submitDisabled={!floorName.trim()}
submitLabel="Etage hinzufügen" submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"}
title="Etage hinzufügen" title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"}
> >
<label className="form-label" htmlFor="floor-name"> <label className="form-label" htmlFor="floor-name">
Bezeichnung Bezeichnung
@@ -1450,17 +1561,30 @@ export default function ProjectDetailPage() {
required required
value={floorName} value={floorName}
/> />
{editingFloor ? (
<div className="border-top mt-4 pt-3">
<p className="text-secondary small mb-2">
Etagen mit zugeordneten Räumen oder Verteilungen können nicht gelöscht werden.
</p>
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteFloor()} type="button">
Etage löschen
</button>
</div>
) : null}
</FormModal> </FormModal>
) : null} ) : null}
{structureModal === "room" ? ( {structureModal === "room" ? (
<FormModal <FormModal
isSaving={isSaving} isSaving={isSaving}
onClose={() => setStructureModal(null)} onClose={() => {
onSubmit={handleCreateRoom} setStructureModal(null);
setEditingRoom(null);
}}
onSubmit={handleSaveRoom}
submitDisabled={!roomNumber.trim() || !roomName.trim()} submitDisabled={!roomNumber.trim() || !roomName.trim()}
submitLabel="Raum anlegen" submitLabel={editingRoom ? "Änderungen speichern" : "Raum anlegen"}
title="Raum hinzufügen" title={editingRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
> >
<div className="row g-3"> <div className="row g-3">
<div className="col-12 col-md-4"> <div className="col-12 col-md-4">
@@ -1507,6 +1631,16 @@ export default function ProjectDetailPage() {
</select> </select>
</div> </div>
</div> </div>
{editingRoom ? (
<div className="border-top mt-4 pt-3">
<p className="text-secondary small mb-2">
In Stromkreisen verwendete Räume können nicht gelöscht werden.
</p>
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteRoom()} type="button">
Raum löschen
</button>
</div>
) : null}
</FormModal> </FormModal>
) : null} ) : null}
+9 -1
View File
@@ -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 type { AppDatabase } from "../database-context.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
@@ -13,4 +13,12 @@ export class FloorRepository {
.where(eq(floors.projectId, projectId)) .where(eq(floors.projectId, projectId))
.orderBy(asc(floors.sortOrder), asc(floors.name)); .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;
}
} }
@@ -2,16 +2,22 @@ import { and, eq } from "drizzle-orm";
import { import {
assertProjectFloorDeleteProjectCommand, assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand, assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand, assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand, assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand,
createProjectFloorDeleteProjectCommand, createProjectFloorDeleteProjectCommand,
createProjectFloorInsertProjectCommand, createProjectFloorInsertProjectCommand,
createProjectFloorUpdateProjectCommand,
createProjectRoomDeleteProjectCommand, createProjectRoomDeleteProjectCommand,
createProjectRoomInsertProjectCommand, createProjectRoomInsertProjectCommand,
createProjectRoomUpdateProjectCommand,
projectFloorDeleteCommandType, projectFloorDeleteCommandType,
projectFloorInsertCommandType, projectFloorInsertCommandType,
projectFloorUpdateCommandType,
projectRoomDeleteCommandType, projectRoomDeleteCommandType,
projectRoomInsertCommandType, projectRoomInsertCommandType,
projectRoomUpdateCommandType,
type ProjectFloorSnapshot, type ProjectFloorSnapshot,
type ProjectLocationStructureProjectCommand, type ProjectLocationStructureProjectCommand,
type ProjectRoomSnapshot, type ProjectRoomSnapshot,
@@ -62,6 +68,9 @@ export class ProjectLocationStructureProjectCommandRepository
projectId, projectId,
command.payload.floor command.payload.floor
); );
case projectFloorUpdateCommandType:
assertProjectFloorUpdateProjectCommand(command);
return this.updateFloor(database, projectId, command.payload.expected, command.payload.target);
case projectRoomInsertCommandType: case projectRoomInsertCommandType:
assertProjectRoomInsertProjectCommand(command); assertProjectRoomInsertProjectCommand(command);
return this.insertRoom( return this.insertRoom(
@@ -76,6 +85,9 @@ export class ProjectLocationStructureProjectCommandRepository
projectId, projectId,
command.payload.room 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(); .get();
if (referencedRoom) { if (referencedRoom) {
throw new Error( 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 const referencedDistributionBoard = database
@@ -137,7 +149,7 @@ export class ProjectLocationStructureProjectCommandRepository
.get(); .get();
if (referencedDistributionBoard) { if (referencedDistributionBoard) {
throw new Error( 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 const deleted = database
@@ -191,6 +203,70 @@ export class ProjectLocationStructureProjectCommandRepository
return createProjectRoomDeleteProjectCommand(room); 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( private deleteRoom(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
@@ -217,7 +293,7 @@ export class ProjectLocationStructureProjectCommandRepository
.get(); .get();
if (referencedDeviceRow) { if (referencedDeviceRow) {
throw new Error( 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 const deleted = database
+9 -1
View File
@@ -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 type { AppDatabase } from "../database-context.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
@@ -13,4 +13,12 @@ export class RoomRepository {
.where(eq(rooms.projectId, projectId)) .where(eq(rooms.projectId, projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName)); .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;
}
} }
@@ -3,8 +3,10 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectFloorInsertCommandType = "project-floor.insert" as const; export const projectFloorInsertCommandType = "project-floor.insert" as const;
export const projectFloorDeleteCommandType = "project-floor.delete" 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 projectRoomInsertCommandType = "project-room.insert" as const;
export const projectRoomDeleteCommandType = "project-room.delete" 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 const projectLocationStructureCommandSchemaVersion = 1 as const;
export interface ProjectFloorSnapshot { export interface ProjectFloorSnapshot {
@@ -30,6 +32,16 @@ interface ProjectRoomStructureCommandPayload {
room: ProjectRoomSnapshot; room: ProjectRoomSnapshot;
} }
interface ProjectFloorUpdateCommandPayload {
expected: ProjectFloorSnapshot;
target: ProjectFloorSnapshot;
}
interface ProjectRoomUpdateCommandPayload {
expected: ProjectRoomSnapshot;
target: ProjectRoomSnapshot;
}
export interface ProjectFloorInsertProjectCommand export interface ProjectFloorInsertProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> { extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion; schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
@@ -42,6 +54,12 @@ export interface ProjectFloorDeleteProjectCommand
type: typeof projectFloorDeleteCommandType; type: typeof projectFloorDeleteCommandType;
} }
export interface ProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
export interface ProjectRoomInsertProjectCommand export interface ProjectRoomInsertProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> { extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion; schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
@@ -54,11 +72,19 @@ export interface ProjectRoomDeleteProjectCommand
type: typeof projectRoomDeleteCommandType; type: typeof projectRoomDeleteCommandType;
} }
export interface ProjectRoomUpdateProjectCommand
extends SerializedProjectCommand<ProjectRoomUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectRoomUpdateCommandType;
}
export type ProjectLocationStructureProjectCommand = export type ProjectLocationStructureProjectCommand =
| ProjectFloorInsertProjectCommand | ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand | ProjectFloorDeleteProjectCommand
| ProjectFloorUpdateProjectCommand
| ProjectRoomInsertProjectCommand | ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand; | ProjectRoomDeleteProjectCommand
| ProjectRoomUpdateProjectCommand;
export function createProjectFloorSnapshot( export function createProjectFloorSnapshot(
projectId: string, 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( export function assertProjectFloorInsertProjectCommand(
command: SerializedProjectCommand<unknown> command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorInsertProjectCommand { ): asserts command is ProjectFloorInsertProjectCommand {
@@ -174,6 +224,59 @@ export function assertProjectRoomDeleteProjectCommand(
); );
} }
export function assertProjectFloorUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): 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<unknown>
): 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( export function assertProjectFloorSnapshot(
floor: unknown floor: unknown
): asserts floor is ProjectFloorSnapshot { ): asserts floor is ProjectFloorSnapshot {
@@ -257,3 +360,9 @@ function assertNormalizedNonEmptyString(
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value); 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<string, unknown>)[key] === value
);
}
@@ -72,12 +72,16 @@ import {
import { import {
assertProjectFloorDeleteProjectCommand, assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand, assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand, assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand, assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand,
projectFloorDeleteCommandType, projectFloorDeleteCommandType,
projectFloorInsertCommandType, projectFloorInsertCommandType,
projectFloorUpdateCommandType,
projectRoomDeleteCommandType, projectRoomDeleteCommandType,
projectRoomInsertCommandType, projectRoomInsertCommandType,
projectRoomUpdateCommandType,
} from "../models/project-location-structure-project-command.model.js"; } from "../models/project-location-structure-project-command.model.js";
import { import {
assertProjectStateRestoreCommand, assertProjectStateRestoreCommand,
@@ -470,6 +474,10 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case projectFloorUpdateCommandType: {
assertProjectFloorUpdateProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case projectRoomInsertCommandType: { case projectRoomInsertCommandType: {
assertProjectRoomInsertProjectCommand(input.command); assertProjectRoomInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ return this.projectLocationStructureStore.execute({
@@ -484,6 +492,10 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case projectRoomUpdateCommandType: {
assertProjectRoomUpdateProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case circuitSectionReorderCommandType: { case circuitSectionReorderCommandType: {
assertCircuitSectionReorderProjectCommand(input.command); assertCircuitSectionReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({ return this.circuitSectionReorderStore.execute({
+38
View File
@@ -786,6 +786,25 @@ export function createFloor(
); );
} }
export function updateFloor(
projectId: string,
floorId: string,
input: CreateFloorInput,
expectedRevision: number
) {
return request<ProjectFloorCommandResultDto>(`/api/projects/${projectId}/floors/${floorId}`, {
method: "PUT",
body: JSON.stringify({ ...input, expectedRevision }),
});
}
export function deleteFloor(projectId: string, floorId: string, expectedRevision: number) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/floors/${floorId}`, {
method: "DELETE",
body: JSON.stringify({ expectedRevision }),
});
}
export function listRooms(projectId: string) { export function listRooms(projectId: string) {
return request<RoomDto[]>(`/api/projects/${projectId}/rooms`); return request<RoomDto[]>(`/api/projects/${projectId}/rooms`);
} }
@@ -804,6 +823,25 @@ export function createRoom(
); );
} }
export function updateRoom(
projectId: string,
roomId: string,
input: CreateRoomInput,
expectedRevision: number
) {
return request<ProjectRoomCommandResultDto>(`/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<ProjectCommandResultDto>(`/api/projects/${projectId}/rooms/${roomId}`, {
method: "DELETE",
body: JSON.stringify({ expectedRevision }),
});
}
export function listGlobalDevices() { export function listGlobalDevices() {
return request<GlobalDeviceDto[]>("/api/global-devices"); return request<GlobalDeviceDto[]>("/api/global-devices");
} }
+60 -1
View File
@@ -1,9 +1,15 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { import {
createProjectFloorDeleteProjectCommand,
createProjectFloorInsertProjectCommand, createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot, createProjectFloorSnapshot,
createProjectFloorUpdateProjectCommand,
} from "../../domain/models/project-location-structure-project-command.model.js"; } 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 { projectCommandService } from "../composition/project-command-stores.js";
import { floorRepository } from "../composition/application-repositories.js"; import { floorRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
@@ -50,3 +56,56 @@ export async function createFloor(req: Request, res: Response) {
return respondWithProjectCommandError(error, res); 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);
}
}
+65 -1
View File
@@ -1,9 +1,15 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { import {
createProjectRoomDeleteProjectCommand,
createProjectRoomInsertProjectCommand, createProjectRoomInsertProjectCommand,
createProjectRoomSnapshot, createProjectRoomSnapshot,
createProjectRoomUpdateProjectCommand,
} from "../../domain/models/project-location-structure-project-command.model.js"; } 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 { projectCommandService } from "../composition/project-command-stores.js";
import { roomRepository } from "../composition/application-repositories.js"; import { roomRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
@@ -42,3 +48,61 @@ export async function createRoom(req: Request, res: Response) {
return respondWithProjectCommandError(error, res); 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);
}
}
+6 -2
View File
@@ -13,8 +13,8 @@ import {
updateDistributionBoard, updateDistributionBoard,
} from "../controllers/distribution-board.controller.js"; } from "../controllers/distribution-board.controller.js";
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js"; import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js"; import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { import {
getProjectHistory, getProjectHistory,
@@ -74,5 +74,9 @@ projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree); projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get("/:projectId/floors", listFloorsByProject); projectRouter.get("/:projectId/floors", listFloorsByProject);
projectRouter.post("/:projectId/floors", createFloor); projectRouter.post("/:projectId/floors", createFloor);
projectRouter.put("/:projectId/floors/:floorId", updateFloor);
projectRouter.delete("/:projectId/floors/:floorId", deleteFloor);
projectRouter.get("/:projectId/rooms", listRoomsByProject); projectRouter.get("/:projectId/rooms", listRoomsByProject);
projectRouter.post("/:projectId/rooms", createRoom); projectRouter.post("/:projectId/rooms", createRoom);
projectRouter.put("/:projectId/rooms/:roomId", updateRoom);
projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom);
@@ -69,6 +69,14 @@ export const createFloorSchema = z
}) })
.strict(); .strict();
export const updateFloorSchema = createFloorSchema;
export const deleteProjectLocationSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
})
.strict();
export const createRoomSchema = z export const createRoomSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
@@ -78,6 +86,15 @@ export const createRoomSchema = z
}) })
.strict(); .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<typeof createProjectSchema>; export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectSettingsInput = z.infer< export type UpdateProjectSettingsInput = z.infer<
typeof updateProjectSettingsSchema typeof updateProjectSettingsSchema
@@ -18,10 +18,19 @@ import { ProjectRevisionConflictError } from "../src/domain/errors/project-revis
import { import {
createProjectFloorInsertProjectCommand, createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot, createProjectFloorSnapshot,
createProjectFloorUpdateProjectCommand,
createProjectRoomInsertProjectCommand, createProjectRoomInsertProjectCommand,
createProjectRoomSnapshot, createProjectRoomSnapshot,
createProjectRoomUpdateProjectCommand,
} from "../src/domain/models/project-location-structure-project-command.model.js"; } 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 { import {
createFloorSchema, createFloorSchema,
createRoomSchema, createRoomSchema,
@@ -83,11 +92,12 @@ describe("project-location structure project command", () => {
false false
); );
const requests: Array<{ url: string; body: unknown }> = []; const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => { globalThis.fetch = async (input, init) => {
requests.push({ requests.push({
url: String(input), url: String(input),
method: init?.method ?? "GET",
body: JSON.parse(String(init?.body)), body: JSON.parse(String(init?.body)),
}); });
return new Response(JSON.stringify({}), { return new Response(JSON.stringify({}), {
@@ -106,16 +116,27 @@ describe("project-location structure project command", () => {
}, },
8 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 { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
assert.deepEqual(requests, [ assert.deepEqual(requests, [
{ {
url: "/api/projects/project-1/floors", url: "/api/projects/project-1/floors",
method: "POST",
body: { name: "EG", expectedRevision: 7 }, body: { name: "EG", expectedRevision: 7 },
}, },
{ {
url: "/api/projects/project-1/rooms", url: "/api/projects/project-1/rooms",
method: "POST",
body: { body: {
floorId: "floor-1", floorId: "floor-1",
roomNumber: "001", roomNumber: "001",
@@ -123,9 +144,119 @@ describe("project-location structure project command", () => {
expectedRevision: 8, 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", () => { it("creates a stable floor and supports persisted undo and redo", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -218,7 +349,7 @@ describe("project-location structure project command", () => {
historyTargetChangeSetId: undoStep.changeSetId, historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse, command: inserted.inverse,
}), }),
/assigned to a distribution board/ /Verteilung zugeordnet/
); );
assert.equal( assert.equal(
context.db.select().from(projectRevisions).all().length, context.db.select().from(projectRevisions).all().length,
@@ -416,7 +547,7 @@ describe("project-location structure project command", () => {
historyTargetChangeSetId: undoStep.changeSetId, historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse, command: inserted.inverse,
}), }),
/assigned rooms/ /Räume zugeordnet/
); );
} finally { } finally {
floorContext.close(); floorContext.close();