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