Expose project revision timeline
This commit is contained in:
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user