From 86f1d42e60161060944e67ab16ce29b648d6ad70 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 31 Jul 2026 11:59:31 +0200 Subject: [PATCH] Expose distribution board copy and delete API --- src/frontend/types.ts | 5 + src/frontend/utils/api.ts | 30 ++++++ src/frontend/utils/project-version-history.ts | 2 + .../distribution-board.controller.ts | 94 ++++++++++++++++++- src/server/routes/project.routes.ts | 10 ++ .../validation/project-structure.schemas.ts | 13 +++ ...ructure-project-command.repository.test.ts | 30 +++++- 7 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/frontend/types.ts b/src/frontend/types.ts index b0e9887..baf92c6 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -114,6 +114,11 @@ export interface DistributionBoardCommandResultDto distributionBoard: DistributionBoardDto; } +export interface DistributionBoardDeleteCommandResultDto + extends ProjectCommandResultDto { + distributionBoardId: string; +} + export interface CircuitListDto { id: string; projectId: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 0c06b38..defe0cd 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -6,6 +6,7 @@ import type { CreateGlobalDeviceInput, DistributionBoardDto, DistributionBoardCommandResultDto, + DistributionBoardDeleteCommandResultDto, FloorDto, GlobalDeviceDto, ProjectCommandDto, @@ -331,6 +332,35 @@ export function updateDistributionBoard( ); } +export function copyDistributionBoard( + projectId: string, + distributionBoardId: string, + name: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/distribution-boards/${distributionBoardId}/copy`, + { + method: "POST", + body: JSON.stringify({ name, expectedRevision }), + } + ); +} + +export function deleteDistributionBoard( + projectId: string, + distributionBoardId: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/distribution-boards/${distributionBoardId}`, + { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + } + ); +} + export function listCircuitLists(projectId: string) { return request(`/api/projects/${projectId}/circuit-lists`); } diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 4ced5a1..3587b6d 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -33,6 +33,8 @@ const commandTypeLabels: Record = { "distribution-board.insert": "Verteilung angelegt", "distribution-board.update": "Verteilung bearbeitet", "distribution-board.delete": "Verteilung entfernt", + "distribution-board.insert-subtree": "Verteilung vollständig eingefügt", + "distribution-board.delete-subtree": "Verteilung vollständig entfernt", "distribution-board-component.insert": "Verteilergerät angelegt", "distribution-board-component.delete": "Verteilergerät entfernt", "distribution-board-component.update": "Verteilergerät bearbeitet", diff --git a/src/server/controllers/distribution-board.controller.ts b/src/server/controllers/distribution-board.controller.ts index 62f200e..70ab7e9 100644 --- a/src/server/controllers/distribution-board.controller.ts +++ b/src/server/controllers/distribution-board.controller.ts @@ -5,10 +5,20 @@ import { createDistributionBoardStructureSnapshot, } from "../../domain/models/distribution-board-structure-project-command.model.js"; import { + createDistributionBoardDeleteSubtreeProjectCommand, + createDistributionBoardInsertSubtreeProjectCommand, +} from "../../domain/models/distribution-board-subtree-project-command.model.js"; +import { cloneDistributionBoardSubtree } from "../../domain/models/distribution-board-subtree-snapshot.model.js"; +import { + copyDistributionBoardSchema, createDistributionBoardSchema, + deleteDistributionBoardSchema, updateDistributionBoardSchema, } from "../../shared/validation/project-structure.schemas.js"; -import { projectCommandService } from "../composition/project-command-stores.js"; +import { + distributionBoardSubtreeProjectCommandStore, + projectCommandService, +} from "../composition/project-command-stores.js"; import { distributionBoardRepository } from "../composition/application-repositories.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; @@ -101,3 +111,85 @@ export async function updateDistributionBoard( return respondWithProjectCommandError(error, res); } } + +export async function copyDistributionBoard( + req: Request, + res: Response +) { + const ids = getDistributionBoardIds(req, res); + if (!ids) { + return; + } + const parsed = copyDistributionBoardSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + try { + const source = distributionBoardSubtreeProjectCommandStore.capture( + ids.projectId, + ids.distributionBoardId + ); + const copy = cloneDistributionBoardSubtree(source, parsed.data.name); + const result = projectCommandService.executeUser({ + projectId: ids.projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Verteilung kopieren", + command: + createDistributionBoardInsertSubtreeProjectCommand(copy), + }); + return res.status(201).json({ + ...result, + distributionBoard: copy.distributionBoard, + }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export async function deleteDistributionBoard( + req: Request, + res: Response +) { + const ids = getDistributionBoardIds(req, res); + if (!ids) { + return; + } + const parsed = deleteDistributionBoardSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + try { + const snapshot = + distributionBoardSubtreeProjectCommandStore.capture( + ids.projectId, + ids.distributionBoardId + ); + const result = projectCommandService.executeUser({ + projectId: ids.projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Verteilung löschen", + command: + createDistributionBoardDeleteSubtreeProjectCommand(snapshot), + }); + return res.json({ + ...result, + distributionBoardId: ids.distributionBoardId, + }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +function getDistributionBoardIds(req: Request, res: Response) { + const { projectId, distributionBoardId } = req.params; + if ( + typeof projectId !== "string" || + typeof distributionBoardId !== "string" || + !projectId.trim() || + !distributionBoardId.trim() + ) { + res.status(400).json({ error: "Invalid distribution board id" }); + return null; + } + return { projectId, distributionBoardId }; +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 74221a0..8bdf909 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -6,7 +6,9 @@ import { updateProjectSettings, } from "../controllers/project.controller.js"; import { + copyDistributionBoard, createDistributionBoard, + deleteDistributionBoard, listDistributionBoardsByProject, updateDistributionBoard, } from "../controllers/distribution-board.controller.js"; @@ -56,10 +58,18 @@ projectRouter.post( projectRouter.put("/:projectId", updateProjectSettings); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); +projectRouter.post( + "/:projectId/distribution-boards/:distributionBoardId/copy", + copyDistributionBoard +); projectRouter.put( "/:projectId/distribution-boards/:distributionBoardId", updateDistributionBoard ); +projectRouter.delete( + "/:projectId/distribution-boards/:distributionBoardId", + deleteDistributionBoard +); projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject); projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree); projectRouter.get("/:projectId/floors", listFloorsByProject); diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index 15c69b1..cb666e2 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -49,6 +49,19 @@ export const updateDistributionBoardSchema = z }) .strict(); +export const copyDistributionBoardSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(200), + }) + .strict(); + +export const deleteDistributionBoardSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + }) + .strict(); + export const createFloorSchema = z .object({ expectedRevision: expectedProjectRevisionSchema, diff --git a/tests/distribution-board-structure-project-command.repository.test.ts b/tests/distribution-board-structure-project-command.repository.test.ts index 570d6b9..9933446 100644 --- a/tests/distribution-board-structure-project-command.repository.test.ts +++ b/tests/distribution-board-structure-project-command.repository.test.ts @@ -28,7 +28,9 @@ import { type DistributionBoardUpdateProjectCommand, } from "../src/domain/models/distribution-board-project-command.model.js"; import { + copyDistributionBoard, createDistributionBoard, + deleteDistributionBoard, updateDistributionBoard, } from "../src/frontend/utils/api.js"; @@ -568,7 +570,7 @@ describe("distribution-board structure project command", () => { } }); - it("sends distribution-board updates to the revision-safe API", async () => { + it("sends distribution-board writes to the revision-safe API", async () => { const requests: Array<{ url: string; method: string; body: unknown }> = []; const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { @@ -593,6 +595,17 @@ describe("distribution-board structure project command", () => { }, 9 ); + await copyDistributionBoard( + "project-1", + "board-1", + "UV 1 Kopie", + 10 + ); + await deleteDistributionBoard( + "project-1", + "board-1", + 11 + ); } finally { globalThis.fetch = originalFetch; } @@ -607,6 +620,21 @@ describe("distribution-board structure project command", () => { expectedRevision: 9, }, }, + { + url: "/api/projects/project-1/distribution-boards/board-1/copy", + method: "POST", + body: { + name: "UV 1 Kopie", + expectedRevision: 10, + }, + }, + { + url: "/api/projects/project-1/distribution-boards/board-1", + method: "DELETE", + body: { + expectedRevision: 11, + }, + }, ]); });