Fix code-review findings across domain, persistence, server and frontend

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>
This commit is contained in:
Julian Appel 2026-08-06 21:31:16 +02:00
parent fa96be2d42
commit b45dc5002d
48 changed files with 3263 additions and 526 deletions

View file

@ -144,6 +144,9 @@ function assertCircuitDeviceRowUpdateFieldValue(
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number.`);
}
if (field === "simultaneityFactor" && value > 1) {
throw new Error("simultaneityFactor must not exceed 1.");
}
return;
}
if (field === "cosPhi") {

View file

@ -134,6 +134,9 @@ export function assertCircuitDeviceRowInsertProjectCommand(
row.simultaneityFactor,
"row.simultaneityFactor"
);
if (row.simultaneityFactor > 1) {
throw new Error("row.simultaneityFactor must not exceed 1.");
}
if (row.cosPhi !== null) {
assertFiniteNumber(row.cosPhi, "row.cosPhi");
if (row.cosPhi <= 0) {

View file

@ -1,24 +0,0 @@
export interface CircuitDeviceRow {
id: string;
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
manualQuantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}

View file

@ -71,15 +71,33 @@ export function assertCircuitProtectionUpdateProjectCommand(
if (target !== null) {
assertCircuitProtectionSnapshot(target, circuitId);
}
if (JSON.stringify(expected) === JSON.stringify(target)) {
if (circuitProtectionSnapshotsEqual(expected, target)) {
throw new Error("Circuit protection update must change state.");
}
}
function circuitProtectionSnapshotsEqual(
left: CircuitProtectionSnapshot | null,
right: CircuitProtectionSnapshot | null
): boolean {
if (left === null || right === null) {
return left === right;
}
return (
left.circuitId === right.circuitId &&
left.type === right.type &&
left.ratedCurrentA === right.ratedCurrentA &&
left.fuseUtilizationCategory === right.fuseUtilizationCategory &&
left.tripCharacteristic === right.tripCharacteristic &&
left.rcdType === right.rcdType &&
left.ratedResidualCurrentMa === right.ratedResidualCurrentMa
);
}
export function assertCircuitProtectionSnapshot(
value: unknown,
circuitId: string
) {
): asserts value is CircuitProtectionSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 7 ||

View file

@ -1,9 +0,0 @@
export interface CircuitSection {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
}

View file

@ -1,19 +0,0 @@
export interface Circuit {
id: string;
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
}

View file

@ -130,7 +130,7 @@ const circuitDeviceRowSchema = z.preprocess(
quantity: finiteNumberSchema.nonnegative(),
manualQuantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema,
overriddenFields: nullableStringSchema,