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, upgradeProjectRoomCommand, 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"; import { assertRoomHasNoExternalObjects } from "./external-object-deletion-guard.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); const current = upgradeProjectRoomCommand(command); return this.insertRoom( database, projectId, current.payload.room ); } case projectRoomDeleteCommandType: { assertProjectRoomDeleteProjectCommand(command); const current = upgradeProjectRoomCommand(command); return this.deleteRoom( database, projectId, current.payload.room ); } case projectRoomUpdateCommandType: { assertProjectRoomUpdateProjectCommand(command); const current = upgradeProjectRoomCommand(command); return this.updateRoom(database, projectId, current.payload.expected, current.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, areaM2: target.areaM2, }) .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." ); } assertRoomHasNoExternalObjects(database, projectId, expected.id); 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); case projectRoomInsertCommandType: assertProjectRoomInsertProjectCommand(command); return upgradeProjectRoomCommand(command); case projectRoomDeleteCommandType: assertProjectRoomDeleteProjectCommand(command); return upgradeProjectRoomCommand(command); case projectRoomUpdateCommandType: assertProjectRoomUpdateProjectCommand(command); return upgradeProjectRoomCommand(command); default: return command; } } function sameRecord( expected: object, actual: object ) { const expectedEntries = Object.entries(expected); const actualRecord = actual as Record; return ( expectedEntries.length === Object.keys(actual).length && expectedEntries.every(([key, value]) => actualRecord[key] === value) ); }