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,21 +1,42 @@
|
|||
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;
|
||||
|
|
@ -24,39 +45,93 @@ export interface ProjectRoomSnapshot {
|
|||
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 projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -79,23 +154,46 @@ export interface ProjectRoomUpdateProjectCommand
|
|||
}
|
||||
|
||||
export type ProjectLocationStructureProjectCommand =
|
||||
| ProjectBuildingInsertProjectCommand
|
||||
| ProjectBuildingDeleteProjectCommand
|
||||
| ProjectBuildingUpdateProjectCommand
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| ProjectFloorUpdateProjectCommand
|
||||
| LegacyProjectFloorInsertProjectCommand
|
||||
| LegacyProjectFloorDeleteProjectCommand
|
||||
| LegacyProjectFloorUpdateProjectCommand
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| ProjectRoomDeleteProjectCommand
|
||||
| ProjectRoomUpdateProjectCommand;
|
||||
|
||||
export function createProjectFloorSnapshot(
|
||||
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;
|
||||
|
|
@ -120,12 +218,46 @@ export function createProjectRoomSnapshot(
|
|||
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: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectFloorCommandSchemaVersion,
|
||||
type: projectFloorInsertCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
|
|
@ -136,7 +268,7 @@ export function createProjectFloorDeleteProjectCommand(
|
|||
): ProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectFloorCommandSchemaVersion,
|
||||
type: projectFloorDeleteCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
|
|
@ -170,7 +302,7 @@ export function createProjectFloorUpdateProjectCommand(
|
|||
): ProjectFloorUpdateProjectCommand {
|
||||
assertMatchingFloorUpdate(expected, target);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectFloorCommandSchemaVersion,
|
||||
type: projectFloorUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
|
|
@ -188,9 +320,43 @@ export function createProjectRoomUpdateProjectCommand(
|
|||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
): asserts command is
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| LegacyProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorInsertCommandType
|
||||
|
|
@ -199,7 +365,9 @@ export function assertProjectFloorInsertProjectCommand(
|
|||
|
||||
export function assertProjectFloorDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorDeleteProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| LegacyProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorDeleteCommandType
|
||||
|
|
@ -226,15 +394,26 @@ export function assertProjectRoomDeleteProjectCommand(
|
|||
|
||||
export function assertProjectFloorUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorUpdateProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectFloorUpdateProjectCommand
|
||||
| LegacyProjectFloorUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +431,68 @@ export function assertProjectRoomUpdateProjectCommand(
|
|||
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);
|
||||
|
|
@ -259,9 +500,11 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
|
|||
expected.id !== target.id ||
|
||||
expected.projectId !== target.projectId ||
|
||||
expected.sortOrder !== target.sortOrder ||
|
||||
expected.name === target.name
|
||||
(expected.name === target.name && expected.buildingId === target.buildingId)
|
||||
) {
|
||||
throw new Error("Project-floor update must change only the name.");
|
||||
throw new Error(
|
||||
"Project-floor update must change only the name or the building."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,22 +520,49 @@ function assertMatchingRoomUpdate(expected: unknown, target: unknown) {
|
|||
}
|
||||
}
|
||||
|
||||
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");
|
||||
if (
|
||||
typeof floor.sortOrder !== "number" ||
|
||||
!Number.isSafeInteger(floor.sortOrder) ||
|
||||
floor.sortOrder < 0
|
||||
) {
|
||||
throw new Error("floor.sortOrder must be a non-negative integer.");
|
||||
}
|
||||
assertSortOrder(floor.sortOrder, "floor.sortOrder");
|
||||
}
|
||||
|
||||
function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
|
||||
assertLegacyProjectFloorSnapshot(floor);
|
||||
return { ...floor, buildingId: null };
|
||||
}
|
||||
|
||||
export function assertProjectRoomSnapshot(
|
||||
|
|
@ -310,11 +580,11 @@ export function assertProjectRoomSnapshot(
|
|||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||
}
|
||||
|
||||
function assertProjectFloorStructureProjectCommand(
|
||||
function assertProjectBuildingStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof projectFloorInsertCommandType
|
||||
| typeof projectFloorDeleteCommandType
|
||||
| typeof projectBuildingInsertCommandType
|
||||
| typeof projectBuildingDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
|
|
@ -322,6 +592,29 @@ function assertProjectFloorStructureProjectCommand(
|
|||
!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);
|
||||
|
|
@ -344,6 +637,16 @@ function assertProjectRoomStructureProjectCommand(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ import {
|
|||
type ExternalModelStateSnapshot,
|
||||
} from "../../external-model/domain/external-model-contracts.js";
|
||||
|
||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
||||
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
|
|
@ -237,7 +238,7 @@ const projectDeviceSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const floorSchema = z
|
||||
const buildingSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
|
|
@ -246,6 +247,19 @@ const floorSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const legacyFloorSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
sortOrder: finiteNumberSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const floorSchema = legacyFloorSchema
|
||||
.extend({ buildingId: idSchema.nullable() })
|
||||
.strict();
|
||||
|
||||
const roomSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
|
|
@ -490,11 +504,24 @@ export function parseExternalModelStateSnapshot(
|
|||
return externalModelStateSchema.parse(value);
|
||||
}
|
||||
|
||||
const projectStateSnapshotContents = {
|
||||
// Versions 1-6 have floors without a building and no building list.
|
||||
const previousProjectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(legacyFloorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||
distributionBoardComponentProtectionDevices: z.array(
|
||||
componentProtectionDeviceSchema
|
||||
),
|
||||
};
|
||||
|
||||
const projectStateSnapshotContents = {
|
||||
...previousProjectStateSnapshotContents,
|
||||
buildings: z.array(buildingSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
|
|
@ -515,6 +542,17 @@ export const projectStateSnapshotSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const coverProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const boardInfoProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
|
||||
|
|
@ -522,7 +560,7 @@ const boardInfoProjectStateSnapshotSchema = z
|
|||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -533,7 +571,7 @@ const previousProjectStateSnapshotSchema = z
|
|||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -543,7 +581,7 @@ const externalModelProjectStateSnapshotSchema = z
|
|||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -552,7 +590,7 @@ const legacyProjectStateSnapshotSchema = z
|
|||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -561,7 +599,7 @@ const baselineProjectStateSnapshotSchema = z
|
|||
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
||||
project: previousProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -595,6 +633,28 @@ export function parseProjectStateSnapshot(
|
|||
}
|
||||
|
||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||
const cover = upgradeToCoverProjectStateSnapshot(value);
|
||||
if (
|
||||
cover === null ||
|
||||
typeof cover !== "object" ||
|
||||
Array.isArray(cover) ||
|
||||
(cover as { schemaVersion?: unknown }).schemaVersion !==
|
||||
coverProjectStateSnapshotSchemaVersion
|
||||
) {
|
||||
return cover;
|
||||
}
|
||||
// Every supported version before 7 lacks buildings; its floors had no
|
||||
// building.
|
||||
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
buildings: [],
|
||||
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
|
|
@ -611,7 +671,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
|
|
@ -622,7 +682,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const previous = previousProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
|
|
@ -633,7 +693,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const previous = externalModelProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
|
|
@ -645,7 +705,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const legacy = legacyProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...legacy,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...legacy.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
legacy.distributionBoards
|
||||
|
|
@ -658,7 +718,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const baseline = baselineProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...baseline,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
...baseline.project,
|
||||
isPublicBuilding: false,
|
||||
|
|
@ -765,6 +825,7 @@ function assertProjectStateSnapshotRelations(
|
|||
snapshot.projectDevices,
|
||||
"project device"
|
||||
);
|
||||
const buildingIds = uniqueIds(snapshot.buildings, "building");
|
||||
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||
const roomIds = uniqueIds(snapshot.rooms, "room");
|
||||
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
|
||||
|
|
@ -843,8 +904,14 @@ function assertProjectStateSnapshotRelations(
|
|||
);
|
||||
}
|
||||
}
|
||||
for (const building of snapshot.buildings) {
|
||||
assertProjectOwnership(building.projectId, projectId, "building");
|
||||
}
|
||||
for (const floor of snapshot.floors) {
|
||||
assertProjectOwnership(floor.projectId, projectId, "floor");
|
||||
if (floor.buildingId !== null) {
|
||||
assertReference(buildingIds, floor.buildingId, "floor building");
|
||||
}
|
||||
}
|
||||
for (const room of snapshot.rooms) {
|
||||
assertProjectOwnership(room.projectId, projectId, "room");
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export function remapProjectState(
|
|||
createId
|
||||
);
|
||||
const deviceIds = idMap(source.projectDevices, createId);
|
||||
const buildingIds = idMap(source.buildings, createId);
|
||||
const floorIds = idMap(source.floors, createId);
|
||||
const roomIds = idMap(source.rooms, createId);
|
||||
const rowIds = new Map(
|
||||
|
|
@ -242,10 +243,19 @@ export function remapProjectState(
|
|||
id: requiredId(deviceIds, device.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
buildings: source.buildings.map((building) => ({
|
||||
...building,
|
||||
id: requiredId(buildingIds, building.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
floors: source.floors.map((floor) => ({
|
||||
...floor,
|
||||
id: requiredId(floorIds, floor.id),
|
||||
projectId: targetProjectId,
|
||||
buildingId:
|
||||
floor.buildingId === null
|
||||
? null
|
||||
: requiredId(buildingIds, floor.buildingId),
|
||||
})),
|
||||
rooms: source.rooms.map((room) => ({
|
||||
...room,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue