diff --git a/src/db/repositories/circuit-device-row-move-project-command.repository.ts b/src/db/repositories/circuit-device-row-move-project-command.repository.ts index 2c5bfc9..e1e3f5a 100644 --- a/src/db/repositories/circuit-device-row-move-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-move-project-command.repository.ts @@ -23,6 +23,7 @@ import { circuits } from "../schema/circuits.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; +import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; interface PersistedMoveRow { id: string; @@ -171,6 +172,9 @@ export class CircuitDeviceRowMoveProjectCommandRepository projectId, destinationCircuitIds ); + assertCircuitsAreNotReferencedAsFeeders(database, projectId, [ + targetCircuit.id, + ]); const deleted = database .delete(circuits) .where( diff --git a/src/db/repositories/circuit-group-subtree-project-command.repository.ts b/src/db/repositories/circuit-group-subtree-project-command.repository.ts index 448cf99..9bf27a0 100644 --- a/src/db/repositories/circuit-group-subtree-project-command.repository.ts +++ b/src/db/repositories/circuit-group-subtree-project-command.repository.ts @@ -26,6 +26,7 @@ import { distributionBoardComponentProtectionDevices } from "../schema/distribut import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; export class CircuitGroupSubtreeProjectCommandRepository implements CircuitGroupSubtreeProjectCommandStore @@ -65,6 +66,11 @@ export class CircuitGroupSubtreeProjectCommandRepository "Circuit-group subtree changed before deletion." ); } + assertCircuitsAreNotReferencedAsFeeders( + database, + projectId, + current.circuits.map((entry) => entry.circuit.id) + ); const deleted = database .delete(circuitSections) .where(eq(circuitSections.id, current.group.id)) diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts index 9b8941d..e7cfcb2 100644 --- a/src/db/repositories/circuit-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-structure-project-command.repository.ts @@ -26,6 +26,7 @@ import { toCircuitDeviceRowSnapshot, } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; @@ -236,6 +237,8 @@ export class CircuitStructureProjectCommandRepository protectionDevice: protectionDevice ?? null, }); + assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]); + const result = database .delete(circuits) .where( diff --git a/src/db/repositories/distribution-board-feeding-deletion-guard.persistence.ts b/src/db/repositories/distribution-board-feeding-deletion-guard.persistence.ts new file mode 100644 index 0000000..679f31e --- /dev/null +++ b/src/db/repositories/distribution-board-feeding-deletion-guard.persistence.ts @@ -0,0 +1,65 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; + +export function assertBoardsAreNotReferencedAsFeeders( + database: AppDatabase, + projectId: string, + distributionBoardIds: string[] +) { + if (distributionBoardIds.length === 0) return; + const dependent = database + .select({ + name: distributionBoards.name, + boardNumber: distributionBoards.boardNumber, + }) + .from(distributionBoards) + .where( + and( + eq(distributionBoards.projectId, projectId), + inArray( + distributionBoards.feedingDistributionBoardId, + [...new Set(distributionBoardIds)] + ) + ) + ) + .orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name)) + .limit(1) + .get(); + if (dependent) throwFeedingReferenceError(dependent); +} + +export function assertCircuitsAreNotReferencedAsFeeders( + database: AppDatabase, + projectId: string, + circuitIds: string[] +) { + if (circuitIds.length === 0) return; + const dependent = database + .select({ + name: distributionBoards.name, + boardNumber: distributionBoards.boardNumber, + }) + .from(distributionBoards) + .where( + and( + eq(distributionBoards.projectId, projectId), + inArray( + distributionBoards.feedingCircuitId, + [...new Set(circuitIds)] + ) + ) + ) + .orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name)) + .limit(1) + .get(); + if (dependent) throwFeedingReferenceError(dependent); +} + +function throwFeedingReferenceError(dependent: { + name: string; + boardNumber: string | null; +}): never { + const label = dependent.boardNumber ?? dependent.name; + throw new Error(`Speist ${label} – erst Einspeisung ändern.`); +} diff --git a/src/db/repositories/distribution-board-structure-project-command.repository.ts b/src/db/repositories/distribution-board-structure-project-command.repository.ts index a4fba2b..9346df7 100644 --- a/src/db/repositories/distribution-board-structure-project-command.repository.ts +++ b/src/db/repositories/distribution-board-structure-project-command.repository.ts @@ -30,6 +30,7 @@ import { distributionBoards } from "../schema/distribution-boards.js"; import { floors } from "../schema/floors.js"; import { projects } from "../schema/projects.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; export class DistributionBoardStructureProjectCommandRepository implements DistributionBoardStructureProjectCommandStore @@ -248,6 +249,8 @@ export class DistributionBoardStructureProjectCommandRepository ); } + assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]); + const deleted = database .delete(distributionBoards) .where( diff --git a/src/db/repositories/distribution-board-subtree-project-command.repository.ts b/src/db/repositories/distribution-board-subtree-project-command.repository.ts index 5d33b75..969c287 100644 --- a/src/db/repositories/distribution-board-subtree-project-command.repository.ts +++ b/src/db/repositories/distribution-board-subtree-project-command.repository.ts @@ -32,6 +32,7 @@ import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; export class DistributionBoardSubtreeProjectCommandRepository implements DistributionBoardSubtreeProjectCommandStore @@ -82,6 +83,9 @@ export class DistributionBoardSubtreeProjectCommandRepository "Distribution-board subtree changed before deletion." ); } + assertBoardsAreNotReferencedAsFeeders(database, projectId, [ + current.distributionBoard.id, + ]); const deleted = database .delete(distributionBoards) .where( diff --git a/src/db/repositories/external-object-new-circuit-project-command.repository.ts b/src/db/repositories/external-object-new-circuit-project-command.repository.ts index bea64ff..7667a28 100644 --- a/src/db/repositories/external-object-new-circuit-project-command.repository.ts +++ b/src/db/repositories/external-object-new-circuit-project-command.repository.ts @@ -33,6 +33,7 @@ import { snapshotsEqual, } from "./external-object-assignment.persistence.js"; import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; +import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; export class ExternalObjectNewCircuitProjectCommandRepository implements ExternalObjectNewCircuitProjectCommandStore @@ -162,6 +163,9 @@ export class ExternalObjectNewCircuitProjectCommandRepository objects, "External object changed during new-circuit assignment." ); + assertCircuitsAreNotReferencedAsFeeders(database, projectId, [ + expectedCircuit.id, + ]); const deleted = database.delete(circuits).where(and( eq(circuits.id, expectedCircuit.id), eq(circuits.circuitListId, expectedCircuit.circuitListId) diff --git a/tests/circuit-device-row-move-project-command.repository.test.ts b/tests/circuit-device-row-move-project-command.repository.test.ts index 0569099..8975368 100644 --- a/tests/circuit-device-row-move-project-command.repository.test.ts +++ b/tests/circuit-device-row-move-project-command.repository.test.ts @@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { @@ -611,6 +612,62 @@ describe("circuit device-row move project-command repository", () => { } }); + it("blocks undo from removing a generated circuit used as a feeder", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + const targetCircuit = createMoveTargetSnapshot(fixture); + const command = createCircuitDeviceRowMoveWithNewCircuitProjectCommand( + "create", + targetCircuit, + [{ + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: targetCircuit.id, + targetSortOrder: 10, + }] + ); + const moved = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + const downstream = new DistributionBoardFixtureRepository( + fixture.context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-02"); + fixture.context.db + .update(distributionBoards) + .set({ + feedingDistributionBoardId: fixture.circuitListId, + feedingCircuitId: targetCircuit.id, + }) + .where(eq(distributionBoards.id, downstream.id)) + .run(); + + assert.throws( + () => store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: moved.revision.changeSetId, + command: moved.inverse, + }), + /Speist UV-02 – erst Einspeisung ändern\./ + ); + assert.equal(getRow(fixture.context, "row-1")?.circuitId, targetCircuit.id); + assert.ok( + fixture.context.db.select().from(circuits) + .where(eq(circuits.id, targetCircuit.id)).get() + ); + } finally { + fixture.context.close(); + } + }); + it("rejects deletion when the created target changed outside history", () => { const fixture = createTestDatabase(); try { diff --git a/tests/circuit-group-subtree-project-command.repository.test.ts b/tests/circuit-group-subtree-project-command.repository.test.ts index 05d7dcc..8494798 100644 --- a/tests/circuit-group-subtree-project-command.repository.test.ts +++ b/tests/circuit-group-subtree-project-command.repository.test.ts @@ -16,6 +16,7 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js"; @@ -228,6 +229,45 @@ describe("circuit-group subtree project command", () => { } }); + it("blocks deleting a group containing a feeding circuit", () => { + const context = createTestDatabase(); + try { + const list = context.db.select().from(circuitLists).get()!; + const downstream = new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler"); + context.db + .update(distributionBoards) + .set({ + feedingDistributionBoardId: list.distributionBoardId, + feedingCircuitId: "subtree-circuit", + }) + .where(eq(distributionBoards.id, downstream.id)) + .run(); + const repository = + new CircuitGroupSubtreeProjectCommandRepository(context.db); + const groupId = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "subtree-circuit")) + .get()!.sectionId; + const snapshot = repository.capture("project-1", groupId); + + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupDeleteSubtreeProjectCommand(snapshot), + }), + /Speist Unterverteiler – erst Einspeisung ändern\./ + ); + assert.deepEqual(repository.capture("project-1", groupId), snapshot); + } finally { + context.close(); + } + }); + it("rolls back a cascaded deletion when history persistence fails", () => { const context = createTestDatabase(); try { diff --git a/tests/circuit-structure-project-command.repository.test.ts b/tests/circuit-structure-project-command.repository.test.ts index 4c05554..8985ad0 100644 --- a/tests/circuit-structure-project-command.repository.test.ts +++ b/tests/circuit-structure-project-command.repository.test.ts @@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; @@ -442,6 +443,48 @@ describe("circuit structure project-command repository", () => { } }); + it("blocks deleting a circuit that feeds a distribution board", () => { + const fixture = createTestDatabase(); + try { + const downstream = new DistributionBoardFixtureRepository( + fixture.context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV Unterverteiler"); + fixture.context.db + .update(distributionBoards) + .set({ + boardNumber: "UV-02", + feedingDistributionBoardId: fixture.circuitListId, + feedingCircuitId: "circuit-existing", + }) + .where(eq(distributionBoards.id, downstream.id)) + .run(); + + const store = new CircuitStructureProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeleteProjectCommand( + "circuit-existing", + fixture.circuitListId + ), + }), + /Speist UV-02 – erst Einspeisung ändern\./ + ); + assert.ok(getCircuit(fixture.context, "circuit-existing")); + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + it("rolls back insert and delete for late history failures", () => { const fixture = createTestDatabase(); try { diff --git a/tests/distribution-board-structure-project-command.repository.test.ts b/tests/distribution-board-structure-project-command.repository.test.ts index e6aff4b..a02f005 100644 --- a/tests/distribution-board-structure-project-command.repository.test.ts +++ b/tests/distribution-board-structure-project-command.repository.test.ts @@ -219,6 +219,54 @@ describe("distribution-board structure project command", () => { } }); + it("blocks undo from removing an empty board used as a feeder", () => { + const context = createTestDatabase(); + try { + const repository = + new DistributionBoardStructureProjectCommandRepository(context.db); + const upstream = createDistributionBoardStructureSnapshot( + "project-1", + "NSHV" + ); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createDistributionBoardInsertProjectCommand(upstream), + }); + const downstream = createDistributionBoardStructureSnapshot( + "project-1", + "UV-01" + ); + context.db.insert(distributionBoards).values({ + ...downstream.distributionBoard, + boardNumber: "UV-01", + feedingDistributionBoardId: upstream.distributionBoard.id, + }).run(); + context.db.insert(circuitLists).values(downstream.circuitList).run(); + context.db.insert(circuitSections).values(downstream.sections).run(); + context.db.insert(distributionBoardComponents) + .values(downstream.components).run(); + + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }), + /Speist UV-01 – erst Einspeisung ändern\./ + ); + assert.ok( + context.db.select().from(distributionBoards) + .where(eq(distributionBoards.id, upstream.distributionBoard.id)).get() + ); + } finally { + context.close(); + } + }); + it("allows only supply types enabled in the project", () => { const context = createTestDatabase(); try { diff --git a/tests/distribution-board-subtree-project-command.repository.test.ts b/tests/distribution-board-subtree-project-command.repository.test.ts index 7975f2b..6508b9b 100644 --- a/tests/distribution-board-subtree-project-command.repository.test.ts +++ b/tests/distribution-board-subtree-project-command.repository.test.ts @@ -291,6 +291,43 @@ describe("distribution-board subtree project command", () => { } }); + it("blocks deleting a board subtree that feeds another board", () => { + const context = createTestDatabase(); + try { + const upstream = createPopulatedBoard(context); + const downstream = new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler"); + context.db + .update(distributionBoards) + .set({ + boardNumber: "UV-03", + feedingDistributionBoardId: upstream.id, + }) + .where(eq(distributionBoards.id, downstream.id)) + .run(); + const repository = + new DistributionBoardSubtreeProjectCommandRepository(context.db); + const snapshot = repository.capture("project-1", upstream.id); + + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardDeleteSubtreeProjectCommand(snapshot), + }), + /Speist UV-03 – erst Einspeisung ändern\./ + ); + assert.deepEqual(repository.capture("project-1", upstream.id), snapshot); + assert.equal(context.db.select().from(projectRevisions).all().length, 0); + } finally { + context.close(); + } + }); + it("rolls back a cascaded deletion when history persistence fails", () => { const context = createTestDatabase(); try { diff --git a/tests/external-object-row-assignment-project-command.repository.test.ts b/tests/external-object-row-assignment-project-command.repository.test.ts index da8551e..35579eb 100644 --- a/tests/external-object-row-assignment-project-command.repository.test.ts +++ b/tests/external-object-row-assignment-project-command.repository.test.ts @@ -533,6 +533,61 @@ describe("external object new-circuit project command", () => { } }); + it("blocks history removal when the generated circuit became a feeder", () => { + const context = createFixture(null); + try { + const object = objectSnapshot(null); + const circuit = newExternalCircuit(context.db); + const command = createExternalObjectNewCircuitProjectCommand( + externalObjectAssignToNewCircuitCommandType, + { + circuit, + objects: [{ + expected: object, + target: { ...object, circuitDeviceRowId: "row-new" }, + }], + confirmedConflictObjectIds: [], + } + ); + const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + context.db.insert(distributionBoards).values({ + id: "board-2", + projectId: "project-1", + name: "Unterverteiler", + boardNumber: "UV-02", + feedingDistributionBoardId: "board-1", + feedingCircuitId: circuit.id, + }).run(); + + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }), + /Speist UV-02 – erst Einspeisung ändern\./ + ); + assert.ok( + context.db.select().from(circuits) + .where(eq(circuits.id, circuit.id)).get() + ); + assert.equal( + context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, + "row-new" + ); + } finally { + context.close(); + } + }); + it("rolls back the complete circuit subtree when history persistence fails", () => { const context = createFixture(null); try {