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

@ -688,9 +688,9 @@ export function deleteCircuitCommand(
);
}
export function getNextCircuitIdentifier(sectionId: string) {
export function getNextCircuitIdentifier(projectId: string, sectionId: string) {
return request<{ sectionId: string; nextIdentifier: string }>(
`/api/circuit-sections/${sectionId}/next-identifier`
`/api/projects/${projectId}/circuit-sections/${sectionId}/next-identifier`
);
}

View file

@ -396,6 +396,16 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
if (trimmed === "") {
return undefined;
}
// A "." is ambiguous in German number entry: it could be a decimal point
// (English convention) or a thousands separator (e.g. "1.500" meaning
// one thousand five hundred). Silently guessing either way risks a wrong
// value entering the power balance without any visible error, so "."
// is rejected outright and "," is the only accepted decimal separator.
if (trimmed.includes(".")) {
throw new Error(
`Ungültiger Zahlenwert in ${cellKey}: Dezimalstellen mit Komma eingeben.`
);
}
const parsed = Number(trimmed.replace(",", "."));
if (Number.isNaN(parsed)) {
throw new Error(`Ungültiger Zahlenwert in ${cellKey}`);

View file

@ -169,13 +169,6 @@ function makeVisibleGridRow(
return { rowKey, rowType, sectionId, circuit, device, cells };
}
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
return buildVisibleGridRowsWithStructure(sections, {
headerComponents: [],
footerComponents: [],
});
}
export function buildVisibleGridRowsWithStructure(
sections: readonly CircuitTreeSectionDto[],
structure: {

View file

@ -259,22 +259,28 @@ export function buildCircuitGroupRenumberPlan(
targetGroupNumber,
expectedPrefix,
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
circuits: group.circuits.map((circuit) => {
const circuitNumber = parseCircuitNumber(
circuit.equipmentIdentifier,
category,
group.groupNumber
);
return {
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
circuits: [...group.circuits]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map((circuit) => {
const circuitNumber = parseCircuitNumber(
circuit.equipmentIdentifier,
category,
targetGroupNumber,
circuitNumber
),
};
}),
group.groupNumber
);
return {
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
category,
targetGroupNumber,
circuitNumber
),
};
}),
components: group.components.map((component) => ({
componentId: component.id,
expectedEquipmentIdentifier: component.equipmentIdentifier,

View file

@ -23,9 +23,13 @@ export function buildCircuitSectionRenumberAssignments(
)
)
);
const orderedCircuits = [...targetSection.circuits].sort(
(left, right) =>
left.sortOrder - right.sortOrder || left.id.localeCompare(right.id)
);
const assignments: CircuitSectionRenumberAssignment[] = [];
let suffix = 1;
for (const circuit of targetSection.circuits) {
for (const circuit of orderedCircuits) {
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
while (
identifiersOutsideSection.has(targetEquipmentIdentifier)

View file

@ -64,7 +64,7 @@ export function buildCircuitStructureProjection(
component,
});
}
for (const circuit of section.circuits) {
for (const circuit of ordered(section.circuits)) {
rows.push({
rowKey: `circuit-block:${circuit.id}`,
rowType: "circuitBlock",

View file

@ -47,10 +47,24 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.update": "Geschoss bearbeitet",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
"project-room.update": "Raum bearbeitet",
"project-room.delete": "Raum entfernt",
"project.restore-state": "Projektstand wiederhergestellt",
"circuit-protection.update": "Stromkreisschutz bearbeitet",
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen",
"external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen",
"external-object.unassign-and-delete-created-circuit":
"Externes Objekt aus erzeugtem Stromkreis gelöst",
"external-object.assign-to-new-row":
"Externes Objekt in neue Gerätezeile übernommen",
"external-object.unassign-and-delete-created-row":
"Externes Objekt aus erzeugter Gerätezeile gelöst",
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
};
export function getProjectRevisionSourceLabel(