diff --git a/AGENTS.md b/AGENTS.md index ad490c6..de27816 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,14 +220,15 @@ After saving, the row becomes linked to the new project device. ## Undo / Redo -The editor's visible undo/redo stack remains session-local while move, reorder -and renumber operations are being migrated. Existing Circuit and -CircuitDeviceRow cell edits plus standalone insertion/deletion already execute -persistent project commands; their toolbar undo/redo actions use the -project-wide history endpoints. Insertions use client-generated stable UUIDs, -and deletion undo restores the same ids from complete server snapshots. The -tree response supplies the optimistic `currentRevision`; obsolete direct field -PATCH, structure POST and CircuitDeviceRow DELETE routes are removed. +The editor's visible undo/redo stack remains session-local while reorder and +renumber operations are being migrated. Existing Circuit and CircuitDeviceRow +cell edits, standalone insertion/deletion and CircuitDeviceRow moves already +execute persistent project commands; their toolbar undo/redo actions use the +project-wide history endpoints. Insertions and generated move targets use +client-generated stable UUIDs, and undo restores the same ids from complete +server snapshots. The tree response supplies the optimistic `currentRevision`; +obsolete direct field PATCH, structure POST, move, Circuit and CircuitDeviceRow +DELETE routes are removed. Reloading the page does not yet reconstruct the visible editor stack. CircuitDeviceRow moves between existing circuits and moves that create one new placeholder target circuit are persisted. The latter stores the complete empty diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index bca62cd..b347750 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -36,9 +36,9 @@ snapshots contain zero, one or multiple complete device rows. Move commands contain each row's expected and target circuit plus its exact expected and target sort order. This makes deletion and moves undoable without generating replacement identities or renumbering circuits. Existing Circuit and -CircuitDeviceRow cell edits plus standalone insert/delete actions in the editor -consume these endpoints. Move, reorder and renumber UI paths are still being -migrated. +CircuitDeviceRow cell edits, standalone insert/delete actions and device-row +moves in the editor consume these endpoints. Reorder and renumber UI paths are +still being migrated. `circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move-with-new-circuit` atomically creates exactly one @@ -169,48 +169,11 @@ Response sketch: - `GET /circuit-sections/:sectionId/next-identifier` - preview next identifier for section (`prefix + maxSuffix + 1`) -Circuit and device-row field updates, standalone insertions and standalone -deletions are available only as the corresponding versioned commands through -`POST /projects/:projectId/commands`. There are no direct field-update PATCH, -structure POST or CircuitDeviceRow DELETE routes. The direct -`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the -not-yet-migrated placeholder move UI and must not be used by new features. - -### Move Device Row - -- `PATCH /circuit-device-rows/:rowId/move` -- Purpose: move one row to existing circuit or to newly created circuit in target section. - -Request sketch: - -```json -{ - "targetCircuitId": "cir_target" -} -``` - -or - -```json -{ - "targetSectionId": "sec_target", - "createNewCircuit": true -} -``` - -### Bulk Move Device Rows - -- `PATCH /circuit-device-rows/move-bulk` -- Purpose: move multiple rows in one command flow. - -Request sketch: - -```json -{ - "rowIds": ["row_1", "row_2"], - "targetCircuitId": "cir_target" -} -``` +Circuit and device-row field updates, standalone insertions/deletions and +single or bulk device-row moves are available only as the corresponding +versioned commands through `POST /projects/:projectId/commands`. There are no +direct field-update PATCH, structure POST, move, Circuit DELETE or +CircuitDeviceRow DELETE routes. ### Reorder Circuits diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 1754773..c8bf3be 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -1,10 +1,10 @@ # Circuit List Editor Known Limitations - Existing Circuit and CircuitDeviceRow cell edits plus standalone - insert/delete actions use persistent project commands and project-wide - toolbar undo/redo. The editor's visible stack is still session-local, is - empty after reload and still contains direct move/reorder/renumber operations - during the cutover. + insert/delete actions and CircuitDeviceRow moves use persistent project + commands and project-wide toolbar undo/redo. The editor's visible stack is + still session-local, is empty after reload and still contains direct + reorder/renumber operations during the cutover. - Server-side project revisions and persistent undo/redo eligibility exist for the command types documented in `circuit-list-editor-api.md`; complete editor command coverage and history reconstruction are not complete. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 89328b0..d71efc5 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -91,10 +91,11 @@ sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über di öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite -Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE- -Endpunkte sind entfernt. Move-, Reorder- und Renumber-UI-Pfade verbleiben -vorerst im sichtbaren sitzungslokalen Stack; nach einem Reload wird dieser noch -nicht aus der Serverhistorie rekonstruiert. +Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und +Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves im Grid +verwenden die persistenten Kommandos bereits. Nur Reorder- und +Renumber-UI-Pfade verbleiben vorerst im sichtbaren sitzungslokalen Stack; nach +einem Reload wird dieser noch nicht aus der Serverhistorie rekonstruiert. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; @@ -104,6 +105,9 @@ Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen Felder und vollständiger Zeilenbestand unverändert sind. +Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree, +vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das +Toolbar-Undo/Redo über die projektweite Historie aus. `circuit.reorder-section` speichert die erwartete und neue Sortierposition jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs diff --git a/docs/local-db-circuit-first-migration.md b/docs/local-db-circuit-first-migration.md index 206d8ff..995f6bf 100644 --- a/docs/local-db-circuit-first-migration.md +++ b/docs/local-db-circuit-first-migration.md @@ -130,27 +130,8 @@ Expected response shape: If migrations were not applied, endpoint may return an empty fallback with a warning. -## Dev-only visual test helper (multi-device circuit) +## Visual verification -Add one extra manual device row to an existing circuit: - -```bash -npm run dev:add-manual-circuit-row -- -``` - -Default inserted values: -- `name`: `Test sub device` -- `displayName`: `Beleuchtung WC` -- `phaseType`: `single_phase` -- `quantity`: `1` -- `powerPerUnit`: `0.05` -- `simultaneityFactor`: `1` -- `cosPhi`: `1` - -The script prints the created row id. - -Delete the test row again: - -```bash -npm run dev:delete-circuit-row -- -``` +Manual device rows and multi-device circuits can be created directly in the +circuit-list editor. The former development scripts for direct database writes +were removed because they bypassed project revisions and persistent undo/redo. diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index a8c49f2..40b2c9c 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -194,6 +194,9 @@ Completed foundation: empty target-circuit snapshot; forward creation and all row moves share one transaction, while undo restores the source positions and deletes only an unchanged generated target +- the circuit-list editor executes existing-target and generated-target row + moves through these commands; obsolete direct move and Circuit DELETE routes + plus their separate transaction adapter are removed - `circuit.reorder-section` requires the complete section circuit set and stores exact expected/target sort positions; its inverse changes no equipment identifier and never splits a circuit block @@ -211,9 +214,8 @@ Completed foundation: Remaining constraints before completing persistent history: -- route the remaining move, reorder and renumber editor writes through their - existing typed persistent commands and remove the final direct mutation - routes +- route the remaining reorder and renumber editor writes through their existing + typed persistent commands and remove the final direct mutation routes - reconstruct the visible frontend history state after reload; Circuit and CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations already use persistent commands and project-wide undo diff --git a/package.json b/package.json index 0669604..874b1bf 100644 --- a/package.json +++ b/package.json @@ -14,16 +14,14 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", "db:backfill:sections": "tsx scripts/db-backfill-sections.ts", - "db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts", - "dev:add-manual-circuit-row": "tsx scripts/dev-add-manual-circuit-row.ts", - "dev:delete-circuit-row": "tsx scripts/dev-delete-circuit-row.ts" + "db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts" }, "keywords": [], "author": "", diff --git a/scripts/dev-add-manual-circuit-row.ts b/scripts/dev-add-manual-circuit-row.ts deleted file mode 100644 index 80d65d4..0000000 --- a/scripts/dev-add-manual-circuit-row.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { db } from "../src/db/client.js"; -import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js"; -import { CircuitRepository } from "../src/db/repositories/circuit.repository.js"; - -async function run() { - const circuitId = process.argv[2]; - if (!circuitId) { - console.error("Usage: npm run dev:add-manual-circuit-row -- "); - process.exit(1); - } - - const circuitRepository = new CircuitRepository(); - const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db); - - const circuit = await circuitRepository.findById(circuitId); - if (!circuit) { - console.error(`Circuit not found: ${circuitId}`); - process.exit(1); - } - - const createdRowId = rowTransactionRepository.createInCircuit({ - circuitId, - name: "Test sub device", - displayName: "Beleuchtung WC", - phaseType: "single_phase", - quantity: 1, - powerPerUnit: 0.05, - simultaneityFactor: 1, - cosPhi: 1, - }); - - console.log(`Created test row id: ${createdRowId}`); -} - -run().catch((error) => { - console.error("Failed to create test row:", error); - process.exit(1); -}); - diff --git a/scripts/dev-delete-circuit-row.ts b/scripts/dev-delete-circuit-row.ts deleted file mode 100644 index b89006f..0000000 --- a/scripts/dev-delete-circuit-row.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { db } from "../src/db/client.js"; -import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js"; -import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js"; - -async function run() { - const rowId = process.argv[2]; - if (!rowId) { - console.error("Usage: npm run dev:delete-circuit-row -- "); - process.exit(1); - } - - const rowRepository = new CircuitDeviceRowRepository(); - const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db); - const row = await rowRepository.findById(rowId); - if (!row) { - console.error(`Row not found: ${rowId}`); - process.exit(1); - } - - rowTransactionRepository.deleteFromCircuit(rowId, row.circuitId); - console.log(`Deleted row id: ${rowId}`); -} - -run().catch((error) => { - console.error("Failed to delete row:", error); - process.exit(1); -}); - diff --git a/src/db/repositories/circuit-device-row-transaction.repository.ts b/src/db/repositories/circuit-device-row-transaction.repository.ts deleted file mode 100644 index 44aa8e2..0000000 --- a/src/db/repositories/circuit-device-row-transaction.repository.ts +++ /dev/null @@ -1,282 +0,0 @@ -import crypto from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; -import type { - CircuitDeviceRowTransactionStore, - CreateCircuitDeviceRowTransactionInput, - CreateCircuitWithDeviceRowsTransactionInput, - MoveCircuitDeviceRowsTransactionInput, -} from "../../domain/ports/circuit-device-row-transaction.store.js"; -import type { AppDatabase } from "../database-context.js"; -import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; -import { circuits } from "../schema/circuits.js"; -import { toCircuitCreateValues } from "./circuit.persistence.js"; -import { - toCircuitDeviceRowCreateValues, -} from "./circuit-device-row.persistence.js"; - -export class CircuitDeviceRowTransactionRepository - implements CircuitDeviceRowTransactionStore -{ - constructor(private readonly database: AppDatabase) {} - - createInCircuit(input: CreateCircuitDeviceRowTransactionInput) { - const id = crypto.randomUUID(); - this.database.transaction((tx) => { - const [circuit] = tx - .select({ id: circuits.id }) - .from(circuits) - .where(eq(circuits.id, input.circuitId)) - .limit(1) - .all(); - if (!circuit) { - throw new Error("Der Stromkreis ist ungültig."); - } - - const existingRows = tx - .select({ sortOrder: circuitDeviceRows.sortOrder }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, input.circuitId)) - .all(); - const lastSortOrder = existingRows.reduce( - (highest, row) => Math.max(highest, row.sortOrder), - 0 - ); - const sortOrder = input.sortOrder ?? lastSortOrder + 10; - - tx - .insert(circuitDeviceRows) - .values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder })) - .run(); - tx - .update(circuits) - .set({ isReserve: 0 }) - .where(eq(circuits.id, input.circuitId)) - .run(); - }); - return id; - } - - createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) { - if (input.deviceRows.length === 0) { - throw new Error("Mindestens eine Gerätezeile ist erforderlich."); - } - - const circuitId = crypto.randomUUID(); - const rowIds = input.deviceRows.map(() => crypto.randomUUID()); - this.database.transaction((tx) => { - tx - .insert(circuits) - .values( - toCircuitCreateValues(circuitId, { - ...input.circuit, - isReserve: false, - }) - ) - .run(); - - for (let index = 0; index < input.deviceRows.length; index += 1) { - const row = input.deviceRows[index]; - tx - .insert(circuitDeviceRows) - .values( - toCircuitDeviceRowCreateValues(rowIds[index], { - ...row, - circuitId, - sortOrder: row.sortOrder ?? (index + 1) * 10, - }) - ) - .run(); - } - }); - - return { circuitId, rowIds }; - } - - deleteFromCircuit(rowId: string, expectedCircuitId: string) { - this.database.transaction((tx) => { - const [row] = tx - .select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.id, rowId)) - .limit(1) - .all(); - if (!row || row.circuitId !== expectedCircuitId) { - throw new Error("Die Gerätezeile wurde vor dem Löschen verändert."); - } - - const result = tx - .delete(circuitDeviceRows) - .where( - and( - eq(circuitDeviceRows.id, rowId), - eq(circuitDeviceRows.circuitId, expectedCircuitId) - ) - ) - .run(); - if (result.changes !== 1) { - throw new Error("Die Gerätezeile konnte nicht gelöscht werden."); - } - - const remainingRows = tx - .select({ id: circuitDeviceRows.id }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, expectedCircuitId)) - .all(); - tx - .update(circuits) - .set({ isReserve: remainingRows.length === 0 ? 1 : 0 }) - .where(eq(circuits.id, expectedCircuitId)) - .run(); - }); - } - - moveRows(input: MoveCircuitDeviceRowsTransactionInput) { - if (input.rows.length === 0) { - throw new Error("Keine Gerätezeilen zum Verschieben angegeben."); - } - if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) { - throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden."); - } - - const rowIds = input.rows.map((row) => row.id); - if (new Set(rowIds).size !== rowIds.length) { - throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein."); - } - - const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined; - const targetCircuitId = input.targetCircuitId ?? createdCircuitId!; - - this.database.transaction((tx) => { - const persistedRows = tx - .select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId }) - .from(circuitDeviceRows) - .where(inArray(circuitDeviceRows.id, rowIds)) - .all(); - if (persistedRows.length !== input.rows.length) { - throw new Error("Eine oder mehrere Gerätezeilen sind ungültig."); - } - - const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row])); - for (const expectedRow of input.rows) { - if ( - persistedRowsById.get(expectedRow.id)?.circuitId !== - expectedRow.expectedCircuitId - ) { - throw new Error( - "Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert." - ); - } - } - - let targetCircuit: { id: string; circuitListId: string }; - if (input.createTargetCircuit) { - tx - .insert(circuits) - .values({ - id: targetCircuitId, - circuitListId: input.createTargetCircuit.circuitListId, - sectionId: input.createTargetCircuit.sectionId, - equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier, - displayName: input.createTargetCircuit.displayName, - sortOrder: input.createTargetCircuit.sortOrder, - isReserve: 0, - }) - .run(); - targetCircuit = { - id: targetCircuitId, - circuitListId: input.createTargetCircuit.circuitListId, - }; - } else { - const [persistedTargetCircuit] = tx - .select({ id: circuits.id, circuitListId: circuits.circuitListId }) - .from(circuits) - .where(eq(circuits.id, targetCircuitId)) - .limit(1) - .all(); - if (!persistedTargetCircuit) { - throw new Error("Der Zielstromkreis ist ungültig."); - } - targetCircuit = persistedTargetCircuit; - } - - const sourceCircuitIds = [ - ...new Set(input.rows.map((row) => row.expectedCircuitId)), - ]; - const sourceCircuits = tx - .select({ id: circuits.id, circuitListId: circuits.circuitListId }) - .from(circuits) - .where(inArray(circuits.id, sourceCircuitIds)) - .all(); - if (sourceCircuits.length !== sourceCircuitIds.length) { - throw new Error("Einer oder mehrere Quellstromkreise sind ungültig."); - } - if ( - sourceCircuits.some( - (circuit) => circuit.circuitListId !== targetCircuit.circuitListId - ) - ) { - throw new Error( - "Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören." - ); - } - - const movedRowIdSet = new Set(rowIds); - const retainedTargetRows = tx - .select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, targetCircuitId)) - .all() - .filter((row) => !movedRowIdSet.has(row.id)); - const lastTargetSortOrder = retainedTargetRows.reduce( - (highest, row) => Math.max(highest, row.sortOrder), - 0 - ); - - for (let index = 0; index < input.rows.length; index += 1) { - const row = input.rows[index]; - const result = tx - .update(circuitDeviceRows) - .set({ - circuitId: targetCircuitId, - sortOrder: lastTargetSortOrder + (index + 1) * 10, - }) - .where( - and( - eq(circuitDeviceRows.id, row.id), - eq(circuitDeviceRows.circuitId, row.expectedCircuitId) - ) - ) - .run(); - if (result.changes !== 1) { - throw new Error( - "Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert." - ); - } - } - - for (const sourceCircuitId of sourceCircuitIds) { - const remainingRows = tx - .select({ id: circuitDeviceRows.id }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, sourceCircuitId)) - .all(); - tx - .update(circuits) - .set({ isReserve: remainingRows.length === 0 ? 1 : 0 }) - .where(eq(circuits.id, sourceCircuitId)) - .run(); - } - tx - .update(circuits) - .set({ isReserve: 0 }) - .where(eq(circuits.id, targetCircuitId)) - .run(); - }); - - return { - movedRowIds: rowIds, - targetCircuitId, - createdCircuitId, - }; - } -} diff --git a/src/domain/ports/circuit-device-row-transaction.store.ts b/src/domain/ports/circuit-device-row-transaction.store.ts deleted file mode 100644 index 25440a8..0000000 --- a/src/domain/ports/circuit-device-row-transaction.store.ts +++ /dev/null @@ -1,75 +0,0 @@ -export interface CreateCircuitDeviceRowTransactionInput { - 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; - powerPerUnit: number; - simultaneityFactor: number; - cosPhi?: number; - remark?: string; - overriddenFields?: string; -} - -export interface CreateCircuitTransactionInput { - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName?: string; - sortOrder: number; - protectionType?: string; - protectionRatedCurrent?: number; - protectionCharacteristic?: string; - cableType?: string; - cableCrossSection?: string; - cableLength?: number; - voltage?: number; - controlRequirement?: string; - remark?: string; - rcdAssignment?: string; - terminalDesignation?: string; - status?: string; -} - -export interface CreateCircuitWithDeviceRowsTransactionInput { - circuit: CreateCircuitTransactionInput; - deviceRows: Array>; -} - -export interface MoveCircuitDeviceRowsTransactionInput { - rows: Array<{ id: string; expectedCircuitId: string }>; - targetCircuitId?: string; - createTargetCircuit?: { - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName: string; - sortOrder: number; - }; -} - -export interface MoveCircuitDeviceRowsTransactionResult { - movedRowIds: string[]; - targetCircuitId: string; - createdCircuitId?: string; -} - -export interface CircuitDeviceRowTransactionStore { - createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string; - createCircuitWithDeviceRows( - input: CreateCircuitWithDeviceRowsTransactionInput - ): { circuitId: string; rowIds: string[] }; - deleteFromCircuit(rowId: string, expectedCircuitId: string): void; - moveRows( - input: MoveCircuitDeviceRowsTransactionInput - ): MoveCircuitDeviceRowsTransactionResult; -} diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 4dd33bf..72fcb98 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -1,11 +1,7 @@ -import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; -import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js"; import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js"; import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; import type { - MoveCircuitDeviceRowInput, - MoveCircuitDeviceRowsBulkInput, ReorderSectionCircuitsInput, UpdateSectionEquipmentIdentifiersInput, } from "../../shared/validation/circuit.schemas.js"; @@ -14,46 +10,21 @@ import { CircuitNumberingService } from "./circuit-numbering.service.js"; export class CircuitWriteService { private readonly circuitRepository: CircuitRepository; private readonly circuitSectionRepository: CircuitSectionRepository; - private readonly deviceRowRepository: CircuitDeviceRowRepository; - private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore; private readonly numberingService: CircuitNumberingService; constructor(deps?: { circuitRepository?: CircuitRepository; circuitSectionRepository?: CircuitSectionRepository; - deviceRowRepository?: CircuitDeviceRowRepository; - deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; circuitSectionTransactionStore?: CircuitSectionTransactionStore; numberingService?: CircuitNumberingService; }) { this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository(); this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository(); - this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); - this.deviceRowTransactionStore = deps?.deviceRowTransactionStore; this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore; this.numberingService = deps?.numberingService ?? new CircuitNumberingService(); } - // Ensures writes never connect a section to the wrong circuit list. - private async assertSectionInList(sectionId: string, circuitListId: string) { - const section = await this.circuitSectionRepository.findById(sectionId); - if (!section) { - throw new Error("Invalid section id."); - } - if (section.circuitListId !== circuitListId) { - throw new Error("Section does not belong to circuit list."); - } - return section; - } - - private getDeviceRowTransactionStore() { - if (!this.deviceRowTransactionStore) { - throw new Error("Circuit device-row transactions are not configured."); - } - return this.deviceRowTransactionStore; - } - private getCircuitSectionTransactionStore() { if (!this.circuitSectionTransactionStore) { throw new Error("Circuit-section transactions are not configured."); @@ -61,157 +32,6 @@ export class CircuitWriteService { return this.circuitSectionTransactionStore; } - async deleteCircuit(circuitId: string) { - const current = await this.circuitRepository.findById(circuitId); - if (!current) { - throw new Error("Invalid circuit id."); - } - await this.circuitRepository.delete(circuitId); - } - - async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) { - const row = await this.deviceRowRepository.findById(rowId); - if (!row) { - throw new Error("Invalid device row id."); - } - - const sourceCircuit = await this.circuitRepository.findById(row.circuitId); - if (!sourceCircuit) { - throw new Error("Invalid circuit id."); - } - - let targetCircuit = input.targetCircuitId - ? await this.circuitRepository.findById(input.targetCircuitId) - : null; - if (input.targetCircuitId && !targetCircuit) { - throw new Error("Invalid target circuit id."); - } - - let createTargetCircuit: - | { - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName: string; - sortOrder: number; - } - | undefined; - - // Placeholder-target move prepares a new circuit explicitly. Its creation and - // the row assignment are committed together without renumbering other circuits. - if (!targetCircuit) { - if (!input.targetSectionId || !input.createNewCircuit) { - throw new Error("Invalid move target."); - } - const section = await this.assertSectionInList(input.targetSectionId, sourceCircuit.circuitListId); - const nextIdentifier = await this.numberingService.getNextIdentifier(section.id); - const sectionCircuits = await this.circuitRepository.listBySection(section.id); - const nextSortOrder = - sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10; - createTargetCircuit = { - circuitListId: sourceCircuit.circuitListId, - sectionId: section.id, - equipmentIdentifier: nextIdentifier, - displayName: "Neuer Stromkreis", - sortOrder: nextSortOrder, - }; - } - - if (targetCircuit && targetCircuit.circuitListId !== sourceCircuit.circuitListId) { - throw new Error("Target circuit does not belong to same circuit list."); - } - if (targetCircuit?.id === sourceCircuit.id) { - return this.deviceRowRepository.findById(rowId); - } - - this.getDeviceRowTransactionStore().moveRows({ - rows: [{ id: row.id, expectedCircuitId: row.circuitId }], - targetCircuitId: targetCircuit?.id, - createTargetCircuit, - }); - - return this.deviceRowRepository.findById(rowId); - } - - async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) { - // Bulk move keeps input order and resolves all source circuits first so undo can - // restore per-source assignment deterministically. - const uniqueRowIds = [...new Set(input.rowIds)]; - if (uniqueRowIds.length === 0) { - throw new Error("No device rows provided."); - } - - const rows = []; - for (const rowId of uniqueRowIds) { - const row = await this.deviceRowRepository.findById(rowId); - if (!row) { - throw new Error("Invalid device row id."); - } - rows.push(row); - } - - const sourceCircuits = new Map>>(); - for (const row of rows) { - if (!sourceCircuits.has(row.circuitId)) { - const circuit = await this.circuitRepository.findById(row.circuitId); - if (!circuit) { - throw new Error("Invalid circuit id."); - } - sourceCircuits.set(row.circuitId, circuit); - } - } - - const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!; - let targetCircuit = input.targetCircuitId - ? await this.circuitRepository.findById(input.targetCircuitId) - : null; - if (input.targetCircuitId && !targetCircuit) { - throw new Error("Invalid target circuit id."); - } - - // Bulk placeholder move prepares exactly one new circuit as common target. - // Its actual creation happens together with the row moves in one transaction. - let createTargetCircuit: - | { - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName: string; - sortOrder: number; - } - | undefined; - if (!targetCircuit) { - if (!input.targetSectionId || !input.createNewCircuit) { - throw new Error("Invalid move target."); - } - const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId); - const nextIdentifier = await this.numberingService.getNextIdentifier(section.id); - const sectionCircuits = await this.circuitRepository.listBySection(section.id); - const nextSortOrder = - sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10; - createTargetCircuit = { - circuitListId: referenceSourceCircuit.circuitListId, - sectionId: section.id, - equipmentIdentifier: nextIdentifier, - displayName: "Neuer Stromkreis", - sortOrder: nextSortOrder, - }; - } - - const targetCircuitListId = targetCircuit?.circuitListId ?? createTargetCircuit!.circuitListId; - for (const sourceCircuit of sourceCircuits.values()) { - if (sourceCircuit.circuitListId !== targetCircuitListId) { - throw new Error("All moved rows must belong to same circuit list as target."); - } - } - - return this.getDeviceRowTransactionStore().moveRows({ - rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })), - targetCircuitId: targetCircuit?.id, - createTargetCircuit, - }); - } - async getNextIdentifier(sectionId: string) { return this.numberingService.getNextIdentifier(sectionId); } diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 40f2dd8..9a4b157 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -27,6 +27,9 @@ import { buildCircuitDeviceRowInsertSnapshot, buildCircuitInsertSnapshot, } from "../utils/circuit-structure-command"; +import { + buildCircuitDeviceRowMoveAssignments, +} from "../utils/circuit-device-row-move-command"; import type { CellKey, CellKind, @@ -40,7 +43,6 @@ import { } from "../utils/circuit-grid-projection"; import type { VisibleGridRow } from "../utils/circuit-grid-projection"; import { - deleteCircuitById, deleteCircuitCommand, deleteCircuitDeviceRowCommand, getCircuitTree, @@ -48,8 +50,8 @@ import { insertCircuitCommand, insertCircuitDeviceRowCommand, listProjectDevices, - moveCircuitDeviceRowsBulk, - moveCircuitDeviceRowById, + moveCircuitDeviceRowsCommand, + moveCircuitDeviceRowsToNewCircuitCommand, reorderSectionCircuits, renumberCircuitSection, redoProjectCommand, @@ -2230,8 +2232,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } - // Handles device-row drag intent (single or bulk) and preserves enough source grouping - // for deterministic undo back to original circuits. + // Handles single and bulk device-row drag through persistent project history. + // Exact source and target positions make undo deterministic. async function handleDeviceRowDropWithIntent(event: DragEvent, intent: DeviceRowMoveDropIntent) { event.preventDefault(); event.stopPropagation(); @@ -2269,65 +2271,117 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } - // Device-row drag supports bulk selection, but target intent is always explicit: - // move into existing circuit or create new circuit in a target section. + const circuits = (data?.sections ?? []).flatMap( + (section) => section.circuits + ); + const label = + rowIds.length > 1 + ? `${rowIds.length} Gerätezeilen verschieben` + : "Gerätezeile verschieben"; + if (intent.kind === "move-to-circuit") { - const groupedBySource = new Map(); - for (const rowId of rowIds) { - const source = sourceByRowId.get(rowId)!; - if (!groupedBySource.has(source)) { - groupedBySource.set(source, []); - } - groupedBySource.get(source)!.push(rowId); + const targetCircuit = circuits.find( + (circuit) => circuit.id === intent.circuitId + ); + if (!targetCircuit) { + setError("Der Zielstromkreis ist ungültig."); + return; + } + const moves = buildCircuitDeviceRowMoveAssignments({ + circuits, + rowIds, + targetCircuitId: targetCircuit.id, + targetDeviceRows: targetCircuit.deviceRows, + }); + if (moves.length === 0) { + return; } await runCommand({ - label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen verschieben` : "Gerätezeile verschieben", + label, redo: async () => { - await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId }); + const result = await moveCircuitDeviceRowsCommand( + projectId, + getExpectedProjectRevision(), + moves, + label + ); + applyProjectCommandResult(result); pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, - undo: async () => { - for (const [source, ids] of groupedBySource) { - await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source }); - } - pendingSelectedDeviceRowIdsAfterReload.current = rowIds; - return null; + undo: async () => null, + persistent: { + undoIntent: () => { + pendingSelectedDeviceRowIdsAfterReload.current = rowIds; + return null; + }, + redoIntent: () => { + pendingSelectedDeviceRowIdsAfterReload.current = rowIds; + return null; + }, }, }); return; } - let createdCircuitId: string | null = null; - const groupedBySource = new Map(); - for (const rowId of rowIds) { - const source = sourceByRowId.get(rowId)!; - if (!groupedBySource.has(source)) { - groupedBySource.set(source, []); - } - groupedBySource.get(source)!.push(rowId); + const targetSection = data?.sections.find( + (section) => section.id === intent.sectionId + ); + if (!targetSection) { + setError("Der Zielbereich ist ungültig."); + return; } + const newCircuitLabel = + rowIds.length > 1 + ? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben` + : "Gerätezeile in neuen Stromkreis verschieben"; await runCommand({ - label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben` : "Gerätezeile in neuen Stromkreis verschieben", + label: newCircuitLabel, redo: async () => { - const moved = await moveCircuitDeviceRowsBulk({ - rowIds, - targetSectionId: intent.sectionId, - createNewCircuit: true, + const next = await getNextCircuitIdentifier(intent.sectionId); + const sortOrder = + targetSection.circuits.length > 0 + ? Math.max( + ...targetSection.circuits.map( + (circuit) => circuit.sortOrder + ) + ) + 10 + : 10; + const targetCircuit = createCircuitSnapshot({ + sectionId: intent.sectionId, + equipmentIdentifier: next.nextIdentifier, + displayName: "Neuer Stromkreis", + sortOrder, + isReserve: true, }); - createdCircuitId = moved.createdCircuitId ?? null; + const moves = buildCircuitDeviceRowMoveAssignments({ + circuits, + rowIds, + targetCircuitId: targetCircuit.id, + targetDeviceRows: [], + }); + const result = + await moveCircuitDeviceRowsToNewCircuitCommand( + projectId, + getExpectedProjectRevision(), + targetCircuit, + moves, + newCircuitLabel + ); + applyProjectCommandResult(result); pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, - undo: async () => { - for (const [source, ids] of groupedBySource) { - await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source }); - } - if (createdCircuitId) { - await deleteCircuitById(createdCircuitId); - } - pendingSelectedDeviceRowIdsAfterReload.current = rowIds; - return null; + undo: async () => null, + persistent: { + undoIntent: () => { + pendingSelectedDeviceRowIdsAfterReload.current = rowIds; + return null; + }, + redoIntent: () => { + pendingSelectedDeviceRowIdsAfterReload.current = rowIds; + return null; + }, }, }); } diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index bcd171a..0400040 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -33,6 +33,9 @@ import type { import type { CircuitSnapshot, } from "../../domain/models/circuit-structure-project-command.model"; +import type { + CircuitDeviceRowMoveAssignment, +} from "../../domain/models/circuit-device-row-move-project-command.model"; async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { @@ -212,12 +215,6 @@ export function deleteCircuitCommand( ); } -export function deleteCircuitById(circuitId: string) { - return request(`/api/circuits/${circuitId}`, { - method: "DELETE", - }); -} - export function getNextCircuitIdentifier(sectionId: string) { return request<{ sectionId: string; nextIdentifier: string }>( `/api/circuit-sections/${sectionId}/next-identifier` @@ -275,28 +272,44 @@ export function deleteCircuitDeviceRowCommand( ); } -export function moveCircuitDeviceRowById( - rowId: string, - input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean } +export function moveCircuitDeviceRowsCommand( + projectId: string, + expectedRevision: number, + moves: CircuitDeviceRowMoveAssignment[], + description: string ) { - return request(`/api/circuit-device-rows/${rowId}/move`, { - method: "PATCH", - body: JSON.stringify(input), - }); + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "circuit-device-row.move", + payload: { moves }, + }, + description + ); } -export function moveCircuitDeviceRowsBulk(input: { - rowIds: string[]; - targetCircuitId?: string; - targetSectionId?: string; - createNewCircuit?: boolean; -}) { - return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>( - "/api/circuit-device-rows/move-bulk", +export function moveCircuitDeviceRowsToNewCircuitCommand( + projectId: string, + expectedRevision: number, + targetCircuit: CircuitSnapshot, + moves: CircuitDeviceRowMoveAssignment[], + description: string +) { + return executeProjectCommand( + projectId, + expectedRevision, { - method: "PATCH", - body: JSON.stringify(input), - } + schemaVersion: 1, + type: "circuit-device-row.move-with-new-circuit", + payload: { + targetCircuitAction: "create", + targetCircuit, + moves, + }, + }, + description ); } diff --git a/src/frontend/utils/circuit-device-row-move-command.ts b/src/frontend/utils/circuit-device-row-move-command.ts new file mode 100644 index 0000000..2c7c366 --- /dev/null +++ b/src/frontend/utils/circuit-device-row-move-command.ts @@ -0,0 +1,66 @@ +import type { + CircuitTreeCircuitDto, + CircuitTreeDeviceRowDto, +} from "../types.js"; +import type { + CircuitDeviceRowMoveAssignment, +} from "../../domain/models/circuit-device-row-move-project-command.model.js"; + +export function buildCircuitDeviceRowMoveAssignments(input: { + circuits: CircuitTreeCircuitDto[]; + rowIds: string[]; + targetCircuitId: string; + targetDeviceRows: CircuitTreeDeviceRowDto[]; +}): CircuitDeviceRowMoveAssignment[] { + const { circuits, rowIds, targetCircuitId, targetDeviceRows } = + input; + if (new Set(rowIds).size !== rowIds.length) { + throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein."); + } + + const sourceByRowId = new Map< + string, + { circuitId: string; sortOrder: number } + >(); + for (const circuit of circuits) { + for (const row of circuit.deviceRows) { + sourceByRowId.set(row.id, { + circuitId: circuit.id, + sortOrder: row.sortOrder, + }); + } + } + + const movedRowIds = new Set(rowIds); + const retainedTargetRows = targetDeviceRows.filter( + (row) => !movedRowIds.has(row.id) + ); + const lastTargetSortOrder = retainedTargetRows.reduce( + (highest, row) => Math.max(highest, row.sortOrder), + 0 + ); + + return rowIds.flatMap((rowId, index) => { + const source = sourceByRowId.get(rowId); + if (!source) { + throw new Error("Eine ausgewählte Gerätezeile wurde nicht gefunden."); + } + const targetSortOrder = + lastTargetSortOrder + (index + 1) * 10; + if ( + source.circuitId === targetCircuitId && + source.sortOrder === targetSortOrder + ) { + return []; + } + return [ + { + rowId, + expectedCircuitId: source.circuitId, + expectedSortOrder: source.sortOrder, + targetCircuitId, + targetSortOrder, + }, + ]; + }); +} diff --git a/src/server/composition/circuit-write-service.ts b/src/server/composition/circuit-write-service.ts index 52265b2..2d3153b 100644 --- a/src/server/composition/circuit-write-service.ts +++ b/src/server/composition/circuit-write-service.ts @@ -1,9 +1,7 @@ import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; import { db } from "../../db/client.js"; -import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js"; import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js"; export const circuitWriteService = new CircuitWriteService({ - deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db), circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db), }); diff --git a/src/server/controllers/circuit-device-row.controller.ts b/src/server/controllers/circuit-device-row.controller.ts deleted file mode 100644 index 97fe3a9..0000000 --- a/src/server/controllers/circuit-device-row.controller.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Request, Response } from "express"; -import { circuitWriteService } from "../composition/circuit-write-service.js"; -import { - moveCircuitDeviceRowsBulkSchema, - moveCircuitDeviceRowSchema, -} from "../../shared/validation/circuit.schemas.js"; - -export async function moveCircuitDeviceRow(req: Request, res: Response) { - const { rowId } = req.params; - if (typeof rowId !== "string") { - return res.status(400).json({ error: "Invalid rowId" }); - } - - const parsed = moveCircuitDeviceRowSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - - try { - const moved = await circuitWriteService.moveDeviceRow(rowId, parsed.data); - return res.json(moved); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." }); - } -} - -export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) { - const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - - try { - const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data); - return res.json(moved); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." }); - } -} diff --git a/src/server/controllers/circuit.controller.ts b/src/server/controllers/circuit.controller.ts index d1f8a11..43cad10 100644 --- a/src/server/controllers/circuit.controller.ts +++ b/src/server/controllers/circuit.controller.ts @@ -1,20 +1,6 @@ import type { Request, Response } from "express"; import { circuitWriteService } from "../composition/circuit-write-service.js"; -export async function deleteCircuit(req: Request, res: Response) { - const { circuitId } = req.params; - if (typeof circuitId !== "string") { - return res.status(400).json({ error: "Invalid circuitId" }); - } - - try { - await circuitWriteService.deleteCircuit(circuitId); - return res.status(204).send(); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete circuit." }); - } -} - export async function getNextCircuitIdentifier(req: Request, res: Response) { const { sectionId } = req.params; if (typeof sectionId !== "string") { diff --git a/src/server/index.ts b/src/server/index.ts index 00778b7..6881a17 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,4 @@ import express from "express"; -import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js"; import { circuitRouter } from "./routes/circuit.routes.js"; import { circuitSectionRouter } from "./routes/circuit-section.routes.js"; import { globalDeviceRouter } from "./routes/global-device.routes.js"; @@ -18,7 +17,6 @@ app.get("/health", (_req, res) => { app.use("/api/projects", projectRouter); app.use("/api", circuitRouter); -app.use("/api", circuitDeviceRowRouter); app.use("/api", circuitSectionRouter); app.use("/api/global-devices", globalDeviceRouter); app.use("/api/project-devices", projectDeviceRouter); diff --git a/src/server/routes/circuit-device-row.routes.ts b/src/server/routes/circuit-device-row.routes.ts deleted file mode 100644 index 5630253..0000000 --- a/src/server/routes/circuit-device-row.routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Router } from "express"; -import { - moveCircuitDeviceRowsBulk, - moveCircuitDeviceRow, -} from "../controllers/circuit-device-row.controller.js"; - -export const circuitDeviceRowRouter = Router(); - -circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk); -circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow); diff --git a/src/server/routes/circuit.routes.ts b/src/server/routes/circuit.routes.ts index 8af9728..a841a7c 100644 --- a/src/server/routes/circuit.routes.ts +++ b/src/server/routes/circuit.routes.ts @@ -1,11 +1,9 @@ import { Router } from "express"; import { - deleteCircuit, getNextCircuitIdentifier, } from "../controllers/circuit.controller.js"; export const circuitRouter = Router(); -circuitRouter.delete("/circuits/:circuitId", deleteCircuit); circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier); diff --git a/src/shared/validation/circuit.schemas.ts b/src/shared/validation/circuit.schemas.ts index 30c480b..8d2e797 100644 --- a/src/shared/validation/circuit.schemas.ts +++ b/src/shared/validation/circuit.schemas.ts @@ -1,32 +1,5 @@ import { z } from "zod"; -export const moveCircuitDeviceRowSchema = z - .object({ - targetCircuitId: z.string().min(1).optional(), - targetSectionId: z.string().min(1).optional(), - createNewCircuit: z.boolean().optional(), - }) - .refine( - (value) => - Boolean(value.targetCircuitId) || - (Boolean(value.targetSectionId) && value.createNewCircuit === true), - { message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." } - ); - -export const moveCircuitDeviceRowsBulkSchema = z - .object({ - rowIds: z.array(z.string().min(1)).min(1), - targetCircuitId: z.string().min(1).optional(), - targetSectionId: z.string().min(1).optional(), - createNewCircuit: z.boolean().optional(), - }) - .refine( - (value) => - Boolean(value.targetCircuitId) || - (Boolean(value.targetSectionId) && value.createNewCircuit === true), - { message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." } - ); - export const reorderSectionCircuitsSchema = z.object({ orderedCircuitIds: z.array(z.string().min(1)).min(1), }); @@ -42,7 +15,5 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({ .min(1), }); -export type MoveCircuitDeviceRowInput = z.infer; -export type MoveCircuitDeviceRowsBulkInput = z.infer; export type ReorderSectionCircuitsInput = z.infer; export type UpdateSectionEquipmentIdentifiersInput = z.infer; diff --git a/tests/circuit-device-row-move-command.test.ts b/tests/circuit-device-row-move-command.test.ts new file mode 100644 index 0000000..57805e2 --- /dev/null +++ b/tests/circuit-device-row-move-command.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildCircuitDeviceRowMoveAssignments, +} from "../src/frontend/utils/circuit-device-row-move-command.js"; +import type { + CircuitTreeCircuitDto, + CircuitTreeDeviceRowDto, +} from "../src/frontend/types.js"; + +function row( + id: string, + sortOrder: number +): CircuitTreeDeviceRowDto { + return { + id, + sortOrder, + name: id, + displayName: id, + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + rowTotalPower: 0.1, + }; +} + +function circuit( + id: string, + deviceRows: CircuitTreeDeviceRowDto[] +): CircuitTreeCircuitDto { + return { + id, + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: id, + sortOrder: 10, + isReserve: deviceRows.length === 0, + circuitTotalPower: 0, + deviceRows, + }; +} + +describe("circuit device-row move command builder", () => { + it("records exact sources and appends rows in selection order", () => { + const targetRows = [row("target-row", 20)]; + const circuits = [ + circuit("source-1", [row("row-1", 30)]), + circuit("source-2", [row("row-2", 10)]), + circuit("target", targetRows), + ]; + + assert.deepEqual( + buildCircuitDeviceRowMoveAssignments({ + circuits, + rowIds: ["row-2", "row-1"], + targetCircuitId: "target", + targetDeviceRows: targetRows, + }), + [ + { + rowId: "row-2", + expectedCircuitId: "source-2", + expectedSortOrder: 10, + targetCircuitId: "target", + targetSortOrder: 30, + }, + { + rowId: "row-1", + expectedCircuitId: "source-1", + expectedSortOrder: 30, + targetCircuitId: "target", + targetSortOrder: 40, + }, + ] + ); + }); + + it("starts a generated target circuit at sort order ten", () => { + assert.deepEqual( + buildCircuitDeviceRowMoveAssignments({ + circuits: [circuit("source", [row("row-1", 40)])], + rowIds: ["row-1"], + targetCircuitId: "generated-target", + targetDeviceRows: [], + }), + [ + { + rowId: "row-1", + expectedCircuitId: "source", + expectedSortOrder: 40, + targetCircuitId: "generated-target", + targetSortOrder: 10, + }, + ] + ); + }); + + it("omits unchanged assignments during a same-circuit reorder", () => { + const rows = [row("row-1", 10), row("row-2", 20)]; + assert.deepEqual( + buildCircuitDeviceRowMoveAssignments({ + circuits: [circuit("target", rows)], + rowIds: ["row-1", "row-2"], + targetCircuitId: "target", + targetDeviceRows: rows, + }), + [] + ); + }); + + it("rejects duplicate or missing selected rows", () => { + const circuits = [circuit("source", [row("row-1", 10)])]; + assert.throws( + () => + buildCircuitDeviceRowMoveAssignments({ + circuits, + rowIds: ["row-1", "row-1"], + targetCircuitId: "target", + targetDeviceRows: [], + }), + /mehrfach/ + ); + assert.throws( + () => + buildCircuitDeviceRowMoveAssignments({ + circuits, + rowIds: ["missing"], + targetCircuitId: "target", + targetDeviceRows: [], + }), + /nicht gefunden/ + ); + }); +}); diff --git a/tests/circuit-device-row-transaction.repository.test.ts b/tests/circuit-device-row-transaction.repository.test.ts deleted file mode 100644 index e317347..0000000 --- a/tests/circuit-device-row-transaction.repository.test.ts +++ /dev/null @@ -1,529 +0,0 @@ -import path from "node:path"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { eq } from "drizzle-orm"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { - createDatabaseContext, - type DatabaseContext, -} from "../src/db/database-context.js"; -import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js"; -import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; -import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; -import { circuitSections } from "../src/db/schema/circuit-sections.js"; -import { circuits } from "../src/db/schema/circuits.js"; -import { projects } from "../src/db/schema/projects.js"; - -function createTestDatabase(): DatabaseContext { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { - migrationsFolder: path.resolve("src", "db", "migrations"), - }); - context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run(); - const board = new DistributionBoardRepository( - context.db - ).createWithCircuitListAndDefaultSections( - "project-1", - "UV-01" - ); - const [section] = context.db - .select() - .from(circuitSections) - .where(eq(circuitSections.circuitListId, board.id)) - .limit(1) - .all(); - context.db - .insert(circuits) - .values({ - id: "circuit-1", - circuitListId: board.id, - sectionId: section.id, - equipmentIdentifier: "-1F1", - displayName: "Reserve", - sortOrder: 10, - isReserve: 1, - }) - .run(); - return context; -} - -function insertCircuit( - context: DatabaseContext, - input: { - id: string; - equipmentIdentifier: string; - sortOrder: number; - isReserve?: number; - } -) { - const [seedCircuit] = context.db.select().from(circuits).limit(1).all(); - context.db - .insert(circuits) - .values({ - id: input.id, - circuitListId: seedCircuit.circuitListId, - sectionId: seedCircuit.sectionId, - equipmentIdentifier: input.equipmentIdentifier, - displayName: input.equipmentIdentifier, - sortOrder: input.sortOrder, - isReserve: input.isReserve ?? 1, - }) - .run(); -} - -function insertDeviceRow( - context: DatabaseContext, - input: { - id?: string; - circuitId?: string; - sortOrder?: number; - displayName?: string; - } = {} -) { - const rowId = input.id ?? "row-1"; - const circuitId = input.circuitId ?? "circuit-1"; - context.db - .insert(circuitDeviceRows) - .values({ - id: rowId, - circuitId, - sortOrder: input.sortOrder ?? 10, - name: "Leuchte", - displayName: input.displayName ?? "Leuchte", - quantity: 1, - powerPerUnit: 0.1, - simultaneityFactor: 1, - }) - .run(); - context.db - .update(circuits) - .set({ isReserve: 0 }) - .where(eq(circuits.id, circuitId)) - .run(); -} - -describe("circuit device-row transaction repository", () => { - it("commits a new device row and clears the circuit reserve status together", () => { - const context = createTestDatabase(); - try { - const repository = new CircuitDeviceRowTransactionRepository(context.db); - const rowId = repository.createInCircuit({ - circuitId: "circuit-1", - name: "Steckdose", - displayName: "Steckdose", - quantity: 1, - powerPerUnit: 0.2, - simultaneityFactor: 1, - }); - - const [row] = context.db - .select() - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.id, rowId)) - .all(); - const [circuit] = context.db - .select() - .from(circuits) - .where(eq(circuits.id, "circuit-1")) - .all(); - - assert.equal(row.circuitId, "circuit-1"); - assert.equal(row.sortOrder, 10); - assert.equal(circuit.isReserve, 0); - } finally { - context.close(); - } - }); - - it("rolls back the row insert when clearing the reserve status fails", () => { - const context = createTestDatabase(); - try { - context.sqlite.exec(` - CREATE TRIGGER fail_reserve_clear - BEFORE UPDATE OF is_reserve ON circuits - WHEN NEW.is_reserve = 0 - BEGIN - SELECT RAISE(ABORT, 'forced reserve clear failure'); - END; - `); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - assert.throws( - () => - repository.createInCircuit({ - circuitId: "circuit-1", - name: "Steckdose", - displayName: "Steckdose", - quantity: 1, - powerPerUnit: 0.2, - simultaneityFactor: 1, - }), - /forced reserve clear failure/ - ); - - assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0); - assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1); - } finally { - context.close(); - } - }); - - it("commits a new circuit and all initial device rows together", () => { - const context = createTestDatabase(); - try { - const [seedCircuit] = context.db.select().from(circuits).limit(1).all(); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - const created = repository.createCircuitWithDeviceRows({ - circuit: { - circuitListId: seedCircuit.circuitListId, - sectionId: seedCircuit.sectionId, - equipmentIdentifier: "-1F2", - displayName: "Beleuchtung", - sortOrder: 20, - }, - deviceRows: [ - { - name: "Leuchte", - displayName: "Leuchte 1", - quantity: 1, - powerPerUnit: 0.1, - simultaneityFactor: 1, - sortOrder: 15, - }, - { - name: "Leuchte", - displayName: "Leuchte 2", - quantity: 2, - powerPerUnit: 0.1, - simultaneityFactor: 0.8, - }, - ], - }); - - const [createdCircuit] = context.db - .select() - .from(circuits) - .where(eq(circuits.id, created.circuitId)) - .all(); - const createdRows = context.db - .select() - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, created.circuitId)) - .all() - .sort((left, right) => left.sortOrder - right.sortOrder); - - assert.equal(createdCircuit.equipmentIdentifier, "-1F2"); - assert.equal(createdCircuit.isReserve, 0); - assert.deepEqual( - createdRows.map((row) => [row.id, row.displayName, row.sortOrder]), - [ - [created.rowIds[0], "Leuchte 1", 15], - [created.rowIds[1], "Leuchte 2", 20], - ] - ); - } finally { - context.close(); - } - }); - - it("rolls back the circuit and earlier rows when an initial row insert fails", () => { - const context = createTestDatabase(); - try { - const [seedCircuit] = context.db.select().from(circuits).limit(1).all(); - context.sqlite.exec(` - CREATE TRIGGER fail_initial_device_row - BEFORE INSERT ON circuit_device_rows - WHEN NEW.name = 'Fail' - BEGIN - SELECT RAISE(ABORT, 'forced initial row failure'); - END; - `); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - assert.throws( - () => - repository.createCircuitWithDeviceRows({ - circuit: { - circuitListId: seedCircuit.circuitListId, - sectionId: seedCircuit.sectionId, - equipmentIdentifier: "-1F2", - displayName: "Rollback", - sortOrder: 20, - }, - deviceRows: [ - { - name: "First", - displayName: "Erste Zeile", - quantity: 1, - powerPerUnit: 0.1, - simultaneityFactor: 1, - }, - { - name: "Fail", - displayName: "Fehlerhafte Zeile", - quantity: 1, - powerPerUnit: 0.1, - simultaneityFactor: 1, - }, - ], - }), - /forced initial row failure/ - ); - - assert.equal(context.db.select().from(circuits).all().length, 1); - assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0); - } finally { - context.close(); - } - }); - - it("commits the last-row deletion and activates the circuit reserve status together", () => { - const context = createTestDatabase(); - try { - insertDeviceRow(context); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - repository.deleteFromCircuit("row-1", "circuit-1"); - - assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0); - assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1); - } finally { - context.close(); - } - }); - - it("rolls back the row deletion when activating the reserve status fails", () => { - const context = createTestDatabase(); - try { - insertDeviceRow(context); - context.sqlite.exec(` - CREATE TRIGGER fail_reserve_activation - BEFORE UPDATE OF is_reserve ON circuits - WHEN NEW.is_reserve = 1 - BEGIN - SELECT RAISE(ABORT, 'forced reserve activation failure'); - END; - `); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - assert.throws( - () => repository.deleteFromCircuit("row-1", "circuit-1"), - /forced reserve activation failure/ - ); - - assert.equal(context.db.select().from(circuitDeviceRows).all().length, 1); - assert.equal(context.db.select().from(circuits).all()[0].isReserve, 0); - } finally { - context.close(); - } - }); - - it("moves rows to an existing circuit and updates both reserve states together", () => { - const context = createTestDatabase(); - try { - insertCircuit(context, { - id: "circuit-2", - equipmentIdentifier: "-1F2", - sortOrder: 20, - }); - insertCircuit(context, { - id: "circuit-3", - equipmentIdentifier: "-1F3", - sortOrder: 30, - }); - insertDeviceRow(context); - insertDeviceRow(context, { - id: "row-2", - circuitId: "circuit-2", - sortOrder: 10, - displayName: "Zweite Quellzeile", - }); - insertDeviceRow(context, { - id: "target-row", - circuitId: "circuit-3", - sortOrder: 10, - displayName: "Bestehende Zielzeile", - }); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - const result = repository.moveRows({ - rows: [ - { id: "row-1", expectedCircuitId: "circuit-1" }, - { id: "row-2", expectedCircuitId: "circuit-2" }, - ], - targetCircuitId: "circuit-3", - }); - - const movedRows = context.db - .select() - .from(circuitDeviceRows) - .all() - .filter((row) => row.id === "row-1" || row.id === "row-2") - .sort((left, right) => left.sortOrder - right.sortOrder); - const persistedCircuits = context.db.select().from(circuits).all(); - const targetCircuit = persistedCircuits.find( - (circuit) => circuit.id === "circuit-3" - ); - - assert.deepEqual(result, { - movedRowIds: ["row-1", "row-2"], - targetCircuitId: "circuit-3", - createdCircuitId: undefined, - }); - assert.deepEqual( - movedRows.map((row) => [row.id, row.circuitId, row.sortOrder]), - [ - ["row-1", "circuit-3", 20], - ["row-2", "circuit-3", 30], - ] - ); - assert.equal( - persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve, - 1 - ); - assert.equal( - persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve, - 1 - ); - assert.equal(targetCircuit?.isReserve, 0); - } finally { - context.close(); - } - }); - - it("rolls back an existing-target move when a reserve update fails", () => { - const context = createTestDatabase(); - try { - insertCircuit(context, { - id: "circuit-2", - equipmentIdentifier: "-1F2", - sortOrder: 20, - }); - insertDeviceRow(context); - context.sqlite.exec(` - CREATE TRIGGER fail_source_reserve_update - BEFORE UPDATE OF is_reserve ON circuits - WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1 - BEGIN - SELECT RAISE(ABORT, 'forced source reserve failure'); - END; - `); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - assert.throws( - () => - repository.moveRows({ - rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }], - targetCircuitId: "circuit-2", - }), - /forced source reserve failure/ - ); - - const [row] = context.db.select().from(circuitDeviceRows).all(); - const persistedCircuits = context.db.select().from(circuits).all(); - assert.equal(row.circuitId, "circuit-1"); - assert.equal( - persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve, - 0 - ); - assert.equal( - persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve, - 1 - ); - } finally { - context.close(); - } - }); - - it("creates a new target circuit and moves rows into it atomically", () => { - const context = createTestDatabase(); - try { - insertDeviceRow(context); - const [sourceCircuit] = context.db - .select() - .from(circuits) - .where(eq(circuits.id, "circuit-1")) - .all(); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - const result = repository.moveRows({ - rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }], - createTargetCircuit: { - circuitListId: sourceCircuit.circuitListId, - sectionId: sourceCircuit.sectionId, - equipmentIdentifier: "-1F2", - displayName: "Neues Ziel", - sortOrder: 20, - }, - }); - - const [movedRow] = context.db - .select() - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.id, "row-1")) - .all(); - const [createdCircuit] = context.db - .select() - .from(circuits) - .where(eq(circuits.id, result.createdCircuitId!)) - .all(); - const [updatedSource] = context.db - .select() - .from(circuits) - .where(eq(circuits.id, "circuit-1")) - .all(); - - assert.equal(result.targetCircuitId, result.createdCircuitId); - assert.equal(createdCircuit.equipmentIdentifier, "-1F2"); - assert.equal(createdCircuit.isReserve, 0); - assert.equal(movedRow.circuitId, createdCircuit.id); - assert.equal(updatedSource.isReserve, 1); - } finally { - context.close(); - } - }); - - it("rolls back a newly created target circuit when the move fails", () => { - const context = createTestDatabase(); - try { - insertDeviceRow(context); - const [sourceCircuit] = context.db - .select() - .from(circuits) - .where(eq(circuits.id, "circuit-1")) - .all(); - context.sqlite.exec(` - CREATE TRIGGER fail_new_target_source_reserve - BEFORE UPDATE OF is_reserve ON circuits - WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1 - BEGIN - SELECT RAISE(ABORT, 'forced new-target move failure'); - END; - `); - const repository = new CircuitDeviceRowTransactionRepository(context.db); - - assert.throws( - () => - repository.moveRows({ - rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }], - createTargetCircuit: { - circuitListId: sourceCircuit.circuitListId, - sectionId: sourceCircuit.sectionId, - equipmentIdentifier: "-1F2", - displayName: "Rollback-Ziel", - sortOrder: 20, - }, - }), - /forced new-target move failure/ - ); - - const [row] = context.db.select().from(circuitDeviceRows).all(); - const persistedCircuits = context.db.select().from(circuits).all(); - assert.equal(row.circuitId, "circuit-1"); - assert.equal(persistedCircuits.length, 1); - assert.equal(persistedCircuits[0].isReserve, 0); - } finally { - context.close(); - } - }); -}); diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 1b5f155..580eef5 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -23,7 +23,10 @@ describe("circuit write service rules", () => { }, } as never, circuitSectionTransactionStore: { - updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) { + updateEquipmentIdentifiers( + _listId: string, + updates: Array<{ id: string; equipmentIdentifier: string }> + ) { safeUpdatePayload = updates; }, } as never, @@ -58,7 +61,10 @@ describe("circuit write service rules", () => { }, } as never, circuitSectionTransactionStore: { - updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) { + updateEquipmentIdentifiers( + _listId: string, + updates: Array<{ id: string; equipmentIdentifier: string }> + ) { safeUpdatePayload = updates; }, } as never, @@ -72,7 +78,7 @@ describe("circuit write service rules", () => { ]); }); - it("renumber handles gaps and keeps device rows untouched by identifier-only update path", async () => { + it("renumber handles gaps without touching device rows", async () => { let safeCalled = 0; const service = new CircuitWriteService({ circuitSectionRepository: { @@ -97,292 +103,12 @@ describe("circuit write service rules", () => { safeCalled += 1; }, } as never, - deviceRowRepository: { - async update() { - throw new Error("device rows must not be touched"); - }, - } as never, }); await service.renumberSection("s1"); assert.equal(safeCalled, 1); }); - it("moving a device row to another circuit preserves row and toggles reserve flags", async () => { - let transactionalMove: - | { - rows: Array<{ id: string; expectedCircuitId: string }>; - targetCircuitId?: string; - createTargetCircuit?: unknown; - } - | undefined; - const service = new CircuitWriteService({ - deviceRowRepository: { - async findById() { - return { id: "r1", circuitId: "c1" } as never; - }, - } as never, - deviceRowTransactionStore: { - moveRows(input: typeof transactionalMove) { - transactionalMove = input; - return { - movedRowIds: input!.rows.map((row) => row.id), - targetCircuitId: input!.targetCircuitId!, - }; - }, - } as never, - circuitRepository: { - async findById(circuitId: string) { - if (circuitId === "c1") { - return { - id: "c1", - sectionId: "s1", - circuitListId: "l1", - equipmentIdentifier: "-1F1", - sortOrder: 10, - isReserve: 0, - } as never; - } - return { - id: "c2", - sectionId: "s1", - circuitListId: "l1", - equipmentIdentifier: "-1F2", - sortOrder: 20, - isReserve: 1, - } as never; - }, - } as never, - }); - - await service.moveDeviceRow("r1", { targetCircuitId: "c2" }); - assert.deepEqual(transactionalMove, { - rows: [{ id: "r1", expectedCircuitId: "c1" }], - targetCircuitId: "c2", - createTargetCircuit: undefined, - }); - }); - - it("moving a device row to placeholder creates a new circuit in target section", async () => { - let preparedTarget: - | { - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName: string; - sortOrder: number; - } - | undefined; - const service = new CircuitWriteService({ - deviceRowRepository: { - async findById() { - return { id: "r1", circuitId: "c1" } as never; - }, - } as never, - deviceRowTransactionStore: { - moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) { - preparedTarget = input.createTargetCircuit; - return { - movedRowIds: input.rows.map((row) => row.id), - targetCircuitId: "c-new", - createdCircuitId: "c-new", - }; - }, - } as never, - circuitRepository: { - async findById(circuitId: string) { - if (circuitId === "c1") { - return { - id: "c1", - sectionId: "s1", - circuitListId: "l1", - equipmentIdentifier: "-1F1", - sortOrder: 10, - isReserve: 0, - } as never; - } - return null as never; - }, - async listBySection() { - return [{ sortOrder: 40 }] as never[]; - }, - } as never, - circuitSectionRepository: { - async findById() { - return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never; - }, - } as never, - numberingService: { - async getNextIdentifier() { - return "-2F8"; - }, - } as never, - }); - - await service.moveDeviceRow("r1", { targetSectionId: "s2", createNewCircuit: true }); - assert.deepEqual(preparedTarget, { - circuitListId: "l1", - sectionId: "s2", - equipmentIdentifier: "-2F8", - displayName: "Neuer Stromkreis", - sortOrder: 50, - }); - }); - - it("moving a device row to its current circuit remains a no-op", async () => { - let transactionalMoveCount = 0; - const service = new CircuitWriteService({ - deviceRowRepository: { - async findById() { - return { id: "r1", circuitId: "c1" } as never; - }, - } as never, - deviceRowTransactionStore: { - moveRows() { - transactionalMoveCount += 1; - throw new Error("same-circuit move must not write"); - }, - } as never, - circuitRepository: { - async findById() { - return { - id: "c1", - sectionId: "s1", - circuitListId: "l1", - equipmentIdentifier: "-1F1", - sortOrder: 10, - isReserve: 0, - } as never; - }, - } as never, - }); - - const result = await service.moveDeviceRow("r1", { targetCircuitId: "c1" }); - assert.equal(transactionalMoveCount, 0); - assert.equal(result?.id, "r1"); - }); - - it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => { - let transactionalMove: - | { - rows: Array<{ id: string; expectedCircuitId: string }>; - targetCircuitId?: string; - createTargetCircuit?: unknown; - } - | undefined; - const service = new CircuitWriteService({ - deviceRowRepository: { - async findById(rowId: string) { - if (rowId === "r1") { - return { id: "r1", circuitId: "c1" } as never; - } - return { id: "r2", circuitId: "c2" } as never; - }, - } as never, - deviceRowTransactionStore: { - moveRows(input: typeof transactionalMove) { - transactionalMove = input; - return { - movedRowIds: input!.rows.map((row) => row.id), - targetCircuitId: input!.targetCircuitId!, - }; - }, - } as never, - circuitRepository: { - async findById(circuitId: string) { - if (circuitId === "c1") { - return { id: "c1", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never; - } - if (circuitId === "c2") { - return { id: "c2", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 } as never; - } - return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never; - }, - } as never, - }); - - const result = await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" }); - assert.deepEqual(transactionalMove, { - rows: [ - { id: "r1", expectedCircuitId: "c1" }, - { id: "r2", expectedCircuitId: "c2" }, - ], - targetCircuitId: "c3", - createTargetCircuit: undefined, - }); - assert.deepEqual(result, { - movedRowIds: ["r1", "r2"], - targetCircuitId: "c3", - }); - }); - - it("moving multiple device rows to placeholder creates exactly one new circuit", async () => { - let transactionCount = 0; - let preparedTarget: - | { - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName: string; - sortOrder: number; - } - | undefined; - const service = new CircuitWriteService({ - deviceRowRepository: { - async findById(rowId: string) { - return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never; - }, - } as never, - deviceRowTransactionStore: { - moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) { - transactionCount += 1; - preparedTarget = input.createTargetCircuit; - return { - movedRowIds: input.rows.map((row) => row.id), - targetCircuitId: "c-new", - createdCircuitId: "c-new", - }; - }, - } as never, - circuitRepository: { - async findById(circuitId: string) { - if (circuitId === "c1" || circuitId === "c2") { - return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never; - } - return null as never; - }, - async listBySection() { - return [{ sortOrder: 30 }] as never[]; - }, - } as never, - circuitSectionRepository: { - async findById() { - return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never; - }, - } as never, - numberingService: { - async getNextIdentifier() { - return "-2F8"; - }, - } as never, - }); - - const result = await service.moveDeviceRowsBulk({ - rowIds: ["r1", "r2"], - targetSectionId: "s2", - createNewCircuit: true, - }); - assert.equal(transactionCount, 1); - assert.deepEqual(preparedTarget, { - circuitListId: "l1", - sectionId: "s2", - equipmentIdentifier: "-2F8", - displayName: "Neuer Stromkreis", - sortOrder: 40, - }); - assert.equal(result.createdCircuitId, "c-new"); - }); - it("reorders circuits inside one section without renumbering identifiers", async () => { let safeReorder: { sectionId: string; circuitIds: string[] } | undefined; const service = new CircuitWriteService({ @@ -407,14 +133,16 @@ describe("circuit write service rules", () => { } as never, }); - await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] }); + await service.reorderCircuitsInSection("s1", { + orderedCircuitIds: ["c3", "c1", "c2"], + }); assert.deepEqual(safeReorder, { sectionId: "s1", circuitIds: ["c3", "c1", "c2"], }); }); - it("safe section identifier bulk update validates full section set (undo safety)", async () => { + it("validates the complete section set before safe identifier updates", async () => { let safeCalled = false; const service = new CircuitWriteService({ circuitSectionRepository: { @@ -436,6 +164,7 @@ describe("circuit write service rules", () => { }, } as never, }); + await service.updateSectionEquipmentIdentifiers("s1", { identifiers: [ { circuitId: "c1", equipmentIdentifier: "-1F2" }, @@ -444,5 +173,4 @@ describe("circuit write service rules", () => { }); assert.equal(safeCalled, true); }); - });