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.
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:
+15 -4
View File
@@ -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
+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-,
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
+158 -24
View File
@@ -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<FloorDto | null>(null);
const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState("");
const [roomFloorId, setRoomFloorId] = useState("");
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(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<HTMLFormElement>) {
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
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(
const result = editingFloor
? await updateFloor(
projectId,
editingFloor.id,
{ name: floorName.trim() },
project.currentRevision
)
: await createFloor(
projectId,
{ name: floorName.trim() },
project.currentRevision
);
setFloors((current) => [...current, result.floor]);
setFloors((current) =>
editingFloor
? current.map((floor) =>
floor.id === result.floor.id ? result.floor : floor
)
: [...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<HTMLFormElement>) {
async function handleSaveRoom(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (
!projectId ||
@@ -276,23 +296,34 @@ export default function ProjectDetailPage() {
setIsSaving(true);
setError(null);
try {
const result = await createRoom(
projectId,
{
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() {
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={() => setStructureModal("floor")}
onClick={openCreateFloor}
type="button"
>
Hinzufügen
@@ -915,8 +1014,11 @@ export default function ProjectDetailPage() {
</div>
<ul className="list-group list-group-flush">
{floors.map((floor) => (
<li className="list-group-item" key={floor.id}>
{floor.name}
<li className="list-group-item d-flex justify-content-between align-items-center gap-2" key={floor.id}>
<span>{floor.name}</span>
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditFloor(floor)} type="button">
Bearbeiten
</button>
</li>
))}
{!floors.length ? (
@@ -935,7 +1037,7 @@ export default function ProjectDetailPage() {
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={() => setStructureModal("room")}
onClick={openCreateRoom}
type="button"
>
Hinzufügen
@@ -948,6 +1050,7 @@ export default function ProjectDetailPage() {
<th>Raumnummer</th>
<th>Raumname</th>
<th>Etage</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
@@ -956,11 +1059,16 @@ export default function ProjectDetailPage() {
<td>{room.roomNumber}</td>
<td>{room.roomName}</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>
))}
{!rooms.length ? (
<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.
</td>
</tr>
@@ -1432,11 +1540,14 @@ export default function ProjectDetailPage() {
{structureModal === "floor" ? (
<FormModal
isSaving={isSaving}
onClose={() => 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"}
>
<label className="form-label" htmlFor="floor-name">
Bezeichnung
@@ -1450,17 +1561,30 @@ export default function ProjectDetailPage() {
required
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>
) : null}
{structureModal === "room" ? (
<FormModal
isSaving={isSaving}
onClose={() => 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"}
>
<div className="row g-3">
<div className="col-12 col-md-4">
@@ -1507,6 +1631,16 @@ export default function ProjectDetailPage() {
</select>
</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>
) : 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 { 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;
}
}
@@ -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
+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 { 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;
}
}
@@ -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<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
@@ -42,6 +54,12 @@ export interface ProjectFloorDeleteProjectCommand
type: typeof projectFloorDeleteCommandType;
}
export interface ProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
export interface ProjectRoomInsertProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
@@ -54,11 +72,19 @@ export interface ProjectRoomDeleteProjectCommand
type: typeof projectRoomDeleteCommandType;
}
export interface ProjectRoomUpdateProjectCommand
extends SerializedProjectCommand<ProjectRoomUpdateCommandPayload> {
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<unknown>
): 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(
floor: unknown
): asserts floor is ProjectFloorSnapshot {
@@ -257,3 +360,9 @@ function assertNormalizedNonEmptyString(
function isPlainObject(value: unknown): value is Record<string, unknown> {
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 {
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({
+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) {
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() {
return request<GlobalDeviceDto[]>("/api/global-devices");
}
+60 -1
View File
@@ -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);
}
}
+65 -1
View File
@@ -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);
}
}
+6 -2
View File
@@ -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);
@@ -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<typeof createProjectSchema>;
export type UpdateProjectSettingsInput = z.infer<
typeof updateProjectSettingsSchema
@@ -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();