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
@@ -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()) {