Add project command API

This commit is contained in:
2026-07-23 21:56:13 +02:00
parent 1e4dd26bb8
commit e4c7cf06e9
19 changed files with 781 additions and 21 deletions
@@ -1,10 +1,15 @@
import { asc, eq } from "drizzle-orm";
import { and, asc, desc, eq } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ProjectHistoryState,
ProjectHistoryStore,
} from "../../domain/ports/project-history.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";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js";
export class ProjectHistoryRepository implements ProjectHistoryStore {
@@ -41,4 +46,50 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null,
};
}
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
): ProjectHistoryCommand | null {
const entry = this.database
.select({
changeSetId: projectHistoryStackEntries.changeSetId,
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
inversePayloadJson: projectChangeSets.inversePayloadJson,
})
.from(projectHistoryStackEntries)
.innerJoin(
projectChangeSets,
eq(
projectChangeSets.id,
projectHistoryStackEntries.changeSetId
)
)
.innerJoin(
projectRevisions,
eq(projectRevisions.id, projectChangeSets.projectRevisionId)
)
.where(
and(
eq(projectHistoryStackEntries.projectId, projectId),
eq(projectHistoryStackEntries.stack, direction),
eq(projectRevisions.projectId, projectId)
)
)
.orderBy(desc(projectHistoryStackEntries.position))
.limit(1)
.get();
if (!entry) {
return null;
}
return {
changeSetId: entry.changeSetId,
command: deserializeProjectCommand(
direction === "undo"
? entry.inversePayloadJson
: entry.forwardPayloadJson
),
};
}
}
@@ -0,0 +1,13 @@
import type { ProjectHistoryDirection } from "../ports/project-history.store.js";
export class ProjectHistoryOperationUnavailableError extends Error {
constructor(
readonly projectId: string,
readonly direction: ProjectHistoryDirection
) {
super(
`Project ${projectId} has no ${direction} operation available.`
);
this.name = "ProjectHistoryOperationUnavailableError";
}
}
@@ -0,0 +1,33 @@
import type { AppendedProjectRevision } from "./project-revision.store.js";
import type { ProjectHistoryState } from "./project-history.store.js";
export interface ExecuteUserProjectCommandInput {
projectId: string;
expectedRevision: number;
description?: string;
actorId?: string;
command: unknown;
}
export interface ExecuteProjectHistoryCommandInput {
projectId: string;
expectedRevision: number;
actorId?: string;
}
export interface ExecutedProjectCommand {
revision: AppendedProjectRevision;
history: ProjectHistoryState;
}
export interface ProjectCommandExecutor {
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand;
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
redo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
}
+13
View File
@@ -1,3 +1,7 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
export type ProjectHistoryDirection = "undo" | "redo";
export interface ProjectHistoryState {
projectId: string;
currentRevision: number;
@@ -7,6 +11,15 @@ export interface ProjectHistoryState {
redoChangeSetId: string | null;
}
export interface ProjectHistoryCommand {
changeSetId: string;
command: SerializedProjectCommand;
}
export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null;
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
): ProjectHistoryCommand | null;
}
@@ -0,0 +1,138 @@
import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js";
import {
assertCircuitDeviceRowUpdateProjectCommand,
circuitDeviceRowUpdateCommandType,
} from "../models/circuit-device-row-project-command.model.js";
import {
assertCircuitUpdateProjectCommand,
circuitUpdateCommandType,
} from "../models/circuit-project-command.model.js";
import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
ExecuteUserProjectCommandInput,
ProjectCommandExecutor,
} from "../ports/project-command.executor.js";
import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
interface DispatchProjectCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: SerializedProjectCommand;
}
export class ProjectCommandService implements ProjectCommandExecutor {
constructor(
private readonly circuitStore: CircuitProjectCommandStore,
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand {
assertExpectedRevision(input.expectedRevision);
assertSerializedProjectCommand(input.command);
return this.dispatch({
...input,
source: "user",
command: input.command,
});
}
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
return this.executeHistory(input, "undo");
}
redo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
return this.executeHistory(input, "redo");
}
private executeHistory(
input: ExecuteProjectHistoryCommandInput,
direction: ProjectHistoryDirection
) {
assertExpectedRevision(input.expectedRevision);
const step = this.historyStore.getNextCommand(
input.projectId,
direction
);
if (!step) {
throw new ProjectHistoryOperationUnavailableError(
input.projectId,
direction
);
}
return this.dispatch({
...input,
source: direction,
description: `${direction === "undo" ? "Undo" : "Redo"} ${step.command.type}`,
historyTargetChangeSetId: step.changeSetId,
command: step.command,
});
}
private dispatch(
input: DispatchProjectCommandInput
): ExecutedProjectCommand {
const revision = this.executeTypedCommand(input);
const history = this.historyStore.getState(input.projectId);
if (!history) {
throw new Error("Project history disappeared after command execution.");
}
return { revision, history };
}
private executeTypedCommand(input: DispatchProjectCommandInput) {
switch (input.command.type) {
case circuitUpdateCommandType: {
assertCircuitUpdateProjectCommand(input.command);
return this.circuitStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case circuitDeviceRowUpdateCommandType: {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.deviceRowStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
);
}
}
}
function assertExpectedRevision(expectedRevision: number) {
if (
!Number.isSafeInteger(expectedRevision) ||
expectedRevision < 0
) {
throw new Error(
"Expected project revision must be a non-negative integer."
);
}
}
+17
View File
@@ -15,6 +15,23 @@ export interface ProjectHistoryStateDto {
redoChangeSetId: string | null;
}
export interface ProjectCommandDto {
schemaVersion: number;
type: string;
payload: unknown;
}
export interface ProjectCommandResultDto {
revision: {
revisionId: string;
changeSetId: string;
projectId: string;
revisionNumber: number;
createdAtIso: string;
};
history: ProjectHistoryStateDto;
}
export interface DistributionBoardDto {
id: string;
projectId: string;
+57
View File
@@ -7,6 +7,8 @@ import type {
DistributionBoardDto,
FloorDto,
GlobalDeviceDto,
ProjectCommandDto,
ProjectCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ProjectDeviceDisconnectResultDto,
@@ -61,6 +63,61 @@ export function getProjectHistory(projectId: string) {
);
}
export function executeProjectCommand(
projectId: string,
expectedRevision: number,
command: ProjectCommandDto,
description?: string
) {
return request<ProjectCommandResultDto>(
`/api/projects/${projectId}/commands`,
{
method: "POST",
body: JSON.stringify({
expectedRevision,
command,
...(description ? { description } : {}),
}),
}
);
}
export function undoProjectCommand(
projectId: string,
expectedRevision: number
) {
return executeProjectHistoryCommand(
projectId,
expectedRevision,
"undo"
);
}
export function redoProjectCommand(
projectId: string,
expectedRevision: number
) {
return executeProjectHistoryCommand(
projectId,
expectedRevision,
"redo"
);
}
function executeProjectHistoryCommand(
projectId: string,
expectedRevision: number,
direction: "undo" | "redo"
) {
return request<ProjectCommandResultDto>(
`/api/projects/${projectId}/history/${direction}`,
{
method: "POST",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function createProject(name: string) {
return request<ProjectDto>("/api/projects", {
method: "POST",
@@ -1,7 +1,15 @@
import { db } from "../../db/client.js";
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
projectHistoryStore
);
@@ -0,0 +1,99 @@
import type { Request, Response } from "express";
import { ProjectHistoryOperationUnavailableError } from "../../domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import {
executeProjectCommandSchema,
executeProjectHistoryCommandSchema,
} from "../../shared/validation/project-command.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
export function executeProjectCommand(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = executeProjectCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
return res.json(
projectCommandService.executeUser({
projectId,
...parsed.data,
})
);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function undoProjectCommand(req: Request, res: Response) {
return executeHistoryCommand(req, res, "undo");
}
export function redoProjectCommand(req: Request, res: Response) {
return executeHistoryCommand(req, res, "redo");
}
function executeHistoryCommand(
req: Request,
res: Response,
direction: "undo" | "redo"
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = executeProjectHistoryCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = projectCommandService[direction]({
projectId,
...parsed.data,
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}
export function respondWithProjectCommandError(
error: unknown,
res: Response
) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_REVISION_CONFLICT",
expectedRevision: error.expectedRevision,
currentRevision: error.actualRevision,
});
}
if (error instanceof ProjectHistoryOperationUnavailableError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_HISTORY_OPERATION_UNAVAILABLE",
direction: error.direction,
});
}
return res.status(400).json({
error:
error instanceof Error
? error.message
: "Project command could not be executed.",
});
}
@@ -1,8 +1,5 @@
import type { Request, Response } from "express";
import { db } from "../../db/client.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
const projectHistoryRepository = new ProjectHistoryRepository(db);
import { projectHistoryStore } from "../composition/project-command-stores.js";
export function getProjectHistory(req: Request, res: Response) {
const { projectId } = req.params;
@@ -10,7 +7,7 @@ export function getProjectHistory(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid projectId" });
}
const state = projectHistoryRepository.getState(projectId);
const state = projectHistoryStore.getState(projectId);
if (!state) {
return res.status(404).json({ error: "Project not found" });
}
+8
View File
@@ -14,6 +14,11 @@ import { createFloor, listFloorsByProject } from "../controllers/floor.controlle
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 {
executeProjectCommand,
redoProjectCommand,
undoProjectCommand,
} from "../controllers/project-command.controller.js";
export const projectRouter = Router();
@@ -21,6 +26,9 @@ projectRouter.get("/", listProjects);
projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
@@ -0,0 +1,17 @@
import { z } from "zod";
const projectCommandEnvelopeSchema = z.object({
schemaVersion: z.number().int().positive(),
type: z.string().trim().min(1),
payload: z.unknown(),
});
export const executeProjectCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
description: z.string().trim().min(1).max(500).optional(),
command: projectCommandEnvelopeSchema,
});
export const executeProjectHistoryCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
});