From b2763f72d5b6e10790d151cd44e5bbe357a7a0a3 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 25 Jul 2026 23:08:07 +0200 Subject: [PATCH] Persist project settings updates --- AGENTS.md | 3 + docs/circuit-list-editor-api.md | 15 +- docs/current-architecture.md | 6 + ...history-and-external-model-architecture.md | 3 + docs/spec/07-implementation-phases-todo.md | 3 + package.json | 4 +- src/app/projects/[projectId]/page.tsx | 25 +- ...ect-settings-project-command.repository.ts | 71 +++++ src/db/repositories/project.repository.ts | 11 - .../project-settings-project-command.model.ts | 61 +++++ .../project-settings-project-command.store.ts | 26 ++ .../services/project-command.service.ts | 13 + src/frontend/types.ts | 5 + src/frontend/utils/api.ts | 11 +- src/frontend/utils/project-version-history.ts | 1 + .../composition/project-command-stores.ts | 4 + src/server/controllers/project.controller.ts | 23 +- .../validation/project-structure.schemas.ts | 12 +- tests/project-command.service.test.ts | 55 ++++ ...ettings-project-command.repository.test.ts | 248 ++++++++++++++++++ ...t-state-restore-command.repository.test.ts | 2 + 21 files changed, 569 insertions(+), 33 deletions(-) create mode 100644 src/db/repositories/project-settings-project-command.repository.ts create mode 100644 src/domain/models/project-settings-project-command.model.ts create mode 100644 src/domain/ports/project-settings-project-command.store.ts create mode 100644 tests/project-settings-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index e06a61b..037d46e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,6 +262,9 @@ that have remained unchanged. ProjectDevice create, update, delete and global-to-project copy API/UI paths use these persistent commands and track the returned project revision. ProjectDevice synchronization/disconnect API and UI paths do the same; their undo action uses the project-wide history endpoint. +Project voltage settings use the persistent `project.update-settings` command. +Both voltage defaults change in one revision and Undo/Redo restores them +together; the project PUT route requires `expectedRevision`. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 43dac62..eb67c16 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -62,7 +62,8 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.reorder-section`, `circuit.reorder-sections`, `circuit.renumber-section` and `project-device.update`, `project-device.insert`, `project-device.delete` and -`project-device.sync-rows`, all with schema version `1`. Other command types +`project-device.sync-rows` as well as `project.update-settings`, all with +schema version `1`. Other command types are rejected. Insert commands contain the complete entity or circuit block with stable ids; delete commands include the expected parent identity. Circuit snapshots contain zero, one or multiple complete device rows. Move commands @@ -136,6 +137,18 @@ These write endpoints execute the typed `project-device.update`, returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never synchronizes linked circuit rows implicitly. +### Project Settings + +- `PUT /projects/:projectId` + - request: + `{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }` + - executes `project.update-settings` as one atomic revision + - response: + `{ "project": { ... }, "revision": { ... }, "history": { ... } }` + - persistent Undo/Redo restores both voltage values together + - unchanged values are rejected without creating a revision; a stale + revision returns `409 PROJECT_REVISION_CONFLICT` + Example: ```json diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 94d2877..ba7e160 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -175,6 +175,12 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten liefern Gerät und aktualisierten Historienstand an die Projektseite zurück. +`project.update-settings` versioniert die beiden Projekt-Standardspannungen als +eine atomare Änderung. Der Store leitet das inverse Kommando aus dem +gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel +gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb +`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und +besitzt keinen separaten direkten Settings-Schreibweg mehr. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 89abe2c..4dfac2f 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -252,6 +252,9 @@ Completed foundation: - `project-device.insert` and `project-device.delete` preserve stable device ids; delete undo restores links only from complete unchanged disconnected-row snapshots in the same transaction +- `project.update-settings` persists both project voltage defaults, its exact + inverse, revision and history transition atomically; the project page and + existing PUT route require the current expected revision Remaining constraints before completing project version history: diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index c9cfafc..c63aa95 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -438,6 +438,9 @@ Implemented foundation: - ProjectDevice synchronization and disconnect endpoints also require `expectedRevision`; their UI undo uses project-wide persistent history and the obsolete direct restore/reconnect write paths are removed +- project voltage settings use `project.update-settings`; both values, inverse, + revision and history transition commit atomically and the former direct + settings write is removed - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index d771b0c..905df35 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.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-section-reorder-command.test.ts tests/circuit-section-renumber-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-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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.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-section-reorder-command.test.ts tests/circuit-section-renumber-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-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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.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-section-reorder-command.test.ts tests/circuit-section-renumber-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-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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.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-section-reorder-command.test.ts tests/circuit-section-renumber-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-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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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", diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 8790986..c2ac898 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -245,17 +245,21 @@ export default function ProjectDetailPage() { } async function handleSaveProjectSettings() { - if (!projectId) { + if (!projectId || !project) { return; } setIsSaving(true); setError(null); try { - const updated = await updateProjectSettings(projectId, { - singlePhaseVoltageV: Number(singlePhaseVoltageV), - threePhaseVoltageV: Number(threePhaseVoltageV), - }); - setProject(updated); + const result = await updateProjectSettings( + projectId, + project.currentRevision, + { + singlePhaseVoltageV: Number(singlePhaseVoltageV), + threePhaseVoltageV: Number(threePhaseVoltageV), + } + ); + setProject(result.project); } catch (err) { setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden."); } finally { @@ -599,7 +603,14 @@ export default function ProjectDetailPage() { className="btn btn-primary w-100" type="button" onClick={handleSaveProjectSettings} - disabled={isSaving} + disabled={ + isSaving || + !project || + (Number(singlePhaseVoltageV) === + project.singlePhaseVoltageV && + Number(threePhaseVoltageV) === + project.threePhaseVoltageV) + } > Projekteigenschaften speichern diff --git a/src/db/repositories/project-settings-project-command.repository.ts b/src/db/repositories/project-settings-project-command.repository.ts new file mode 100644 index 0000000..e60c579 --- /dev/null +++ b/src/db/repositories/project-settings-project-command.repository.ts @@ -0,0 +1,71 @@ +import { eq } from "drizzle-orm"; +import { + assertProjectSettingsUpdateProjectCommand, + createProjectSettingsUpdateProjectCommand, +} from "../../domain/models/project-settings-project-command.model.js"; +import type { + ExecuteProjectSettingsUpdateCommandInput, + ProjectSettingsProjectCommandStore, +} from "../../domain/ports/project-settings-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { projects } from "../schema/projects.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +export class ProjectSettingsProjectCommandRepository + implements ProjectSettingsProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) { + assertProjectSettingsUpdateProjectCommand(input.command); + return this.database.transaction((tx) => { + const current = tx + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!current) { + throw new Error("Project not found."); + } + if ( + current.singlePhaseVoltageV === + input.command.payload.singlePhaseVoltageV && + current.threePhaseVoltageV === + input.command.payload.threePhaseVoltageV + ) { + throw new Error("Project settings did not change."); + } + + const inverse = createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: current.singlePhaseVoltageV, + threePhaseVoltageV: current.threePhaseVoltageV, + }); + const updated = tx + .update(projects) + .set(input.command.payload) + .where(eq(projects.id, input.projectId)) + .run(); + if (updated.changes !== 1) { + throw new Error("Project changed before settings update."); + } + + const revision = appendProjectRevision(tx, { + projectId: input.projectId, + expectedRevision: input.expectedRevision, + source: input.source, + description: input.description, + actorId: input.actorId, + forward: input.command, + inverse, + }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); + return { revision, inverse }; + }); + } +} diff --git a/src/db/repositories/project.repository.ts b/src/db/repositories/project.repository.ts index 914049b..3af0eed 100644 --- a/src/db/repositories/project.repository.ts +++ b/src/db/repositories/project.repository.ts @@ -4,7 +4,6 @@ import { db } from "../client.js"; import { projects } from "../schema/projects.js"; import type { CreateProjectInput, - UpdateProjectSettingsInput, } from "../../shared/validation/project-structure.schemas.js"; export class ProjectRepository { @@ -30,16 +29,6 @@ export class ProjectRepository { return row ?? null; } - async updateSettings(projectId: string, input: UpdateProjectSettingsInput) { - await db - .update(projects) - .set({ - singlePhaseVoltageV: input.singlePhaseVoltageV, - threePhaseVoltageV: input.threePhaseVoltageV, - }) - .where(eq(projects.id, projectId)); - } - async delete(projectId: string) { await db.delete(projects).where(eq(projects.id, projectId)); } diff --git a/src/domain/models/project-settings-project-command.model.ts b/src/domain/models/project-settings-project-command.model.ts new file mode 100644 index 0000000..bd9110a --- /dev/null +++ b/src/domain/models/project-settings-project-command.model.ts @@ -0,0 +1,61 @@ +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const projectSettingsUpdateCommandType = + "project.update-settings" as const; +export const projectSettingsUpdateCommandSchemaVersion = 1 as const; + +export interface ProjectSettingsValues { + singlePhaseVoltageV: number; + threePhaseVoltageV: number; +} + +export interface ProjectSettingsUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion; + type: typeof projectSettingsUpdateCommandType; +} + +export function createProjectSettingsUpdateProjectCommand( + values: ProjectSettingsValues +): ProjectSettingsUpdateProjectCommand { + const command: ProjectSettingsUpdateProjectCommand = { + schemaVersion: projectSettingsUpdateCommandSchemaVersion, + type: projectSettingsUpdateCommandType, + payload: { ...values }, + }; + assertProjectSettingsUpdateProjectCommand(command); + return command; +} + +export function assertProjectSettingsUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectSettingsUpdateProjectCommand { + if ( + command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion || + command.type !== projectSettingsUpdateCommandType + ) { + throw new Error("Unsupported project settings update command."); + } + if ( + !isPlainObject(command.payload) || + !isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) || + !isPositiveFiniteNumber(command.payload.threePhaseVoltageV) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error( + "Project settings update command contains invalid voltages." + ); + } +} + +function isPositiveFiniteNumber(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + value > 0 + ); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/project-settings-project-command.store.ts b/src/domain/ports/project-settings-project-command.store.ts new file mode 100644 index 0000000..432490a --- /dev/null +++ b/src/domain/ports/project-settings-project-command.store.ts @@ -0,0 +1,26 @@ +import type { ProjectSettingsUpdateProjectCommand } from "../models/project-settings-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteProjectSettingsUpdateCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ProjectSettingsUpdateProjectCommand; +} + +export interface ExecutedProjectSettingsUpdateCommand { + revision: AppendedProjectRevision; + inverse: ProjectSettingsUpdateProjectCommand; +} + +export interface ProjectSettingsProjectCommandStore { + executeUpdate( + input: ExecuteProjectSettingsUpdateCommandInput + ): ExecutedProjectSettingsUpdateCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 8ada794..ddfa32d 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -45,6 +45,10 @@ import { assertProjectStateRestoreCommand, projectStateRestoreCommandType, } from "../models/project-state-restore-command.model.js"; +import { + assertProjectSettingsUpdateProjectCommand, + projectSettingsUpdateCommandType, +} from "../models/project-settings-project-command.model.js"; import { assertProjectDeviceRowSyncProjectCommand, projectDeviceRowSyncCommandType, @@ -80,6 +84,7 @@ import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-p import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js"; import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js"; import type { ProjectRevisionSource } from "../ports/project-revision.store.js"; +import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js"; import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js"; interface DispatchProjectCommandInput { @@ -104,6 +109,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore, private readonly projectDeviceStore: ProjectDeviceProjectCommandStore, private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore, + private readonly projectSettingsStore: ProjectSettingsProjectCommandStore, private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -294,6 +300,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectSettingsUpdateCommandType: { + assertProjectSettingsUpdateProjectCommand(input.command); + return this.projectSettingsStore.executeUpdate({ + ...input, + command: input.command, + }).revision; + } case projectStateRestoreCommandType: { assertProjectStateRestoreCommand(input.command); return this.projectStateRestoreStore.execute({ diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 8d9291b..8724709 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -58,6 +58,11 @@ export interface ProjectSnapshotRestoreResultDto snapshot: ProjectSnapshotMetadataDto; } +export interface ProjectSettingsCommandResultDto + extends ProjectCommandResultDto { + project: ProjectDto; +} + export interface ProjectCommandDto { schemaVersion: number; type: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index f622e29..657a027 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -15,6 +15,7 @@ import type { ProjectRevisionPageDto, ProjectSnapshotMetadataDto, ProjectSnapshotRestoreResultDto, + ProjectSettingsCommandResultDto, ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncPreviewDto, ProjectDto, @@ -205,12 +206,16 @@ export function createProject(name: string) { export function updateProjectSettings( projectId: string, + expectedRevision: number, input: { singlePhaseVoltageV: number; threePhaseVoltageV: number } ) { - return request(`/api/projects/${projectId}`, { + return request( + `/api/projects/${projectId}`, + { method: "PUT", - body: JSON.stringify(input), - }); + body: JSON.stringify({ expectedRevision, ...input }), + } + ); } export function listDistributionBoards(projectId: string) { diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 27da285..fe5976e 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -28,6 +28,7 @@ const commandTypeLabels: Record = { "project-device.insert": "Projektgerät angelegt", "project-device.delete": "Projektgerät gelöscht", "project-device.sync-rows": "Projektgerät-Verknüpfungen geändert", + "project.update-settings": "Projekteinstellungen bearbeitet", "project.restore-state": "Projektstand wiederhergestellt", }; diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 9d3a123..3773ee6 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -10,6 +10,7 @@ import { ProjectHistoryRepository } from "../../db/repositories/project-history. import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js"; +import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; @@ -32,6 +33,8 @@ export const projectDeviceProjectCommandStore = new ProjectDeviceProjectCommandRepository(db); export const projectDeviceRowSyncProjectCommandStore = new ProjectDeviceRowSyncProjectCommandRepository(db); +export const projectSettingsProjectCommandStore = + new ProjectSettingsProjectCommandRepository(db); export const projectStateRestoreCommandStore = new ProjectStateRestoreCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); @@ -46,6 +49,7 @@ export const projectCommandService = new ProjectCommandService( projectDeviceStructureProjectCommandStore, projectDeviceProjectCommandStore, projectDeviceRowSyncProjectCommandStore, + projectSettingsProjectCommandStore, projectStateRestoreCommandStore, projectHistoryStore ); diff --git a/src/server/controllers/project.controller.ts b/src/server/controllers/project.controller.ts index ca64f29..7711a55 100644 --- a/src/server/controllers/project.controller.ts +++ b/src/server/controllers/project.controller.ts @@ -4,6 +4,9 @@ import { createProjectSchema, updateProjectSettingsSchema, } from "../../shared/validation/project-structure.schemas.js"; +import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; const projectRepository = new ProjectRepository(); @@ -44,10 +47,20 @@ export async function updateProjectSettings(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - await projectRepository.updateSettings(projectId, parsed.data); - const row = await projectRepository.findById(projectId); - if (!row) { - return res.status(404).json({ error: "Project not found" }); + const { expectedRevision, ...settings } = parsed.data; + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision, + description: "Projekteinstellungen bearbeiten", + command: createProjectSettingsUpdateProjectCommand(settings), + }); + const project = await projectRepository.findById(projectId); + if (!project) { + throw new Error("Updated project could not be loaded."); + } + return res.json({ ...result, project }); + } catch (error) { + return respondWithProjectCommandError(error, res); } - return res.json(row); } diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index a47e775..323e42a 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; export const createProjectSchema = z.object({ name: z.string().min(1), @@ -6,10 +7,13 @@ export const createProjectSchema = z.object({ threePhaseVoltageV: z.number().positive().optional(), }); -export const updateProjectSettingsSchema = z.object({ - singlePhaseVoltageV: z.number().positive(), - threePhaseVoltageV: z.number().positive(), -}); +export const updateProjectSettingsSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + singlePhaseVoltageV: z.number().positive(), + threePhaseVoltageV: z.number().positive(), + }) + .strict(); export const createDistributionBoardSchema = z.object({ name: z.string().min(1), diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 9c8e172..7e68022 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -20,6 +20,7 @@ import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/pr import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; +import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.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"; @@ -43,6 +44,7 @@ import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit- import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js"; import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js"; +import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; function createTestDatabase(): DatabaseContext { @@ -102,6 +104,7 @@ function createService(context: DatabaseContext) { new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db), + new ProjectSettingsProjectCommandRepository(context.db), new ProjectStateRestoreCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); @@ -924,6 +927,58 @@ describe("project command service", () => { } }); + it("dispatches project settings updates through persistent history", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const updated = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }), + }); + assert.equal(updated.history.currentRevision, 1); + assert.deepEqual( + context.db + .select({ + singlePhaseVoltageV: projects.singlePhaseVoltageV, + threePhaseVoltageV: projects.threePhaseVoltageV, + }) + .from(projects) + .where(eq(projects.id, "project-1")) + .get(), + { + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + } + ); + + const undone = service.undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.history.currentRevision, 2); + assert.deepEqual( + context.db + .select({ + singlePhaseVoltageV: projects.singlePhaseVoltageV, + threePhaseVoltageV: projects.threePhaseVoltageV, + }) + .from(projects) + .where(eq(projects.id, "project-1")) + .get(), + { + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + } + ); + } finally { + context.close(); + } + }); + it("rejects unsupported and malformed commands before domain writes", () => { const context = createTestDatabase(); try { diff --git a/tests/project-settings-project-command.repository.test.ts b/tests/project-settings-project-command.repository.test.ts new file mode 100644 index 0000000..dbccad8 --- /dev/null +++ b/tests/project-settings-project-command.repository.test.ts @@ -0,0 +1,248 @@ +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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js"; +import { updateProjectSettings } from "../src/frontend/utils/api.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: "Testprojekt", + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + }) + .run(); + return context; +} + +function getSettings(context: DatabaseContext) { + return context.db + .select({ + singlePhaseVoltageV: projects.singlePhaseVoltageV, + threePhaseVoltageV: projects.threePhaseVoltageV, + currentRevision: projects.currentRevision, + }) + .from(projects) + .where(eq(projects.id, "project-1")) + .get(); +} + +describe("project settings project-command repository", () => { + it("validates settings commands and sends the expected revision from the frontend", async () => { + assert.throws( + () => + createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: 0, + threePhaseVoltageV: 400, + }), + /invalid voltages/ + ); + const requests: Array<{ url: string; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body)), + }); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await updateProjectSettings("project-1", 7, { + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }); + } finally { + globalThis.fetch = originalFetch; + } + assert.deepEqual(requests, [ + { + url: "/api/projects/project-1", + body: { + expectedRevision: 7, + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }, + }, + ]); + }); + + it("updates both voltages and supports persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectSettingsProjectCommandRepository( + context.db + ); + const history = new ProjectHistoryRepository(context.db); + const command = createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }); + const forward = repository.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual(getSettings(context), { + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + currentRevision: 1, + }); + assert.deepEqual(forward.inverse.payload, { + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + }); + + const undoStep = history.getNextCommand("project-1", "undo"); + assert.ok(undoStep); + repository.executeUpdate({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: undoStep.changeSetId, + command: forward.inverse, + }); + assert.deepEqual(getSettings(context), { + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + currentRevision: 2, + }); + + const redoStep = history.getNextCommand("project-1", "redo"); + assert.ok(redoStep); + repository.executeUpdate({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: redoStep.changeSetId, + command, + }); + assert.deepEqual(getSettings(context), { + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + currentRevision: 3, + }); + } finally { + context.close(); + } + }); + + it("rejects no-op, invalid project and stale revision without partial writes", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectSettingsProjectCommandRepository( + context.db + ); + assert.throws( + () => + repository.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + }), + }), + /did not change/ + ); + assert.throws( + () => + repository.executeUpdate({ + projectId: "missing", + expectedRevision: 0, + source: "user", + command: createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }), + }), + /Project not found/ + ); + assert.throws( + () => + repository.executeUpdate({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }), + }), + ProjectRevisionConflictError + ); + assert.deepEqual(getSettings(context), { + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + currentRevision: 0, + }); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); + + it("rolls back both values and revision after a late history failure", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_project_settings_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced project settings history failure'); + END; + `); + const repository = new ProjectSettingsProjectCommandRepository( + context.db + ); + assert.throws( + () => + repository.executeUpdate({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectSettingsUpdateProjectCommand({ + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }), + }), + /forced project settings history failure/ + ); + assert.deepEqual(getSettings(context), { + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + currentRevision: 0, + }); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index 3b44397..7bbda57 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -21,6 +21,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/reposi import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; +import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; @@ -136,6 +137,7 @@ function createService(context: DatabaseContext) { new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db), + new ProjectSettingsProjectCommandRepository(context.db), new ProjectStateRestoreCommandRepository(context.db), new ProjectHistoryRepository(context.db) );