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. nicht Bestandteil des logischen Snapshots sind.
Die Projektseite bindet diese APIs in einem einklappbaren Bereich Die Projektseite bindet diese APIs in einem einklappbaren Bereich
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte „Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte benennen, jeden aufgeführten benannten oder automatischen Stand nach
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen. 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 Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt 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 ## 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. 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. 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. undo/redo.
## Distribution Boards ## Distribution Boards
- Assign an optional project floor to a distribution board. - [x] Assign an optional project floor to a distribution board.
- Store a supply classification for each distribution board: - [x] Store a supply classification for each distribution board:
- `AV` (Allgemeine Stromversorgung) - `AV` (Allgemeine Stromversorgung)
- `SV` (Sicherheitsstromversorgung) - `SV` (Sicherheitsstromversorgung)
- `EV` (Ersatzstromversorgung) - `EV` (Ersatzstromversorgung)
- `USV` (Unterbrechungsfreie Stromversorgung) - `USV` (Unterbrechungsfreie Stromversorgung)
- `MSR` (Mess-, Steuerungs- und Regelungstechnik) - `MSR` (Mess-, Steuerungs- und Regelungstechnik)
- `SiBe` (Sicherheitsbeleuchtung) - `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 project. Distribution-board dialogs offer only that selection; a type in
use cannot be disabled. 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. 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. restart.
## Device Voltage Derivation ## 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; - single-phase uses the project's single-phase voltage;
- three-phase uses the project's three-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 Global devices store only their phase; copying one into a project derives
the target project's voltage. 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 section phase; an unassigned circuit is three-phase only when all assigned
device rows with a valid phase are three-phase. 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. 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 stored values. Version-four and older imports remain supported and are
normalized while being upgraded. normalized while being upgraded.
## Recommended Sequence ## Recommended Sequence
1. Distribution-board floor and supply fields. 1. [x] Distribution-board floor and supply fields.
2. Device voltage derivation without overrides. 2. [x] Device voltage derivation without overrides.
3. Snapshot-to-revision descriptions and history presentation. 3. [x] Snapshot-to-revision descriptions and history presentation.
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
must not be inferred from these tasks. 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 { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type { import type {
ProjectHistoryCommand, ProjectHistoryCommand,
ProjectHistoryDirection, ProjectHistoryDirection,
ListProjectRevisionsInput, ListProjectRevisionsInput,
ProjectRevisionPage, ProjectRevisionPage,
ProjectRevisionSummary,
ProjectHistoryState, ProjectHistoryState,
ProjectHistoryStore, ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js"; } 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( getNextCommand(
projectId: string, projectId: string,
direction: ProjectHistoryDirection direction: ProjectHistoryDirection
@@ -47,6 +47,10 @@ export interface ProjectHistoryStore {
listRevisions( listRevisions(
input: ListProjectRevisionsInput input: ListProjectRevisionsInput
): ProjectRevisionPage | null; ): ProjectRevisionPage | null;
listRevisionsByNumbers(
projectId: string,
revisionNumbers: number[]
): ProjectRevisionSummary[];
getNextCommand( getNextCommand(
projectId: string, projectId: string,
direction: ProjectHistoryDirection direction: ProjectHistoryDirection
@@ -25,6 +25,7 @@ import {
getProjectRevisionDescription, getProjectRevisionDescription,
getProjectRevisionSourceLabel, getProjectRevisionSourceLabel,
getProjectSnapshotKindLabel, getProjectSnapshotKindLabel,
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages, mergeProjectRevisionPages,
} from "../utils/project-version-history"; } from "../utils/project-version-history";
@@ -343,7 +344,7 @@ export function ProjectVersionHistory({
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Projektstand</th> <th>Gesicherter Projektstand</th>
<th>Erstellt</th> <th>Erstellt</th>
<th className="text-end">Aktion</th> <th className="text-end">Aktion</th>
</tr> </tr>
@@ -368,7 +369,16 @@ export function ProjectVersionHistory({
</div> </div>
) : null} ) : null}
</td> </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>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end"> <td className="text-end">
<button <button
+1
View File
@@ -52,6 +52,7 @@ export interface ProjectSnapshotMetadataDto {
id: string; id: string;
projectId: string; projectId: string;
sourceRevision: number; sourceRevision: number;
sourceRevisionMetadata: ProjectRevisionSummaryDto | null;
schemaVersion: number; schemaVersion: number;
kind: "named" | "automatic"; kind: "named" | "automatic";
name: string; name: string;
+28 -1
View File
@@ -49,8 +49,15 @@ export function getProjectRevisionSourceLabel(
export function getProjectRevisionDescription( export function getProjectRevisionDescription(
revision: ProjectRevisionSummaryDto revision: ProjectRevisionSummaryDto
) { ) {
const description = revision.description?.trim();
if (
description === `Undo ${revision.commandType}` ||
description === `Redo ${revision.commandType}`
) {
return commandTypeLabels[revision.commandType] || revision.commandType;
}
return ( return (
revision.description?.trim() || description ||
commandTypeLabels[revision.commandType] || commandTypeLabels[revision.commandType] ||
revision.commandType revision.commandType
); );
@@ -62,6 +69,26 @@ export function getProjectSnapshotKindLabel(
return kind === "automatic" ? "Automatisch" : "Benannt"; 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( export function mergeProjectRevisionPages(
current: ProjectRevisionSummaryDto[], current: ProjectRevisionSummaryDto[],
next: ProjectRevisionSummaryDto[] next: ProjectRevisionSummaryDto[]
@@ -6,7 +6,10 @@ import {
createNamedProjectSnapshotSchema, createNamedProjectSnapshotSchema,
restoreProjectSnapshotSchema, restoreProjectSnapshotSchema,
} from "../../shared/validation/project-snapshot.schemas.js"; } 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"; import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
export function listProjectSnapshots(req: Request, res: Response) { export function listProjectSnapshots(req: Request, res: Response) {
@@ -18,7 +21,7 @@ export function listProjectSnapshots(req: Request, res: Response) {
if (!snapshots) { if (!snapshots) {
return res.status(404).json({ error: "Project not found" }); return res.status(404).json({ error: "Project not found" });
} }
return res.json(snapshots); return res.json(withSourceRevisionMetadata(projectId, snapshots));
} }
export function createNamedProjectSnapshot( export function createNamedProjectSnapshot(
@@ -43,7 +46,9 @@ export function createNamedProjectSnapshot(
if (!snapshot) { if (!snapshot) {
return res.status(404).json({ error: "Project not found" }); 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) { } catch (error) {
if (error instanceof ProjectRevisionConflictError) { if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({ return res.status(409).json({
@@ -97,7 +102,9 @@ export function restoreProjectSnapshot(
command: prepared.command, command: prepared.command,
}); });
return res.json({ return res.json({
snapshot: prepared.snapshot, snapshot: withSourceRevisionMetadata(projectId, [
prepared.snapshot,
])[0],
revision: result.revision, revision: result.revision,
history: result.history, 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) { function getProjectId(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) { if (typeof projectId !== "string" || !projectId.trim()) {
+27
View File
@@ -177,6 +177,33 @@ describe("project history repository", () => {
nextBeforeRevision: null, 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 { } finally {
context.close(); 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 { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js"; import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import { ProjectSettingsModal } from "../src/frontend/components/project-settings-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 { import {
createNamedProjectSnapshot, createNamedProjectSnapshot,
getProjectHistory, getProjectHistory,
@@ -19,6 +22,7 @@ import {
getProjectRevisionDescription, getProjectRevisionDescription,
getProjectRevisionSourceLabel, getProjectRevisionSourceLabel,
getProjectSnapshotKindLabel, getProjectSnapshotKindLabel,
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages, mergeProjectRevisionPages,
} from "../src/frontend/utils/project-version-history.js"; } 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", () => { describe("project version history presentation", () => {
it("renders a compact collapsed German entry point", () => { it("renders a compact collapsed German entry point", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
@@ -82,6 +105,16 @@ describe("project version history presentation", () => {
), ),
"future.command" "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("named"), "Benannt");
assert.equal( assert.equal(
getProjectSnapshotKindLabel("automatic"), getProjectSnapshotKindLabel("automatic"),
@@ -98,6 +131,28 @@ describe("project version history presentation", () => {
[5, 4, 3] [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", () => { describe("project settings presentation", () => {