Add buildings above floors and a building structure page

Projects with several buildings could not be structured: floors and rooms
hung directly off the project. Floors can now belong to a building
(migration 0009: table buildings, nullable floors.building_id). Rooms and
distribution boards keep their floor; their building follows from it.
Existing floors stay without a building.

Buildings use the location-structure command store with
project-building.insert/.update/.delete, inverses and persistent Undo/Redo;
deleting a building with assigned floors is rejected. Floor commands carry
buildingId since command version 2. Version 1 floor commands from existing
history predate buildings and are upgraded with buildingId = null before
they are executed and persisted, so forward and inverse keep one version.

Project snapshot schema 7 stores buildings and floor.buildingId; versions
1-6 are upgraded without buildings. Restore and portable transfer include
buildings, and the transfer remaps building ids.

Buildings, floors and rooms move from the project overview to their own
page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume)
that filter each other, and a sidebar entry. Floor selects in the board and
room dialogs are grouped by building. Unchanged dialogs close without a
command. Details in docs/building-structure.md; see issue #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 12:52:34 +02:00
parent 7692699ee2
commit cd89c4a499
42 changed files with 5883 additions and 373 deletions

View file

@ -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