leistungsbilanz-ts/src/domain/models/project-location-structure-project-command.model.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

671 lines
21 KiB
TypeScript

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<ProjectFloorSnapshot, "buildingId">;
export interface ProjectRoomSnapshot {
id: string;
projectId: string;
floorId: string | null;
roomNumber: string;
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<ProjectBuildingStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingInsertCommandType;
}
export interface ProjectBuildingDeleteProjectCommand
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingDeleteCommandType;
}
export interface ProjectBuildingUpdateProjectCommand
extends SerializedProjectCommand<ProjectBuildingUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingUpdateCommandType;
}
export interface ProjectFloorInsertProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorInsertCommandType;
}
export interface ProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType;
}
export interface ProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
export interface LegacyProjectFloorInsertProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorInsertCommandType;
}
export interface LegacyProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType;
}
export interface LegacyProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorUpdateCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
export interface ProjectRoomInsertProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectRoomInsertCommandType;
}
export interface ProjectRoomDeleteProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectRoomDeleteCommandType;
}
export interface ProjectRoomUpdateProjectCommand
extends SerializedProjectCommand<ProjectRoomUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectRoomUpdateCommandType;
}
export type ProjectLocationStructureProjectCommand =
| ProjectBuildingInsertProjectCommand
| ProjectBuildingDeleteProjectCommand
| ProjectBuildingUpdateProjectCommand
| ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorInsertProjectCommand
| LegacyProjectFloorDeleteProjectCommand
| LegacyProjectFloorUpdateProjectCommand
| ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand
| ProjectRoomUpdateProjectCommand;
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;
}
export function createProjectRoomSnapshot(
projectId: string,
input: {
floorId?: string;
roomNumber: string;
roomName: string;
}
): ProjectRoomSnapshot {
const room: ProjectRoomSnapshot = {
id: crypto.randomUUID(),
projectId,
floorId: input.floorId?.trim() || null,
roomNumber: input.roomNumber.trim(),
roomName: input.roomName.trim(),
};
assertProjectRoomSnapshot(room);
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: projectFloorCommandSchemaVersion,
type: projectFloorInsertCommandType,
payload: { floor },
};
}
export function createProjectFloorDeleteProjectCommand(
floor: ProjectFloorSnapshot
): ProjectFloorDeleteProjectCommand {
assertProjectFloorSnapshot(floor);
return {
schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorDeleteCommandType,
payload: { floor },
};
}
export function createProjectRoomInsertProjectCommand(
room: ProjectRoomSnapshot
): ProjectRoomInsertProjectCommand {
assertProjectRoomSnapshot(room);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectRoomInsertCommandType,
payload: { room },
};
}
export function createProjectRoomDeleteProjectCommand(
room: ProjectRoomSnapshot
): ProjectRoomDeleteProjectCommand {
assertProjectRoomSnapshot(room);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectRoomDeleteCommandType,
payload: { room },
};
}
export function createProjectFloorUpdateProjectCommand(
expected: ProjectFloorSnapshot,
target: ProjectFloorSnapshot
): ProjectFloorUpdateProjectCommand {
assertMatchingFloorUpdate(expected, target);
return {
schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorUpdateCommandType,
payload: { expected, target },
};
}
export function createProjectRoomUpdateProjectCommand(
expected: ProjectRoomSnapshot,
target: ProjectRoomSnapshot
): ProjectRoomUpdateProjectCommand {
assertMatchingRoomUpdate(expected, target);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectRoomUpdateCommandType,
payload: { expected, target },
};
}
export function assertProjectBuildingInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingInsertProjectCommand {
assertProjectBuildingStructureProjectCommand(
command,
projectBuildingInsertCommandType
);
}
export function assertProjectBuildingDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingDeleteProjectCommand {
assertProjectBuildingStructureProjectCommand(
command,
projectBuildingDeleteCommandType
);
}
export function assertProjectBuildingUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): 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<unknown>
): asserts command is
| ProjectFloorInsertProjectCommand
| LegacyProjectFloorInsertProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorInsertCommandType
);
}
export function assertProjectFloorDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is
| ProjectFloorDeleteProjectCommand
| LegacyProjectFloorDeleteProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorDeleteCommandType
);
}
export function assertProjectRoomInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomInsertProjectCommand {
assertProjectRoomStructureProjectCommand(
command,
projectRoomInsertCommandType
);
}
export function assertProjectRoomDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomDeleteProjectCommand {
assertProjectRoomStructureProjectCommand(
command,
projectRoomDeleteCommandType
);
}
export function assertProjectFloorUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorUpdateProjectCommand {
if (
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);
}
export function assertProjectRoomUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomUpdateProjectCommand {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== projectRoomUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported project-room update command.");
}
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);
if (
expected.id !== target.id ||
expected.projectId !== target.projectId ||
expected.sortOrder !== target.sortOrder ||
(expected.name === target.name && expected.buildingId === target.buildingId)
) {
throw new Error(
"Project-floor update must change only the name or the building."
);
}
}
function assertMatchingRoomUpdate(expected: unknown, target: unknown) {
assertProjectRoomSnapshot(expected);
assertProjectRoomSnapshot(target);
if (
expected.id !== target.id ||
expected.projectId !== target.projectId ||
sameSnapshot(expected, target)
) {
throw new Error("Project-room update must change room values.");
}
}
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<string, unknown>) {
assertNormalizedNonEmptyString(floor.id, "floor.id");
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
assertNormalizedNonEmptyString(floor.name, "floor.name");
assertSortOrder(floor.sortOrder, "floor.sortOrder");
}
function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
assertLegacyProjectFloorSnapshot(floor);
return { ...floor, buildingId: null };
}
export function assertProjectRoomSnapshot(
room: unknown
): asserts room is ProjectRoomSnapshot {
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
throw new Error("Project-room snapshot is invalid.");
}
assertNormalizedNonEmptyString(room.id, "room.id");
assertNormalizedNonEmptyString(room.projectId, "room.projectId");
if (room.floorId !== null) {
assertNormalizedNonEmptyString(room.floorId, "room.floorId");
}
assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber");
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
}
function assertProjectBuildingStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectBuildingInsertCommandType
| typeof projectBuildingDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== expectedType ||
!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<unknown>,
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);
}
function assertProjectRoomStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectRoomInsertCommandType
| typeof projectRoomDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-room structure command.");
}
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
): asserts value is string {
if (
typeof value !== "string" ||
!value ||
value !== value.trim()
) {
throw new Error(`${field} must be a normalized non-empty string.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function sameSnapshot(left: object, right: object) {
return Object.entries(left).every(
([key, value]) => (right as Record<string, unknown>)[key] === value
);
}