Persist project settings updates

This commit is contained in:
2026-07-25 23:08:07 +02:00
parent d00ae30bda
commit b2763f72d5
21 changed files with 569 additions and 33 deletions
+3
View File
@@ -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 global-to-project copy API/UI paths use these persistent commands and track the
returned project revision. ProjectDevice synchronization/disconnect API and UI returned project revision. ProjectDevice synchronization/disconnect API and UI
paths do the same; their undo action uses the project-wide history endpoint. 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: Required operations:
+14 -1
View File
@@ -62,7 +62,8 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.reorder-section`, `circuit.reorder-sections`, `circuit.reorder-section`, `circuit.reorder-sections`,
`circuit.renumber-section` and `circuit.renumber-section` and
`project-device.update`, `project-device.insert`, `project-device.delete` 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 are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit with stable ids; delete commands include the expected parent identity. Circuit
snapshots contain zero, one or multiple complete device rows. Move commands 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 returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never
synchronizes linked circuit rows implicitly. 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: Example:
```json ```json
+6
View File
@@ -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 und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück. 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 ## Projektgeräte
@@ -252,6 +252,9 @@ Completed foundation:
- `project-device.insert` and `project-device.delete` preserve stable device - `project-device.insert` and `project-device.delete` preserve stable device
ids; delete undo restores links only from complete unchanged disconnected-row ids; delete undo restores links only from complete unchanged disconnected-row
snapshots in the same transaction 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: Remaining constraints before completing project version history:
@@ -438,6 +438,9 @@ Implemented foundation:
- ProjectDevice synchronization and disconnect endpoints also require - ProjectDevice synchronization and disconnect endpoints also require
`expectedRevision`; their UI undo uses project-wide persistent history and `expectedRevision`; their UI undo uses project-wide persistent history and
the obsolete direct restore/reconnect write paths are removed 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 - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "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": "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-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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
+18 -7
View File
@@ -245,17 +245,21 @@ export default function ProjectDetailPage() {
} }
async function handleSaveProjectSettings() { async function handleSaveProjectSettings() {
if (!projectId) { if (!projectId || !project) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const updated = await updateProjectSettings(projectId, { const result = await updateProjectSettings(
singlePhaseVoltageV: Number(singlePhaseVoltageV), projectId,
threePhaseVoltageV: Number(threePhaseVoltageV), project.currentRevision,
}); {
setProject(updated); singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
}
);
setProject(result.project);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden."); setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
} finally { } finally {
@@ -599,7 +603,14 @@ export default function ProjectDetailPage() {
className="btn btn-primary w-100" className="btn btn-primary w-100"
type="button" type="button"
onClick={handleSaveProjectSettings} onClick={handleSaveProjectSettings}
disabled={isSaving} disabled={
isSaving ||
!project ||
(Number(singlePhaseVoltageV) ===
project.singlePhaseVoltageV &&
Number(threePhaseVoltageV) ===
project.threePhaseVoltageV)
}
> >
Projekteigenschaften speichern Projekteigenschaften speichern
</button> </button>
@@ -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 };
});
}
}
-11
View File
@@ -4,7 +4,6 @@ import { db } from "../client.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import type { import type {
CreateProjectInput, CreateProjectInput,
UpdateProjectSettingsInput,
} from "../../shared/validation/project-structure.schemas.js"; } from "../../shared/validation/project-structure.schemas.js";
export class ProjectRepository { export class ProjectRepository {
@@ -30,16 +29,6 @@ export class ProjectRepository {
return row ?? null; 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) { async delete(projectId: string) {
await db.delete(projects).where(eq(projects.id, projectId)); await db.delete(projects).where(eq(projects.id, projectId));
} }
@@ -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<ProjectSettingsValues> {
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<unknown>
): 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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -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;
}
@@ -45,6 +45,10 @@ import {
assertProjectStateRestoreCommand, assertProjectStateRestoreCommand,
projectStateRestoreCommandType, projectStateRestoreCommandType,
} from "../models/project-state-restore-command.model.js"; } from "../models/project-state-restore-command.model.js";
import {
assertProjectSettingsUpdateProjectCommand,
projectSettingsUpdateCommandType,
} from "../models/project-settings-project-command.model.js";
import { import {
assertProjectDeviceRowSyncProjectCommand, assertProjectDeviceRowSyncProjectCommand,
projectDeviceRowSyncCommandType, 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 { 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 { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.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"; import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
interface DispatchProjectCommandInput { interface DispatchProjectCommandInput {
@@ -104,6 +109,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore, private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore, private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore, private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore, private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -294,6 +300,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case projectSettingsUpdateCommandType: {
assertProjectSettingsUpdateProjectCommand(input.command);
return this.projectSettingsStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: { case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command); assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({ return this.projectStateRestoreStore.execute({
+5
View File
@@ -58,6 +58,11 @@ export interface ProjectSnapshotRestoreResultDto
snapshot: ProjectSnapshotMetadataDto; snapshot: ProjectSnapshotMetadataDto;
} }
export interface ProjectSettingsCommandResultDto
extends ProjectCommandResultDto {
project: ProjectDto;
}
export interface ProjectCommandDto { export interface ProjectCommandDto {
schemaVersion: number; schemaVersion: number;
type: string; type: string;
+8 -3
View File
@@ -15,6 +15,7 @@ import type {
ProjectRevisionPageDto, ProjectRevisionPageDto,
ProjectSnapshotMetadataDto, ProjectSnapshotMetadataDto,
ProjectSnapshotRestoreResultDto, ProjectSnapshotRestoreResultDto,
ProjectSettingsCommandResultDto,
ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ProjectDto, ProjectDto,
@@ -205,12 +206,16 @@ export function createProject(name: string) {
export function updateProjectSettings( export function updateProjectSettings(
projectId: string, projectId: string,
expectedRevision: number,
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number } input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
) { ) {
return request<ProjectDto>(`/api/projects/${projectId}`, { return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`,
{
method: "PUT", method: "PUT",
body: JSON.stringify(input), body: JSON.stringify({ expectedRevision, ...input }),
}); }
);
} }
export function listDistributionBoards(projectId: string) { export function listDistributionBoards(projectId: string) {
@@ -28,6 +28,7 @@ const commandTypeLabels: Record<string, string> = {
"project-device.insert": "Projektgerät angelegt", "project-device.insert": "Projektgerät angelegt",
"project-device.delete": "Projektgerät gelöscht", "project-device.delete": "Projektgerät gelöscht",
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert", "project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
"project.update-settings": "Projekteinstellungen bearbeitet",
"project.restore-state": "Projektstand wiederhergestellt", "project.restore-state": "Projektstand wiederhergestellt",
}; };
@@ -10,6 +10,7 @@ import { ProjectHistoryRepository } from "../../db/repositories/project-history.
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js"; 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 { 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 { 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 { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -32,6 +33,8 @@ export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db); new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore = export const projectDeviceRowSyncProjectCommandStore =
new ProjectDeviceRowSyncProjectCommandRepository(db); new ProjectDeviceRowSyncProjectCommandRepository(db);
export const projectSettingsProjectCommandStore =
new ProjectSettingsProjectCommandRepository(db);
export const projectStateRestoreCommandStore = export const projectStateRestoreCommandStore =
new ProjectStateRestoreCommandRepository(db); new ProjectStateRestoreCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db);
@@ -46,6 +49,7 @@ export const projectCommandService = new ProjectCommandService(
projectDeviceStructureProjectCommandStore, projectDeviceStructureProjectCommandStore,
projectDeviceProjectCommandStore, projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore, projectDeviceRowSyncProjectCommandStore,
projectSettingsProjectCommandStore,
projectStateRestoreCommandStore, projectStateRestoreCommandStore,
projectHistoryStore projectHistoryStore
); );
+18 -5
View File
@@ -4,6 +4,9 @@ import {
createProjectSchema, createProjectSchema,
updateProjectSettingsSchema, updateProjectSettingsSchema,
} from "../../shared/validation/project-structure.schemas.js"; } 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(); 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() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
await projectRepository.updateSettings(projectId, parsed.data); const { expectedRevision, ...settings } = parsed.data;
const row = await projectRepository.findById(projectId); try {
if (!row) { const result = projectCommandService.executeUser({
return res.status(404).json({ error: "Project not found" }); 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);
} }
@@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectSchema = z.object({ export const createProjectSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@@ -6,10 +7,13 @@ export const createProjectSchema = z.object({
threePhaseVoltageV: z.number().positive().optional(), threePhaseVoltageV: z.number().positive().optional(),
}); });
export const updateProjectSettingsSchema = z.object({ export const updateProjectSettingsSchema = z
singlePhaseVoltageV: z.number().positive(), .object({
threePhaseVoltageV: z.number().positive(), expectedRevision: expectedProjectRevisionSchema,
}); singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(),
})
.strict();
export const createDistributionBoardSchema = z.object({ export const createDistributionBoardSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
+55
View File
@@ -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 { 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 { 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 { 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 { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.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 { 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 { 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 { 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"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext { function createTestDatabase(): DatabaseContext {
@@ -102,6 +104,7 @@ function createService(context: DatabaseContext) {
new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectSettingsProjectCommandRepository(context.db),
new ProjectStateRestoreCommandRepository(context.db), new ProjectStateRestoreCommandRepository(context.db),
new ProjectHistoryRepository(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", () => { it("rejects unsupported and malformed commands before domain writes", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -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();
}
});
});
@@ -21,6 +21,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/reposi
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.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 { 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 { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js";
@@ -136,6 +137,7 @@ function createService(context: DatabaseContext) {
new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceStructureProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectSettingsProjectCommandRepository(context.db),
new ProjectStateRestoreCommandRepository(context.db), new ProjectStateRestoreCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );