Describe snapshot source revisions

This commit is contained in:
2026-07-29 11:52:26 +02:00
parent 602ae6da0b
commit dcb303284c
10 changed files with 246 additions and 27 deletions
+8 -2
View File
@@ -149,8 +149,14 @@ Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
nicht Bestandteil des logischen Snapshots sind.
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
benennen, jeden aufgeführten benannten oder automatischen Stand nach
expliziter Bestätigung wiederherstellen und die paginierte Revisions-Timeline
mit deutschen Quellen- und Änderungsbezeichnungen lesen. Die Snapshot-Liste
ordnet jede positive `sourceRevision` serverseitig ihren unveränderlichen
Revisionsmetadaten zu. Dadurch zeigt auch ein älterer automatischer Stand die
auslösende Änderung, ohne eine zweite frei formulierte Beschreibung zu
speichern oder von den zuletzt paginiert geladenen Timeline-Einträgen
abzuhängen. Revision null wird als Projektstart dargestellt.
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
+16 -16
View File
@@ -10,53 +10,53 @@ requirements and intended sequencing, not proof of implementation.
## Project History
- Every listed logical snapshot, including automatic snapshots, must be
- [x] Every listed logical snapshot, including automatic snapshots, must be
explicitly restorable from the project history UI.
- Show which project change produced a snapshot. Prefer immutable revision
- [x] Show which project change produced a snapshot. Prefer immutable revision
metadata over storing a second free-form description of the same change.
- Keep restoration auditable as a new project revision and preserve persistent
- [x] Keep restoration auditable as a new project revision and preserve persistent
undo/redo.
## Distribution Boards
- Assign an optional project floor to a distribution board.
- Store a supply classification for each distribution board:
- [x] Assign an optional project floor to a distribution board.
- [x] Store a supply classification for each distribution board:
- `AV` (Allgemeine Stromversorgung)
- `SV` (Sicherheitsstromversorgung)
- `EV` (Ersatzstromversorgung)
- `USV` (Unterbrechungsfreie Stromversorgung)
- `MSR` (Mess-, Steuerungs- und Regelungstechnik)
- `SiBe` (Sicherheitsbeleuchtung)
- Let project settings select which catalog supply types are used in a
- [x] Let project settings select which catalog supply types are used in a
project. Distribution-board dialogs offer only that selection; a type in
use cannot be disabled.
- Include both fields in project snapshots and portable project
- [x] Include both fields in project snapshots and portable project
export/import.
- Persist changes through project commands so they remain undoable after a
- [x] Persist changes through project commands so they remain undoable after a
restart.
## Device Voltage Derivation
- Derive voltage from the selected phase type and the project settings:
- [x] Derive voltage from the selected phase type and the project settings:
- single-phase uses the project's single-phase voltage;
- three-phase uses the project's three-phase voltage.
- Voltage is not editable on global devices, project devices or circuits.
- [x] Voltage is not editable on global devices, project devices or circuits.
Global devices store only their phase; copying one into a project derives
the target project's voltage.
- Project-device voltage follows its phase. Circuit voltage follows the
- [x] Project-device voltage follows its phase. Circuit voltage follows the
section phase; an unassigned circuit is three-phase only when all assigned
device rows with a valid phase are three-phase.
- Changing project voltage settings updates all project devices and circuits
- [x] Changing project voltage settings updates all project devices and circuits
atomically in the same persistent Undo/Redo step.
- Database migration `0019` and snapshot schema version `5` normalize older
- [x] Database migration `0019` and snapshot schema version `5` normalize older
stored values. Version-four and older imports remain supported and are
normalized while being upgraded.
## Recommended Sequence
1. Distribution-board floor and supply fields.
2. Device voltage derivation without overrides.
3. Snapshot-to-revision descriptions and history presentation.
1. [x] Distribution-board floor and supply fields.
2. [x] Device voltage derivation without overrides.
3. [x] Snapshot-to-revision descriptions and history presentation.
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
must not be inferred from these tasks.
@@ -1,10 +1,11 @@
import { and, asc, desc, eq, lt } from "drizzle-orm";
import { and, asc, desc, eq, inArray, lt } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ListProjectRevisionsInput,
ProjectRevisionPage,
ProjectRevisionSummary,
ProjectHistoryState,
ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js";
@@ -118,6 +119,64 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
};
}
listRevisionsByNumbers(
projectId: string,
revisionNumbers: number[]
): ProjectRevisionSummary[] {
for (const revisionNumber of revisionNumbers) {
if (
!Number.isSafeInteger(revisionNumber) ||
revisionNumber < 0
) {
throw new Error(
"Project revision numbers must be non-negative integers."
);
}
}
const normalizedRevisionNumbers = [
...new Set(revisionNumbers),
].filter((revisionNumber) => revisionNumber > 0);
if (!normalizedRevisionNumbers.length) {
return [];
}
return this.database
.select({
revisionId: projectRevisions.id,
changeSetId: projectChangeSets.id,
revisionNumber: projectRevisions.revisionNumber,
createdAtIso: projectRevisions.createdAtIso,
actorId: projectRevisions.actorId,
source: projectRevisions.source,
description: projectRevisions.description,
commandType: projectChangeSets.commandType,
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
})
.from(projectRevisions)
.innerJoin(
projectChangeSets,
eq(
projectChangeSets.projectRevisionId,
projectRevisions.id
)
)
.where(
and(
eq(projectRevisions.projectId, projectId),
inArray(
projectRevisions.revisionNumber,
normalizedRevisionNumbers
)
)
)
.orderBy(desc(projectRevisions.revisionNumber))
.all()
.map((row) => ({
...row,
source: row.source as ProjectRevisionSource,
}));
}
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -47,6 +47,10 @@ export interface ProjectHistoryStore {
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null;
listRevisionsByNumbers(
projectId: string,
revisionNumbers: number[]
): ProjectRevisionSummary[];
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -25,6 +25,7 @@ import {
getProjectRevisionDescription,
getProjectRevisionSourceLabel,
getProjectSnapshotKindLabel,
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages,
} from "../utils/project-version-history";
@@ -343,7 +344,7 @@ export function ProjectVersionHistory({
<thead>
<tr>
<th>Name</th>
<th>Projektstand</th>
<th>Gesicherter Projektstand</th>
<th>Erstellt</th>
<th className="text-end">Aktion</th>
</tr>
@@ -368,7 +369,16 @@ export function ProjectVersionHistory({
</div>
) : null}
</td>
<td>Revision {snapshot.sourceRevision}</td>
<td>
<strong>
{snapshot.sourceRevision === 0
? "Ausgangsstand"
: `Revision ${snapshot.sourceRevision}`}
</strong>
<div className="small text-secondary">
{getProjectSnapshotRevisionDescription(snapshot)}
</div>
</td>
<td>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end">
<button
+1
View File
@@ -52,6 +52,7 @@ export interface ProjectSnapshotMetadataDto {
id: string;
projectId: string;
sourceRevision: number;
sourceRevisionMetadata: ProjectRevisionSummaryDto | null;
schemaVersion: number;
kind: "named" | "automatic";
name: string;
+28 -1
View File
@@ -49,8 +49,15 @@ export function getProjectRevisionSourceLabel(
export function getProjectRevisionDescription(
revision: ProjectRevisionSummaryDto
) {
const description = revision.description?.trim();
if (
description === `Undo ${revision.commandType}` ||
description === `Redo ${revision.commandType}`
) {
return commandTypeLabels[revision.commandType] || revision.commandType;
}
return (
revision.description?.trim() ||
description ||
commandTypeLabels[revision.commandType] ||
revision.commandType
);
@@ -62,6 +69,26 @@ export function getProjectSnapshotKindLabel(
return kind === "automatic" ? "Automatisch" : "Benannt";
}
export function getProjectSnapshotRevisionDescription(
snapshot: ProjectSnapshotMetadataDto
) {
if (snapshot.sourceRevision === 0) {
return "Projektstart";
}
if (
!snapshot.sourceRevisionMetadata ||
snapshot.sourceRevisionMetadata.revisionNumber !==
snapshot.sourceRevision
) {
return "Änderungsdetails nicht verfügbar";
}
return `${getProjectRevisionSourceLabel(
snapshot.sourceRevisionMetadata.source
)}: ${getProjectRevisionDescription(
snapshot.sourceRevisionMetadata
)}`;
}
export function mergeProjectRevisionPages(
current: ProjectRevisionSummaryDto[],
next: ProjectRevisionSummaryDto[]
@@ -6,7 +6,10 @@ import {
createNamedProjectSnapshotSchema,
restoreProjectSnapshotSchema,
} from "../../shared/validation/project-snapshot.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import {
projectCommandService,
projectHistoryStore,
} from "../composition/project-command-stores.js";
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
export function listProjectSnapshots(req: Request, res: Response) {
@@ -18,7 +21,7 @@ export function listProjectSnapshots(req: Request, res: Response) {
if (!snapshots) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(snapshots);
return res.json(withSourceRevisionMetadata(projectId, snapshots));
}
export function createNamedProjectSnapshot(
@@ -43,7 +46,9 @@ export function createNamedProjectSnapshot(
if (!snapshot) {
return res.status(404).json({ error: "Project not found" });
}
return res.status(201).json(snapshot);
return res
.status(201)
.json(withSourceRevisionMetadata(projectId, [snapshot])[0]);
} catch (error) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
@@ -97,7 +102,9 @@ export function restoreProjectSnapshot(
command: prepared.command,
});
return res.json({
snapshot: prepared.snapshot,
snapshot: withSourceRevisionMetadata(projectId, [
prepared.snapshot,
])[0],
revision: result.revision,
history: result.history,
});
@@ -120,6 +127,29 @@ export function restoreProjectSnapshot(
}
}
function withSourceRevisionMetadata<
TSnapshot extends { sourceRevision: number },
>(
projectId: string,
snapshots: TSnapshot[]
) {
const revisions = projectHistoryStore.listRevisionsByNumbers(
projectId,
snapshots.map((snapshot) => snapshot.sourceRevision)
);
const revisionByNumber = new Map(
revisions.map((revision) => [
revision.revisionNumber,
revision,
])
);
return snapshots.map((snapshot) => ({
...snapshot,
sourceRevisionMetadata:
revisionByNumber.get(snapshot.sourceRevision) ?? null,
}));
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
+27
View File
@@ -177,6 +177,33 @@ describe("project history repository", () => {
nextBeforeRevision: null,
}
);
assert.deepEqual(
history
.listRevisionsByNumbers("project-1", [3, 1, 3, 99])
.map((revision) => ({
revisionNumber: revision.revisionNumber,
description: revision.description,
})),
[
{
revisionNumber: 3,
description: "Anzahl zurücknehmen",
},
{
revisionNumber: 1,
description: "Stromkreis umbenennen",
},
]
);
assert.deepEqual(
history.listRevisionsByNumbers("project-1", [0]),
[]
);
assert.throws(
() =>
history.listRevisionsByNumbers("project-1", [-1]),
/non-negative integers/
);
} finally {
context.close();
}
+56 -1
View File
@@ -5,7 +5,10 @@ import { renderToStaticMarkup } from "react-dom/server";
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import { ProjectSettingsModal } from "../src/frontend/components/project-settings-modal.js";
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
import type {
ProjectRevisionSummaryDto,
ProjectSnapshotMetadataDto,
} from "../src/frontend/types.js";
import {
createNamedProjectSnapshot,
getProjectHistory,
@@ -19,6 +22,7 @@ import {
getProjectRevisionDescription,
getProjectRevisionSourceLabel,
getProjectSnapshotKindLabel,
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages,
} from "../src/frontend/utils/project-version-history.js";
@@ -40,6 +44,25 @@ function revision(
};
}
function snapshot(
sourceRevision: number,
sourceRevisionMetadata: ProjectRevisionSummaryDto | null
): ProjectSnapshotMetadataDto {
return {
id: `snapshot-${sourceRevision}`,
projectId: "project-1",
sourceRevision,
sourceRevisionMetadata,
schemaVersion: 5,
kind: "automatic",
name: `Revision ${sourceRevision}`,
description: null,
payloadSha256: "checksum",
createdAtIso: "2026-07-25T12:00:00.000Z",
createdByActorId: null,
};
}
describe("project version history presentation", () => {
it("renders a compact collapsed German entry point", () => {
const markup = renderToStaticMarkup(
@@ -82,6 +105,16 @@ describe("project version history presentation", () => {
),
"future.command"
);
assert.equal(
getProjectRevisionDescription(
revision(4, {
source: "undo",
description: "Undo project.update-settings",
commandType: "project.update-settings",
})
),
"Projekteinstellungen bearbeitet"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),
@@ -98,6 +131,28 @@ describe("project version history presentation", () => {
[5, 4, 3]
);
});
it("describes the immutable source revision of a snapshot", () => {
assert.equal(
getProjectSnapshotRevisionDescription(
snapshot(
25,
revision(25, {
description: "Verteilung UV-01 angelegt",
})
)
),
"Bearbeitung: Verteilung UV-01 angelegt"
);
assert.equal(
getProjectSnapshotRevisionDescription(snapshot(0, null)),
"Projektstart"
);
assert.equal(
getProjectSnapshotRevisionDescription(snapshot(25, null)),
"Änderungsdetails nicht verfügbar"
);
});
});
describe("project settings presentation", () => {