Add versioned project settings modal

This commit is contained in:
2026-07-28 18:16:22 +02:00
parent e1fc81a083
commit d77cfbeb49
22 changed files with 2563 additions and 111 deletions
+5
View File
@@ -951,6 +951,11 @@ describe("project command service", () => {
projectId: "project-1",
expectedRevision: 0,
command: createProjectSettingsUpdateProjectCommand({
name: "Test project",
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -25,6 +25,7 @@ function createTestDatabase(): DatabaseContext {
.values({
id: "project-1",
name: "Testprojekt",
internalProjectNumber: "INT-1",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
})
@@ -35,6 +36,11 @@ function createTestDatabase(): DatabaseContext {
function getSettings(context: DatabaseContext) {
return context.db
.select({
name: projects.name,
internalProjectNumber: projects.internalProjectNumber,
externalProjectNumber: projects.externalProjectNumber,
buildingOwner: projects.buildingOwner,
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
@@ -44,15 +50,32 @@ function getSettings(context: DatabaseContext) {
.get();
}
function settings(
overrides: Partial<Parameters<
typeof createProjectSettingsUpdateProjectCommand
>[0]> = {}
) {
return {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
...overrides,
};
}
describe("project settings project-command repository", () => {
it("validates settings commands and sends the expected revision from the frontend", async () => {
assert.throws(
() =>
createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 0,
threePhaseVoltageV: 400,
}),
/invalid voltages/
/invalid values/
);
const requests: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
@@ -68,6 +91,7 @@ describe("project settings project-command repository", () => {
};
try {
await updateProjectSettings("project-1", 7, {
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
});
@@ -79,6 +103,11 @@ describe("project settings project-command repository", () => {
url: "/api/projects/project-1",
body: {
expectedRevision: 7,
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
},
@@ -94,6 +123,9 @@ describe("project settings project-command repository", () => {
);
const history = new ProjectHistoryRepository(context.db);
const command = createProjectSettingsUpdateProjectCommand({
...settings(),
name: "Neuer Projektname",
buildingOwner: "Beispiel Bau GmbH",
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
});
@@ -104,11 +136,21 @@ describe("project settings project-command repository", () => {
command,
});
assert.deepEqual(getSettings(context), {
name: "Neuer Projektname",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: "Beispiel Bau GmbH",
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
currentRevision: 1,
});
assert.deepEqual(forward.inverse.payload, {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
});
@@ -123,6 +165,11 @@ describe("project settings project-command repository", () => {
command: forward.inverse,
});
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 2,
@@ -138,6 +185,11 @@ describe("project settings project-command repository", () => {
command,
});
assert.deepEqual(getSettings(context), {
name: "Neuer Projektname",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: "Beispiel Bau GmbH",
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
currentRevision: 3,
@@ -147,6 +199,37 @@ describe("project settings project-command repository", () => {
}
});
it("applies persisted version-one voltage commands without clearing metadata", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: {
schemaVersion: 1,
type: "project.update-settings",
payload: {
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
},
},
});
assert.deepEqual(getSettings(context), {
...settings({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
currentRevision: 1,
});
} finally {
context.close();
}
});
it("rejects no-op, invalid project and stale revision without partial writes", () => {
const context = createTestDatabase();
try {
@@ -160,8 +243,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
...settings(),
}),
}),
/did not change/
@@ -173,6 +255,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -186,6 +269,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 1,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -193,6 +277,11 @@ describe("project settings project-command repository", () => {
ProjectRevisionConflictError
);
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 0,
@@ -226,6 +315,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -233,6 +323,11 @@ describe("project settings project-command repository", () => {
/forced project settings history failure/
);
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 0,
+38 -1
View File
@@ -20,6 +20,7 @@ import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js";
@@ -133,7 +134,7 @@ describe("project snapshot repository", () => {
});
assert.ok(created);
assert.equal(created.sourceRevision, 0);
assert.equal(created.schemaVersion, 1);
assert.equal(created.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(created.name, "Vor Ausschreibung");
assert.equal(created.description, "Sicherer Stand");
assert.equal(created.createdByActorId, "planner-1");
@@ -262,6 +263,42 @@ describe("project snapshot repository", () => {
prepared.command.payload.targetState.project.id,
"project-1"
);
const currentPayload = JSON.parse(
context.db
.select({ payloadJson: projectSnapshots.payloadJson })
.from(projectSnapshots)
.where(eq(projectSnapshots.id, snapshot.id))
.get()!.payloadJson
) as Record<string, unknown> & {
project: Record<string, unknown>;
};
currentPayload.schemaVersion = 1;
delete currentPayload.project.internalProjectNumber;
delete currentPayload.project.externalProjectNumber;
delete currentPayload.project.buildingOwner;
delete currentPayload.project.description;
const legacyPayloadJson = JSON.stringify(currentPayload);
context.db
.update(projectSnapshots)
.set({
schemaVersion: 1,
payloadJson: legacyPayloadJson,
payloadSha256: crypto
.createHash("sha256")
.update(legacyPayloadJson)
.digest("hex"),
})
.where(eq(projectSnapshots.id, snapshot.id))
.run();
const legacyPrepared = repository.prepareRestore(
"project-1",
snapshot.id
);
assert.ok(legacyPrepared);
assert.equal(
legacyPrepared.command.payload.targetState.project.buildingOwner,
null
);
context.db
.update(projectSnapshots)
.set({ payloadJson: `${JSON.stringify({})}\n` })
+28 -1
View File
@@ -3,16 +3,21 @@ import { describe, it } from "node:test";
import {
deserializeProjectStateSnapshot,
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
function minimalSnapshot() {
return {
schemaVersion: 1 as const,
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
id: "project-1",
name: "Projekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
},
@@ -37,6 +42,28 @@ describe("project state snapshot model", () => {
);
});
it("upgrades version-one snapshots with empty project metadata", () => {
const legacy = {
...minimalSnapshot(),
schemaVersion: 1 as const,
project: {
id: "project-1",
name: "Altprojekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
},
};
const snapshot = parseProjectStateSnapshot(legacy);
assert.equal(snapshot.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(snapshot.project, {
...legacy.project,
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
});
});
it("rejects duplicate ids and cross-project ownership", () => {
assert.throws(
() =>