Full-codebase review turned up five real correctness/security bugs and
a dozen smaller inconsistencies; all are fixed here with matching test
coverage:
- BMK uniqueness silently allowed German-umlaut duplicates ("Ä1" vs
"ä1") because the DB's normalized index only folds ASCII case. Added
a shared Unicode-aware pre-check used by every circuit/component
insert and rename path (one of which had no pre-check at all).
- CircuitDeviceRow.simultaneityFactor had no upper bound at the row
level (command model and snapshot/restore schema), unlike every
sibling entity, letting a bad value silently corrupt power totals.
- Grid cell editing silently misread German thousands-separator input
("1.500" parsed as 1.5); "." is now rejected outright with a clear
message instead of guessing.
- The editor's shared command runner (runCommand/applyHistory) had no
re-entrancy guard, so a double click/drop could fire the same
command twice and race a BMK collision or revision conflict. Added a
synchronous ref guard plus isSaving on the buttons that lacked it.
- GET .../next-identifier leaked circuit-numbering state for sections
in other projects (no ownership check, 400 instead of 404). Moved
under /projects/:projectId and scoped it.
Also: added the missing circuits.section_id / circuit_device_rows.
circuit_id indexes (migration 0006), gave FormModal a focus trap /
Escape-to-close / focus restore and rebuilt ProjectSettingsModal on
top of it instead of duplicated markup, removed dead code (3 orphaned
domain model files, an unused persistence helper, a wrapper only used
by its own test), pointed the project page at GET /projects/:id
instead of listing+filtering client-side, closed the gap between the
documented 18 MB CSV limit and the ~17.17 MiB actually enforced, added
missing upper bounds on several free-text fields, filled in nine
missing German labels in the revision timeline, replaced a
key-order-fragile JSON.stringify equality check with a real field
comparison, made an implicit sort-order assumption in three
renumbering helpers explicit, cleared the sidebar's target selection
when it no longer resolves after a tree reload, and fixed
updateGlobalDevice to check-then-write instead of write-then-check.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
289 lines
8.6 KiB
TypeScript
289 lines
8.6 KiB
TypeScript
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
import {
|
|
assertCircuitDeleteProjectCommand,
|
|
assertCircuitInsertProjectCommand,
|
|
circuitDeleteCommandType,
|
|
circuitInsertCommandType,
|
|
createCircuitDeleteProjectCommand,
|
|
createCircuitInsertProjectCommand,
|
|
type CircuitSnapshot,
|
|
type CircuitStructureProjectCommand,
|
|
} from "../../domain/models/circuit-structure-project-command.model.js";
|
|
import type {
|
|
CircuitStructureProjectCommandStore,
|
|
ExecuteCircuitStructureCommandInput,
|
|
} from "../../domain/ports/circuit-structure-project-command.store.js";
|
|
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
|
import type { AppDatabase } from "../database-context.js";
|
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
|
import { circuitLists } from "../schema/circuit-lists.js";
|
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
|
import { circuitSections } from "../schema/circuit-sections.js";
|
|
import { circuits } from "../schema/circuits.js";
|
|
import {
|
|
assertCircuitDeviceRowReferencesInProject,
|
|
toCircuitDeviceRowInsertValues,
|
|
toCircuitDeviceRowSnapshot,
|
|
} from "./circuit-device-row-structure.persistence.js";
|
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
|
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
|
|
|
export class CircuitStructureProjectCommandRepository
|
|
implements CircuitStructureProjectCommandStore
|
|
{
|
|
constructor(private readonly database: AppDatabase) {}
|
|
|
|
execute(input: ExecuteCircuitStructureCommandInput) {
|
|
return executeProjectCommandTransaction(
|
|
this.database,
|
|
input,
|
|
(tx) =>
|
|
this.applyCommand(
|
|
tx,
|
|
input.projectId,
|
|
input.source,
|
|
input.command
|
|
)
|
|
);
|
|
}
|
|
|
|
private applyCommand(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
source: ExecuteCircuitStructureCommandInput["source"],
|
|
command: CircuitStructureProjectCommand
|
|
): CircuitStructureProjectCommand {
|
|
if (command.type === circuitInsertCommandType) {
|
|
assertCircuitInsertProjectCommand(command);
|
|
return this.insert(
|
|
database,
|
|
projectId,
|
|
source,
|
|
command.payload.circuit
|
|
);
|
|
}
|
|
if (command.type === circuitDeleteCommandType) {
|
|
assertCircuitDeleteProjectCommand(command);
|
|
return this.delete(
|
|
database,
|
|
projectId,
|
|
command.payload.circuitId,
|
|
command.payload.expectedCircuitListId
|
|
);
|
|
}
|
|
throw new Error("Unsupported circuit structure command.");
|
|
}
|
|
|
|
private insert(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
source: ExecuteCircuitStructureCommandInput["source"],
|
|
snapshot: CircuitSnapshot
|
|
) {
|
|
this.assertCircuitLocation(database, projectId, snapshot);
|
|
if (source === "user") {
|
|
const derivedVoltage = resolveCircuitVoltage(
|
|
database,
|
|
projectId,
|
|
snapshot.sectionId,
|
|
snapshot.deviceRows.map((row) => row.phaseType)
|
|
);
|
|
if (snapshot.voltage !== derivedVoltage) {
|
|
throw new Error(
|
|
"Circuit voltage must match the project phase voltage."
|
|
);
|
|
}
|
|
}
|
|
|
|
const existingCircuit = database
|
|
.select({ id: circuits.id })
|
|
.from(circuits)
|
|
.where(eq(circuits.id, snapshot.id))
|
|
.get();
|
|
if (existingCircuit) {
|
|
throw new Error("Circuit id already exists.");
|
|
}
|
|
assertEquipmentIdentifierAvailable(
|
|
database,
|
|
snapshot.circuitListId,
|
|
snapshot.equipmentIdentifier
|
|
);
|
|
|
|
if (snapshot.deviceRows.length > 0) {
|
|
const rowIds = snapshot.deviceRows.map((row) => row.id);
|
|
const existingRow = database
|
|
.select({ id: circuitDeviceRows.id })
|
|
.from(circuitDeviceRows)
|
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
|
.limit(1)
|
|
.get();
|
|
if (existingRow) {
|
|
throw new Error("Circuit device-row id already exists.");
|
|
}
|
|
for (const row of snapshot.deviceRows) {
|
|
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
|
throw new Error("Device-row phase type is invalid.");
|
|
}
|
|
assertCircuitDeviceRowReferencesInProject(
|
|
database,
|
|
projectId,
|
|
row
|
|
);
|
|
}
|
|
}
|
|
|
|
database
|
|
.insert(circuits)
|
|
.values({
|
|
id: snapshot.id,
|
|
circuitListId: snapshot.circuitListId,
|
|
sectionId: snapshot.sectionId,
|
|
equipmentIdentifier: snapshot.equipmentIdentifier,
|
|
displayName: snapshot.displayName,
|
|
sortOrder: snapshot.sortOrder,
|
|
cableType: snapshot.cableType,
|
|
cableCrossSection: snapshot.cableCrossSection,
|
|
cableLength: snapshot.cableLength,
|
|
rcdAssignment: snapshot.rcdAssignment,
|
|
terminalDesignation: snapshot.terminalDesignation,
|
|
voltage: snapshot.voltage,
|
|
controlRequirement: snapshot.controlRequirement,
|
|
status: snapshot.status,
|
|
isReserve: snapshot.isReserve ? 1 : 0,
|
|
remark: snapshot.remark,
|
|
})
|
|
.run();
|
|
if (snapshot.deviceRows.length > 0) {
|
|
database
|
|
.insert(circuitDeviceRows)
|
|
.values(snapshot.deviceRows.map(toCircuitDeviceRowInsertValues))
|
|
.run();
|
|
}
|
|
if (snapshot.protectionDevice) {
|
|
database
|
|
.insert(circuitProtectionDevices)
|
|
.values(snapshot.protectionDevice)
|
|
.run();
|
|
}
|
|
|
|
return createCircuitDeleteProjectCommand(
|
|
snapshot.id,
|
|
snapshot.circuitListId
|
|
);
|
|
}
|
|
|
|
private delete(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
circuitId: string,
|
|
expectedCircuitListId: string
|
|
) {
|
|
const circuit = database
|
|
.select()
|
|
.from(circuits)
|
|
.where(eq(circuits.id, circuitId))
|
|
.get();
|
|
if (!circuit || circuit.circuitListId !== expectedCircuitListId) {
|
|
throw new Error("Circuit changed before command execution.");
|
|
}
|
|
const owningList = database
|
|
.select({ id: circuitLists.id })
|
|
.from(circuitLists)
|
|
.where(
|
|
and(
|
|
eq(circuitLists.id, circuit.circuitListId),
|
|
eq(circuitLists.projectId, projectId)
|
|
)
|
|
)
|
|
.get();
|
|
if (!owningList) {
|
|
throw new Error("Circuit does not belong to project.");
|
|
}
|
|
|
|
const rows = database
|
|
.select()
|
|
.from(circuitDeviceRows)
|
|
.where(eq(circuitDeviceRows.circuitId, circuit.id))
|
|
.orderBy(
|
|
asc(circuitDeviceRows.sortOrder),
|
|
asc(circuitDeviceRows.id)
|
|
)
|
|
.all();
|
|
const protectionDevice = database
|
|
.select()
|
|
.from(circuitProtectionDevices)
|
|
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
|
.get();
|
|
const inverse = createCircuitInsertProjectCommand({
|
|
id: circuit.id,
|
|
circuitListId: circuit.circuitListId,
|
|
sectionId: circuit.sectionId,
|
|
equipmentIdentifier: circuit.equipmentIdentifier,
|
|
displayName: circuit.displayName,
|
|
sortOrder: circuit.sortOrder,
|
|
cableType: circuit.cableType,
|
|
cableCrossSection: circuit.cableCrossSection,
|
|
cableLength: circuit.cableLength,
|
|
rcdAssignment: circuit.rcdAssignment,
|
|
terminalDesignation: circuit.terminalDesignation,
|
|
voltage: circuit.voltage,
|
|
controlRequirement: circuit.controlRequirement,
|
|
status: circuit.status,
|
|
isReserve: Boolean(circuit.isReserve),
|
|
remark: circuit.remark,
|
|
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
|
protectionDevice: protectionDevice ?? null,
|
|
});
|
|
|
|
const result = database
|
|
.delete(circuits)
|
|
.where(
|
|
and(
|
|
eq(circuits.id, circuitId),
|
|
eq(circuits.circuitListId, expectedCircuitListId)
|
|
)
|
|
)
|
|
.run();
|
|
if (result.changes !== 1) {
|
|
throw new Error("Circuit could not be deleted.");
|
|
}
|
|
return inverse;
|
|
}
|
|
|
|
private assertCircuitLocation(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
snapshot: CircuitSnapshot
|
|
) {
|
|
const list = database
|
|
.select({ id: circuitLists.id })
|
|
.from(circuitLists)
|
|
.where(
|
|
and(
|
|
eq(circuitLists.id, snapshot.circuitListId),
|
|
eq(circuitLists.projectId, projectId)
|
|
)
|
|
)
|
|
.get();
|
|
if (!list) {
|
|
throw new Error("Circuit list does not belong to project.");
|
|
}
|
|
const section = database
|
|
.select({ id: circuitSections.id })
|
|
.from(circuitSections)
|
|
.where(
|
|
and(
|
|
eq(circuitSections.id, snapshot.sectionId),
|
|
eq(
|
|
circuitSections.circuitListId,
|
|
snapshot.circuitListId
|
|
)
|
|
)
|
|
)
|
|
.get();
|
|
if (!section) {
|
|
throw new Error("Section does not belong to circuit list.");
|
|
}
|
|
}
|
|
}
|