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>
492 lines
15 KiB
TypeScript
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)
|
|
);
|
|
}
|