Add circuit group commands

This commit is contained in:
2026-07-30 19:46:55 +02:00
parent 69b020339e
commit 1250f9a6af
16 changed files with 864 additions and 5 deletions
@@ -0,0 +1,189 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupUpdateCommandType,
createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand,
createCircuitGroupUpdateProjectCommand,
type CircuitGroupSnapshot,
type CircuitGroupStructureProjectCommand,
} from "../../domain/models/circuit-group-structure-project-command.model.js";
import type {
CircuitGroupStructureProjectCommandStore,
ExecuteCircuitGroupStructureCommandInput,
} from "../../domain/ports/circuit-group-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupStructureProjectCommandRepository
implements CircuitGroupStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitGroupStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitGroupStructureProjectCommand
): CircuitGroupStructureProjectCommand {
if (command.type === circuitGroupInsertCommandType) {
assertCircuitGroupInsertProjectCommand(command);
this.insert(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupDeleteCommandType) {
assertCircuitGroupDeleteProjectCommand(command);
this.delete(database, projectId, command.payload.snapshot);
return createCircuitGroupInsertProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupUpdateCommandType) {
assertCircuitGroupUpdateProjectCommand(command);
this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
return createCircuitGroupUpdateProjectCommand(
command.payload.target,
command.payload.expected
);
}
throw new Error("Unsupported circuit-group structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, snapshot.circuitListId);
const existing = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.id))
.get();
if (existing) {
throw new Error("Circuit-group id already exists.");
}
database.insert(circuitSections).values(snapshot).run();
}
private delete(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, snapshot.circuitListId);
const current = this.getCurrent(database, snapshot.id);
if (!current || !sameRecord(snapshot, current)) {
throw new Error("Circuit group changed before deletion.");
}
const circuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.sectionId, snapshot.id))
.get();
const component = database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.sectionId, snapshot.id))
.get();
if (circuit || component) {
throw new Error(
"Only empty circuit groups can be deleted by this command."
);
}
const deleted = database
.delete(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.id),
eq(circuitSections.circuitListId, snapshot.circuitListId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Circuit group could not be deleted.");
}
}
private update(
database: AppDatabase,
projectId: string,
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, expected.circuitListId);
const current = this.getCurrent(database, expected.id);
if (!current || !sameRecord(expected, current)) {
throw new Error("Circuit group changed before update.");
}
const updated = database
.update(circuitSections)
.set({ displayName: target.displayName })
.where(
and(
eq(circuitSections.id, expected.id),
eq(circuitSections.circuitListId, expected.circuitListId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit group could not be updated.");
}
}
private assertListOwnership(
database: AppDatabase,
projectId: string,
circuitListId: string
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error("Circuit group does not belong to project.");
}
}
private getCurrent(database: AppDatabase, id: string) {
return database
.select()
.from(circuitSections)
.where(eq(circuitSections.id, id))
.get();
}
}
function sameRecord(expected: object, actual: object) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actualRecord[key] === value
)
);
}
@@ -0,0 +1,192 @@
import {
circuitGroupCategories,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group.js";
import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
export const circuitGroupDeleteCommandType = "circuit-group.delete" as const;
export const circuitGroupUpdateCommandType = "circuit-group.update" as const;
export const circuitGroupStructureCommandSchemaVersion = 1 as const;
export interface CircuitGroupSnapshot {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
category: CircuitGroupCategory;
groupNumber: number;
}
interface SnapshotPayload {
snapshot: CircuitGroupSnapshot;
}
interface UpdatePayload {
expected: CircuitGroupSnapshot;
target: CircuitGroupSnapshot;
}
export interface CircuitGroupInsertProjectCommand
extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupInsertCommandType;
}
export interface CircuitGroupDeleteProjectCommand
extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupDeleteCommandType;
}
export interface CircuitGroupUpdateProjectCommand
extends SerializedProjectCommand<UpdatePayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupUpdateCommandType;
}
export type CircuitGroupStructureProjectCommand =
| CircuitGroupInsertProjectCommand
| CircuitGroupDeleteProjectCommand
| CircuitGroupUpdateProjectCommand;
export function createCircuitGroupInsertProjectCommand(
snapshot: CircuitGroupSnapshot
): CircuitGroupInsertProjectCommand {
const command: CircuitGroupInsertProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupInsertCommandType,
payload: { snapshot },
};
assertCircuitGroupInsertProjectCommand(command);
return command;
}
export function createCircuitGroupDeleteProjectCommand(
snapshot: CircuitGroupSnapshot
): CircuitGroupDeleteProjectCommand {
const command: CircuitGroupDeleteProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupDeleteCommandType,
payload: { snapshot },
};
assertCircuitGroupDeleteProjectCommand(command);
return command;
}
export function createCircuitGroupUpdateProjectCommand(
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
): CircuitGroupUpdateProjectCommand {
const command: CircuitGroupUpdateProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupUpdateCommandType,
payload: { expected, target },
};
assertCircuitGroupUpdateProjectCommand(command);
return command;
}
export function assertCircuitGroupInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupInsertProjectCommand {
assertSnapshotCommand(command, circuitGroupInsertCommandType);
}
export function assertCircuitGroupDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupDeleteProjectCommand {
assertSnapshotCommand(command, circuitGroupDeleteCommandType);
}
export function assertCircuitGroupUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupUpdateProjectCommand {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== circuitGroupUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported circuit-group update command.");
}
assertCircuitGroupSnapshot(command.payload.expected);
assertCircuitGroupSnapshot(command.payload.target);
for (const field of [
"id",
"circuitListId",
"key",
"prefix",
"sortOrder",
"category",
"groupNumber",
] as const) {
if (command.payload.expected[field] !== command.payload.target[field]) {
throw new Error(
"Circuit-group updates can only change the display name."
);
}
}
}
function assertSnapshotCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof circuitGroupInsertCommandType
| typeof circuitGroupDeleteCommandType
) {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported circuit-group structure command.");
}
assertCircuitGroupSnapshot(command.payload.snapshot);
}
export function assertCircuitGroupSnapshot(
value: unknown
): asserts value is CircuitGroupSnapshot {
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
throw new Error("Circuit-group snapshot is invalid.");
}
for (const field of [
"id",
"circuitListId",
"key",
"displayName",
"prefix",
] as const) {
if (typeof value[field] !== "string" || !value[field].trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
if (
!circuitGroupCategories.includes(
value.category as CircuitGroupCategory
) ||
!Number.isInteger(value.groupNumber) ||
(value.groupNumber as number) < 1 ||
!Number.isFinite(value.sortOrder)
) {
throw new Error("Circuit-group category, number or order is invalid.");
}
if (
value.prefix !==
formatCircuitGroupPrefix(
value.category as CircuitGroupCategory,
value.groupNumber as number
)
) {
throw new Error("Circuit-group prefix does not match its category and number.");
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,22 @@
import type { CircuitGroupStructureProjectCommand } from "../models/circuit-group-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitGroupStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitGroupStructureProjectCommand;
}
export interface CircuitGroupStructureProjectCommandStore {
execute(input: ExecuteCircuitGroupStructureCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitGroupStructureProjectCommand;
};
}
@@ -36,6 +36,13 @@ function getGroupStem(
return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`;
}
export function formatCircuitGroupPrefix(
category: CircuitGroupCategory,
groupNumber: number
) {
return `${getGroupStem(category, "F", groupNumber)}.`;
}
export function formatGroupUpstreamProtectionIdentifier(
category: CircuitGroupCategory,
groupNumber: number
@@ -98,6 +98,7 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js";
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
import type {
@@ -110,6 +111,14 @@ import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupUpdateCommandType,
} from "../models/circuit-group-structure-project-command.model.js";
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
@@ -145,6 +154,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -333,6 +343,27 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case circuitGroupInsertCommandType: {
assertCircuitGroupInsertProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupDeleteCommandType: {
assertCircuitGroupDeleteProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupUpdateCommandType: {
assertCircuitGroupUpdateProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -36,6 +36,9 @@ const commandTypeLabels: Record<string, string> = {
"distribution-board-component.insert": "Verteilergerät angelegt",
"distribution-board-component.delete": "Verteilergerät entfernt",
"distribution-board-component.update": "Verteilergerät bearbeitet",
"circuit-group.insert": "Stromkreisgruppe angelegt",
"circuit-group.delete": "Stromkreisgruppe entfernt",
"circuit-group.update": "Stromkreisgruppe bearbeitet",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
@@ -6,6 +6,7 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
@@ -26,6 +27,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const circuitGroupStructureProjectCommandStore =
new CircuitGroupStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db);
export const distributionBoardComponentStructureProjectCommandStore =
@@ -63,5 +66,6 @@ export const projectCommandService = new ProjectCommandService(
projectSettingsProjectCommandStore,
projectStateRestoreCommandStore,
distributionBoardComponentStructureProjectCommandStore,
circuitGroupStructureProjectCommandStore,
projectHistoryStore
);