Files
leistungsbilanz-ts/src/db/repositories/project-device-structure-project-command.repository.ts
T

339 lines
9.3 KiB
TypeScript

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,
};
}