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; 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 { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; type: typeof projectBuildingInsertCommandType; } export interface ProjectBuildingDeleteProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; type: typeof projectBuildingDeleteCommandType; } export interface ProjectBuildingUpdateProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; type: typeof projectBuildingUpdateCommandType; } export interface ProjectFloorInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectFloorCommandSchemaVersion; type: typeof projectFloorInsertCommandType; } export interface ProjectFloorDeleteProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectFloorCommandSchemaVersion; type: typeof projectFloorDeleteCommandType; } export interface ProjectFloorUpdateProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectFloorCommandSchemaVersion; type: typeof projectFloorUpdateCommandType; } export interface LegacyProjectFloorInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; type: typeof projectFloorInsertCommandType; } export interface LegacyProjectFloorDeleteProjectCommand extends SerializedProjectCommand { schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; type: typeof projectFloorDeleteCommandType; } export interface LegacyProjectFloorUpdateProjectCommand extends SerializedProjectCommand { schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; type: typeof projectFloorUpdateCommandType; } export interface ProjectRoomInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; type: typeof projectRoomInsertCommandType; } export interface ProjectRoomDeleteProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; type: typeof projectRoomDeleteCommandType; } export interface ProjectRoomUpdateProjectCommand extends SerializedProjectCommand { 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 ): asserts command is ProjectBuildingInsertProjectCommand { assertProjectBuildingStructureProjectCommand( command, projectBuildingInsertCommandType ); } export function assertProjectBuildingDeleteProjectCommand( command: SerializedProjectCommand ): asserts command is ProjectBuildingDeleteProjectCommand { assertProjectBuildingStructureProjectCommand( command, projectBuildingDeleteCommandType ); } export function assertProjectBuildingUpdateProjectCommand( command: SerializedProjectCommand ): 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 ): asserts command is | ProjectFloorInsertProjectCommand | LegacyProjectFloorInsertProjectCommand { assertProjectFloorStructureProjectCommand( command, projectFloorInsertCommandType ); } export function assertProjectFloorDeleteProjectCommand( command: SerializedProjectCommand ): asserts command is | ProjectFloorDeleteProjectCommand | LegacyProjectFloorDeleteProjectCommand { assertProjectFloorStructureProjectCommand( command, projectFloorDeleteCommandType ); } export function assertProjectRoomInsertProjectCommand( command: SerializedProjectCommand ): asserts command is ProjectRoomInsertProjectCommand { assertProjectRoomStructureProjectCommand( command, projectRoomInsertCommandType ); } export function assertProjectRoomDeleteProjectCommand( command: SerializedProjectCommand ): asserts command is ProjectRoomDeleteProjectCommand { assertProjectRoomStructureProjectCommand( command, projectRoomDeleteCommandType ); } export function assertProjectFloorUpdateProjectCommand( command: SerializedProjectCommand ): 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 ): 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) { 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, 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, 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, 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 { 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)[key] === value ); }