Describe snapshot source revisions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface ProjectSnapshotMetadataDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
sourceRevision: number;
|
||||
sourceRevisionMetadata: ProjectRevisionSummaryDto | null;
|
||||
schemaVersion: number;
|
||||
kind: "named" | "automatic";
|
||||
name: string;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user