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
+10 -6
View File
@@ -209,12 +209,16 @@ 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 `project.update-settings` versioniert Projektname, interne und externe
eine atomare Änderung. Der Store leitet das inverse Kommando aus dem Projektnummer, Bauherr, Beschreibung sowie beide Standardspannungen als eine
gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel atomare Änderung. Die Projektseite bearbeitet diese Angaben in einem
gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. Der
`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
besitzt keinen separaten direkten Settings-Schreibweg mehr. 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.
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen `distribution-board.insert` versioniert die Anlage einer Verteilung als einen
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen. vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
+18
View File
@@ -48,12 +48,25 @@ const circuitColumns = new Set(
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name) db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
); );
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name)); const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
const requiredProjectColumns = [
"internal_project_number",
"external_project_number",
"building_owner",
"description",
];
const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
);
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
console.log("Database:", dbPath); console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", ")); console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)"); console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", ")); console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", ")); console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log("Required project columns:", requiredProjectColumns.join(", "));
if (missing.length > 0) { if (missing.length > 0) {
console.error("Missing tables:", missing.join(", ")); console.error("Missing tables:", missing.join(", "));
@@ -78,4 +91,9 @@ if (missingCircuitColumns.length > 0) {
process.exit(1); process.exit(1);
} }
if (missingProjectColumns.length > 0) {
console.error("Missing project columns:", missingProjectColumns.join(", "));
process.exit(1);
}
console.log("Circuit-first schema verification passed."); console.log("Circuit-first schema verification passed.");
+36 -63
View File
@@ -40,6 +40,10 @@ import {
type ProjectDeviceSyncField, type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields"; } from "../../../shared/constants/project-device-sync-fields";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history"; import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
import {
ProjectSettingsModal,
type ProjectSettingsInput,
} from "../../../frontend/components/project-settings-modal";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = { const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name", name: "Technischer Name",
@@ -92,8 +96,7 @@ export default function ProjectDetailPage() {
const [roomNumber, setRoomNumber] = useState(""); const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState(""); const [roomName, setRoomName] = useState("");
const [roomFloorId, setRoomFloorId] = useState(""); const [roomFloorId, setRoomFloorId] = useState("");
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState("230"); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState("400");
const [projectDeviceForm, setProjectDeviceForm] = useState<Record<string, string>>({ const [projectDeviceForm, setProjectDeviceForm] = useState<Record<string, string>>({
name: "", name: "",
displayName: "", displayName: "",
@@ -142,10 +145,6 @@ export default function ProjectDetailPage() {
]) => { ]) => {
const currentProject = projects.find((item) => item.id === projectId) ?? null; const currentProject = projects.find((item) => item.id === projectId) ?? null;
setProject(currentProject); setProject(currentProject);
if (currentProject) {
setSinglePhaseVoltageV(String(currentProject.singlePhaseVoltageV));
setThreePhaseVoltageV(String(currentProject.threePhaseVoltageV));
}
setBoards(distributionBoards); setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists); setCircuitLists(loadedCircuitLists);
setFloors(loadedFloors); setFloors(loadedFloors);
@@ -259,7 +258,7 @@ export default function ProjectDetailPage() {
} }
} }
async function handleSaveProjectSettings() { async function handleSaveProjectSettings(input: ProjectSettingsInput) {
if (!projectId || !project) { if (!projectId || !project) {
return; return;
} }
@@ -269,12 +268,10 @@ export default function ProjectDetailPage() {
const result = await updateProjectSettings( const result = await updateProjectSettings(
projectId, projectId,
project.currentRevision, project.currentRevision,
{ input
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
}
); );
setProject(result.project); setProject(result.project);
setIsProjectSettingsOpen(false);
} 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 {
@@ -532,66 +529,34 @@ export default function ProjectDetailPage() {
return ( return (
<main className="container py-4"> <main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3"> <div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div> <div>
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1> <h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
<p className="text-secondary mb-0">Verteilerübersicht und Einstieg in die Stromkreislisten</p> <p className="text-secondary mb-0">
{project?.internalProjectNumber
? `Projektnummer ${project.internalProjectNumber} · `
: ""}
Verteilerübersicht und Einstieg in die Stromkreislisten
</p>
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary"
disabled={!project}
onClick={() => setIsProjectSettingsOpen(true)}
type="button"
>
Projekteinstellungen
</button>
<Link className="btn btn-outline-secondary" href="/projects">
Zur Projektübersicht
</Link>
</div> </div>
<Link className="btn btn-outline-secondary" href="/projects">
Zur Projektseite
</Link>
</div> </div>
{error ? <div className="alert alert-warning">{error}</div> : null} {error ? <div className="alert alert-warning">{error}</div> : null}
<div className="row g-4"> <div className="row g-4">
<section className="col-12">
<div className="card shadow-sm">
<div className="card-header">Projekteigenschaften</div>
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-12 col-md-4">
<label className="form-label">Standardspannung 1-phasig [V]</label>
<input
className="form-control"
type="number"
min="1"
value={singlePhaseVoltageV}
onChange={(event) => setSinglePhaseVoltageV(event.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Standardspannung 3-phasig [V]</label>
<input
className="form-control"
type="number"
min="1"
value={threePhaseVoltageV}
onChange={(event) => setThreePhaseVoltageV(event.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<button
className="btn btn-primary w-100"
type="button"
onClick={handleSaveProjectSettings}
disabled={
isSaving ||
!project ||
(Number(singlePhaseVoltageV) ===
project.singlePhaseVoltageV &&
Number(threePhaseVoltageV) ===
project.threePhaseVoltageV)
}
>
Projekteigenschaften speichern
</button>
</div>
</div>
</div>
</div>
</section>
{project ? ( {project ? (
<section className="col-12"> <section className="col-12">
<ProjectVersionHistory <ProjectVersionHistory
@@ -1156,6 +1121,14 @@ export default function ProjectDetailPage() {
) : null} ) : null}
</section> </section>
{project && isProjectSettingsOpen ? (
<ProjectSettingsModal
isSaving={isSaving}
onClose={() => setIsProjectSettingsOpen(false)}
onSave={handleSaveProjectSettings}
project={project}
/>
) : null}
</main> </main>
); );
} }
@@ -0,0 +1,4 @@
ALTER TABLE `projects` ADD `internal_project_number` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `external_project_number` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `building_owner` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `description` text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -113,6 +113,13 @@
"when": 1785014383032, "when": 1785014383032,
"tag": "0015_modern_madame_masque", "tag": "0015_modern_madame_masque",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1785254079435,
"tag": "0016_dashing_darkstar",
"breakpoints": true
} }
] ]
} }
@@ -2,6 +2,8 @@ import { eq } from "drizzle-orm";
import { import {
assertProjectSettingsUpdateProjectCommand, assertProjectSettingsUpdateProjectCommand,
createProjectSettingsUpdateProjectCommand, createProjectSettingsUpdateProjectCommand,
legacyProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js"; } from "../../domain/models/project-settings-project-command.model.js";
import type { import type {
ExecuteProjectSettingsUpdateCommandInput, ExecuteProjectSettingsUpdateCommandInput,
@@ -37,22 +39,45 @@ export class ProjectSettingsProjectCommandRepository
if (!current) { if (!current) {
throw new Error("Project not found."); throw new Error("Project not found.");
} }
if ( const target: ProjectSettingsValues =
current.singlePhaseVoltageV === input.command.schemaVersion ===
input.command.payload.singlePhaseVoltageV && legacyProjectSettingsUpdateCommandSchemaVersion
current.threePhaseVoltageV === ? {
input.command.payload.threePhaseVoltageV name: current.name,
) { internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
...input.command.payload,
}
: input.command.payload;
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change."); throw new Error("Project settings did not change.");
} }
const inverse = createProjectSettingsUpdateProjectCommand({ const inverse =
singlePhaseVoltageV: current.singlePhaseVoltageV, input.command.schemaVersion ===
threePhaseVoltageV: current.threePhaseVoltageV, legacyProjectSettingsUpdateCommandSchemaVersion
}); ? {
schemaVersion: legacyProjectSettingsUpdateCommandSchemaVersion,
type: input.command.type,
payload: {
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: createProjectSettingsUpdateProjectCommand({
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
});
const updated = tx const updated = tx
.update(projects) .update(projects)
.set(input.command.payload) .set(target)
.where(eq(projects.id, input.projectId)) .where(eq(projects.id, input.projectId))
.run(); .run();
if (updated.changes !== 1) { if (updated.changes !== 1) {
@@ -62,3 +87,18 @@ export class ProjectSettingsProjectCommandRepository
return inverse; return inverse;
} }
} }
function projectSettingsEqual(
current: ProjectSettingsValues,
target: ProjectSettingsValues
) {
return (
current.name === target.name &&
current.internalProjectNumber === target.internalProjectNumber &&
current.externalProjectNumber === target.externalProjectNumber &&
current.buildingOwner === target.buildingOwner &&
current.description === target.description &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV
);
}
@@ -5,6 +5,7 @@ import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-sn
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js"; import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import { import {
deserializeProjectStateSnapshot, deserializeProjectStateSnapshot,
legacyProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion, projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js"; } from "../../domain/models/project-state-snapshot.model.js";
import type { import type {
@@ -138,7 +139,10 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
if (actualChecksum !== stored.payloadSha256) { if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed."); throw new Error("Project snapshot checksum verification failed.");
} }
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) { if (
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion
) {
throw new Error("Project snapshot schema version is not supported."); throw new Error("Project snapshot schema version is not supported.");
} }
const targetState = deserializeProjectStateSnapshot(stored.payloadJson); const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
@@ -5,6 +5,7 @@ import {
createProjectStateRestoreCommand, createProjectStateRestoreCommand,
} from "../../domain/models/project-state-restore-command.model.js"; } from "../../domain/models/project-state-restore-command.model.js";
import { import {
parseProjectStateSnapshot,
serializeProjectStateSnapshot, serializeProjectStateSnapshot,
type ProjectStateSnapshot, type ProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js"; } from "../../domain/models/project-state-snapshot.model.js";
@@ -38,7 +39,10 @@ export class ProjectStateRestoreCommandRepository
execute(input: ExecuteProjectStateRestoreCommandInput) { execute(input: ExecuteProjectStateRestoreCommandInput) {
assertProjectStateRestoreCommand(input.command); assertProjectStateRestoreCommand(input.command);
if (input.command.payload.targetState.project.id !== input.projectId) { const targetState = parseProjectStateSnapshot(
input.command.payload.targetState
);
if (targetState.project.id !== input.projectId) {
throw new Error("Restore state belongs to a different project."); throw new Error("Restore state belongs to a different project.");
} }
@@ -68,15 +72,16 @@ export class ProjectStateRestoreCommandRepository
); );
} }
const targetPayloadJson = serializeProjectStateSnapshot( const targetState = parseProjectStateSnapshot(
input.command.payload.targetState input.command.payload.targetState
); );
const targetPayloadJson = serializeProjectStateSnapshot(targetState);
const inverse = createProjectStateRestoreCommand( const inverse = createProjectStateRestoreCommand(
hashProjectStatePayload(targetPayloadJson), hashProjectStatePayload(targetPayloadJson),
current.state current.state
); );
replaceProjectState(tx, input.command.payload.targetState); replaceProjectState(tx, targetState);
return inverse; return inverse;
} }
@@ -111,6 +116,10 @@ function replaceProjectState(
.update(projects) .update(projects)
.set({ .set({
name: state.project.name, name: state.project.name,
internalProjectNumber: state.project.internalProjectNumber,
externalProjectNumber: state.project.externalProjectNumber,
buildingOwner: state.project.buildingOwner,
description: state.project.description,
singlePhaseVoltageV: state.project.singlePhaseVoltageV, singlePhaseVoltageV: state.project.singlePhaseVoltageV,
threePhaseVoltageV: state.project.threePhaseVoltageV, threePhaseVoltageV: state.project.threePhaseVoltageV,
}) })
@@ -32,6 +32,10 @@ export function readProjectStateSnapshot(
.select({ .select({
id: projects.id, id: projects.id,
name: projects.name, name: projects.name,
internalProjectNumber: projects.internalProjectNumber,
externalProjectNumber: projects.externalProjectNumber,
buildingOwner: projects.buildingOwner,
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV, singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV, threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision, currentRevision: projects.currentRevision,
@@ -172,6 +176,10 @@ export function readProjectStateSnapshot(
project: { project: {
id: project.id, id: project.id,
name: project.name, name: project.name,
internalProjectNumber: project.internalProjectNumber,
externalProjectNumber: project.externalProjectNumber,
buildingOwner: project.buildingOwner,
description: project.description,
singlePhaseVoltageV: project.singlePhaseVoltageV, singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV,
}, },
@@ -20,6 +20,10 @@ export class ProjectRepository {
const project = { const project = {
id, id,
name: input.name, name: input.name,
internalProjectNumber: input.internalProjectNumber ?? null,
externalProjectNumber: input.externalProjectNumber ?? null,
buildingOwner: input.buildingOwner ?? null,
description: input.description ?? null,
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230, singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
threePhaseVoltageV: input.threePhaseVoltageV ?? 400, threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
currentRevision: 0, currentRevision: 0,
+4
View File
@@ -3,6 +3,10 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const projects = sqliteTable("projects", { export const projects = sqliteTable("projects", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
internalProjectNumber: text("internal_project_number"),
externalProjectNumber: text("external_project_number"),
buildingOwner: text("building_owner"),
description: text("description"),
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230), singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400), threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
currentRevision: integer("current_revision").notNull().default(0), currentRevision: integer("current_revision").notNull().default(0),
@@ -2,23 +2,42 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectSettingsUpdateCommandType = export const projectSettingsUpdateCommandType =
"project.update-settings" as const; "project.update-settings" as const;
export const projectSettingsUpdateCommandSchemaVersion = 1 as const; export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
export interface ProjectSettingsValues { export interface LegacyProjectSettingsValues {
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
} }
export interface ProjectSettingsUpdateProjectCommand export interface ProjectSettingsValues extends LegacyProjectSettingsValues {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
}
export interface LegacyProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectSettingsValues> {
schemaVersion: typeof legacyProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export interface CurrentProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<ProjectSettingsValues> { extends SerializedProjectCommand<ProjectSettingsValues> {
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion; schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType; type: typeof projectSettingsUpdateCommandType;
} }
export type ProjectSettingsUpdateProjectCommand =
| LegacyProjectSettingsUpdateProjectCommand
| CurrentProjectSettingsUpdateProjectCommand;
export function createProjectSettingsUpdateProjectCommand( export function createProjectSettingsUpdateProjectCommand(
values: ProjectSettingsValues values: ProjectSettingsValues
): ProjectSettingsUpdateProjectCommand { ): CurrentProjectSettingsUpdateProjectCommand {
const command: ProjectSettingsUpdateProjectCommand = { const command: CurrentProjectSettingsUpdateProjectCommand = {
schemaVersion: projectSettingsUpdateCommandSchemaVersion, schemaVersion: projectSettingsUpdateCommandSchemaVersion,
type: projectSettingsUpdateCommandType, type: projectSettingsUpdateCommandType,
payload: { ...values }, payload: { ...values },
@@ -31,16 +50,45 @@ export function assertProjectSettingsUpdateProjectCommand(
command: SerializedProjectCommand<unknown> command: SerializedProjectCommand<unknown>
): asserts command is ProjectSettingsUpdateProjectCommand { ): asserts command is ProjectSettingsUpdateProjectCommand {
if ( if (
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion || command.type !== projectSettingsUpdateCommandType ||
command.type !== projectSettingsUpdateCommandType (command.schemaVersion !==
legacyProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion)
) { ) {
throw new Error("Unsupported project settings update command."); throw new Error("Unsupported project settings update command.");
} }
if (!isPlainObject(command.payload)) {
throw new Error(
"Project settings update command contains invalid values."
);
}
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
assertLegacyValues(command.payload);
return;
}
if ( if (
!isPlainObject(command.payload) || !isTrimmedString(command.payload.name, 1, 200) ||
!isNullableTrimmedString(command.payload.internalProjectNumber, 100) ||
!isNullableTrimmedString(command.payload.externalProjectNumber, 100) ||
!isNullableTrimmedString(command.payload.buildingOwner, 200) ||
!isNullableTrimmedString(command.payload.description, 2000) ||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) || !isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) || !isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
Object.keys(command.payload).length !== 2 Object.keys(command.payload).length !== 7
) {
throw new Error(
"Project settings update command contains invalid values."
);
}
}
function assertLegacyValues(
payload: Record<string, unknown>
): asserts payload is Record<string, unknown> & LegacyProjectSettingsValues {
if (
!isPositiveFiniteNumber(payload.singlePhaseVoltageV) ||
!isPositiveFiniteNumber(payload.threePhaseVoltageV) ||
Object.keys(payload).length !== 2
) { ) {
throw new Error( throw new Error(
"Project settings update command contains invalid voltages." "Project settings update command contains invalid voltages."
@@ -59,3 +107,26 @@ function isPositiveFiniteNumber(value: unknown): value is number {
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value); return value !== null && typeof value === "object" && !Array.isArray(value);
} }
function isTrimmedString(
value: unknown,
minimumLength: number,
maximumLength: number
): value is string {
return (
typeof value === "string" &&
value === value.trim() &&
value.length >= minimumLength &&
value.length <= maximumLength
);
}
function isNullableTrimmedString(
value: unknown,
maximumLength: number
): value is string | null {
return (
value === null ||
(isTrimmedString(value, 1, maximumLength))
);
}
@@ -1,12 +1,13 @@
import { z } from "zod"; import { z } from "zod";
export const projectStateSnapshotSchemaVersion = 1 as const; export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
export const projectStateSnapshotSchemaVersion = 2 as const;
const idSchema = z.string().trim().min(1); const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable(); const nullableStringSchema = z.string().nullable();
const finiteNumberSchema = z.number().finite(); const finiteNumberSchema = z.number().finite();
const projectSchema = z const legacyProjectSchema = z
.object({ .object({
id: idSchema, id: idSchema,
name: z.string().trim().min(1), name: z.string().trim().min(1),
@@ -15,6 +16,13 @@ const projectSchema = z
}) })
.strict(); .strict();
const projectSchema = legacyProjectSchema.extend({
internalProjectNumber: z.string().trim().min(1).max(100).nullable(),
externalProjectNumber: z.string().trim().min(1).max(100).nullable(),
buildingOwner: z.string().trim().min(1).max(200).nullable(),
description: z.string().trim().min(1).max(2000).nullable(),
});
const distributionBoardSchema = z const distributionBoardSchema = z
.object({ .object({
id: idSchema, id: idSchema,
@@ -132,17 +140,29 @@ const roomSchema = z
}) })
.strict(); .strict();
const projectStateSnapshotContents = {
distributionBoards: z.array(distributionBoardSchema),
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
};
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: legacyProjectSchema,
...projectStateSnapshotContents,
})
.strict();
export const projectStateSnapshotSchema = z export const projectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion), schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardSchema), ...projectStateSnapshotContents,
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
}) })
.strict(); .strict();
@@ -153,11 +173,37 @@ export type ProjectStateSnapshot = z.infer<
export function parseProjectStateSnapshot( export function parseProjectStateSnapshot(
value: unknown value: unknown
): ProjectStateSnapshot { ): ProjectStateSnapshot {
const snapshot = projectStateSnapshotSchema.parse(value); const version = isPlainObject(value) ? value.schemaVersion : undefined;
const snapshot =
version === legacyProjectStateSnapshotSchemaVersion
? upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
)
: projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot); assertProjectStateSnapshotRelations(snapshot);
return snapshot; return snapshot;
} }
function upgradeLegacyProjectStateSnapshot(
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
): ProjectStateSnapshot {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
...snapshot.project,
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
},
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function serializeProjectStateSnapshot( export function serializeProjectStateSnapshot(
snapshot: ProjectStateSnapshot snapshot: ProjectStateSnapshot
): string { ): string {
@@ -0,0 +1,225 @@
"use client";
import { FormEvent, useState } from "react";
import type { ProjectDto } from "../types";
export interface ProjectSettingsInput {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
interface ProjectSettingsModalProps {
isSaving: boolean;
onClose: () => void;
onSave: (input: ProjectSettingsInput) => Promise<void>;
project: ProjectDto;
}
export function ProjectSettingsModal({
isSaving,
onClose,
onSave,
project,
}: ProjectSettingsModalProps) {
const [name, setName] = useState(project.name);
const [internalProjectNumber, setInternalProjectNumber] = useState(
project.internalProjectNumber ?? ""
);
const [externalProjectNumber, setExternalProjectNumber] = useState(
project.externalProjectNumber ?? ""
);
const [buildingOwner, setBuildingOwner] = useState(
project.buildingOwner ?? ""
);
const [description, setDescription] = useState(
project.description ?? ""
);
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState(
String(project.singlePhaseVoltageV)
);
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
String(project.threePhaseVoltageV)
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({
name: name.trim(),
internalProjectNumber: toNullableString(internalProjectNumber),
externalProjectNumber: toNullableString(externalProjectNumber),
buildingOwner: toNullableString(buildingOwner),
description: toNullableString(description),
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
});
}
const isValid =
name.trim().length > 0 &&
Number(singlePhaseVoltageV) > 0 &&
Number(threePhaseVoltageV) > 0;
return (
<>
<div
aria-labelledby="project-settings-title"
aria-modal="true"
className="modal fade show d-block"
role="dialog"
tabIndex={-1}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<form className="modal-content" onSubmit={handleSubmit}>
<div className="modal-header">
<div>
<h2 className="modal-title fs-5" id="project-settings-title">
Projekteinstellungen
</h2>
<p className="text-secondary small mb-0">
Stammdaten und elektrische Standardwerte des Projekts
</p>
</div>
<button
aria-label="Schließen"
className="btn-close"
disabled={isSaving}
onClick={onClose}
type="button"
/>
</div>
<div className="modal-body">
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="project-name">
Projektname
</label>
<input
autoFocus
className="form-control"
id="project-name"
maxLength={200}
onChange={(event) => setName(event.target.value)}
required
value={name}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="internal-project-number">
Projektnummer intern
</label>
<input
className="form-control"
id="internal-project-number"
maxLength={100}
onChange={(event) =>
setInternalProjectNumber(event.target.value)
}
value={internalProjectNumber}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="external-project-number">
Projektnummer extern
</label>
<input
className="form-control"
id="external-project-number"
maxLength={100}
onChange={(event) =>
setExternalProjectNumber(event.target.value)
}
value={externalProjectNumber}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="building-owner">
Bauherr
</label>
<input
className="form-control"
id="building-owner"
maxLength={200}
onChange={(event) => setBuildingOwner(event.target.value)}
value={buildingOwner}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="project-description">
Beschreibung
</label>
<textarea
className="form-control"
id="project-description"
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
rows={4}
value={description}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="single-phase-voltage">
Standardspannung 1-phasig [V]
</label>
<input
className="form-control"
id="single-phase-voltage"
min="1"
onChange={(event) =>
setSinglePhaseVoltageV(event.target.value)
}
required
type="number"
value={singlePhaseVoltageV}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="three-phase-voltage">
Standardspannung 3-phasig [V]
</label>
<input
className="form-control"
id="three-phase-voltage"
min="1"
onChange={(event) =>
setThreePhaseVoltageV(event.target.value)
}
required
type="number"
value={threePhaseVoltageV}
/>
</div>
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-outline-secondary"
disabled={isSaving}
onClick={onClose}
type="button"
>
Abbrechen
</button>
<button
className="btn btn-primary"
disabled={isSaving || !isValid}
type="submit"
>
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
);
}
function toNullableString(value: string) {
return value.trim() || null;
}
+4
View File
@@ -1,6 +1,10 @@
export interface ProjectDto { export interface ProjectDto {
id: string; id: string;
name: string; name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
currentRevision: number; currentRevision: number;
+9 -1
View File
@@ -210,7 +210,15 @@ export function createProject(name: string) {
export function updateProjectSettings( export function updateProjectSettings(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number } input: {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
) { ) {
return request<ProjectSettingsCommandResultDto>( return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`, `/api/projects/${projectId}`,
@@ -2,7 +2,11 @@ import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; 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().trim().min(1).max(200),
internalProjectNumber: z.string().trim().max(100).optional(),
externalProjectNumber: z.string().trim().max(100).optional(),
buildingOwner: z.string().trim().max(200).optional(),
description: z.string().trim().max(2000).optional(),
singlePhaseVoltageV: z.number().positive().optional(), singlePhaseVoltageV: z.number().positive().optional(),
threePhaseVoltageV: z.number().positive().optional(), threePhaseVoltageV: z.number().positive().optional(),
}); });
@@ -10,6 +14,11 @@ export const createProjectSchema = z.object({
export const updateProjectSettingsSchema = z export const updateProjectSettingsSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
internalProjectNumber: z.string().trim().max(100).nullable(),
externalProjectNumber: z.string().trim().max(100).nullable(),
buildingOwner: z.string().trim().max(200).nullable(),
description: z.string().trim().max(2000).nullable(),
singlePhaseVoltageV: z.number().positive(), singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(), threePhaseVoltageV: z.number().positive(),
}) })
+5
View File
@@ -951,6 +951,11 @@ describe("project command service", () => {
projectId: "project-1", projectId: "project-1",
expectedRevision: 0, expectedRevision: 0,
command: createProjectSettingsUpdateProjectCommand({ command: createProjectSettingsUpdateProjectCommand({
name: "Test project",
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
}), }),
@@ -25,6 +25,7 @@ function createTestDatabase(): DatabaseContext {
.values({ .values({
id: "project-1", id: "project-1",
name: "Testprojekt", name: "Testprojekt",
internalProjectNumber: "INT-1",
singlePhaseVoltageV: 230, singlePhaseVoltageV: 230,
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
}) })
@@ -35,6 +36,11 @@ function createTestDatabase(): DatabaseContext {
function getSettings(context: DatabaseContext) { function getSettings(context: DatabaseContext) {
return context.db return context.db
.select({ .select({
name: projects.name,
internalProjectNumber: projects.internalProjectNumber,
externalProjectNumber: projects.externalProjectNumber,
buildingOwner: projects.buildingOwner,
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV, singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV, threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision, currentRevision: projects.currentRevision,
@@ -44,15 +50,32 @@ function getSettings(context: DatabaseContext) {
.get(); .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", () => { describe("project settings project-command repository", () => {
it("validates settings commands and sends the expected revision from the frontend", async () => { it("validates settings commands and sends the expected revision from the frontend", async () => {
assert.throws( assert.throws(
() => () =>
createProjectSettingsUpdateProjectCommand({ createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 0, singlePhaseVoltageV: 0,
threePhaseVoltageV: 400,
}), }),
/invalid voltages/ /invalid values/
); );
const requests: Array<{ url: string; body: unknown }> = []; const requests: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -68,6 +91,7 @@ describe("project settings project-command repository", () => {
}; };
try { try {
await updateProjectSettings("project-1", 7, { await updateProjectSettings("project-1", 7, {
...settings(),
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
}); });
@@ -79,6 +103,11 @@ describe("project settings project-command repository", () => {
url: "/api/projects/project-1", url: "/api/projects/project-1",
body: { body: {
expectedRevision: 7, expectedRevision: 7,
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
}, },
@@ -94,6 +123,9 @@ describe("project settings project-command repository", () => {
); );
const history = new ProjectHistoryRepository(context.db); const history = new ProjectHistoryRepository(context.db);
const command = createProjectSettingsUpdateProjectCommand({ const command = createProjectSettingsUpdateProjectCommand({
...settings(),
name: "Neuer Projektname",
buildingOwner: "Beispiel Bau GmbH",
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
}); });
@@ -104,11 +136,21 @@ describe("project settings project-command repository", () => {
command, command,
}); });
assert.deepEqual(getSettings(context), { assert.deepEqual(getSettings(context), {
name: "Neuer Projektname",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: "Beispiel Bau GmbH",
description: null,
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
currentRevision: 1, currentRevision: 1,
}); });
assert.deepEqual(forward.inverse.payload, { assert.deepEqual(forward.inverse.payload, {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230, singlePhaseVoltageV: 230,
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
}); });
@@ -123,6 +165,11 @@ describe("project settings project-command repository", () => {
command: forward.inverse, command: forward.inverse,
}); });
assert.deepEqual(getSettings(context), { assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230, singlePhaseVoltageV: 230,
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
currentRevision: 2, currentRevision: 2,
@@ -138,6 +185,11 @@ describe("project settings project-command repository", () => {
command, command,
}); });
assert.deepEqual(getSettings(context), { assert.deepEqual(getSettings(context), {
name: "Neuer Projektname",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: "Beispiel Bau GmbH",
description: null,
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
currentRevision: 3, 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", () => { it("rejects no-op, invalid project and stale revision without partial writes", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -160,8 +243,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0, expectedRevision: 0,
source: "user", source: "user",
command: createProjectSettingsUpdateProjectCommand({ command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 230, ...settings(),
threePhaseVoltageV: 400,
}), }),
}), }),
/did not change/ /did not change/
@@ -173,6 +255,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0, expectedRevision: 0,
source: "user", source: "user",
command: createProjectSettingsUpdateProjectCommand({ command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
}), }),
@@ -186,6 +269,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 1, expectedRevision: 1,
source: "user", source: "user",
command: createProjectSettingsUpdateProjectCommand({ command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
}), }),
@@ -193,6 +277,11 @@ describe("project settings project-command repository", () => {
ProjectRevisionConflictError ProjectRevisionConflictError
); );
assert.deepEqual(getSettings(context), { assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230, singlePhaseVoltageV: 230,
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
currentRevision: 0, currentRevision: 0,
@@ -226,6 +315,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0, expectedRevision: 0,
source: "user", source: "user",
command: createProjectSettingsUpdateProjectCommand({ command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240, singlePhaseVoltageV: 240,
threePhaseVoltageV: 415, threePhaseVoltageV: 415,
}), }),
@@ -233,6 +323,11 @@ describe("project settings project-command repository", () => {
/forced project settings history failure/ /forced project settings history failure/
); );
assert.deepEqual(getSettings(context), { assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230, singlePhaseVoltageV: 230,
threePhaseVoltageV: 400, threePhaseVoltageV: 400,
currentRevision: 0, 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 { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js"; import { rooms } from "../src/db/schema/rooms.js";
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.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 { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-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.ok(created);
assert.equal(created.sourceRevision, 0); assert.equal(created.sourceRevision, 0);
assert.equal(created.schemaVersion, 1); assert.equal(created.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(created.name, "Vor Ausschreibung"); assert.equal(created.name, "Vor Ausschreibung");
assert.equal(created.description, "Sicherer Stand"); assert.equal(created.description, "Sicherer Stand");
assert.equal(created.createdByActorId, "planner-1"); assert.equal(created.createdByActorId, "planner-1");
@@ -262,6 +263,42 @@ describe("project snapshot repository", () => {
prepared.command.payload.targetState.project.id, prepared.command.payload.targetState.project.id,
"project-1" "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 context.db
.update(projectSnapshots) .update(projectSnapshots)
.set({ payloadJson: `${JSON.stringify({})}\n` }) .set({ payloadJson: `${JSON.stringify({})}\n` })
+28 -1
View File
@@ -3,16 +3,21 @@ import { describe, it } from "node:test";
import { import {
deserializeProjectStateSnapshot, deserializeProjectStateSnapshot,
parseProjectStateSnapshot, parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot, serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js"; } from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
function minimalSnapshot() { function minimalSnapshot() {
return { return {
schemaVersion: 1 as const, schemaVersion: projectStateSnapshotSchemaVersion,
project: { project: {
id: "project-1", id: "project-1",
name: "Projekt", name: "Projekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230, singlePhaseVoltageV: 230,
threePhaseVoltageV: 400, 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", () => { it("rejects duplicate ids and cross-project ownership", () => {
assert.throws( assert.throws(
() => () =>