import { and, asc, eq, inArray, isNull, } from "drizzle-orm"; import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; import { assertProjectDeviceDeleteProjectCommand, assertProjectDeviceInsertProjectCommand, createProjectDeviceDeleteProjectCommand, createProjectDeviceInsertProjectCommand, projectDeviceDeleteCommandType, projectDeviceInsertCommandType, type ProjectDeviceSnapshot, type ProjectDeviceStructureProjectCommand, } from "../../domain/models/project-device-structure-project-command.model.js"; import type { ExecuteProjectDeviceStructureCommandInput, ProjectDeviceStructureProjectCommandStore, } from "../../domain/ports/project-device-structure-project-command.store.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js"; const circuitDeviceRowSnapshotFields = [ "id", "circuitId", "linkedProjectDeviceId", "sortOrder", "name", "displayName", "phaseType", "connectionKind", "costGroup", "category", "level", "roomId", "roomNumberSnapshot", "roomNameSnapshot", "quantity", "powerPerUnit", "simultaneityFactor", "cosPhi", "remark", "overriddenFields", ] as const satisfies readonly (keyof CircuitDeviceRowSnapshot)[]; export class ProjectDeviceStructureProjectCommandRepository implements ProjectDeviceStructureProjectCommandStore { constructor(private readonly database: AppDatabase) {} execute(input: ExecuteProjectDeviceStructureCommandInput) { return executeProjectCommandTransaction( this.database, input, (tx) => this.applyCommand( tx, input.projectId, input.source, input.command ) ); } private applyCommand( database: AppDatabase, projectId: string, source: ExecuteProjectDeviceStructureCommandInput["source"], command: ProjectDeviceStructureProjectCommand ): ProjectDeviceStructureProjectCommand { if (command.type === projectDeviceInsertCommandType) { assertProjectDeviceInsertProjectCommand(command); return this.insert( database, projectId, source, command.payload.projectDevice, command.payload.linkedRows ); } if (command.type === projectDeviceDeleteCommandType) { assertProjectDeviceDeleteProjectCommand(command); return this.delete( database, projectId, command.payload.projectDeviceId, command.payload.expectedProjectId ); } throw new Error("Unsupported project-device structure command."); } private insert( database: AppDatabase, projectId: string, source: ExecuteProjectDeviceStructureCommandInput["source"], snapshot: ProjectDeviceSnapshot, linkedRows: CircuitDeviceRowSnapshot[] ) { if (snapshot.projectId !== projectId) { throw new Error( "Project-device snapshot does not belong to project." ); } if (source === "user" && linkedRows.length > 0) { throw new Error( "User project-device inserts cannot reconnect existing rows." ); } const project = database .select({ id: projects.id }) .from(projects) .where(eq(projects.id, projectId)) .get(); if (!project) { throw new Error("Project does not exist."); } if ( source === "user" && snapshot.voltageV !== resolveProjectDeviceVoltage( database, projectId, snapshot.phaseType ) ) { throw new Error( "Project-device voltage must match the project phase voltage." ); } const existing = database .select({ id: projectDevices.id }) .from(projectDevices) .where(eq(projectDevices.id, snapshot.id)) .get(); if (existing) { throw new Error("Project-device id already exists."); } this.assertRestorableRows(database, projectId, linkedRows); database.insert(projectDevices).values(snapshot).run(); for (const row of linkedRows) { const updated = database .update(circuitDeviceRows) .set({ linkedProjectDeviceId: snapshot.id }) .where( and( eq(circuitDeviceRows.id, row.id), isNull(circuitDeviceRows.linkedProjectDeviceId) ) ) .run(); if (updated.changes !== 1) { throw new Error( "A restored project-device row changed during insertion." ); } } return createProjectDeviceDeleteProjectCommand( snapshot.id, projectId ); } private delete( database: AppDatabase, projectId: string, projectDeviceId: string, expectedProjectId: string ) { if (expectedProjectId !== projectId) { throw new Error( "Project-device delete project does not match command project." ); } const projectDevice = database .select() .from(projectDevices) .where( and( eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId) ) ) .get(); if (!projectDevice) { throw new Error( "Project device does not belong to project." ); } const linkedRows = database .select({ row: circuitDeviceRows, projectId: circuitLists.projectId, }) .from(circuitDeviceRows) .innerJoin( circuits, eq(circuits.id, circuitDeviceRows.circuitId) ) .innerJoin( circuitLists, eq(circuitLists.id, circuits.circuitListId) ) .where( eq( circuitDeviceRows.linkedProjectDeviceId, projectDevice.id ) ) .orderBy( asc(circuitDeviceRows.sortOrder), asc(circuitDeviceRows.id) ) .all(); if ( linkedRows.some((linked) => linked.projectId !== projectId) ) { throw new Error( "Project device has linked rows outside its project." ); } const inverse = createProjectDeviceInsertProjectCommand( toProjectDeviceSnapshot(projectDevice), linkedRows.map(({ row }) => ({ ...toCircuitDeviceRowSnapshot(row), linkedProjectDeviceId: null, })) ); const deleted = database .delete(projectDevices) .where( and( eq(projectDevices.id, projectDevice.id), eq(projectDevices.projectId, projectId) ) ) .run(); if (deleted.changes !== 1) { throw new Error("Project device could not be deleted."); } return inverse; } private assertRestorableRows( database: AppDatabase, projectId: string, snapshots: CircuitDeviceRowSnapshot[] ) { if (snapshots.length === 0) { return; } const rowIds = snapshots.map((row) => row.id); const persistedRows = database .select({ row: circuitDeviceRows, projectId: circuitLists.projectId, }) .from(circuitDeviceRows) .innerJoin( circuits, eq(circuits.id, circuitDeviceRows.circuitId) ) .innerJoin( circuitLists, eq(circuitLists.id, circuits.circuitListId) ) .where(inArray(circuitDeviceRows.id, rowIds)) .all(); if ( persistedRows.length !== snapshots.length || persistedRows.some( (persisted) => persisted.projectId !== projectId ) ) { throw new Error( "One or more restored rows do not belong to project." ); } const persistedById = new Map( persistedRows.map(({ row }) => [ row.id, toCircuitDeviceRowSnapshot(row), ]) ); for (const snapshot of snapshots) { const persisted = persistedById.get(snapshot.id); if ( !persisted || !circuitDeviceRowSnapshotFields.every( (field) => persisted[field] === snapshot[field] ) ) { throw new Error( "A restored project-device row changed before insertion." ); } } } } function toProjectDeviceSnapshot( projectDevice: typeof projectDevices.$inferSelect ): ProjectDeviceSnapshot { if ( projectDevice.phaseType !== "single_phase" && projectDevice.phaseType !== "three_phase" ) { throw new Error("Persisted project-device phase type is invalid."); } return { id: projectDevice.id, projectId: projectDevice.projectId, name: projectDevice.name, displayName: projectDevice.displayName, phaseType: projectDevice.phaseType, connectionKind: projectDevice.connectionKind, costGroup: projectDevice.costGroup, category: projectDevice.category, quantity: projectDevice.quantity, powerPerUnit: projectDevice.powerPerUnit, simultaneityFactor: projectDevice.simultaneityFactor, cosPhi: projectDevice.cosPhi, remark: projectDevice.remark, voltageV: projectDevice.voltageV, }; }