Expose project revision timeline

This commit is contained in:
2026-07-25 21:57:03 +02:00
parent 4b47bc2bcc
commit 5a0c9019af
14 changed files with 365 additions and 8 deletions
@@ -1,11 +1,14 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, lt } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ListProjectRevisionsInput,
ProjectRevisionPage,
ProjectHistoryState,
ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js";
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
@@ -47,6 +50,74 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
};
}
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null {
validateRevisionPageInput(input);
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
return null;
}
const projectCondition = eq(
projectRevisions.projectId,
input.projectId
);
const rows = 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(
input.beforeRevision === undefined
? projectCondition
: and(
projectCondition,
lt(
projectRevisions.revisionNumber,
input.beforeRevision
)
)
)
.orderBy(desc(projectRevisions.revisionNumber))
.limit(input.limit + 1)
.all();
const hasMore = rows.length > input.limit;
const revisions = rows.slice(0, input.limit).map((row) => ({
...row,
source: row.source as ProjectRevisionSource,
}));
return {
projectId: input.projectId,
currentRevision: project.currentRevision,
revisions,
nextBeforeRevision:
hasMore && revisions.length > 0
? revisions.at(-1)!.revisionNumber
: null,
};
}
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -93,3 +164,22 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
};
}
}
function validateRevisionPageInput(input: ListProjectRevisionsInput) {
if (
!Number.isSafeInteger(input.limit) ||
input.limit < 1 ||
input.limit > 100
) {
throw new Error("Project revision page limit must be between 1 and 100.");
}
if (
input.beforeRevision !== undefined &&
(!Number.isSafeInteger(input.beforeRevision) ||
input.beforeRevision < 1)
) {
throw new Error(
"Project revision cursor must be a positive integer."
);
}
}
+29
View File
@@ -1,4 +1,5 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
import type { ProjectRevisionSource } from "./project-revision.store.js";
export type ProjectHistoryDirection = "undo" | "redo";
@@ -16,8 +17,36 @@ export interface ProjectHistoryCommand {
command: SerializedProjectCommand;
}
export interface ProjectRevisionSummary {
revisionId: string;
changeSetId: string;
revisionNumber: number;
createdAtIso: string;
actorId: string | null;
source: ProjectRevisionSource;
description: string | null;
commandType: string;
payloadSchemaVersion: number;
}
export interface ProjectRevisionPage {
projectId: string;
currentRevision: number;
revisions: ProjectRevisionSummary[];
nextBeforeRevision: number | null;
}
export interface ListProjectRevisionsInput {
projectId: string;
limit: number;
beforeRevision?: number;
}
export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null;
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null;
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -1,4 +1,5 @@
import type { Request, Response } from "express";
import { listProjectRevisionsQuerySchema } from "../../shared/validation/project-history.schemas.js";
import { projectHistoryStore } from "../composition/project-command-stores.js";
export function getProjectHistory(req: Request, res: Response) {
@@ -13,3 +14,23 @@ export function getProjectHistory(req: Request, res: Response) {
}
return res.json(state);
}
export function listProjectRevisions(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = listProjectRevisionsQuerySchema.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const page = projectHistoryStore.listRevisions({
projectId,
...parsed.data,
});
if (!page) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(page);
}
+5 -1
View File
@@ -13,7 +13,10 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getProjectHistory } from "../controllers/project-history.controller.js";
import {
getProjectHistory,
listProjectRevisions,
} from "../controllers/project-history.controller.js";
import {
executeProjectCommand,
redoProjectCommand,
@@ -26,6 +29,7 @@ projectRouter.get("/", listProjects);
projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
@@ -0,0 +1,8 @@
import { z } from "zod";
export const listProjectRevisionsQuerySchema = z
.object({
limit: z.coerce.number().int().min(1).max(100).default(25),
beforeRevision: z.coerce.number().int().positive().optional(),
})
.strict();