import { and, asc, eq, inArray } from "drizzle-orm"; import { isDeepStrictEqual } from "node:util"; import { assertDistributionBoardDeleteProjectCommand, assertDistributionBoardInsertProjectCommand, distributionBoardDeleteCommandType, distributionBoardInsertCommandType, invertDistributionBoardStructureProjectCommand, normalizeDistributionBoardStructureProjectCommand, type DistributionBoardStructureProjectCommand, type NormalizedDistributionBoardStructureSnapshot, } from "../../domain/models/distribution-board-structure-project-command.model.js"; import { assertDistributionBoardUpdateProjectCommand, createDistributionBoardUpdateProjectCommand, type DistributionBoardUpdateField, type DistributionBoardUpdatePatch, type DistributionBoardUpdateProjectCommand, type DistributionBoardUpdateValues, } from "../../domain/models/distribution-board-project-command.model.js"; import { assertDistributionBoardFeederCircuitProjectCommand, createDistributionBoardCreateFeederCircuitProjectCommand, createDistributionBoardRemoveFeederCircuitProjectCommand, distributionBoardCreateFeederCircuitCommandType, distributionBoardRemoveFeederCircuitCommandType, type DistributionBoardFeederCircuitProjectCommand, } from "../../domain/models/distribution-board-feeder-circuit-project-command.model.js"; import { circuitDeleteCommandType, circuitInsertCommandType, createCircuitDeleteProjectCommand, createCircuitInsertProjectCommand, } from "../../domain/models/circuit-structure-project-command.model.js"; import type { DistributionBoardStructureProjectCommandStore, ExecuteDistributionBoardStructureCommandInput, } from "../../domain/ports/distribution-board-structure-project-command.store.js"; import type { AppDatabase } from "../database-context.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { floors } from "../schema/floors.js"; import { projects } from "../schema/projects.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js"; import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js"; export class DistributionBoardStructureProjectCommandRepository implements DistributionBoardStructureProjectCommandStore { constructor(private readonly database: AppDatabase) {} execute(input: ExecuteDistributionBoardStructureCommandInput) { return executeProjectCommandTransaction( this.database, input, (tx) => this.applyCommand(tx, input.projectId, input.command) ); } executeUpdate( input: Omit< ExecuteDistributionBoardStructureCommandInput, "command" > & { command: DistributionBoardUpdateProjectCommand; } ) { assertDistributionBoardUpdateProjectCommand(input.command); return executeProjectCommandTransaction( this.database, input, (tx) => this.update(tx, input) ); } executeFeederCircuit( input: Omit< ExecuteDistributionBoardStructureCommandInput, "command" > & { command: DistributionBoardFeederCircuitProjectCommand; } ) { assertDistributionBoardFeederCircuitProjectCommand(input.command); if ( input.source === "user" && input.command.type === distributionBoardRemoveFeederCircuitCommandType ) { throw new Error( "Generated feeder circuits may only be removed through project history." ); } return executeProjectCommandTransaction( this.database, input, (tx) => this.applyFeederCircuitCommand(tx, input) ); } private applyCommand( database: AppDatabase, projectId: string, command: DistributionBoardStructureProjectCommand ): DistributionBoardStructureProjectCommand { const structure = normalizeDistributionBoardStructureProjectCommand(command); if (command.type === distributionBoardInsertCommandType) { this.insert(database, projectId, structure); return invertDistributionBoardStructureProjectCommand(command); } if (command.type === distributionBoardDeleteCommandType) { this.delete(database, projectId, structure); return invertDistributionBoardStructureProjectCommand(command); } throw new Error("Unsupported distribution-board structure command."); } private insert( database: AppDatabase, projectId: string, structure: NormalizedDistributionBoardStructureSnapshot ) { if ( structure.distributionBoard.projectId !== projectId || structure.circuitList.projectId !== projectId ) { throw new Error( "Distribution-board structure does not belong to project." ); } const project = database .select({ id: projects.id, enabledSupplyTypes: projects.enabledDistributionBoardSupplyTypes, }) .from(projects) .where(eq(projects.id, projectId)) .get(); if (!project) { throw new Error("Project not found."); } assertSupplyTypeEnabled( project.enabledSupplyTypes, structure.distributionBoard.supplyType ); if (structure.distributionBoard.floorId !== null) { const floor = database .select({ projectId: floors.projectId }) .from(floors) .where(eq(floors.id, structure.distributionBoard.floorId)) .get(); if (!floor || floor.projectId !== projectId) { throw new Error( "Distribution-board floor does not belong to project." ); } } const existingBoard = database .select({ id: distributionBoards.id }) .from(distributionBoards) .where(eq(distributionBoards.id, structure.distributionBoard.id)) .get(); const existingList = database .select({ id: circuitLists.id }) .from(circuitLists) .where(eq(circuitLists.id, structure.circuitList.id)) .get(); const existingSection = database .select({ id: circuitSections.id }) .from(circuitSections) .where( inArray( circuitSections.id, structure.sections.map((section) => section.id) ) ) .limit(1) .get(); const existingComponent = structure.components.length === 0 ? undefined : database .select({ id: distributionBoardComponents.id }) .from(distributionBoardComponents) .where( inArray( distributionBoardComponents.id, structure.components.map((component) => component.id) ) ) .limit(1) .get(); if ( existingBoard || existingList || existingSection || existingComponent ) { throw new Error("Distribution-board structure id already exists."); } database .insert(distributionBoards) .values(structure.distributionBoard) .run(); database.insert(circuitLists).values(structure.circuitList).run(); database .insert(circuitSections) .values(structure.sections) .run(); if (structure.components.length > 0) { database .insert(distributionBoardComponents) .values(structure.components) .run(); } } private delete( database: AppDatabase, projectId: string, structure: NormalizedDistributionBoardStructureSnapshot ) { const board = database .select() .from(distributionBoards) .where(eq(distributionBoards.id, structure.distributionBoard.id)) .get(); const list = database .select() .from(circuitLists) .where(eq(circuitLists.id, structure.circuitList.id)) .get(); const sections = database .select() .from(circuitSections) .where( eq(circuitSections.circuitListId, structure.circuitList.id) ) .orderBy( asc(circuitSections.sortOrder), asc(circuitSections.id) ) .all(); const components = database .select() .from(distributionBoardComponents) .where( eq( distributionBoardComponents.circuitListId, structure.circuitList.id ) ) .orderBy( asc(distributionBoardComponents.sortOrder), asc(distributionBoardComponents.id) ) .all(); if ( !board || !list || board.projectId !== projectId || !structureMatches(structure, { board, list, sections, components, }) ) { throw new Error( "Distribution-board structure changed before deletion." ); } const populatedCircuit = database .select({ id: circuits.id }) .from(circuits) .where(eq(circuits.circuitListId, structure.circuitList.id)) .limit(1) .get(); if (populatedCircuit) { throw new Error( "A populated distribution board cannot be removed by history." ); } assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]); assertDistributionBoardHasNoExternalObjects(database, projectId, board.id); const deleted = database .delete(distributionBoards) .where( and( eq( distributionBoards.id, structure.distributionBoard.id ), eq(distributionBoards.projectId, projectId) ) ) .run(); if (deleted.changes !== 1) { throw new Error("Distribution board could not be deleted."); } } private update( database: AppDatabase, input: Omit< ExecuteDistributionBoardStructureCommandInput, "command" > & { command: DistributionBoardUpdateProjectCommand; } ) { const current = database .select() .from(distributionBoards) .where( and( eq( distributionBoards.id, input.command.payload.distributionBoardId ), eq(distributionBoards.projectId, input.projectId) ) ) .get(); if (!current) { throw new Error("Distribution board does not belong to project."); } const project = database .select({ enabledSupplyTypes: projects.enabledDistributionBoardSupplyTypes, }) .from(projects) .where(eq(projects.id, input.projectId)) .get(); if (!project) { throw new Error("Project not found."); } const patch = Object.fromEntries( input.command.payload.changes.map((change) => [ change.field, change.value, ]) ) as DistributionBoardUpdatePatch; if (patch.floorId !== undefined && patch.floorId !== null) { const floor = database .select({ projectId: floors.projectId }) .from(floors) .where(eq(floors.id, patch.floorId)) .get(); if (!floor || floor.projectId !== input.projectId) { throw new Error( "Distribution-board floor does not belong to project." ); } } if (patch.supplyType !== undefined) { assertSupplyTypeEnabled( project.enabledSupplyTypes, patch.supplyType ); } this.assertFeedingReference(database, input.projectId, current.id, { feedingDistributionBoardId: patch.feedingDistributionBoardId === undefined ? current.feedingDistributionBoardId : patch.feedingDistributionBoardId, feedingCircuitId: patch.feedingCircuitId === undefined ? current.feedingCircuitId : patch.feedingCircuitId, }); const inversePatch = Object.fromEntries( input.command.payload.changes.map((change) => [ change.field, getDistributionBoardFieldValue(current, change.field), ]) ) as DistributionBoardUpdatePatch; const inverse = createDistributionBoardUpdateProjectCommand( current.id, inversePatch, input.command.schemaVersion ); const updated = database .update(distributionBoards) .set(patch) .where( and( eq(distributionBoards.id, current.id), eq(distributionBoards.projectId, input.projectId) ) ) .run(); if (updated.changes !== 1) { throw new Error( "Distribution board changed before command execution." ); } return inverse; } private applyFeederCircuitCommand( database: AppDatabase, input: Omit< ExecuteDistributionBoardStructureCommandInput, "command" > & { command: DistributionBoardFeederCircuitProjectCommand; } ): DistributionBoardFeederCircuitProjectCommand { const circuitRepository = new CircuitStructureProjectCommandRepository(database); const { circuit, boardUpdate } = input.command.payload; if ( input.command.type === distributionBoardCreateFeederCircuitCommandType ) { const circuitInverse = circuitRepository.applyInTransaction( database, input.projectId, input.source, createCircuitInsertProjectCommand(circuit) ); if (circuitInverse.type !== circuitDeleteCommandType) { throw new Error("Created feeder circuit returned an invalid inverse."); } const boardInverse = this.update(database, { ...input, command: boardUpdate, }); return createDistributionBoardRemoveFeederCircuitProjectCommand( circuit, boardInverse ); } const boardInverse = this.update(database, { ...input, command: boardUpdate, }); const circuitInverse = circuitRepository.applyInTransaction( database, input.projectId, input.source, createCircuitDeleteProjectCommand(circuit.id, circuit.circuitListId) ); if ( circuitInverse.type !== circuitInsertCommandType || !isDeepStrictEqual(circuitInverse.payload.circuit, circuit) ) { throw new Error( "Generated feeder circuit changed before history removal." ); } return createDistributionBoardCreateFeederCircuitProjectCommand( circuit, boardInverse ); } private assertFeedingReference( database: AppDatabase, projectId: string, distributionBoardId: string, reference: { feedingDistributionBoardId: string | null; feedingCircuitId: string | null; } ) { const { feedingDistributionBoardId, feedingCircuitId } = reference; if (feedingDistributionBoardId === null) { if (feedingCircuitId !== null) { throw new Error( "Ein speisender Stromkreis erfordert eine speisende Verteilung." ); } return; } if (feedingDistributionBoardId === distributionBoardId) { throw new Error("Eine Verteilung kann sich nicht selbst speisen."); } const projectBoards = database .select({ id: distributionBoards.id, feedingDistributionBoardId: distributionBoards.feedingDistributionBoardId, }) .from(distributionBoards) .where(eq(distributionBoards.projectId, projectId)) .all(); const boardById = new Map(projectBoards.map((board) => [board.id, board])); if (!boardById.has(feedingDistributionBoardId)) { throw new Error( "Die speisende Verteilung gehört nicht zum Projekt." ); } const visited = new Set([distributionBoardId]); let currentId: string | null = feedingDistributionBoardId; while (currentId !== null) { if (visited.has(currentId)) { throw new Error("Die Einspeisungsverknüpfung würde einen Zyklus erzeugen."); } visited.add(currentId); currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null; } if (feedingCircuitId === null) { return; } const feedingCircuit = database .select({ projectId: circuitLists.projectId, distributionBoardId: circuitLists.distributionBoardId, }) .from(circuits) .innerJoin( circuitLists, eq(circuitLists.id, circuits.circuitListId) ) .where(eq(circuits.id, feedingCircuitId)) .get(); if ( !feedingCircuit || feedingCircuit.projectId !== projectId || feedingCircuit.distributionBoardId !== feedingDistributionBoardId ) { throw new Error( "Der speisende Stromkreis gehört nicht zur gewählten Verteilung." ); } } } function assertSupplyTypeEnabled( enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"], supplyType: (typeof distributionBoards.$inferSelect)["supplyType"] ) { if ( supplyType !== null && !enabledSupplyTypes.includes(supplyType) ) { throw new Error( `Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.` ); } } function getDistributionBoardFieldValue< TField extends DistributionBoardUpdateField, >( distributionBoard: typeof distributionBoards.$inferSelect, field: TField ): DistributionBoardUpdateValues[TField] { return distributionBoard[field] as DistributionBoardUpdateValues[TField]; } function structureMatches( expected: NormalizedDistributionBoardStructureSnapshot, actual: { board: typeof distributionBoards.$inferSelect; list: typeof circuitLists.$inferSelect; sections: Array; components: Array; } ) { const { simultaneityFactor, boardNumber, location, fedFrom, remark, feedingDistributionBoardId, feedingCircuitId, ...actualDistributionBoard } = actual.board; if ( simultaneityFactor !== 1 || [ boardNumber, location, fedFrom, remark, feedingDistributionBoardId, feedingCircuitId, ].some((value) => value !== null) || !sameRecord( expected.distributionBoard, actualDistributionBoard ) || !sameRecord(expected.circuitList, actual.list) || expected.sections.length !== actual.sections.length || expected.components.length !== actual.components.length ) { return false; } const expectedSections = [...expected.sections].sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ); const sectionsMatch = expectedSections.every((section, index) => sameRecord(section, actual.sections[index]) ); const expectedComponents = [...expected.components].sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ); return ( sectionsMatch && expectedComponents.every((component, index) => sameRecord(component, actual.components[index]) ) ); } 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 ) ); }