diff --git a/AGENTS.md b/AGENTS.md index 51b6616..bcd2ca6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,11 +250,14 @@ 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 inverse command. Restore can therefore be undone and redone after a restart. -Current snapshot schema version 6 additionally stores the distribution-board -info fields `boardNumber`, `location`, `fedFrom` and `remark` and the project -cover fields `siteAddress`, `workStage`, `projectLead` and `projectEditor`; -all older supported versions are upgraded with `null` for these fields. -Stored named and automatic snapshots of every supported version (1 to 6) +Current snapshot schema version 7 additionally stores the project buildings +and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6 +are upgraded with no buildings and `buildingId = null`. Version 6 stores the +distribution-board info fields `boardNumber`, `location`, `fedFrom` and +`remark` and the project cover fields `siteAddress`, `workStage`, +`projectLead` and `projectEditor`; all older supported versions are upgraded +with `null` for these fields. +Stored named and automatic snapshots of every supported version (1 to 7) remain restorable; they are upgraded while parsing. Version 5 stores the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with `manualQuantity = quantity`; they already contain the complete confirmed @@ -270,7 +273,7 @@ project graph. Project-wide Revit CSV configuration uses `external-csv-configuration.update`. Its complete expected/target snapshot is persisted through the shared project-command transaction, so create, update, -delete, Undo and Redo are revision-safe. Snapshot schema 5/6, restore and portable +delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable project duplication preserve the configuration; duplication remaps its internal id and project link. The project page exposes the Revit CSV modal. It edits the complete diff --git a/docs/README.md b/docs/README.md index 395b1ac..cdddb67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we Drag-and-drop, Sortierung, Filterung und Undo/Redo - [Seitenansicht und PDF-Export](print-view.md) – Deckblatt, Verteilerübersicht, Verteilerpläne, Büro-Logo +- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume, + Befehle, Snapshot-Schema 7, eigene Seite - [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – bewusst noch nicht implementierte oder begrenzte Funktionen diff --git a/docs/building-structure.md b/docs/building-structure.md new file mode 100644 index 0000000..1c1a455 --- /dev/null +++ b/docs/building-structure.md @@ -0,0 +1,79 @@ +# Gebäudestruktur (Gebäude → Etagen → Räume) + +Projekte mit mehreren Gebäuden werden über eine eigene Ebene **Gebäude** +gegliedert. Etagen können einem Gebäude zugeordnet sein, Räume und Verteilungen +hängen wie bisher an der Etage. Das Gebäude eines Raums oder einer Verteilung +ergibt sich also aus der Etage; eine zweite, konkurrierende Zuordnung gibt es +nicht. Abstimmung mit Julian: Issue #6. + +## Datenmodell + +| Tabelle | Feld | Bedeutung | +|---|---|---| +| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts | +| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude | + +Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und +erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration +und kein automatisch angelegtes Standardgebäude. + +## Befehle, Undo/Redo + +Alle Änderungen laufen über die bestehende Orts-Struktur +(`ProjectLocationStructureProjectCommandRepository`) mit Revision, Inverse und +persistentem Undo/Redo: + +- `project-building.insert` / `project-building.delete` mit vollständigem + Gebäude-Snapshot (Schema-Version 1) +- `project-building.update`: ändert nur den Namen +- Löschen eines Gebäudes wird abgelehnt, solange Etagen zugeordnet sind + („Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.“) +- Etagenbefehle `project-floor.insert/.update/.delete` haben seit + Schema-Version **2** das Feld `buildingId`. `project-floor.update` darf Name + und/oder Gebäude ändern, nicht aber `sortOrder`. Ein referenziertes Gebäude + muss zum Projekt gehören. +- Version-1-Etagenbefehle aus bestehender Historie bleiben ausführbar. Sie + stammen aus der Zeit vor den Gebäuden und werden vor der Ausführung mit + `buildingId = null` auf Version 2 gehoben; auch die daraus neu gespeicherten + Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie + exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im + Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen. +- Raumbefehle bleiben unverändert (Version 1). + +## Snapshots und Transfer + +Projekt-Snapshot-Schema **7** enthält `buildings` und `floors[].buildingId`. +Alle älteren unterstützten Versionen (1–6) werden beim Einlesen mit leerer +Gebäudeliste und `buildingId = null` hochgestuft; gespeicherte Sicherungspunkte +bleiben wiederherstellbar. Die Relationen werden geprüft (Gebäude gehört zum +Projekt, `buildingId` verweist auf ein Gebäude des Snapshots). Restore löscht +und schreibt Gebäude zusammen mit den Etagen; der Projekt-Transfer vergibt neue +Gebäude-IDs und stellt die Etagenverweise um. + +## API + +- `GET /api/projects/:projectId/buildings` – Gebäude sortiert nach + `sortOrder`, Name +- `POST /api/projects/:projectId/buildings` – `{ "name": "Haus A", "expectedRevision": 3 }` +- `PUT /api/projects/:projectId/buildings/:buildingId` – `{ "name": "Haus B", "expectedRevision": 4 }` +- `DELETE /api/projects/:projectId/buildings/:buildingId` – `{ "expectedRevision": 5 }` +- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich + optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten + das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert. + +## Oberfläche + +Gebäude, Etagen und Räume werden auf der eigenen Seite +`/projects/:projectId/struktur` („Gebäudestruktur“) gepflegt, erreichbar über +die Seitenleiste und die Kennzahlen der Projektübersicht. Drei Spalten +Gebäude → Etagen → Räume filtern sich gegenseitig: Klick auf ein Gebäude zeigt +nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Filter +auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der +Stromkreisliste. + +## Offen + +- Revit: Das Gebäude kann später aus einem Revit-Parameter kommen und über die + bestehende CSV-Zuordnung gemappt werden (Frage an Julian in Issue #6). +- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue + Gebäude werden hinten angehängt. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index d325944..156be62 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -257,15 +257,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. `distribution-board.delete-subtree`. Persistent Undo/Redo restores or removes the same complete subtree, including protection devices. -### Project Floors and Rooms +### Project Buildings, Floors and Rooms +- `GET /projects/:projectId/buildings` +- `POST /projects/:projectId/buildings` + - body: `{ "name": "Haus A", "expectedRevision": 12 }` + - executes `project-building.insert` with a stable building id + - response: `{ "building": { ... }, "revision": { ... }, "history": { ... } }` +- `PUT /projects/:projectId/buildings/:buildingId` + - body: `{ "name": "Haus B", "expectedRevision": 13 }` (name only) +- `DELETE /projects/:projectId/buildings/:buildingId` + - body: `{ "expectedRevision": 14 }` + - rejects buildings with assigned floors - `POST /projects/:projectId/floors` - - body: `{ "name": "EG", "expectedRevision": 13 }` - - executes `project-floor.insert` with a stable floor id + - body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }` + (`buildingId` optional, `null` = no building) + - executes `project-floor.insert` (command version 2) 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 + - body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }` + - executes an exact persistent floor update of name and/or building; a + missing `buildingId` keeps the current building - `DELETE /projects/:projectId/floors/:floorId` - body: `{ "expectedRevision": 15 }` - rejects floors with assigned rooms or distribution boards diff --git a/docs/current-architecture.md b/docs/current-architecture.md index fe55413..191c3f4 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -246,9 +246,11 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen. -Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte -Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals -statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare, +Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre +versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter +Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite +„Gebäudestruktur“ (`/projects/:projectId/struktur`) gepflegt; Etagen können +einem Gebäude zugeordnet sein (`docs/building-structure.md`). Projektgeräte werden als durchsuchbare, fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, @@ -422,6 +424,8 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext. +Snapshot-Schema 7 enthält zusätzlich die Gebäude und die Gebäudezuordnung +der Etagen; ältere Versionen werden ohne Gebäude hochgestuft. Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des Projekts; ältere Versionen werden mit `null` hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 4c0a8ea..a7c1300 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -275,6 +275,10 @@ Completed foundation: - `project-floor.insert` and `project-room.insert` persist complete location records with stable ids; their inverses reject changed or referenced records so Undo never clears room assignments or device-row links implicitly +- `project-building.insert/.update/.delete` persist complete building records; + deleting a building with assigned floors is rejected. Floor commands carry + `buildingId` since command version 2; version 1 floor commands from older + history are upgraded with `buildingId = null` before execution - the floor and room POST routes require the current expected revision and the project page tracks the returned history state diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 8d27b1d..947767a 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -5,6 +5,7 @@ const dbPath = path.resolve("data", "leistungsbilanz.db"); const db = new Database(dbPath, { readonly: true }); const requiredTables = [ + "buildings", "circuit_sections", "circuits", "circuit_device_rows", @@ -100,6 +101,13 @@ const projectColumns = new Set( const missingProjectColumns = requiredProjectColumns.filter( (name) => !projectColumns.has(name) ); +const requiredFloorColumns = ["building_id"]; +const floorColumns = new Set( + db.prepare("PRAGMA table_info(floors)").all().map((column) => column.name) +); +const missingFloorColumns = requiredFloorColumns.filter( + (name) => !floorColumns.has(name) +); const requiredDistributionBoardColumns = [ "floor_id", "supply_type", @@ -155,6 +163,7 @@ console.log( requiredCircuitSectionColumns.join(", ") ); console.log("Required project columns:", requiredProjectColumns.join(", ")); +console.log("Required floor columns:", requiredFloorColumns.join(", ")); console.log( "Required distribution-board columns:", requiredDistributionBoardColumns.join(", ") @@ -171,6 +180,11 @@ if (missing.length > 0) { process.exit(1); } +if (missingFloorColumns.length > 0) { + console.error("Missing floor columns:", missingFloorColumns.join(", ")); + process.exit(1); +} + if (missingProjectDeviceColumns.length > 0) { console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", ")); process.exit(1); diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..f392303 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1461,3 +1461,144 @@ a.kpi:hover { .sidebar-collapsed .sidebar-label { display: none; } + +/* ── Gebäudestruktur (Gebäude → Etagen → Räume) ─────────────────────── */ + +.structure-columns { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--space-md); + align-items: start; +} + +@media (min-width: 992px) { + .structure-columns { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.structure-column-title { + font-size: 1rem; + font-weight: 700; + margin: 0; + display: flex; + align-items: center; +} + +.structure-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.4rem; + line-height: 1; +} + +.structure-back-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.45rem; + margin-top: 0.1rem; +} + +.structure-list { + max-height: 70vh; + overflow-y: auto; +} + +.structure-entry { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 0; +} + +.structure-entry-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: 0.55rem 0.75rem; + border: 0; + background: transparent; + color: inherit; + text-align: left; + font: inherit; +} + +button.structure-entry-main { + cursor: pointer; +} + +button.structure-entry-main:hover { + background: var(--surface-hover); +} + +button.structure-entry-main:focus-visible { + outline: 2px solid var(--accent-blue); + outline-offset: -2px; +} + +.structure-entry.is-selected { + background: var(--surface-selected); + box-shadow: inset 3px 0 0 var(--color-signal); +} + +.structure-entry.is-selected .structure-entry-label { + font-weight: 600; +} + +.structure-entry.is-selected button.structure-entry-main:hover { + background: transparent; +} + +.structure-entry-text { + min-width: 0; + display: flex; + flex-direction: column; +} + +.structure-entry-label { + overflow-wrap: anywhere; +} + +.structure-entry-hint { + font-size: 0.78rem; + color: var(--color-ink-soft); +} + +.structure-entry-meta { + flex: 0 0 auto; + font-size: 0.78rem; + color: var(--color-ink-soft); + white-space: nowrap; +} + +.structure-entry-actions { + flex: 0 0 auto; + display: inline-flex; + gap: 4px; + padding-right: 0.75rem; +} + +.structure-room-number { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.structure-group-label { + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + color: var(--color-accent); + background: var(--panel-bg-subtle); + padding: 0.35rem 0.75rem; +} + +.structure-empty { + color: var(--color-ink-soft); + font-size: 0.9rem; +} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 73f9460..0719078 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -8,17 +8,15 @@ import { copyDistributionBoard, copyProjectDeviceToGlobal, createDistributionBoard, - createFloor, createProjectDevice, createRoom, - deleteFloor, deleteProjectDevice, - deleteRoom, deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, getProject, getProjectDeviceSyncPreview, + listBuildings, listCircuitLists, listDistributionBoards, listFloors, @@ -28,12 +26,11 @@ import { importProjectTransfer, synchronizeProjectDeviceRows, updateDistributionBoard, - updateFloor, updateProjectDevice, updateProjectSettings, - updateRoom, } from "../../../frontend/utils/api"; import type { + BuildingDto, CircuitListDto, CreateRoomInput, CreateProjectDeviceInput, @@ -67,7 +64,7 @@ import { import { FormModal } from "../../../frontend/components/form-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal"; -import { RoomModal } from "../../../frontend/components/room-modal"; +import { FloorSelectOptions } from "../../../frontend/components/floor-select-options"; const projectDeviceSyncFieldLabels: Record = { name: "Technischer Name", @@ -89,6 +86,7 @@ export default function ProjectDetailPage() { const [project, setProject] = useState(null); const [boards, setBoards] = useState([]); const [circuitLists, setCircuitLists] = useState([]); + const [buildings, setBuildings] = useState([]); const [floors, setFloors] = useState([]); const [rooms, setRooms] = useState([]); const [projectDevices, setProjectDevices] = useState([]); @@ -116,14 +114,9 @@ export default function ProjectDetailPage() { remark: null, }) ); - const [floorName, setFloorName] = useState(""); - const [editingFloor, setEditingFloor] = useState(null); - const [editingRoom, setEditingRoom] = useState(null); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); - const [structureModal, setStructureModal] = useState< - "board" | "floor" | "room" | null - >(null); + const [structureModal, setStructureModal] = useState<"board" | null>(null); const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = useState(false); const [editingProjectDevice, setEditingProjectDevice] = @@ -147,6 +140,7 @@ export default function ProjectDetailPage() { getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), + listBuildings(projectId), listFloors(projectId), listRooms(projectId), listProjectDevices(projectId), @@ -156,6 +150,7 @@ export default function ProjectDetailPage() { currentProject, distributionBoards, loadedCircuitLists, + loadedBuildings, loadedFloors, loadedRooms, loadedProjectDevices, @@ -164,6 +159,7 @@ export default function ProjectDetailPage() { setProject(currentProject); setBoards(distributionBoards); setCircuitLists(loadedCircuitLists); + setBuildings(loadedBuildings); setFloors(loadedFloors); setRooms(loadedRooms); setProjectDevices(loadedProjectDevices); @@ -176,6 +172,7 @@ export default function ProjectDetailPage() { }, [projectId]); const boardCount = useMemo(() => boards.length, [boards.length]); + const buildingCount = useMemo(() => buildings.length, [buildings.length]); const floorCount = useMemo(() => floors.length, [floors.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]); const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); @@ -260,74 +257,6 @@ export default function ProjectDetailPage() { } } - async function handleSaveFloor(event: FormEvent) { - event.preventDefault(); - if (!projectId || !project || !floorName.trim()) { - return; - } - setIsSaving(true); - setError(null); - try { - const result = editingFloor - ? await updateFloor( - projectId, - editingFloor.id, - { name: floorName.trim() }, - project.currentRevision - ) - : await createFloor( - projectId, - { name: floorName.trim() }, - project.currentRevision - ); - setFloors((current) => - editingFloor - ? current.map((floor) => - floor.id === result.floor.id ? result.floor : floor - ) - : [...current, result.floor] - ); - applyProjectRevision(result.history.currentRevision); - setFloorName(""); - setEditingFloor(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden."); - } finally { - setIsSaving(false); - } - } - - async function handleSaveRoom(input: CreateRoomInput) { - if (!projectId || !project) return; - setIsSaving(true); - setError(null); - try { - 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] - ); - applyProjectRevision(result.history.currentRevision); - setEditingRoom(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert werden."); - } finally { - setIsSaving(false); - } - } - async function handleSaveProjectSettings(input: ProjectSettingsInput) { if (!projectId || !project) { return; @@ -515,65 +444,6 @@ export default function ProjectDetailPage() { return result; } - function openCreateFloor() { - setEditingFloor(null); - setFloorName(""); - setStructureModal("floor"); - } - - function openEditFloor(floor: FloorDto) { - setEditingFloor(floor); - setFloorName(floor.name); - setStructureModal("floor"); - } - - function openCreateRoom() { - setEditingRoom(null); - setStructureModal("room"); - } - - function openEditRoom(room: RoomDto) { - setEditingRoom(room); - 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); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden."); - } finally { - setIsSaving(false); - } - } - async function handleCopyBoard() { if ( !projectId || @@ -902,14 +772,18 @@ export default function ProjectDetailPage() {
Verteilungen
{boardCount}
- + +
Gebäude
+
{buildingCount}
+ +
Etagen
{floorCount}
-
- + +
Räume
{roomCount}
-
+
Projektgeräte
{projectDeviceCount}
@@ -1025,88 +899,6 @@ export default function ProjectDetailPage() { - -
-
-
-
- Etagen - {floorCount} -
- -
-
    - {floors.map((floor) => ( -
  • - {floor.name} - -
  • - ))} - {!floors.length ? ( -
  • Noch keine Etagen vorhanden.
  • - ) : null} -
-
-
- -
-
-
-
- Räume - {roomCount} -
- -
-
- - - - - - - - - - - {rooms.map((room) => ( - - - - - - - ))} - {!rooms.length ? ( - - - - ) : null} - -
RaumnummerRaumnameEtageAktionen
{room.roomNumber}{room.roomName}{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"} - -
- Noch keine Räume vorhanden. -
-
-
-
@@ -1366,11 +1158,7 @@ export default function ProjectDetailPage() { value={boardFloorId} > - {floors.map((floor) => ( - - ))} +
@@ -1436,11 +1224,7 @@ export default function ProjectDetailPage() { value={editingBoardFloorId} > - {floors.map((floor) => ( - - ))} +
@@ -1572,57 +1356,6 @@ export default function ProjectDetailPage() { ) : null} - {structureModal === "floor" ? ( - { - setStructureModal(null); - setEditingFloor(null); - }} - onSubmit={handleSaveFloor} - submitDisabled={!floorName.trim()} - submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"} - title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"} - > - - setFloorName(event.target.value)} - placeholder="z. B. EG" - required - value={floorName} - /> - {editingFloor ? ( -
-

- Etagen mit zugeordneten Räumen oder Verteilungen können nicht gelöscht werden. -

- -
- ) : null} -
- ) : null} - - {structureModal === "room" ? ( - { - setStructureModal(null); - setEditingRoom(null); - }} - onDelete={editingRoom ? () => void handleDeleteRoom() : undefined} - onSave={handleSaveRoom} - /> - ) : null} - {isProjectDeviceModalOpen ? ( void; + title: string; +} + +function StructureColumn({ + addLabel, + children, + count, + headingId, + isAddDisabled, + onAdd, + title, +}: StructureColumnProps) { + return ( +
+
+

+ {title} + {count} +

+ +
+
    {children}
+
+ ); +} + +interface StructureEntryProps { + actions?: ReactNode; + hint?: string; + isSelected?: boolean; + label: ReactNode; + meta?: string; + onSelect?: () => void; +} + +function StructureEntry({ + actions, + hint, + isSelected = false, + label, + meta, + onSelect, +}: StructureEntryProps) { + const content = ( + <> + + {label} + {hint ? {hint} : null} + + {meta ? {meta} : null} + + ); + return ( +
  • + {onSelect ? ( + + ) : ( +
    {content}
    + )} + {actions ?
    {actions}
    : null} +
  • + ); +} + +interface EntryActionsProps { + deleteLabel: string; + editLabel: string; + isDisabled: boolean; + onDelete: () => void; + onEdit: () => void; +} + +function EntryActions({ + deleteLabel, + editLabel, + isDisabled, + onDelete, + onEdit, +}: EntryActionsProps) { + return ( + <> + + + + ); +} + +export default function BuildingStructurePage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const [project, setProject] = useState(null); + const [buildings, setBuildings] = useState([]); + const [floors, setFloors] = useState([]); + const [rooms, setRooms] = useState([]); + const [buildingSelection, setBuildingSelection] = + useState(ALL_SELECTION); + const [floorSelection, setFloorSelection] = + useState(ALL_SELECTION); + const [dialog, setDialog] = useState(null); + const [dialogName, setDialogName] = useState(""); + const [dialogBuildingId, setDialogBuildingId] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const reloadStructure = useCallback(async () => { + const [currentProject, loadedBuildings, loadedFloors, loadedRooms] = + await Promise.all([ + getProject(projectId), + listBuildings(projectId), + listFloors(projectId), + listRooms(projectId), + ]); + setProject(currentProject); + setBuildings(loadedBuildings); + setFloors(loadedFloors); + setRooms(loadedRooms); + }, [projectId]); + + useEffect(() => { + if (!projectId) { + return; + } + setIsLoading(true); + reloadStructure() + .then(() => setError(null)) + .catch((err: unknown) => + setError( + err instanceof Error + ? err.message + : "Gebäudestruktur konnte nicht geladen werden." + ) + ) + .finally(() => setIsLoading(false)); + }, [projectId, reloadStructure]); + + const effectiveBuildingSelection = normalizeBuildingSelection( + buildingSelection, + floors, + buildings + ); + const effectiveFloorSelection = normalizeFloorSelection( + floorSelection, + rooms, + floors, + buildings, + effectiveBuildingSelection + ); + + const visibleFloors = useMemo( + () => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection), + [buildings, effectiveBuildingSelection, floors] + ); + const visibleFloorGroups = useMemo( + () => + effectiveBuildingSelection === ALL_SELECTION && buildings.length + ? groupFloorsByBuilding(visibleFloors, buildings) + : [{ buildingId: null, label: "", floors: visibleFloors }], + [buildings, effectiveBuildingSelection, visibleFloors] + ); + const visibleRooms = useMemo( + () => + filterRooms( + rooms, + floors, + buildings, + effectiveBuildingSelection, + effectiveFloorSelection + ).sort(compareRooms), + [buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms] + ); + const roomsInBuildingSelection = useMemo( + () => + filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION) + .length, + [buildings, effectiveBuildingSelection, floors, rooms] + ); + const floorCountByBuilding = useMemo( + () => countFloorsByBuilding(floors, buildings), + [buildings, floors] + ); + const roomCountByFloor = useMemo( + () => countRoomsByFloor(rooms, floors), + [floors, rooms] + ); + const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings); + const showUnassignedFloor = showsRoomsWithoutFloorEntry( + rooms, + floors, + effectiveBuildingSelection + ); + const isBusy = isSaving || !project; + + function selectBuilding(selection: StructureSelection) { + setBuildingSelection(selection); + setFloorSelection(ALL_SELECTION); + } + + async function runCommand( + execute: (expectedRevision: number) => Promise, + fallbackMessage: string + ): Promise { + if (!project) { + return false; + } + setIsSaving(true); + setError(null); + try { + const result = await execute(project.currentRevision); + setProject((current) => + current + ? { ...current, currentRevision: result.history.currentRevision } + : current + ); + await reloadStructure(); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : fallbackMessage); + // Refresh the revision and lists so a stale state does not block the + // next attempt; the command error stays visible. + await reloadStructure().catch(() => undefined); + return false; + } finally { + setIsSaving(false); + } + } + + function openBuildingDialog(building: BuildingDto | null) { + setDialogName(building?.name ?? ""); + setDialog({ kind: "building", building }); + } + + function openFloorDialog(floor: FloorDto | null) { + setDialogName(floor?.name ?? ""); + setDialogBuildingId( + floor + ? floor.buildingId ?? "" + : defaultBuildingIdForNewFloor(effectiveBuildingSelection) + ); + setDialog({ kind: "floor", floor }); + } + + function openRoomDialog(room: RoomDto | null) { + setDialog({ + kind: "room", + room, + defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection), + }); + } + + async function handleSaveBuilding(event: FormEvent) { + event.preventDefault(); + if (dialog?.kind !== "building" || !dialogName.trim()) { + return; + } + const editingBuilding = dialog.building; + const name = dialogName.trim(); + // An unchanged dialog is not a command; the backend rejects no-op updates. + if (editingBuilding && editingBuilding.name === name) { + setDialog(null); + return; + } + const saved = await runCommand( + (expectedRevision) => + editingBuilding + ? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision) + : createBuilding(projectId, { name }, expectedRevision), + "Gebäude konnte nicht gespeichert werden." + ); + if (saved) { + setDialog(null); + } + } + + async function handleSaveFloor(event: FormEvent) { + event.preventDefault(); + if (dialog?.kind !== "floor" || !dialogName.trim()) { + return; + } + const editingFloor = dialog.floor; + const input = { + name: dialogName.trim(), + buildingId: dialogBuildingId || null, + }; + if ( + editingFloor && + editingFloor.name === input.name && + editingFloor.buildingId === input.buildingId + ) { + setDialog(null); + return; + } + const saved = await runCommand( + (expectedRevision) => + editingFloor + ? updateFloor(projectId, editingFloor.id, input, expectedRevision) + : createFloor(projectId, input, expectedRevision), + "Etage konnte nicht gespeichert werden." + ); + if (saved) { + setDialog(null); + } + } + + async function handleSaveRoom(input: CreateRoomInput) { + if (dialog?.kind !== "room") { + return; + } + const editingRoom = dialog.room; + if ( + editingRoom && + editingRoom.roomNumber === input.roomNumber.trim() && + editingRoom.roomName === input.roomName.trim() && + editingRoom.floorId === (input.floorId || null) + ) { + setDialog(null); + return; + } + const saved = await runCommand( + (expectedRevision) => + editingRoom + ? updateRoom(projectId, editingRoom.id, input, expectedRevision) + : createRoom(projectId, input, expectedRevision), + "Raum konnte nicht gespeichert werden." + ); + if (saved) { + setDialog(null); + } + } + + async function handleDeleteBuilding(building: BuildingDto) { + if ((floorCountByBuilding.get(building.id) ?? 0) > 0) { + setError(BUILDING_HAS_FLOORS_MESSAGE); + return; + } + if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) { + return; + } + await runCommand( + (expectedRevision) => + deleteBuilding(projectId, building.id, expectedRevision), + "Gebäude konnte nicht gelöscht werden." + ); + } + + async function handleDeleteFloor(floor: FloorDto) { + if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) { + return; + } + await runCommand( + (expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision), + "Etage konnte nicht gelöscht werden." + ); + } + + async function handleDeleteRoom(room: RoomDto) { + if ( + !window.confirm( + `Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?` + ) + ) { + return; + } + await runCommand( + (expectedRevision) => deleteRoom(projectId, room.id, expectedRevision), + "Raum konnte nicht gelöscht werden." + ); + } + + return ( +
    +
    +
    + +
    +
    + + {error ? ( +
    + {error} +
    + ) : null} + {isLoading ? ( +

    + Gebäudestruktur wird geladen … +

    + ) : null} + +
    + openBuildingDialog(null)} + title="Gebäude" + > + selectBuilding(ALL_SELECTION)} + /> + {buildings.map((building) => ( + void handleDeleteBuilding(building)} + onEdit={() => openBuildingDialog(building)} + /> + } + isSelected={effectiveBuildingSelection === building.id} + key={building.id} + label={building.name} + meta={formatCount( + floorCountByBuilding.get(building.id) ?? 0, + "Etage", + "Etagen" + )} + onSelect={() => selectBuilding(building.id)} + /> + ))} + {showUnassignedBuilding ? ( + {UNASSIGNED_BUILDING_LABEL}} + meta={formatCount( + floorCountByBuilding.get(null) ?? 0, + "Etage", + "Etagen" + )} + onSelect={() => selectBuilding(UNASSIGNED_SELECTION)} + /> + ) : null} + {!buildings.length && !isLoading ? ( +
  • + Noch keine Gebäude vorhanden. +
  • + ) : null} +
    + + openFloorDialog(null)} + title="Etagen" + > + setFloorSelection(ALL_SELECTION)} + /> + {visibleFloorGroups.map((group) => ( + + {group.floors.map((floor) => ( + void handleDeleteFloor(floor)} + onEdit={() => openFloorDialog(floor)} + /> + } + isSelected={effectiveFloorSelection === floor.id} + key={floor.id} + label={floor.name} + meta={formatCount( + roomCountByFloor.get(floor.id) ?? 0, + "Raum", + "Räume" + )} + onSelect={() => setFloorSelection(floor.id)} + /> + ))} + + ))} + {showUnassignedFloor ? ( + {UNASSIGNED_FLOOR_LABEL}} + meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")} + onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)} + /> + ) : null} + {!visibleFloors.length && !isLoading ? ( +
  • + {floors.length + ? "Keine Etagen in dieser Auswahl." + : "Noch keine Etagen vorhanden."} +
  • + ) : null} +
    + + openRoomDialog(null)} + title="Räume" + > + {visibleRooms.map((room) => ( + void handleDeleteRoom(room)} + onEdit={() => openRoomDialog(room)} + /> + } + hint={ + effectiveFloorSelection === ALL_SELECTION + ? describeRoomLocation(room, floors, buildings) + : undefined + } + key={room.id} + label={ + <> + {room.roomNumber}{" "} + {room.roomName} + + } + /> + ))} + {!visibleRooms.length && !isLoading ? ( +
  • + {rooms.length + ? "Keine Räume in dieser Auswahl." + : "Noch keine Räume vorhanden."} +
  • + ) : null} +
    +
    + + {dialog?.kind === "building" ? ( + setDialog(null)} + onSubmit={handleSaveBuilding} + submitDisabled={!dialogName.trim()} + submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"} + title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"} + > + + setDialogName(event.target.value)} + placeholder="z. B. Haus A" + required + value={dialogName} + /> + + ) : null} + + {dialog?.kind === "floor" ? ( + setDialog(null)} + onSubmit={handleSaveFloor} + submitDisabled={!dialogName.trim()} + submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"} + title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"} + > +
    +
    + + setDialogName(event.target.value)} + placeholder="z. B. EG" + required + value={dialogName} + /> +
    +
    + + +
    +
    +
    + ) : null} + + {dialog?.kind === "room" ? ( + setDialog(null)} + onSave={handleSaveRoom} + /> + ) : null} +
    + ); +} + +function FloorGroupEntries({ + children, + label, +}: { + children: ReactNode; + label: string; +}) { + return ( + <> + {label ? ( +
  • + {label} +
  • + ) : null} + {children} + + ); +} diff --git a/src/db/migrations/0009_buildings.sql b/src/db/migrations/0009_buildings.sql new file mode 100644 index 0000000..6f65174 --- /dev/null +++ b/src/db/migrations/0009_buildings.sql @@ -0,0 +1,9 @@ +CREATE TABLE `buildings` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null; \ No newline at end of file diff --git a/src/db/migrations/meta/0009_snapshot.json b/src/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..1b7d566 --- /dev/null +++ b/src/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,2626 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2cd9adef-2f08-4635-b3d9-ecf71f624c7f", + "prevId": "fb609719-ea17-4833-a27b-f75da0d3e860", + "tables": { + "buildings": { + "name": "buildings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 8322eb3..485b8f4 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1790411560953, "tag": "0008_project_cover_and_office_profile", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1790419185882, + "tag": "0009_buildings", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/building.repository.ts b/src/db/repositories/building.repository.ts new file mode 100644 index 0000000..011739f --- /dev/null +++ b/src/db/repositories/building.repository.ts @@ -0,0 +1,27 @@ +import { and, asc, eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { buildings } from "../schema/buildings.js"; + +export class BuildingRepository { + constructor(private readonly database: AppDatabase) {} + + async listByProject(projectId: string) { + return this.database + .select() + .from(buildings) + .where(eq(buildings.projectId, projectId)) + .orderBy(asc(buildings.sortOrder), asc(buildings.name)); + } + + async findById(projectId: string, buildingId: string) { + return ( + this.database + .select() + .from(buildings) + .where( + and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId)) + ) + .get() ?? null + ); + } +} diff --git a/src/db/repositories/project-location-structure-project-command.repository.ts b/src/db/repositories/project-location-structure-project-command.repository.ts index 7cbadde..9ec3f48 100644 --- a/src/db/repositories/project-location-structure-project-command.repository.ts +++ b/src/db/repositories/project-location-structure-project-command.repository.ts @@ -1,23 +1,34 @@ import { and, eq } from "drizzle-orm"; import { + assertProjectBuildingDeleteProjectCommand, + assertProjectBuildingInsertProjectCommand, + assertProjectBuildingUpdateProjectCommand, assertProjectFloorDeleteProjectCommand, assertProjectFloorInsertProjectCommand, assertProjectFloorUpdateProjectCommand, assertProjectRoomDeleteProjectCommand, assertProjectRoomInsertProjectCommand, assertProjectRoomUpdateProjectCommand, + createProjectBuildingDeleteProjectCommand, + createProjectBuildingInsertProjectCommand, + createProjectBuildingUpdateProjectCommand, createProjectFloorDeleteProjectCommand, createProjectFloorInsertProjectCommand, createProjectFloorUpdateProjectCommand, createProjectRoomDeleteProjectCommand, createProjectRoomInsertProjectCommand, createProjectRoomUpdateProjectCommand, + projectBuildingDeleteCommandType, + projectBuildingInsertCommandType, + projectBuildingUpdateCommandType, projectFloorDeleteCommandType, projectFloorInsertCommandType, projectFloorUpdateCommandType, projectRoomDeleteCommandType, projectRoomInsertCommandType, projectRoomUpdateCommandType, + upgradeProjectFloorCommand, + type ProjectBuildingSnapshot, type ProjectFloorSnapshot, type ProjectLocationStructureProjectCommand, type ProjectRoomSnapshot, @@ -27,6 +38,7 @@ import type { ProjectLocationStructureProjectCommandStore, } from "../../domain/ports/project-location-structure-project-command.store.js"; import type { AppDatabase } from "../database-context.js"; +import { buildings } from "../schema/buildings.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { floors } from "../schema/floors.js"; import { distributionBoards } from "../schema/distribution-boards.js"; @@ -40,11 +52,14 @@ export class ProjectLocationStructureProjectCommandRepository constructor(private readonly database: AppDatabase) {} execute(input: ExecuteProjectLocationStructureCommandInput) { + // Forward and inverse are persisted with one schema version, so a + // version 1 floor command from older history is upgraded first. + const command = toCurrentCommandVersion(input.command); return executeProjectCommandTransaction( this.database, - input, + { ...input, command }, (tx) => - this.applyCommand(tx, input.projectId, input.command) + this.applyCommand(tx, input.projectId, command) ); } @@ -54,23 +69,43 @@ export class ProjectLocationStructureProjectCommandRepository command: ProjectLocationStructureProjectCommand ): ProjectLocationStructureProjectCommand { switch (command.type) { - case projectFloorInsertCommandType: + case projectBuildingInsertCommandType: + assertProjectBuildingInsertProjectCommand(command); + return this.insertBuilding(database, projectId, command.payload.building); + case projectBuildingDeleteCommandType: + assertProjectBuildingDeleteProjectCommand(command); + return this.deleteBuilding(database, projectId, command.payload.building); + case projectBuildingUpdateCommandType: + assertProjectBuildingUpdateProjectCommand(command); + return this.updateBuilding( + database, + projectId, + command.payload.expected, + command.payload.target + ); + case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(command); + const current = upgradeProjectFloorCommand(command); return this.insertFloor( database, projectId, - command.payload.floor + current.payload.floor ); - case projectFloorDeleteCommandType: + } + case projectFloorDeleteCommandType: { assertProjectFloorDeleteProjectCommand(command); + const current = upgradeProjectFloorCommand(command); return this.deleteFloor( database, projectId, - command.payload.floor + current.payload.floor ); - case projectFloorUpdateCommandType: + } + case projectFloorUpdateCommandType: { assertProjectFloorUpdateProjectCommand(command); - return this.updateFloor(database, projectId, command.payload.expected, command.payload.target); + const current = upgradeProjectFloorCommand(command); + return this.updateFloor(database, projectId, current.payload.expected, current.payload.target); + } case projectRoomInsertCommandType: assertProjectRoomInsertProjectCommand(command); return this.insertRoom( @@ -100,6 +135,7 @@ export class ProjectLocationStructureProjectCommandRepository if (floor.projectId !== projectId) { throw new Error("Project-floor snapshot does not belong to project."); } + this.assertBuildingInProject(database, projectId, floor.buildingId); const existing = database .select({ id: floors.id }) .from(floors) @@ -167,6 +203,104 @@ export class ProjectLocationStructureProjectCommandRepository return createProjectFloorInsertProjectCommand(expected); } + private insertBuilding( + database: AppDatabase, + projectId: string, + building: ProjectBuildingSnapshot + ) { + this.assertProjectExists(database, projectId); + if (building.projectId !== projectId) { + throw new Error("Project-building snapshot does not belong to project."); + } + const existing = database + .select({ id: buildings.id }) + .from(buildings) + .where(eq(buildings.id, building.id)) + .get(); + if (existing) { + throw new Error("Project-building id already exists."); + } + database.insert(buildings).values(building).run(); + return createProjectBuildingDeleteProjectCommand(building); + } + + private deleteBuilding( + database: AppDatabase, + projectId: string, + expected: ProjectBuildingSnapshot + ) { + const persisted = database + .select() + .from(buildings) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .get(); + if (!persisted || !sameRecord(expected, persisted)) { + throw new Error("Project building changed before deletion."); + } + const referencedFloor = database + .select({ id: floors.id }) + .from(floors) + .where(eq(floors.buildingId, expected.id)) + .limit(1) + .get(); + if (referencedFloor) { + throw new Error( + "Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind." + ); + } + const deleted = database + .delete(buildings) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .run(); + if (deleted.changes !== 1) { + throw new Error("Project building could not be deleted."); + } + return createProjectBuildingInsertProjectCommand(expected); + } + + private updateBuilding( + database: AppDatabase, + projectId: string, + expected: ProjectBuildingSnapshot, + target: ProjectBuildingSnapshot + ) { + const persisted = database + .select() + .from(buildings) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .get(); + if (!persisted || !sameRecord(expected, persisted)) { + throw new Error("Project building changed before update."); + } + const updated = database + .update(buildings) + .set({ name: target.name }) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .run(); + if (updated.changes !== 1) { + throw new Error("Das Gebäude konnte nicht aktualisiert werden."); + } + return createProjectBuildingUpdateProjectCommand(target, expected); + } + + private assertBuildingInProject( + database: AppDatabase, + projectId: string, + buildingId: string | null + ) { + if (buildingId === null) { + return; + } + const building = database + .select({ id: buildings.id }) + .from(buildings) + .where(and(eq(buildings.id, buildingId), eq(buildings.projectId, projectId))) + .get(); + if (!building) { + throw new Error("Project floor references a foreign building."); + } + } + private insertRoom( database: AppDatabase, projectId: string, @@ -217,9 +351,10 @@ export class ProjectLocationStructureProjectCommandRepository if (!persisted || !sameRecord(expected, persisted)) { throw new Error("Project floor changed before update."); } + this.assertBuildingInProject(database, projectId, target.buildingId); const updated = database .update(floors) - .set({ name: target.name }) + .set({ name: target.name, buildingId: target.buildingId }) .where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId))) .run(); if (updated.changes !== 1) { @@ -326,6 +461,24 @@ export class ProjectLocationStructureProjectCommandRepository } } +function toCurrentCommandVersion( + command: ProjectLocationStructureProjectCommand +): ProjectLocationStructureProjectCommand { + switch (command.type) { + case projectFloorInsertCommandType: + assertProjectFloorInsertProjectCommand(command); + return upgradeProjectFloorCommand(command); + case projectFloorDeleteCommandType: + assertProjectFloorDeleteProjectCommand(command); + return upgradeProjectFloorCommand(command); + case projectFloorUpdateCommandType: + assertProjectFloorUpdateProjectCommand(command); + return upgradeProjectFloorCommand(command); + default: + return command; + } +} + function sameRecord( expected: object, actual: object diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index cf6f9bc..496f2cb 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -21,6 +21,7 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; +import { buildings } from "../schema/buildings.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projectChangeSets } from "../schema/project-change-sets.js"; @@ -164,6 +165,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) { })) ), projectDevices: byId(state.projectDevices), + buildings: byId(state.buildings), floors: byId(state.floors), rooms: byId(state.rooms), externalCsvConfiguration: state.externalCsvConfiguration, @@ -219,6 +221,10 @@ function replaceProjectState( .delete(floors) .where(eq(floors.projectId, state.project.id)) .run(); + database + .delete(buildings) + .where(eq(buildings.projectId, state.project.id)) + .run(); database .update(projects) @@ -241,6 +247,7 @@ function replaceProjectState( .where(eq(projects.id, state.project.id)) .run(); + insertMany(database, buildings, state.buildings); insertMany(database, floors, state.floors); if (state.externalCsvConfiguration !== null) { database diff --git a/src/db/repositories/project-state-snapshot.persistence.ts b/src/db/repositories/project-state-snapshot.persistence.ts index 84131e7..a11cd58 100644 --- a/src/db/repositories/project-state-snapshot.persistence.ts +++ b/src/db/repositories/project-state-snapshot.persistence.ts @@ -15,6 +15,7 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; +import { buildings } from "../schema/buildings.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; @@ -219,6 +220,12 @@ export function readProjectStateSnapshot( .where(eq(projectDevices.projectId, projectId)) .orderBy(asc(projectDevices.displayName), asc(projectDevices.id)) .all(); + const buildingRows = database + .select() + .from(buildings) + .where(eq(buildings.projectId, projectId)) + .orderBy(asc(buildings.sortOrder), asc(buildings.id)) + .all(); const floorRows = database .select() .from(floors) @@ -273,6 +280,7 @@ export function readProjectStateSnapshot( deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [], })), projectDevices: projectDeviceRows, + buildings: buildingRows, floors: floorRows, rooms: roomRows, }); diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index 07a5067..11f23f7 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -16,6 +16,7 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; +import { buildings } from "../schema/buildings.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; @@ -127,6 +128,7 @@ function insertProjectState( .values(state.externalCsvConfiguration) .run(); } + insertMany(database, buildings, state.buildings); insertMany(database, floors, state.floors); insertMany(database, rooms, state.rooms); insertMany(database, projectDevices, state.projectDevices); diff --git a/src/db/schema/buildings.ts b/src/db/schema/buildings.ts new file mode 100644 index 0000000..7d2fb78 --- /dev/null +++ b/src/db/schema/buildings.ts @@ -0,0 +1,11 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +export const buildings = sqliteTable("buildings", { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + sortOrder: integer("sort_order").notNull().default(0), +}); diff --git a/src/db/schema/floors.ts b/src/db/schema/floors.ts index 5c68753..bd50534 100644 --- a/src/db/schema/floors.ts +++ b/src/db/schema/floors.ts @@ -1,4 +1,5 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { buildings } from "./buildings.js"; import { projects } from "./projects.js"; export const floors = sqliteTable("floors", { @@ -8,4 +9,9 @@ export const floors = sqliteTable("floors", { .references(() => projects.id, { onDelete: "cascade" }), name: text("name").notNull(), sortOrder: integer("sort_order").notNull().default(0), + // Optional parent building. Floors created before buildings existed stay + // without one and are shown as "Ohne Gebäude". + buildingId: text("building_id").references(() => buildings.id, { + onDelete: "set null", + }), }); diff --git a/src/domain/models/project-location-structure-project-command.model.ts b/src/domain/models/project-location-structure-project-command.model.ts index 616add9..21dfa89 100644 --- a/src/domain/models/project-location-structure-project-command.model.ts +++ b/src/domain/models/project-location-structure-project-command.model.ts @@ -1,21 +1,42 @@ import crypto from "node:crypto"; import type { SerializedProjectCommand } from "./project-command.model.js"; +export const projectBuildingInsertCommandType = "project-building.insert" as const; +export const projectBuildingDeleteCommandType = "project-building.delete" as const; +export const projectBuildingUpdateCommandType = "project-building.update" as const; 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; +/** Room commands, building commands and legacy floor commands. */ export const projectLocationStructureCommandSchemaVersion = 1 as const; +/** + * Floor commands carry `buildingId` since version 2. Version 1 floor + * commands stay executable from persisted history: they were recorded before + * buildings existed, so they are upgraded with `buildingId = null`. + */ +export const projectFloorCommandSchemaVersion = 2 as const; +const legacyProjectFloorCommandSchemaVersion = 1 as const; + +export interface ProjectBuildingSnapshot { + id: string; + projectId: string; + name: string; + sortOrder: number; +} export interface ProjectFloorSnapshot { id: string; projectId: string; name: string; sortOrder: number; + buildingId: string | null; } +export type LegacyProjectFloorSnapshot = Omit; + export interface ProjectRoomSnapshot { id: string; projectId: string; @@ -24,39 +45,93 @@ export interface ProjectRoomSnapshot { roomName: string; } +interface ProjectBuildingStructureCommandPayload { + building: ProjectBuildingSnapshot; +} + interface ProjectFloorStructureCommandPayload { floor: ProjectFloorSnapshot; } +interface LegacyProjectFloorStructureCommandPayload { + floor: LegacyProjectFloorSnapshot; +} + interface ProjectRoomStructureCommandPayload { room: ProjectRoomSnapshot; } +interface ProjectBuildingUpdateCommandPayload { + expected: ProjectBuildingSnapshot; + target: ProjectBuildingSnapshot; +} + interface ProjectFloorUpdateCommandPayload { expected: ProjectFloorSnapshot; target: ProjectFloorSnapshot; } +interface LegacyProjectFloorUpdateCommandPayload { + expected: LegacyProjectFloorSnapshot; + target: LegacyProjectFloorSnapshot; +} + interface ProjectRoomUpdateCommandPayload { expected: ProjectRoomSnapshot; target: ProjectRoomSnapshot; } +export interface ProjectBuildingInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectBuildingInsertCommandType; +} + +export interface ProjectBuildingDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectBuildingDeleteCommandType; +} + +export interface ProjectBuildingUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectBuildingUpdateCommandType; +} + export interface ProjectFloorInsertProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectFloorCommandSchemaVersion; type: typeof projectFloorInsertCommandType; } export interface ProjectFloorDeleteProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectFloorCommandSchemaVersion; type: typeof projectFloorDeleteCommandType; } export interface ProjectFloorUpdateProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectFloorCommandSchemaVersion; + type: typeof projectFloorUpdateCommandType; +} + +export interface LegacyProjectFloorInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; + type: typeof projectFloorInsertCommandType; +} + +export interface LegacyProjectFloorDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; + type: typeof projectFloorDeleteCommandType; +} + +export interface LegacyProjectFloorUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; type: typeof projectFloorUpdateCommandType; } @@ -79,23 +154,46 @@ export interface ProjectRoomUpdateProjectCommand } export type ProjectLocationStructureProjectCommand = + | ProjectBuildingInsertProjectCommand + | ProjectBuildingDeleteProjectCommand + | ProjectBuildingUpdateProjectCommand | ProjectFloorInsertProjectCommand | ProjectFloorDeleteProjectCommand | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorInsertProjectCommand + | LegacyProjectFloorDeleteProjectCommand + | LegacyProjectFloorUpdateProjectCommand | ProjectRoomInsertProjectCommand | ProjectRoomDeleteProjectCommand | ProjectRoomUpdateProjectCommand; -export function createProjectFloorSnapshot( +export function createProjectBuildingSnapshot( projectId: string, name: string, sortOrder: number +): ProjectBuildingSnapshot { + const building: ProjectBuildingSnapshot = { + id: crypto.randomUUID(), + projectId, + name: name.trim(), + sortOrder, + }; + assertProjectBuildingSnapshot(building); + return building; +} + +export function createProjectFloorSnapshot( + projectId: string, + name: string, + sortOrder: number, + buildingId: string | null = null ): ProjectFloorSnapshot { const floor: ProjectFloorSnapshot = { id: crypto.randomUUID(), projectId, name: name.trim(), sortOrder, + buildingId: buildingId?.trim() || null, }; assertProjectFloorSnapshot(floor); return floor; @@ -120,12 +218,46 @@ export function createProjectRoomSnapshot( return room; } +export function createProjectBuildingInsertProjectCommand( + building: ProjectBuildingSnapshot +): ProjectBuildingInsertProjectCommand { + assertProjectBuildingSnapshot(building); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectBuildingInsertCommandType, + payload: { building }, + }; +} + +export function createProjectBuildingDeleteProjectCommand( + building: ProjectBuildingSnapshot +): ProjectBuildingDeleteProjectCommand { + assertProjectBuildingSnapshot(building); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectBuildingDeleteCommandType, + payload: { building }, + }; +} + +export function createProjectBuildingUpdateProjectCommand( + expected: ProjectBuildingSnapshot, + target: ProjectBuildingSnapshot +): ProjectBuildingUpdateProjectCommand { + assertMatchingBuildingUpdate(expected, target); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectBuildingUpdateCommandType, + payload: { expected, target }, + }; +} + export function createProjectFloorInsertProjectCommand( floor: ProjectFloorSnapshot ): ProjectFloorInsertProjectCommand { assertProjectFloorSnapshot(floor); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectFloorCommandSchemaVersion, type: projectFloorInsertCommandType, payload: { floor }, }; @@ -136,7 +268,7 @@ export function createProjectFloorDeleteProjectCommand( ): ProjectFloorDeleteProjectCommand { assertProjectFloorSnapshot(floor); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectFloorCommandSchemaVersion, type: projectFloorDeleteCommandType, payload: { floor }, }; @@ -170,7 +302,7 @@ export function createProjectFloorUpdateProjectCommand( ): ProjectFloorUpdateProjectCommand { assertMatchingFloorUpdate(expected, target); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectFloorCommandSchemaVersion, type: projectFloorUpdateCommandType, payload: { expected, target }, }; @@ -188,9 +320,43 @@ export function createProjectRoomUpdateProjectCommand( }; } +export function assertProjectBuildingInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectBuildingInsertProjectCommand { + assertProjectBuildingStructureProjectCommand( + command, + projectBuildingInsertCommandType + ); +} + +export function assertProjectBuildingDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectBuildingDeleteProjectCommand { + assertProjectBuildingStructureProjectCommand( + command, + projectBuildingDeleteCommandType + ); +} + +export function assertProjectBuildingUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectBuildingUpdateProjectCommand { + if ( + command.schemaVersion !== projectLocationStructureCommandSchemaVersion || + command.type !== projectBuildingUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error("Unsupported project-building update command."); + } + assertMatchingBuildingUpdate(command.payload.expected, command.payload.target); +} + export function assertProjectFloorInsertProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectFloorInsertProjectCommand { +): asserts command is + | ProjectFloorInsertProjectCommand + | LegacyProjectFloorInsertProjectCommand { assertProjectFloorStructureProjectCommand( command, projectFloorInsertCommandType @@ -199,7 +365,9 @@ export function assertProjectFloorInsertProjectCommand( export function assertProjectFloorDeleteProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectFloorDeleteProjectCommand { +): asserts command is + | ProjectFloorDeleteProjectCommand + | LegacyProjectFloorDeleteProjectCommand { assertProjectFloorStructureProjectCommand( command, projectFloorDeleteCommandType @@ -226,15 +394,26 @@ export function assertProjectRoomDeleteProjectCommand( export function assertProjectFloorUpdateProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectFloorUpdateProjectCommand { +): asserts command is + | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorUpdateProjectCommand { if ( - command.schemaVersion !== projectLocationStructureCommandSchemaVersion || command.type !== projectFloorUpdateCommandType || !isPlainObject(command.payload) || Object.keys(command.payload).length !== 2 ) { throw new Error("Unsupported project-floor update command."); } + if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) { + assertMatchingFloorUpdate( + upgradeLegacyFloorSnapshot(command.payload.expected), + upgradeLegacyFloorSnapshot(command.payload.target) + ); + return; + } + if (command.schemaVersion !== projectFloorCommandSchemaVersion) { + throw new Error("Unsupported project-floor update command."); + } assertMatchingFloorUpdate(command.payload.expected, command.payload.target); } @@ -252,6 +431,68 @@ export function assertProjectRoomUpdateProjectCommand( assertMatchingRoomUpdate(command.payload.expected, command.payload.target); } +/** + * Upgrades a (validated) floor command of any supported version to the + * current version. Version 1 commands predate buildings; their floors had no + * building, so `buildingId` becomes null. + */ +export function upgradeProjectFloorCommand( + command: + | ProjectFloorInsertProjectCommand + | LegacyProjectFloorInsertProjectCommand +): ProjectFloorInsertProjectCommand; +export function upgradeProjectFloorCommand( + command: + | ProjectFloorDeleteProjectCommand + | LegacyProjectFloorDeleteProjectCommand +): ProjectFloorDeleteProjectCommand; +export function upgradeProjectFloorCommand( + command: + | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorUpdateProjectCommand +): ProjectFloorUpdateProjectCommand; +export function upgradeProjectFloorCommand( + command: + | ProjectFloorInsertProjectCommand + | ProjectFloorDeleteProjectCommand + | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorInsertProjectCommand + | LegacyProjectFloorDeleteProjectCommand + | LegacyProjectFloorUpdateProjectCommand +) { + if (command.schemaVersion === projectFloorCommandSchemaVersion) { + return command; + } + switch (command.type) { + case projectFloorInsertCommandType: + return createProjectFloorInsertProjectCommand( + upgradeLegacyFloorSnapshot(command.payload.floor) + ); + case projectFloorDeleteCommandType: + return createProjectFloorDeleteProjectCommand( + upgradeLegacyFloorSnapshot(command.payload.floor) + ); + case projectFloorUpdateCommandType: + return createProjectFloorUpdateProjectCommand( + upgradeLegacyFloorSnapshot(command.payload.expected), + upgradeLegacyFloorSnapshot(command.payload.target) + ); + } +} + +function assertMatchingBuildingUpdate(expected: unknown, target: unknown) { + assertProjectBuildingSnapshot(expected); + assertProjectBuildingSnapshot(target); + if ( + expected.id !== target.id || + expected.projectId !== target.projectId || + expected.sortOrder !== target.sortOrder || + expected.name === target.name + ) { + throw new Error("Project-building update must change only the name."); + } +} + function assertMatchingFloorUpdate(expected: unknown, target: unknown) { assertProjectFloorSnapshot(expected); assertProjectFloorSnapshot(target); @@ -259,9 +500,11 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) { expected.id !== target.id || expected.projectId !== target.projectId || expected.sortOrder !== target.sortOrder || - expected.name === target.name + (expected.name === target.name && expected.buildingId === target.buildingId) ) { - throw new Error("Project-floor update must change only the name."); + throw new Error( + "Project-floor update must change only the name or the building." + ); } } @@ -277,22 +520,49 @@ function assertMatchingRoomUpdate(expected: unknown, target: unknown) { } } +export function assertProjectBuildingSnapshot( + building: unknown +): asserts building is ProjectBuildingSnapshot { + if (!isPlainObject(building) || Object.keys(building).length !== 4) { + throw new Error("Project-building snapshot is invalid."); + } + assertNormalizedNonEmptyString(building.id, "building.id"); + assertNormalizedNonEmptyString(building.projectId, "building.projectId"); + assertNormalizedNonEmptyString(building.name, "building.name"); + assertSortOrder(building.sortOrder, "building.sortOrder"); +} + export function assertProjectFloorSnapshot( floor: unknown ): asserts floor is ProjectFloorSnapshot { + if (!isPlainObject(floor) || Object.keys(floor).length !== 5) { + throw new Error("Project-floor snapshot is invalid."); + } + assertLegacyFloorFields(floor); + if (floor.buildingId !== null) { + assertNormalizedNonEmptyString(floor.buildingId, "floor.buildingId"); + } +} + +function assertLegacyProjectFloorSnapshot( + floor: unknown +): asserts floor is LegacyProjectFloorSnapshot { if (!isPlainObject(floor) || Object.keys(floor).length !== 4) { throw new Error("Project-floor snapshot is invalid."); } + assertLegacyFloorFields(floor); +} + +function assertLegacyFloorFields(floor: Record) { assertNormalizedNonEmptyString(floor.id, "floor.id"); assertNormalizedNonEmptyString(floor.projectId, "floor.projectId"); assertNormalizedNonEmptyString(floor.name, "floor.name"); - if ( - typeof floor.sortOrder !== "number" || - !Number.isSafeInteger(floor.sortOrder) || - floor.sortOrder < 0 - ) { - throw new Error("floor.sortOrder must be a non-negative integer."); - } + assertSortOrder(floor.sortOrder, "floor.sortOrder"); +} + +function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot { + assertLegacyProjectFloorSnapshot(floor); + return { ...floor, buildingId: null }; } export function assertProjectRoomSnapshot( @@ -310,11 +580,11 @@ export function assertProjectRoomSnapshot( assertNormalizedNonEmptyString(room.roomName, "room.roomName"); } -function assertProjectFloorStructureProjectCommand( +function assertProjectBuildingStructureProjectCommand( command: SerializedProjectCommand, expectedType: - | typeof projectFloorInsertCommandType - | typeof projectFloorDeleteCommandType + | typeof projectBuildingInsertCommandType + | typeof projectBuildingDeleteCommandType ) { if ( command.schemaVersion !== projectLocationStructureCommandSchemaVersion || @@ -322,6 +592,29 @@ function assertProjectFloorStructureProjectCommand( !isPlainObject(command.payload) || Object.keys(command.payload).length !== 1 ) { + throw new Error("Unsupported project-building structure command."); + } + assertProjectBuildingSnapshot(command.payload.building); +} + +function assertProjectFloorStructureProjectCommand( + command: SerializedProjectCommand, + expectedType: + | typeof projectFloorInsertCommandType + | typeof projectFloorDeleteCommandType +) { + if ( + command.type !== expectedType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported project-floor structure command."); + } + if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) { + assertLegacyProjectFloorSnapshot(command.payload.floor); + return; + } + if (command.schemaVersion !== projectFloorCommandSchemaVersion) { throw new Error("Unsupported project-floor structure command."); } assertProjectFloorSnapshot(command.payload.floor); @@ -344,6 +637,16 @@ function assertProjectRoomStructureProjectCommand( assertProjectRoomSnapshot(command.payload.room); } +function assertSortOrder(value: unknown, field: string) { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 0 + ) { + throw new Error(`${field} must be a non-negative integer.`); + } +} + function assertNormalizedNonEmptyString( value: unknown, field: string diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 5564e1f..07db42d 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -35,7 +35,8 @@ import { type ExternalModelStateSnapshot, } from "../../external-model/domain/external-model-contracts.js"; -export const projectStateSnapshotSchemaVersion = 6 as const; +export const projectStateSnapshotSchemaVersion = 7 as const; +const coverProjectStateSnapshotSchemaVersion = 6 as const; const boardInfoProjectStateSnapshotSchemaVersion = 5 as const; const previousProjectStateSnapshotSchemaVersion = 4 as const; const externalModelProjectStateSnapshotSchemaVersion = 3 as const; @@ -237,7 +238,7 @@ const projectDeviceSchema = z }) .strict(); -const floorSchema = z +const buildingSchema = z .object({ id: idSchema, projectId: idSchema, @@ -246,6 +247,19 @@ const floorSchema = z }) .strict(); +const legacyFloorSchema = z + .object({ + id: idSchema, + projectId: idSchema, + name: z.string().trim().min(1), + sortOrder: finiteNumberSchema, + }) + .strict(); + +const floorSchema = legacyFloorSchema + .extend({ buildingId: idSchema.nullable() }) + .strict(); + const roomSchema = z .object({ id: idSchema, @@ -490,11 +504,24 @@ export function parseExternalModelStateSnapshot( return externalModelStateSchema.parse(value); } -const projectStateSnapshotContents = { +// Versions 1-6 have floors without a building and no building list. +const previousProjectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), circuitSections: z.array(circuitSectionSchema), circuits: z.array(circuitSchema), projectDevices: z.array(projectDeviceSchema), + floors: z.array(legacyFloorSchema), + rooms: z.array(roomSchema), + distributionBoardComponents: z.array(distributionBoardComponentSchema), + circuitProtectionDevices: z.array(circuitProtectionDeviceSchema), + distributionBoardComponentProtectionDevices: z.array( + componentProtectionDeviceSchema + ), +}; + +const projectStateSnapshotContents = { + ...previousProjectStateSnapshotContents, + buildings: z.array(buildingSchema), floors: z.array(floorSchema), rooms: z.array(roomSchema), distributionBoardComponents: z.array(distributionBoardComponentSchema), @@ -515,6 +542,17 @@ export const projectStateSnapshotSchema = z }) .strict(); +const coverProjectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(distributionBoardSchema), + externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + externalModel: externalModelStateSchema, + ...previousProjectStateSnapshotContents, + }) + .strict(); + const boardInfoProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion), @@ -522,7 +560,7 @@ const boardInfoProjectStateSnapshotSchema = z distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -533,7 +571,7 @@ const previousProjectStateSnapshotSchema = z distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -543,7 +581,7 @@ const externalModelProjectStateSnapshotSchema = z project: publicBuildingProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -552,7 +590,7 @@ const legacyProjectStateSnapshotSchema = z schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion), project: publicBuildingProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -561,7 +599,7 @@ const baselineProjectStateSnapshotSchema = z schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion), project: previousProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -595,6 +633,28 @@ export function parseProjectStateSnapshot( } function upgradePreviousProjectStateSnapshot(value: unknown): unknown { + const cover = upgradeToCoverProjectStateSnapshot(value); + if ( + cover === null || + typeof cover !== "object" || + Array.isArray(cover) || + (cover as { schemaVersion?: unknown }).schemaVersion !== + coverProjectStateSnapshotSchemaVersion + ) { + return cover; + } + // Every supported version before 7 lacks buildings; its floors had no + // building. + const previous = coverProjectStateSnapshotSchema.parse(cover); + return { + ...previous, + schemaVersion: projectStateSnapshotSchemaVersion, + buildings: [], + floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })), + }; +} + +function upgradeToCoverProjectStateSnapshot(value: unknown): unknown { if ( value === null || typeof value !== "object" || @@ -611,7 +671,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const previous = boardInfoProjectStateSnapshotSchema.parse(value); return { ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...previous.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( previous.distributionBoards @@ -622,7 +682,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const previous = previousProjectStateSnapshotSchema.parse(value); return { ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...previous.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( previous.distributionBoards @@ -633,7 +693,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const previous = externalModelProjectStateSnapshotSchema.parse(value); return { ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...previous.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( previous.distributionBoards @@ -645,7 +705,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const legacy = legacyProjectStateSnapshotSchema.parse(value); return { ...legacy, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...legacy.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( legacy.distributionBoards @@ -658,7 +718,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const baseline = baselineProjectStateSnapshotSchema.parse(value); return { ...baseline, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...baseline.project, isPublicBuilding: false, @@ -765,6 +825,7 @@ function assertProjectStateSnapshotRelations( snapshot.projectDevices, "project device" ); + const buildingIds = uniqueIds(snapshot.buildings, "building"); const floorIds = uniqueIds(snapshot.floors, "floor"); const roomIds = uniqueIds(snapshot.rooms, "room"); const circuitIds = uniqueIds(snapshot.circuits, "circuit"); @@ -843,8 +904,14 @@ function assertProjectStateSnapshotRelations( ); } } + for (const building of snapshot.buildings) { + assertProjectOwnership(building.projectId, projectId, "building"); + } for (const floor of snapshot.floors) { assertProjectOwnership(floor.projectId, projectId, "floor"); + if (floor.buildingId !== null) { + assertReference(buildingIds, floor.buildingId, "floor building"); + } } for (const room of snapshot.rooms) { assertProjectOwnership(room.projectId, projectId, "room"); diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts index 3f28f51..24c0a9c 100644 --- a/src/domain/models/project-transfer.model.ts +++ b/src/domain/models/project-transfer.model.ts @@ -84,6 +84,7 @@ export function remapProjectState( createId ); const deviceIds = idMap(source.projectDevices, createId); + const buildingIds = idMap(source.buildings, createId); const floorIds = idMap(source.floors, createId); const roomIds = idMap(source.rooms, createId); const rowIds = new Map( @@ -242,10 +243,19 @@ export function remapProjectState( id: requiredId(deviceIds, device.id), projectId: targetProjectId, })), + buildings: source.buildings.map((building) => ({ + ...building, + id: requiredId(buildingIds, building.id), + projectId: targetProjectId, + })), floors: source.floors.map((floor) => ({ ...floor, id: requiredId(floorIds, floor.id), projectId: targetProjectId, + buildingId: + floor.buildingId === null + ? null + : requiredId(buildingIds, floor.buildingId), })), rooms: source.rooms.map((room) => ({ ...room, diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index bee31a3..91ee754 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -70,12 +70,18 @@ import { type SerializedProjectCommand, } from "../models/project-command.model.js"; import { + assertProjectBuildingDeleteProjectCommand, + assertProjectBuildingInsertProjectCommand, + assertProjectBuildingUpdateProjectCommand, assertProjectFloorDeleteProjectCommand, assertProjectFloorInsertProjectCommand, assertProjectFloorUpdateProjectCommand, assertProjectRoomDeleteProjectCommand, assertProjectRoomInsertProjectCommand, assertProjectRoomUpdateProjectCommand, + projectBuildingDeleteCommandType, + projectBuildingInsertCommandType, + projectBuildingUpdateCommandType, projectFloorDeleteCommandType, projectFloorInsertCommandType, projectFloorUpdateCommandType, @@ -492,6 +498,18 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectBuildingInsertCommandType: { + assertProjectBuildingInsertProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; + } + case projectBuildingDeleteCommandType: { + assertProjectBuildingDeleteProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; + } + case projectBuildingUpdateCommandType: { + assertProjectBuildingUpdateProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; + } case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index b3cbb4f..a94378e 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -7,14 +7,21 @@ import { listProjects } from "../utils/api"; import type { ProjectDto } from "../types"; import { ThemeToggle } from "./ThemeToggle"; +// Anchors on the project overview page (/projects/[id]). const PROJECT_SECTIONS = [ { id: "verlauf", label: "Verlauf", icon: "↺" }, { id: "verteilungen", label: "Verteilungen", icon: "⌁" }, - { id: "etagen", label: "Etagen", icon: "▥" }, - { id: "raeume", label: "Räume", icon: "⌂" }, { id: "projektgeraete", label: "Projektgeräte", icon: "⚙" }, ] as const; +// Separate project sub-routes (/projects/[id]/). +const PROJECT_ROUTES = [ + { segment: "struktur", label: "Gebäudestruktur", icon: "▥" }, +] as const; + +// Matches the project overview and all of its sub-routes (group 2 = rest). +const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/; + interface SidebarProps { isCollapsed: boolean; onToggleCollapsed: () => void; @@ -22,8 +29,14 @@ interface SidebarProps { export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { const pathname = usePathname(); - const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/); + const projectMatch = pathname?.match(PROJECT_PATH_PATTERN); const activeProjectId = projectMatch ? projectMatch[1] : null; + const projectSubPath = projectMatch?.[2] ?? ""; + // Section anchors only exist (and are observed) on the overview itself. + const overviewProjectId = + activeProjectId && (projectSubPath === "" || projectSubPath === "/") + ? activeProjectId + : null; const [projects, setProjects] = useState([]); const [activeSection, setActiveSection] = useState(null); @@ -42,7 +55,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { }, []); useEffect(() => { - if (!activeProjectId) { + if (!overviewProjectId) { setActiveSection(null); return; } @@ -69,7 +82,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { elements.forEach((element) => observer.observe(element)); return () => observer.disconnect(); - }, [activeProjectId]); + }, [overviewProjectId]); return (
    diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 49bb77f..5c14f5e 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -226,11 +226,24 @@ export interface CircuitListDto { name: string; } +export interface BuildingDto { + id: string; + projectId: string; + name: string; + sortOrder: number; +} + +export interface ProjectBuildingCommandResultDto + extends ProjectCommandResultDto { + building: BuildingDto; +} + export interface FloorDto { id: string; projectId: string; name: string; sortOrder: number; + buildingId: string | null; } export interface ProjectFloorCommandResultDto @@ -288,8 +301,14 @@ export interface ProjectDeviceCommandResultDto projectDevice: ProjectDeviceDto; } +export interface CreateBuildingInput { + name: string; +} + export interface CreateFloorInput { name: string; + /** Omitted = keep the current assignment on update; null = no building. */ + buildingId?: string | null; } export interface CreateRoomInput { diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index d55a915..e1f4608 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -1,5 +1,7 @@ import type { + BuildingDto, CircuitListDto, + CreateBuildingInput, CreateFloorInput, CreateProjectDeviceInput, CreateRoomInput, @@ -17,6 +19,7 @@ import type { ProjectCommandResultDto, ProjectDeviceCommandResultDto, ProjectDeviceDto, + ProjectBuildingCommandResultDto, ProjectFloorCommandResultDto, ProjectHistoryStateDto, ProjectRevisionPageDto, @@ -881,6 +884,61 @@ export function renumberCircuitSectionCommand( ); } +export function listBuildings(projectId: string) { + return request(`/api/projects/${projectId}/buildings`); +} + +export function createBuilding( + projectId: string, + input: CreateBuildingInput, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/buildings`, + { + method: "POST", + body: JSON.stringify({ name: input.name, expectedRevision }), + } + ); +} + +export function updateBuilding( + projectId: string, + buildingId: string, + input: CreateBuildingInput, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/buildings/${buildingId}`, + { + method: "PUT", + body: JSON.stringify({ name: input.name, expectedRevision }), + } + ); +} + +export function deleteBuilding( + projectId: string, + buildingId: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/buildings/${buildingId}`, + { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + } + ); +} + +// buildingId is only sent when the caller passes it (string or null); +// an omitted buildingId keeps the current assignment on update. +function toFloorRequestBody(input: CreateFloorInput, expectedRevision: number) { + return input.buildingId === undefined + ? { name: input.name, expectedRevision } + : { name: input.name, buildingId: input.buildingId, expectedRevision }; +} + export function listFloors(projectId: string) { return request(`/api/projects/${projectId}/floors`); } @@ -894,7 +952,7 @@ export function createFloor( `/api/projects/${projectId}/floors`, { method: "POST", - body: JSON.stringify({ ...input, expectedRevision }), + body: JSON.stringify(toFloorRequestBody(input, expectedRevision)), } ); } @@ -907,7 +965,7 @@ export function updateFloor( ) { return request(`/api/projects/${projectId}/floors/${floorId}`, { method: "PUT", - body: JSON.stringify({ ...input, expectedRevision }), + body: JSON.stringify(toFloorRequestBody(input, expectedRevision)), }); } diff --git a/src/frontend/utils/building-structure.ts b/src/frontend/utils/building-structure.ts new file mode 100644 index 0000000..0c9d6ab --- /dev/null +++ b/src/frontend/utils/building-structure.ts @@ -0,0 +1,284 @@ +// Pure selection, filtering and grouping rules for the building structure +// view (Gebäude → Etagen → Räume) and grouped floor selects. +import type { BuildingDto, FloorDto, RoomDto } from "../types"; + +/** Selection key for "Alle" in the building and floor columns. */ +export const ALL_SELECTION = "__all__"; +/** Selection key for "Ohne Gebäude" / "Ohne Etage". */ +export const UNASSIGNED_SELECTION = "__unassigned__"; + +/** ALL_SELECTION, UNASSIGNED_SELECTION or a building/floor id. */ +export type StructureSelection = string; + +export const UNASSIGNED_BUILDING_LABEL = "Ohne Gebäude"; +export const UNASSIGNED_FLOOR_LABEL = "Ohne Etage"; + +type BuildingLike = Pick; +type FloorLike = Pick & { + buildingId?: string | null; +}; +type RoomLike = Pick; + +export interface FloorGroup { + /** null for floors without (known) building. */ + buildingId: string | null; + label: string; + floors: TFloor[]; +} + +/** + * The building a floor belongs to, or null when it has none or references a + * building that is not (or no longer) loaded. + */ +export function resolveFloorBuildingId( + floor: FloorLike, + buildingIds: ReadonlySet +): string | null { + const buildingId = floor.buildingId ?? null; + return buildingId !== null && buildingIds.has(buildingId) + ? buildingId + : null; +} + +function toIdSet(entries: ReadonlyArray<{ id: string }>) { + return new Set(entries.map((entry) => entry.id)); +} + +export function hasFloorsWithoutBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray +): boolean { + const buildingIds = toIdSet(buildings); + return floors.some( + (floor) => resolveFloorBuildingId(floor, buildingIds) === null + ); +} + +export function hasRoomsWithoutFloor( + rooms: ReadonlyArray, + floors: ReadonlyArray +): boolean { + const floorIds = toIdSet(floors); + return rooms.some( + (room) => room.floorId === null || !floorIds.has(room.floorId) + ); +} + +/** + * Groups floors by building in building order; floors without a known + * building come last under "Ohne Gebäude". Empty groups are omitted and the + * floor order inside a group is preserved. + */ +export function groupFloorsByBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray +): FloorGroup[] { + const buildingIds = toIdSet(buildings); + const floorsByBuilding = new Map(); + for (const floor of floors) { + const buildingId = resolveFloorBuildingId(floor, buildingIds); + const group = floorsByBuilding.get(buildingId) ?? []; + group.push(floor); + floorsByBuilding.set(buildingId, group); + } + const groups: FloorGroup[] = []; + for (const building of buildings) { + const groupFloors = floorsByBuilding.get(building.id); + if (groupFloors?.length) { + groups.push({ + buildingId: building.id, + label: building.name, + floors: groupFloors, + }); + } + } + const unassigned = floorsByBuilding.get(null); + if (unassigned?.length) { + groups.push({ + buildingId: null, + label: UNASSIGNED_BUILDING_LABEL, + floors: unassigned, + }); + } + return groups; +} + +/** Floors shown in the floor column for a building selection. */ +export function filterFloorsByBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray, + buildingSelection: StructureSelection +): TFloor[] { + if (buildingSelection === ALL_SELECTION) { + return [...floors]; + } + const buildingIds = toIdSet(buildings); + const targetBuildingId = + buildingSelection === UNASSIGNED_SELECTION ? null : buildingSelection; + return floors.filter( + (floor) => resolveFloorBuildingId(floor, buildingIds) === targetBuildingId + ); +} + +/** + * "Ohne Etage" is offered only when rooms without floor exist and the + * building selection can contain them ("Alle" or "Ohne Gebäude"), because a + * room without floor has no building either. + */ +export function showsRoomsWithoutFloorEntry( + rooms: ReadonlyArray, + floors: ReadonlyArray, + buildingSelection: StructureSelection +): boolean { + return ( + (buildingSelection === ALL_SELECTION || + buildingSelection === UNASSIGNED_SELECTION) && + hasRoomsWithoutFloor(rooms, floors) + ); +} + +/** Rooms shown in the room column for the current selections. */ +export function filterRooms( + rooms: ReadonlyArray, + floors: ReadonlyArray, + buildings: ReadonlyArray, + buildingSelection: StructureSelection, + floorSelection: StructureSelection +): TRoom[] { + const floorIds = toIdSet(floors); + const isWithoutFloor = (room: RoomLike) => + room.floorId === null || !floorIds.has(room.floorId); + + if (floorSelection === UNASSIGNED_SELECTION) { + return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection) + ? rooms.filter(isWithoutFloor) + : []; + } + if (floorSelection !== ALL_SELECTION) { + return rooms.filter((room) => room.floorId === floorSelection); + } + if (buildingSelection === ALL_SELECTION) { + return [...rooms]; + } + const visibleFloorIds = toIdSet( + filterFloorsByBuilding(floors, buildings, buildingSelection) + ); + return rooms.filter( + (room) => + (room.floorId !== null && visibleFloorIds.has(room.floorId)) || + (buildingSelection === UNASSIGNED_SELECTION && isWithoutFloor(room)) + ); +} + +/** Falls back to "Alle" when a selected building vanished. */ +export function normalizeBuildingSelection( + selection: StructureSelection, + floors: ReadonlyArray, + buildings: ReadonlyArray +): StructureSelection { + if (selection === ALL_SELECTION) { + return selection; + } + if (selection === UNASSIGNED_SELECTION) { + return hasFloorsWithoutBuilding(floors, buildings) + ? selection + : ALL_SELECTION; + } + return buildings.some((building) => building.id === selection) + ? selection + : ALL_SELECTION; +} + +/** Falls back to "Alle" when a selected floor is no longer visible. */ +export function normalizeFloorSelection( + selection: StructureSelection, + rooms: ReadonlyArray, + floors: ReadonlyArray, + buildings: ReadonlyArray, + buildingSelection: StructureSelection +): StructureSelection { + if (selection === ALL_SELECTION) { + return selection; + } + if (selection === UNASSIGNED_SELECTION) { + return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection) + ? selection + : ALL_SELECTION; + } + return filterFloorsByBuilding(floors, buildings, buildingSelection).some( + (floor) => floor.id === selection + ) + ? selection + : ALL_SELECTION; +} + +/** Building preselected in the floor dialog ("" = Ohne Gebäude). */ +export function defaultBuildingIdForNewFloor( + buildingSelection: StructureSelection +): string { + return buildingSelection === ALL_SELECTION || + buildingSelection === UNASSIGNED_SELECTION + ? "" + : buildingSelection; +} + +/** Floor preselected in the room dialog ("" = Ohne Etage). */ +export function defaultFloorIdForNewRoom( + floorSelection: StructureSelection +): string { + return floorSelection === ALL_SELECTION || + floorSelection === UNASSIGNED_SELECTION + ? "" + : floorSelection; +} + +export function countFloorsByBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray +): Map { + const buildingIds = toIdSet(buildings); + const counts = new Map(); + for (const floor of floors) { + const buildingId = resolveFloorBuildingId(floor, buildingIds); + counts.set(buildingId, (counts.get(buildingId) ?? 0) + 1); + } + return counts; +} + +/** Room counts per floor id; rooms without known floor count under null. */ +export function countRoomsByFloor( + rooms: ReadonlyArray, + floors: ReadonlyArray +): Map { + const floorIds = toIdSet(floors); + const counts = new Map(); + for (const room of rooms) { + const floorId = + room.floorId !== null && floorIds.has(room.floorId) + ? room.floorId + : null; + counts.set(floorId, (counts.get(floorId) ?? 0) + 1); + } + return counts; +} + +/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */ +export function describeRoomLocation( + room: RoomLike, + floors: ReadonlyArray, + buildings: ReadonlyArray +): string { + const floor = + room.floorId === null + ? undefined + : floors.find((entry) => entry.id === room.floorId); + if (!floor) { + return UNASSIGNED_FLOOR_LABEL; + } + const buildingId = resolveFloorBuildingId(floor, toIdSet(buildings)); + const building = + buildingId === null + ? undefined + : buildings.find((entry) => entry.id === buildingId); + return building ? `${floor.name} · ${building.name}` : floor.name; +} diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 5685b29..a2880ec 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -46,6 +46,9 @@ const commandTypeLabels: Record = { "circuit.move-group": "Stromkreis in andere Gruppe verschoben", "circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt", "circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt", + "project-building.insert": "Gebäude angelegt", + "project-building.update": "Gebäude bearbeitet", + "project-building.delete": "Gebäude entfernt", "project-floor.insert": "Geschoss angelegt", "project-floor.update": "Geschoss bearbeitet", "project-floor.delete": "Geschoss entfernt", diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 944c843..4f9b5ee 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -6,6 +6,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js"; import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; +import { BuildingRepository } from "../../db/repositories/building.repository.js"; import { FloorRepository } from "../../db/repositories/floor.repository.js"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; @@ -26,6 +27,7 @@ export const distributionBoardComponentRepository = new DistributionBoardComponentRepository(db); export const distributionBoardRepository = new DistributionBoardRepository(db); +export const buildingRepository = new BuildingRepository(db); export const floorRepository = new FloorRepository(db); export const globalDeviceRepository = new GlobalDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db); diff --git a/src/server/controllers/building.controller.ts b/src/server/controllers/building.controller.ts new file mode 100644 index 0000000..3a49120 --- /dev/null +++ b/src/server/controllers/building.controller.ts @@ -0,0 +1,111 @@ +import type { Request, Response } from "express"; +import { + createProjectBuildingDeleteProjectCommand, + createProjectBuildingInsertProjectCommand, + createProjectBuildingSnapshot, + createProjectBuildingUpdateProjectCommand, +} from "../../domain/models/project-location-structure-project-command.model.js"; +import { + createBuildingSchema, + deleteProjectLocationSchema, + updateBuildingSchema, +} from "../../shared/validation/project-structure.schemas.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { buildingRepository } from "../composition/application-repositories.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +export async function listBuildingsByProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const result = await buildingRepository.listByProject(projectId); + return res.json(result); +} + +export async function createBuilding(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const parsed = createBuildingSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + const existingBuildings = await buildingRepository.listByProject(projectId); + const nextSortOrder = existingBuildings.length + ? Math.max(...existingBuildings.map((building) => building.sortOrder)) + 1 + : 0; + const building = createProjectBuildingSnapshot( + projectId, + parsed.data.name, + nextSortOrder + ); + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Gebäude anlegen", + command: createProjectBuildingInsertProjectCommand(building), + }); + return res.status(201).json({ ...result, building }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export async function updateBuilding(req: Request, res: Response) { + const { projectId, buildingId } = req.params; + if (typeof projectId !== "string" || typeof buildingId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + const parsed = updateBuildingSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = await buildingRepository.findById(projectId, buildingId); + if (!current) { + return res.status(404).json({ error: "Building not found" }); + } + const target = { ...current, name: parsed.data.name }; + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Gebäude bearbeiten", + command: createProjectBuildingUpdateProjectCommand(current, target), + }); + return res.json({ ...result, building: target }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export async function deleteBuilding(req: Request, res: Response) { + const { projectId, buildingId } = req.params; + if (typeof projectId !== "string" || typeof buildingId !== "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 buildingRepository.findById(projectId, buildingId); + if (!current) { + return res.status(404).json({ error: "Building not found" }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Gebäude löschen", + command: createProjectBuildingDeleteProjectCommand(current), + }); + return res.json(result); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} diff --git a/src/server/controllers/floor.controller.ts b/src/server/controllers/floor.controller.ts index 085aa10..b000db3 100644 --- a/src/server/controllers/floor.controller.ts +++ b/src/server/controllers/floor.controller.ts @@ -42,7 +42,8 @@ export async function createFloor(req: Request, res: Response) { const floor = createProjectFloorSnapshot( projectId, parsed.data.name, - nextSortOrder + nextSortOrder, + parsed.data.buildingId ?? null ); try { const result = projectCommandService.executeUser({ @@ -70,7 +71,14 @@ export async function updateFloor(req: Request, res: Response) { if (!current) { return res.status(404).json({ error: "Floor not found" }); } - const target = { ...current, name: parsed.data.name }; + const target = { + ...current, + name: parsed.data.name, + buildingId: + parsed.data.buildingId === undefined + ? current.buildingId + : parsed.data.buildingId, + }; try { const result = projectCommandService.executeUser({ projectId, diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 8801784..0b61255 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -13,6 +13,12 @@ import { updateDistributionBoard, } from "../controllers/distribution-board.controller.js"; import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js"; +import { + createBuilding, + deleteBuilding, + listBuildingsByProject, + updateBuilding, +} from "../controllers/building.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"; @@ -100,6 +106,10 @@ projectRouter.get( "/:projectId/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier ); +projectRouter.get("/:projectId/buildings", listBuildingsByProject); +projectRouter.post("/:projectId/buildings", createBuilding); +projectRouter.put("/:projectId/buildings/:buildingId", updateBuilding); +projectRouter.delete("/:projectId/buildings/:buildingId", deleteBuilding); projectRouter.get("/:projectId/floors", listFloorsByProject); projectRouter.post("/:projectId/floors", createFloor); projectRouter.put("/:projectId/floors/:floorId", updateFloor); diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index 143be0a..5ab13bf 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -102,10 +102,22 @@ export const deleteDistributionBoardSchema = z }) .strict(); +export const createBuildingSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(200), + }) + .strict(); + +export const updateBuildingSchema = createBuildingSchema; + +// A missing buildingId keeps the current value (older clients send only the +// name); null removes the floor from its building. export const createFloorSchema = z .object({ expectedRevision: expectedProjectRevisionSchema, name: z.string().trim().min(1).max(200), + buildingId: z.string().trim().min(1).nullable().optional(), }) .strict(); diff --git a/tests/building-structure-frontend-api.test.ts b/tests/building-structure-frontend-api.test.ts new file mode 100644 index 0000000..9e23150 --- /dev/null +++ b/tests/building-structure-frontend-api.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createBuilding, + createFloor, + deleteBuilding, + listBuildings, + updateBuilding, + updateFloor, +} from "../src/frontend/utils/api.js"; + +describe("building structure frontend API", () => { + it("sends building commands and optional floor building assignments", async () => { + 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: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + }); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await listBuildings("project-1"); + await createBuilding("project-1", { name: "Haus A" }, 3); + await updateBuilding("project-1", "building-1", { name: "Haus B" }, 4); + await deleteBuilding("project-1", "building-1", 5); + await createFloor( + "project-1", + { name: "EG", buildingId: "building-1" }, + 6 + ); + await createFloor("project-1", { name: "Dach", buildingId: null }, 7); + await createFloor("project-1", { name: "UG", buildingId: undefined }, 8); + await updateFloor( + "project-1", + "floor-1", + { name: "1. OG", buildingId: null }, + 9 + ); + await updateFloor("project-1", "floor-1", { name: "2. OG" }, 10); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(requests, [ + { + url: "/api/projects/project-1/buildings", + method: "GET", + body: undefined, + }, + { + url: "/api/projects/project-1/buildings", + method: "POST", + body: { name: "Haus A", expectedRevision: 3 }, + }, + { + url: "/api/projects/project-1/buildings/building-1", + method: "PUT", + body: { name: "Haus B", expectedRevision: 4 }, + }, + { + url: "/api/projects/project-1/buildings/building-1", + method: "DELETE", + body: { expectedRevision: 5 }, + }, + { + url: "/api/projects/project-1/floors", + method: "POST", + body: { name: "EG", buildingId: "building-1", expectedRevision: 6 }, + }, + { + url: "/api/projects/project-1/floors", + method: "POST", + body: { name: "Dach", buildingId: null, expectedRevision: 7 }, + }, + { + url: "/api/projects/project-1/floors", + method: "POST", + body: { name: "UG", expectedRevision: 8 }, + }, + { + url: "/api/projects/project-1/floors/floor-1", + method: "PUT", + body: { name: "1. OG", buildingId: null, expectedRevision: 9 }, + }, + { + url: "/api/projects/project-1/floors/floor-1", + method: "PUT", + body: { name: "2. OG", expectedRevision: 10 }, + }, + ]); + const updateWithoutBuilding = requests[8].body as Record; + assert.equal("buildingId" in updateWithoutBuilding, false); + }); +}); diff --git a/tests/building-structure-view.test.ts b/tests/building-structure-view.test.ts new file mode 100644 index 0000000..59271af --- /dev/null +++ b/tests/building-structure-view.test.ts @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { + BuildingDto, + FloorDto, + RoomDto, +} from "../src/frontend/types.js"; +import { + ALL_SELECTION, + UNASSIGNED_SELECTION, + countFloorsByBuilding, + countRoomsByFloor, + defaultBuildingIdForNewFloor, + defaultFloorIdForNewRoom, + describeRoomLocation, + filterFloorsByBuilding, + filterRooms, + groupFloorsByBuilding, + hasFloorsWithoutBuilding, + hasRoomsWithoutFloor, + normalizeBuildingSelection, + normalizeFloorSelection, + showsRoomsWithoutFloorEntry, +} from "../src/frontend/utils/building-structure.js"; + +function building(id: string, name: string, sortOrder: number): BuildingDto { + return { id, projectId: "project-1", name, sortOrder }; +} + +function floor( + id: string, + name: string, + buildingId: string | null, + sortOrder = 0 +): FloorDto { + return { id, projectId: "project-1", name, sortOrder, buildingId }; +} + +function room(id: string, floorId: string | null, roomNumber = id): RoomDto { + return { + id, + projectId: "project-1", + floorId, + roomNumber, + roomName: `Raum ${roomNumber}`, + }; +} + +const buildings = [building("b-a", "Haus A", 0), building("b-b", "Haus B", 1)]; +const floors = [ + floor("f-free", "Außenanlage", null, 0), + floor("f-a-eg", "EG", "b-a", 1), + floor("f-b-eg", "EG", "b-b", 2), + floor("f-a-og", "1. OG", "b-a", 3), + floor("f-orphan", "Keller", "b-deleted", 4), +]; +const rooms = [ + room("r-a-eg", "f-a-eg"), + room("r-a-og", "f-a-og"), + room("r-b-eg", "f-b-eg"), + room("r-free", "f-free"), + room("r-orphan-floor", "f-orphan"), + room("r-none", null), + room("r-missing-floor", "f-deleted"), +]; + +const ids = (entries: ReadonlyArray<{ id: string }>) => + entries.map((entry) => entry.id); + +describe("building structure view", () => { + it("groups floors in building order and puts unassigned floors last", () => { + const groups = groupFloorsByBuilding(floors, buildings); + assert.deepEqual( + groups.map((group) => ({ + buildingId: group.buildingId, + label: group.label, + floors: ids(group.floors), + })), + [ + { buildingId: "b-a", label: "Haus A", floors: ["f-a-eg", "f-a-og"] }, + { buildingId: "b-b", label: "Haus B", floors: ["f-b-eg"] }, + { + buildingId: null, + label: "Ohne Gebäude", + floors: ["f-free", "f-orphan"], + }, + ] + ); + }); + + it("omits empty groups", () => { + const groups = groupFloorsByBuilding( + [floor("f-1", "EG", "b-b")], + buildings + ); + assert.deepEqual( + groups.map((group) => group.buildingId), + ["b-b"] + ); + assert.deepEqual(groupFloorsByBuilding([], buildings), []); + }); + + it("filters floors by building selection", () => { + assert.deepEqual( + ids(filterFloorsByBuilding(floors, buildings, ALL_SELECTION)), + ids(floors) + ); + assert.deepEqual( + ids(filterFloorsByBuilding(floors, buildings, "b-a")), + ["f-a-eg", "f-a-og"] + ); + assert.deepEqual( + ids(filterFloorsByBuilding(floors, buildings, UNASSIGNED_SELECTION)), + ["f-free", "f-orphan"] + ); + }); + + it("detects floors without building and rooms without floor", () => { + assert.equal(hasFloorsWithoutBuilding(floors, buildings), true); + assert.equal( + hasFloorsWithoutBuilding([floor("f-1", "EG", "b-a")], buildings), + false + ); + assert.equal(hasRoomsWithoutFloor(rooms, floors), true); + assert.equal(hasRoomsWithoutFloor([room("r-1", "f-a-eg")], floors), false); + }); + + it("filters rooms for building and floor selections", () => { + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, ALL_SELECTION)), + ids(rooms) + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, "b-a", ALL_SELECTION)), + ["r-a-eg", "r-a-og"] + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, "b-a", "f-a-og")), + ["r-a-og"] + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, "f-b-eg")), + ["r-b-eg"] + ); + assert.deepEqual( + ids( + filterRooms( + rooms, + floors, + buildings, + UNASSIGNED_SELECTION, + ALL_SELECTION + ) + ), + ["r-free", "r-orphan-floor", "r-none", "r-missing-floor"] + ); + assert.deepEqual( + ids( + filterRooms( + rooms, + floors, + buildings, + ALL_SELECTION, + UNASSIGNED_SELECTION + ) + ), + ["r-none", "r-missing-floor"] + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, "b-a", UNASSIGNED_SELECTION)), + [] + ); + }); + + it("offers 'Ohne Etage' only where rooms without floor can belong", () => { + assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, ALL_SELECTION), true); + assert.equal( + showsRoomsWithoutFloorEntry(rooms, floors, UNASSIGNED_SELECTION), + true + ); + assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, "b-a"), false); + assert.equal( + showsRoomsWithoutFloorEntry([room("r-1", "f-a-eg")], floors, ALL_SELECTION), + false + ); + }); + + it("falls back to 'Alle' for vanished or hidden selections", () => { + assert.equal(normalizeBuildingSelection("b-a", floors, buildings), "b-a"); + assert.equal( + normalizeBuildingSelection("b-deleted", floors, buildings), + ALL_SELECTION + ); + assert.equal( + normalizeBuildingSelection(UNASSIGNED_SELECTION, floors, buildings), + UNASSIGNED_SELECTION + ); + assert.equal( + normalizeBuildingSelection( + UNASSIGNED_SELECTION, + [floor("f-1", "EG", "b-a")], + buildings + ), + ALL_SELECTION + ); + + assert.equal( + normalizeFloorSelection("f-a-og", rooms, floors, buildings, "b-a"), + "f-a-og" + ); + assert.equal( + normalizeFloorSelection("f-b-eg", rooms, floors, buildings, "b-a"), + ALL_SELECTION + ); + assert.equal( + normalizeFloorSelection("f-deleted", rooms, floors, buildings, ALL_SELECTION), + ALL_SELECTION + ); + assert.equal( + normalizeFloorSelection( + UNASSIGNED_SELECTION, + rooms, + floors, + buildings, + ALL_SELECTION + ), + UNASSIGNED_SELECTION + ); + assert.equal( + normalizeFloorSelection(UNASSIGNED_SELECTION, rooms, floors, buildings, "b-a"), + ALL_SELECTION + ); + }); + + it("preselects the selected building and floor for new entries", () => { + assert.equal(defaultBuildingIdForNewFloor("b-a"), "b-a"); + assert.equal(defaultBuildingIdForNewFloor(ALL_SELECTION), ""); + assert.equal(defaultBuildingIdForNewFloor(UNASSIGNED_SELECTION), ""); + assert.equal(defaultFloorIdForNewRoom("f-a-eg"), "f-a-eg"); + assert.equal(defaultFloorIdForNewRoom(ALL_SELECTION), ""); + assert.equal(defaultFloorIdForNewRoom(UNASSIGNED_SELECTION), ""); + }); + + it("counts floors per building and rooms per floor", () => { + assert.deepEqual( + [...countFloorsByBuilding(floors, buildings)], + [ + [null, 2], + ["b-a", 2], + ["b-b", 1], + ] + ); + assert.deepEqual( + [...countRoomsByFloor(rooms, floors)], + [ + ["f-a-eg", 1], + ["f-a-og", 1], + ["f-b-eg", 1], + ["f-free", 1], + ["f-orphan", 1], + [null, 2], + ] + ); + }); + + it("describes a room location with floor and building", () => { + assert.equal( + describeRoomLocation(room("r", "f-a-og"), floors, buildings), + "1. OG · Haus A" + ); + assert.equal( + describeRoomLocation(room("r", "f-free"), floors, buildings), + "Außenanlage" + ); + assert.equal( + describeRoomLocation(room("r", "f-orphan"), floors, buildings), + "Keller" + ); + assert.equal( + describeRoomLocation(room("r", null), floors, buildings), + "Ohne Etage" + ); + }); +}); diff --git a/tests/project-building-structure.test.ts b/tests/project-building-structure.test.ts new file mode 100644 index 0000000..2c9ccdf --- /dev/null +++ b/tests/project-building-structure.test.ts @@ -0,0 +1,305 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; +import { buildings } from "../src/db/schema/buildings.js"; +import { floors } from "../src/db/schema/floors.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + assertProjectFloorInsertProjectCommand, + createProjectBuildingDeleteProjectCommand, + createProjectBuildingInsertProjectCommand, + createProjectBuildingSnapshot, + createProjectBuildingUpdateProjectCommand, + createProjectFloorInsertProjectCommand, + createProjectFloorSnapshot, + createProjectFloorUpdateProjectCommand, + projectFloorCommandSchemaVersion, + type ProjectLocationStructureProjectCommand, +} from "../src/domain/models/project-location-structure-project-command.model.js"; +import { + createBuildingSchema, + createFloorSchema, + updateFloorSchema, +} from "../src/shared/validation/project-structure.schemas.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Test project" }, + { id: "project-2", name: "Foreign project" }, + ]) + .run(); + return context; +} + +/** Executes a user command and returns a runner for its undo and redo. */ +function createRunner(context: DatabaseContext) { + const repository = new ProjectLocationStructureProjectCommandRepository( + context.db + ); + const history = new ProjectHistoryRepository(context.db); + let revision = 0; + const run = ( + command: ProjectLocationStructureProjectCommand, + source: "user" | "undo" | "redo" = "user" + ) => { + const step = + source === "user" ? null : history.getNextCommand("project-1", source); + if (source !== "user") { + assert.ok(step); + } + const result = repository.execute({ + projectId: "project-1", + expectedRevision: revision, + source, + historyTargetChangeSetId: step?.changeSetId, + command, + }); + revision += 1; + return result; + }; + return { repository, run }; +} + +function getBuilding(context: DatabaseContext, id: string) { + return context.db.select().from(buildings).where(eq(buildings.id, id)).get(); +} + +function getFloor(context: DatabaseContext, id: string) { + return context.db.select().from(floors).where(eq(floors.id, id)).get(); +} + +describe("project building structure", () => { + it("creates, renames and deletes buildings with persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + const building = createProjectBuildingSnapshot("project-1", " Haus A ", 0); + assert.equal(building.name, "Haus A"); + + const insert = createProjectBuildingInsertProjectCommand(building); + const inserted = run(insert); + assert.deepEqual(getBuilding(context, building.id), building); + + const renamed = { ...building, name: "Haus B" }; + const update = createProjectBuildingUpdateProjectCommand(building, renamed); + const updated = run(update); + assert.equal(getBuilding(context, building.id)?.name, "Haus B"); + + run(updated.inverse, "undo"); + assert.deepEqual(getBuilding(context, building.id), building); + run(inserted.inverse, "undo"); + assert.equal(getBuilding(context, building.id), undefined); + run(insert, "redo"); + run(update, "redo"); + assert.deepEqual(getBuilding(context, building.id), renamed); + + const deleted = run(createProjectBuildingDeleteProjectCommand(renamed)); + assert.equal(getBuilding(context, building.id), undefined); + run(deleted.inverse, "undo"); + assert.deepEqual(getBuilding(context, building.id), renamed); + } finally { + context.close(); + } + }); + + it("assigns floors to buildings and blocks deleting a building in use", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + const houseA = createProjectBuildingSnapshot("project-1", "Haus A", 0); + const houseB = createProjectBuildingSnapshot("project-1", "Haus B", 1); + run(createProjectBuildingInsertProjectCommand(houseA)); + run(createProjectBuildingInsertProjectCommand(houseB)); + + const floor = createProjectFloorSnapshot("project-1", "EG", 0, houseA.id); + const floorInsert = createProjectFloorInsertProjectCommand(floor); + assert.equal(floorInsert.schemaVersion, projectFloorCommandSchemaVersion); + run(floorInsert); + assert.deepEqual(getFloor(context, floor.id), floor); + + const moved = { ...floor, buildingId: houseB.id }; + const moveCommand = createProjectFloorUpdateProjectCommand(floor, moved); + const movedResult = run(moveCommand); + assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id); + + assert.throws( + () => run(createProjectBuildingDeleteProjectCommand(houseB)), + /solange Etagen zugeordnet/ + ); + assert.ok(getBuilding(context, houseB.id)); + + run(movedResult.inverse, "undo"); + assert.equal(getFloor(context, floor.id)?.buildingId, houseA.id); + run(moveCommand, "redo"); + assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id); + + const unassigned = { ...moved, buildingId: null }; + run(createProjectFloorUpdateProjectCommand(moved, unassigned)); + assert.equal(getFloor(context, floor.id)?.buildingId, null); + run(createProjectBuildingDeleteProjectCommand(houseB)); + assert.equal(getBuilding(context, houseB.id), undefined); + } finally { + context.close(); + } + }); + + it("rejects floors that reference a missing or foreign building", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + context.db + .insert(buildings) + .values({ id: "foreign", projectId: "project-2", name: "Fremd", sortOrder: 0 }) + .run(); + assert.throws( + () => + run( + createProjectFloorInsertProjectCommand( + createProjectFloorSnapshot("project-1", "EG", 0, "foreign") + ) + ), + /foreign building/ + ); + assert.throws( + () => + run( + createProjectFloorInsertProjectCommand( + createProjectFloorSnapshot("project-1", "EG", 0, "missing") + ) + ), + /foreign building/ + ); + assert.equal(context.db.select().from(floors).all().length, 0); + + const floor = createProjectFloorSnapshot("project-1", "EG", 0); + run(createProjectFloorInsertProjectCommand(floor)); + assert.throws( + () => + run( + createProjectFloorUpdateProjectCommand(floor, { + ...floor, + buildingId: "foreign", + }) + ), + /foreign building/ + ); + assert.throws( + () => createProjectFloorUpdateProjectCommand(floor, { ...floor }), + /only the name or the building/ + ); + assert.throws( + () => + createProjectFloorUpdateProjectCommand(floor, { + ...floor, + sortOrder: 5, + name: "Erdgeschoss", + }), + /only the name or the building/ + ); + } finally { + context.close(); + } + }); + + it("executes version 1 floor commands recorded before buildings existed", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + const legacyFloor = { + id: "floor-legacy", + projectId: "project-1", + name: "EG", + sortOrder: 0, + }; + const legacyInsert = { + schemaVersion: 1 as const, + type: "project-floor.insert" as const, + payload: { floor: legacyFloor }, + }; + assertProjectFloorInsertProjectCommand(legacyInsert); + const inserted = run(legacyInsert); + assert.deepEqual(getFloor(context, legacyFloor.id), { + ...legacyFloor, + buildingId: null, + }); + // The inverse is always written in the current version. + assert.equal(inserted.inverse.schemaVersion, projectFloorCommandSchemaVersion); + + const legacyUpdate = { + schemaVersion: 1 as const, + type: "project-floor.update" as const, + payload: { + expected: legacyFloor, + target: { ...legacyFloor, name: "Erdgeschoss" }, + }, + }; + const updated = run(legacyUpdate); + assert.equal(getFloor(context, legacyFloor.id)?.name, "Erdgeschoss"); + run(updated.inverse, "undo"); + assert.equal(getFloor(context, legacyFloor.id)?.name, "EG"); + + // A version 1 snapshot must not carry buildingId, a version 2 one must. + assert.throws(() => + assertProjectFloorInsertProjectCommand({ + schemaVersion: 1, + type: "project-floor.insert", + payload: { floor: { ...legacyFloor, buildingId: null } }, + }) + ); + assert.throws(() => + assertProjectFloorInsertProjectCommand({ + schemaVersion: 2, + type: "project-floor.insert", + payload: { floor: legacyFloor }, + }) + ); + } finally { + context.close(); + } + }); + + it("validates building and floor requests", () => { + assert.equal( + createBuildingSchema.safeParse({ expectedRevision: 0, name: "Haus A" }).success, + true + ); + assert.equal( + createBuildingSchema.safeParse({ expectedRevision: 0, name: " " }).success, + false + ); + assert.equal( + createBuildingSchema.safeParse({ + expectedRevision: 0, + name: "Haus A", + sortOrder: 1, + }).success, + false + ); + for (const buildingId of [undefined, null, "building-1"]) { + assert.equal( + createFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId }) + .success, + true + ); + } + assert.equal( + updateFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId: "" }) + .success, + false + ); + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 861edcd..5630cb8 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -41,6 +41,7 @@ import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; +import { buildings } from "../src/db/schema/buildings.js"; import { floors } from "../src/db/schema/floors.js"; import { rooms } from "../src/db/schema/rooms.js"; import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; @@ -78,6 +79,8 @@ import { createDistributionBoardComponentUpdateProjectCommand, } from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; import { + createProjectBuildingInsertProjectCommand, + createProjectBuildingSnapshot, createProjectFloorInsertProjectCommand, createProjectFloorSnapshot, createProjectRoomInsertProjectCommand, @@ -1610,6 +1613,43 @@ describe("project command service", () => { } }); + it("dispatches building commands and floor assignments with persisted inverses", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const building = createProjectBuildingSnapshot("project-1", "Haus A", 0); + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createProjectBuildingInsertProjectCommand(building), + }); + const floor = createProjectFloorSnapshot("project-1", "EG", 0, building.id); + const created = service.executeUser({ + projectId: "project-1", + expectedRevision: 1, + command: createProjectFloorInsertProjectCommand(floor), + }); + assert.equal(created.history.currentRevision, 2); + assert.equal( + context.db.select().from(floors).where(eq(floors.id, floor.id)).get() + ?.buildingId, + building.id + ); + + createService(context).undo({ projectId: "project-1", expectedRevision: 2 }); + createService(context).undo({ projectId: "project-1", expectedRevision: 3 }); + assert.equal(context.db.select().from(buildings).all().length, 0); + createService(context).redo({ projectId: "project-1", expectedRevision: 4 }); + createService(context).redo({ projectId: "project-1", expectedRevision: 5 }); + assert.deepEqual( + context.db.select().from(floors).where(eq(floors.id, floor.id)).get(), + floor + ); + } finally { + context.close(); + } + }); + it("dispatches floor and room setup with their persisted inverses", () => { const context = createTestDatabase(); try { diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index 449864a..0ed798d 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -20,6 +20,7 @@ import { circuits } from "../src/db/schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { buildings } from "../src/db/schema/buildings.js"; import { floors } from "../src/db/schema/floors.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; @@ -59,6 +60,15 @@ function createTestDatabase(): DatabaseContext { .where(eq(circuitSections.circuitListId, list.id)) .get(); assert.ok(section); + context.db + .insert(buildings) + .values({ + id: "building-1", + projectId: "project-1", + name: "Haus A", + sortOrder: 0, + }) + .run(); context.db .insert(floors) .values({ @@ -66,6 +76,7 @@ function createTestDatabase(): DatabaseContext { projectId: "project-1", name: "EG", sortOrder: 10, + buildingId: "building-1", }) .run(); context.db @@ -248,7 +259,9 @@ describe("project snapshot repository", () => { "device-1" ); assert.equal(state.projectDevices.length, 1); + assert.equal(state.buildings.length, 1); assert.equal(state.floors.length, 1); + assert.equal(state.floors[0].buildingId, "building-1"); assert.equal(state.rooms.length, 1); assert.deepEqual(repository.listByProject("project-1"), [ created, @@ -400,8 +413,14 @@ describe("project snapshot repository", () => { schemaVersion: number; project: Record; distributionBoards: Array>; + buildings?: unknown; + floors: Array>; }; payload.schemaVersion = 5; + delete payload.buildings; + for (const floor of payload.floors) { + delete floor.buildingId; + } for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { delete payload.project[field]; } @@ -434,6 +453,9 @@ describe("project snapshot repository", () => { (board) => board.boardNumber === null && board.fedFrom === null ) ); + // Version 5 predates buildings: the upgraded state has none. + assert.deepEqual(targetState.buildings, []); + assert.ok(targetState.floors.every((floor) => floor.buildingId === null)); } finally { context.close(); } @@ -497,6 +519,11 @@ describe("portable project transfer", () => { copied.state.distributionBoards[0].floorId, copied.state.floors[0].id ); + assert.notEqual(copied.state.buildings[0].id, "building-1"); + assert.equal( + copied.state.floors[0].buildingId, + copied.state.buildings[0].id + ); assert.equal( copied.state.distributionBoards[0].supplyType, "SV" diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index 04d4901..c75555f 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -8,10 +8,22 @@ import { } from "../src/domain/models/project-state-snapshot.model.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; -/** Removes the fields introduced by snapshot schema version 6. */ -function withoutVersionSixFields< - T extends { project: object; distributionBoards: object[] }, +/** Removes the fields introduced by snapshot schema version 7. */ +function withoutVersionSevenFields< + T extends { floors: object[] }, >(snapshot: T): T { + delete (snapshot as Record).buildings; + for (const floor of snapshot.floors) { + delete (floor as Record).buildingId; + } + return snapshot; +} + +/** Removes the fields introduced by snapshot schema versions 6 and 7. */ +function withoutVersionSixFields< + T extends { project: object; distributionBoards: object[]; floors: object[] }, +>(snapshot: T): T { + withoutVersionSevenFields(snapshot); const project = snapshot.project as Record; for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { delete project[field]; @@ -66,7 +78,19 @@ function minimalSnapshot() { distributionBoardComponentProtectionDevices: [], circuits: [], projectDevices: [], - floors: [], + buildings: [] as Array<{ + id: string; + projectId: string; + name: string; + sortOrder: number; + }>, + floors: [] as Array<{ + id: string; + projectId: string; + name: string; + sortOrder: number; + buildingId: string | null; + }>, rooms: [], }; } @@ -306,7 +330,10 @@ describe("project state snapshot model", () => { delete (outdated.project as Record).description; assert.throws(() => parseProjectStateSnapshot(outdated)); assert.throws(() => - parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 }) + parseProjectStateSnapshot({ + ...minimalSnapshot(), + schemaVersion: projectStateSnapshotSchemaVersion + 1, + }) ); }); @@ -355,12 +382,99 @@ describe("project state snapshot model", () => { projectId: "project-2", name: "EG", sortOrder: 10, + buildingId: null, }); assert.throws( () => parseProjectStateSnapshot(wrongOwner), /floor belongs to a different project/ ); }); + + it("keeps buildings and validates floor building references", () => { + const snapshot = minimalSnapshot(); + snapshot.buildings.push({ + id: "building-1", + projectId: "project-1", + name: "Haus A", + sortOrder: 0, + }); + snapshot.floors.push( + { + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 0, + buildingId: "building-1", + }, + { + id: "floor-2", + projectId: "project-1", + name: "Keller", + sortOrder: 1, + buildingId: null, + } + ); + const parsed = deserializeProjectStateSnapshot( + serializeProjectStateSnapshot(parseProjectStateSnapshot(snapshot)) + ); + assert.deepEqual(parsed.buildings, snapshot.buildings); + assert.deepEqual( + parsed.floors.map((floor) => floor.buildingId), + ["building-1", null] + ); + + const unknownBuilding = structuredClone(snapshot); + unknownBuilding.floors[0]!.buildingId = "building-x"; + assert.throws( + () => parseProjectStateSnapshot(unknownBuilding), + /floor building/ + ); + + const foreignBuilding = structuredClone(snapshot); + foreignBuilding.buildings[0]!.projectId = "project-2"; + assert.throws( + () => parseProjectStateSnapshot(foreignBuilding), + /building belongs to a different project/ + ); + + const floorWithoutBuildingField = structuredClone(snapshot); + delete (floorWithoutBuildingField.floors[1] as Record) + .buildingId; + assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField)); + }); + + it("upgrades snapshot version 6 without buildings", () => { + const snapshot = minimalSnapshot(); + snapshot.floors.push({ + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 0, + buildingId: null, + }); + const previous = withoutVersionSevenFields(structuredClone(snapshot)); + previous.schemaVersion = 6 as typeof previous.schemaVersion; + + const upgraded = parseProjectStateSnapshot(previous); + + assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); + assert.deepEqual(upgraded.buildings, []); + assert.deepEqual(upgraded.floors, [ + { + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 0, + buildingId: null, + }, + ]); + // Version 6 cover values survive the upgrade. + assert.equal(upgraded.project.workStage, "LPH 5"); + + const mislabeled = structuredClone(snapshot); + mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion; + assert.throws(() => parseProjectStateSnapshot(mislabeled)); + }); }); describe("named project snapshot request", () => {