leistungsbilanz-ts/src/db/repositories/project-location-structure-project-command.repository.ts
Grovy311 cd89c4a499 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>
2026-09-26 12:52:34 +02:00

492 lines
15 KiB
TypeScript

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,
} from "../../domain/models/project-location-structure-project-command.model.js";
import type {
ExecuteProjectLocationStructureCommandInput,
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";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore
{
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, command },
(tx) =>
this.applyCommand(tx, input.projectId, command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand {
switch (command.type) {
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,
current.payload.floor
);
}
case projectFloorDeleteCommandType: {
assertProjectFloorDeleteProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.deleteFloor(
database,
projectId,
current.payload.floor
);
}
case projectFloorUpdateCommandType: {
assertProjectFloorUpdateProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
}
case projectRoomInsertCommandType:
assertProjectRoomInsertProjectCommand(command);
return this.insertRoom(
database,
projectId,
command.payload.room
);
case projectRoomDeleteCommandType:
assertProjectRoomDeleteProjectCommand(command);
return this.deleteRoom(
database,
projectId,
command.payload.room
);
case projectRoomUpdateCommandType:
assertProjectRoomUpdateProjectCommand(command);
return this.updateRoom(database, projectId, command.payload.expected, command.payload.target);
}
}
private insertFloor(
database: AppDatabase,
projectId: string,
floor: ProjectFloorSnapshot
) {
this.assertProjectExists(database, projectId);
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)
.where(eq(floors.id, floor.id))
.get();
if (existing) {
throw new Error("Project-floor id already exists.");
}
database.insert(floors).values(floor).run();
return createProjectFloorDeleteProjectCommand(floor);
}
private deleteFloor(
database: AppDatabase,
projectId: string,
expected: ProjectFloorSnapshot
) {
const persisted = database
.select()
.from(floors)
.where(
and(
eq(floors.id, expected.id),
eq(floors.projectId, projectId)
)
)
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project floor changed before deletion.");
}
const referencedRoom = database
.select({ id: rooms.id })
.from(rooms)
.where(eq(rooms.floorId, expected.id))
.limit(1)
.get();
if (referencedRoom) {
throw new Error(
"Die Etage kann nicht gelöscht werden, solange Räume zugeordnet sind."
);
}
const referencedDistributionBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(eq(distributionBoards.floorId, expected.id))
.limit(1)
.get();
if (referencedDistributionBoard) {
throw new Error(
"Die Etage kann nicht gelöscht werden, solange eine Verteilung zugeordnet ist."
);
}
const deleted = database
.delete(floors)
.where(
and(
eq(floors.id, expected.id),
eq(floors.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project floor could not be deleted.");
}
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,
room: ProjectRoomSnapshot
) {
this.assertProjectExists(database, projectId);
if (room.projectId !== projectId) {
throw new Error("Project-room snapshot does not belong to project.");
}
if (room.floorId) {
const floor = database
.select({ id: floors.id })
.from(floors)
.where(
and(
eq(floors.id, room.floorId),
eq(floors.projectId, projectId)
)
)
.get();
if (!floor) {
throw new Error("Project room references a foreign floor.");
}
}
const existing = database
.select({ id: rooms.id })
.from(rooms)
.where(eq(rooms.id, room.id))
.get();
if (existing) {
throw new Error("Project-room id already exists.");
}
database.insert(rooms).values(room).run();
return createProjectRoomDeleteProjectCommand(room);
}
private updateFloor(
database: AppDatabase,
projectId: string,
expected: ProjectFloorSnapshot,
target: ProjectFloorSnapshot
) {
const persisted = database
.select()
.from(floors)
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project floor changed before update.");
}
this.assertBuildingInProject(database, projectId, target.buildingId);
const updated = database
.update(floors)
.set({ name: target.name, buildingId: target.buildingId })
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
.run();
if (updated.changes !== 1) {
throw new Error("Die Etage konnte nicht aktualisiert werden.");
}
return createProjectFloorUpdateProjectCommand(target, expected);
}
private updateRoom(
database: AppDatabase,
projectId: string,
expected: ProjectRoomSnapshot,
target: ProjectRoomSnapshot
) {
const persisted = database
.select()
.from(rooms)
.where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId)))
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project room changed before update.");
}
if (target.floorId) {
const targetFloor = database
.select({ id: floors.id })
.from(floors)
.where(and(eq(floors.id, target.floorId), eq(floors.projectId, projectId)))
.get();
if (!targetFloor) {
throw new Error("Project room references a foreign floor.");
}
}
const updated = database
.update(rooms)
.set({
floorId: target.floorId,
roomNumber: target.roomNumber,
roomName: target.roomName,
})
.where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId)))
.run();
if (updated.changes !== 1) {
throw new Error("Der Raum konnte nicht aktualisiert werden.");
}
return createProjectRoomUpdateProjectCommand(target, expected);
}
private deleteRoom(
database: AppDatabase,
projectId: string,
expected: ProjectRoomSnapshot
) {
const persisted = database
.select()
.from(rooms)
.where(
and(
eq(rooms.id, expected.id),
eq(rooms.projectId, projectId)
)
)
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project room changed before deletion.");
}
const referencedDeviceRow = database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.roomId, expected.id))
.limit(1)
.get();
if (referencedDeviceRow) {
throw new Error(
"Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird."
);
}
const deleted = database
.delete(rooms)
.where(
and(
eq(rooms.id, expected.id),
eq(rooms.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Project room could not be deleted.");
}
return createProjectRoomInsertProjectCommand(expected);
}
private assertProjectExists(
database: AppDatabase,
projectId: string
) {
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
}
}
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
) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(([key, value]) => actualRecord[key] === value)
);
}