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:
parent
7692699ee2
commit
cd89c4a499
42 changed files with 5883 additions and 373 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue