From d0bd4fe7e0c3b298a6cecdd15a14bdd78ec734ee Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 30 Oct 2026 07:38:28 +0100 Subject: [PATCH] Export and import single distribution boards as JSON A board with its complete circuit list can be downloaded as a leistungsbilanz.distribution-board file and imported into any project as a new board. The import reuses distribution-board.insert-subtree, so it is one revision with restart-safe Undo/Redo. All ids are new; project-bound references (project devices with override metadata, room ids, floor, logical feed, a disabled supply type and the Revit quantity share) are dropped, circuit voltages follow the target project, and the response lists these adjustments. The project page offers export per board and import in the board card header. Refs #26 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 + docs/current-architecture.md | 14 + src/app/projects/[projectId]/page.tsx | 147 +++++- .../distribution-board-transfer.model.ts | 190 ++++++++ src/frontend/types.ts | 6 + src/frontend/utils/api.ts | 25 + .../distribution-board-transfer.controller.ts | 116 +++++ src/server/routes/project.routes.ts | 12 + .../validation/project-structure.schemas.ts | 11 + tests/distribution-board-transfer.test.ts | 433 ++++++++++++++++++ tests/security/security.config.ts | 6 + 11 files changed, 955 insertions(+), 10 deletions(-) create mode 100644 src/domain/models/distribution-board-transfer.model.ts create mode 100644 src/server/controllers/distribution-board-transfer.controller.ts create mode 100644 tests/distribution-board-transfer.test.ts diff --git a/AGENTS.md b/AGENTS.md index 39a9be6..8d097c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -454,6 +454,11 @@ with a complete stable snapshot of the board, circuit list, three default groups, main switch `-Q0` and surge protective device `-FA`. Its inverse removes only the same unchanged and still-empty structure; the POST route requires `expectedRevision` and returns the updated history state. +A single board can be exported as `leistungsbilanz.distribution-board` JSON +and imported into any project as a new board through the same +`distribution-board.insert-subtree` command; the import drops project-bound +references (project devices, rooms, floor, feed, disabled supply type, Revit +quantity share) and reports them as `adjustments`. Complete populated distribution-board copying and deletion use `distribution-board.insert-subtree` and `distribution-board.delete-subtree`. Their exact snapshots include the board, circuit list, groups, circuits, diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 18646ed..dcd2b05 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -352,6 +352,20 @@ Projektgeräten und Räumen erhalten bleiben. Beide Aktionen sind atomare Projektrevisionen und bleiben nach einem Neustart über Undo/Redo umkehrbar. Die Projektseite bietet sie im Einstellungsmodal der jeweiligen Verteilung an; Löschen verlangt dort eine ausdrückliche Bestätigung. +Derselbe Unterbaum ist auch als portable Datei verfügbar: +`GET /api/projects/:projectId/distribution-boards/:distributionBoardId/export` +liefert das Format `leistungsbilanz.distribution-board` (Version 1, +`src/domain/models/distribution-board-transfer.model.ts`), und +`POST /api/projects/:projectId/distribution-boards/import` (`expectedRevision`, +optionaler Name, Datei) legt ihn über `distribution-board.insert-subtree` als +neue Verteilung an – eine Revision, Undo/Redo wie beim Kopieren. Beim Import +werden alle UUIDs neu vergeben und projektgebundene Bezüge entfernt: +Projektgeräte-Verknüpfungen samt Override-Metadaten, Raum-IDs (Raumnummer und +-name bleiben als Text), Etage, logische Einspeisung, eine im Zielprojekt nicht +freigegebene Netzart und der Revit-Anteil der Anzahl (`quantity = +manualQuantity`). Stromkreisspannungen folgen den Spannungen des Zielprojekts. +Die Antwort nennt diese Anpassungen in `adjustments`; die Projektseite bietet +Export je Verteilung und Import im Kopf der Verteilerkarte an. `distribution-board-component.insert` und `distribution-board-component.delete` sowie `distribution-board-component.update` versionieren Anlage, Entfernung, diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 45725b7..5ff2d25 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -2,9 +2,10 @@ import Link from "next/link"; import { useParams } from "next/navigation"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, + Download, FileSpreadsheet, Library, Link2, @@ -15,6 +16,7 @@ import { Settings, Trash2, Unlink, + Upload, X, } from "lucide-react"; import { IconButton } from "../../../frontend/components/icon-button"; @@ -54,6 +56,7 @@ import { deleteProjectDevice, deleteDistributionBoard, disconnectProjectDeviceRows, + exportDistributionBoardTransfer, exportProjectTransfer, getProject, getProjectCircuitTrees, @@ -65,6 +68,7 @@ import { listGlobalDevices, listProjectDevices, listRooms, + importDistributionBoardTransfer, importProjectTransfer, synchronizeProjectDeviceRows, updateDistributionBoard, @@ -214,6 +218,8 @@ export default function ProjectDetailPage() { const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); const [selectedSyncFields, setSelectedSyncFields] = useState([]); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const boardImportInputRef = useRef(null); const [isSaving, setIsSaving] = useState(false); useEffect(() => { @@ -766,6 +772,79 @@ export default function ProjectDetailPage() { } } + async function handleExportBoard(board: DistributionBoardDto) { + if (!projectId) { + return; + } + setError(null); + try { + const transfer = await exportDistributionBoardTransfer(projectId, board.id); + downloadJson( + transfer, + `${(board.boardNumber ?? board.name).replace(/[^a-zA-Z0-9_-]+/g, "-") || "verteiler"}.verteiler.json` + ); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht exportiert werden." + ); + } + } + + // Imports an exported board file as a new board of this project. + async function handleImportBoardFile(file: File | undefined) { + if (boardImportInputRef.current) { + boardImportInputRef.current.value = ""; + } + if (!file || !projectId || !project) { + return; + } + setError(null); + setNotice(null); + let transfer: unknown; + try { + transfer = JSON.parse(await file.text()) as unknown; + } catch { + setError("Die ausgewählte Datei enthält kein gültiges JSON."); + return; + } + const name = window.prompt( + "Bezeichnung der importierten Verteilung", + readTransferBoardName(transfer) ?? "" + ); + if (name === null || !name.trim()) { + return; + } + setIsSaving(true); + try { + const result = await importDistributionBoardTransfer( + projectId, + transfer, + name.trim(), + project.currentRevision + ); + setBoards((current) => [...current, result.distributionBoard]); + setCircuitLists((current) => [...current, result.circuitList]); + applyProjectRevision(result.history.currentRevision); + setCircuitTrees((await getProjectCircuitTrees(projectId)).trees); + setNotice( + [ + `Verteilung „${result.distributionBoard.name}“ importiert.`, + ...result.adjustments, + ].join(" ") + ); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht importiert werden." + ); + } finally { + setIsSaving(false); + } + } + async function handleExportProject() { if (!project) { return; @@ -774,15 +853,10 @@ export default function ProjectDetailPage() { setError(null); try { const transfer = await exportProjectTransfer(project.id); - const blob = new Blob([JSON.stringify(transfer, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json`; - anchor.click(); - URL.revokeObjectURL(url); + downloadJson( + transfer, + `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json` + ); } catch (err) { setError( err instanceof Error @@ -1011,6 +1085,17 @@ export default function ProjectDetailPage() { {error ?
{error}
: null} + {notice ? ( +
+ {notice} +
+ ) : null}
+ boardImportInputRef.current?.click()} + showLabel + title="Exportierte Verteilerdatei (.verteiler.json) als neue Verteilung importieren" + /> + + void handleImportBoardFile(event.target.files?.[0]) + } + ref={boardImportInputRef} + type="file" + /> openBoardEditor(board)} /> + void handleExportBoard(board)} + /> {circuitList ? ( string +): PreparedDistributionBoardImport { + const source = envelope.board; + const name = target.name?.trim() || source.distributionBoard.name; + const clone = cloneDistributionBoardSubtree(source, name, createId); + const rows = clone.circuits.flatMap((circuit) => circuit.deviceRows); + const linkedRowCount = rows.filter((row) => row.linkedProjectDeviceId !== null).length; + const roomRowCount = rows.filter((row) => row.roomId !== null).length; + const externalQuantityRowCount = rows.filter( + (row) => (row.manualQuantity ?? row.quantity) !== row.quantity + ).length; + const sectionKeyById = new Map( + clone.sections.map((section) => [section.id, section.key]) + ); + const supplyType = + clone.distributionBoard.supplyType !== null && + target.enabledSupplyTypes.includes(clone.distributionBoard.supplyType) + ? clone.distributionBoard.supplyType + : null; + + const snapshot: DistributionBoardSubtreeSnapshot = { + ...clone, + distributionBoard: { + ...clone.distributionBoard, + projectId: target.projectId, + floorId: null, + supplyType, + feedingDistributionBoardId: null, + feedingCircuitId: null, + }, + circuitList: { ...clone.circuitList, projectId: target.projectId }, + circuits: clone.circuits.map((circuit) => ({ + ...circuit, + voltage: resolveProjectVoltage( + resolveCircuitPhaseType( + sectionKeyById.get(circuit.sectionId) ?? "", + circuit.deviceRows.map((row) => row.phaseType) + ), + target.voltageSettings + ), + deviceRows: circuit.deviceRows.map((row) => { + const manualQuantity = row.manualQuantity ?? row.quantity; + return { + ...row, + linkedProjectDeviceId: null, + overriddenFields: null, + roomId: null, + quantity: manualQuantity, + manualQuantity, + }; + }), + })), + }; + assertDistributionBoardSubtreeSnapshot(snapshot); + + const adjustments: string[] = []; + if (linkedRowCount > 0) { + adjustments.push( + `${linkedRowCount} Gerätezeile(n) sind nicht mehr mit Projektgeräten verknüpft.` + ); + } + if (roomRowCount > 0) { + adjustments.push( + `${roomRowCount} Raumzuordnung(en) wurden als Raumnummer/-name (Text) übernommen.` + ); + } + if (externalQuantityRowCount > 0) { + adjustments.push( + `${externalQuantityRowCount} Gerätezeile(n) enthalten nur noch die manuelle Anzahl (ohne Revit-Anteil).` + ); + } + if (source.distributionBoard.floorId !== null) { + adjustments.push("Die Etage ist nicht übernommen und muss neu zugeordnet werden."); + } + if (source.distributionBoard.feedingDistributionBoardId !== null) { + adjustments.push("Die Einspeisung aus einem anderen Verteiler ist nicht übernommen."); + } + if (source.distributionBoard.supplyType !== null && supplyType === null) { + adjustments.push( + "Die Netzart ist in diesem Projekt nicht freigegeben und wurde entfernt." + ); + } + return { snapshot, adjustments }; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 78b8227..45a2d76 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -312,6 +312,12 @@ export interface DistributionBoardCopyCommandResultDto circuitList: CircuitListDto; } +export interface DistributionBoardImportCommandResultDto + extends DistributionBoardCopyCommandResultDto { + /** Notes on source-project data that the import did not carry over. */ + adjustments: string[]; +} + export interface DistributionBoardDeleteCommandResultDto extends ProjectCommandResultDto { distributionBoardId: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 8e97efb..e3cfab0 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -13,6 +13,7 @@ import type { DistributionBoardDto, DistributionBoardCommandResultDto, DistributionBoardCopyCommandResultDto, + DistributionBoardImportCommandResultDto, DistributionBoardDeleteCommandResultDto, DistributionBoardInfoDto, DocumentLogoMetadataDto, @@ -633,6 +634,30 @@ export function copyDistributionBoard( ); } +export function exportDistributionBoardTransfer( + projectId: string, + distributionBoardId: string +) { + return request( + `/api/projects/${projectId}/distribution-boards/${distributionBoardId}/export` + ); +} + +export function importDistributionBoardTransfer( + projectId: string, + transfer: unknown, + name: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/distribution-boards/import`, + { + method: "POST", + body: JSON.stringify({ expectedRevision, name, transfer }), + } + ); +} + export function deleteDistributionBoard( projectId: string, distributionBoardId: string, diff --git a/src/server/controllers/distribution-board-transfer.controller.ts b/src/server/controllers/distribution-board-transfer.controller.ts new file mode 100644 index 0000000..29f0e58 --- /dev/null +++ b/src/server/controllers/distribution-board-transfer.controller.ts @@ -0,0 +1,116 @@ +import type { Request, Response } from "express"; +import { createDistributionBoardInsertSubtreeProjectCommand } from "../../domain/models/distribution-board-subtree-project-command.model.js"; +import { + createDistributionBoardTransferEnvelope, + parseDistributionBoardTransferEnvelope, + prepareDistributionBoardImport, +} from "../../domain/models/distribution-board-transfer.model.js"; +import { importDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js"; +import { projectRepository } from "../composition/application-repositories.js"; +import { + distributionBoardSubtreeProjectCommandStore, + projectCommandService, +} from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +// Downloads one board with its complete circuit list as a portable file. +export async function exportDistributionBoard(req: Request, res: Response) { + const { projectId, distributionBoardId } = req.params; + if ( + typeof projectId !== "string" || + typeof distributionBoardId !== "string" || + !projectId.trim() || + !distributionBoardId.trim() + ) { + return res.status(400).json({ error: "Invalid distribution board id" }); + } + const project = await projectRepository.findById(projectId); + if (!project) { + return res.status(404).json({ error: "Project not found" }); + } + let board; + try { + board = distributionBoardSubtreeProjectCommandStore.capture( + projectId, + distributionBoardId + ); + } catch { + return res.status(404).json({ error: "Distribution board not found" }); + } + const transfer = createDistributionBoardTransferEnvelope( + board, + project.name, + new Date().toISOString() + ); + const filename = safeFilename( + board.distributionBoard.boardNumber ?? board.distributionBoard.name + ); + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}.verteiler.json"` + ); + return res.json(transfer); +} + +// Inserts the board of an exported file as a new board of this project. +export async function importDistributionBoard(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string" || !projectId.trim()) { + return res.status(400).json({ error: "Invalid projectId" }); + } + const parsed = importDistributionBoardSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const project = await projectRepository.findById(projectId); + if (!project) { + return res.status(404).json({ error: "Project not found" }); + } + let prepared; + try { + prepared = prepareDistributionBoardImport( + parseDistributionBoardTransferEnvelope(parsed.data.transfer), + { + projectId, + name: parsed.data.name, + voltageSettings: project, + enabledSupplyTypes: project.enabledDistributionBoardSupplyTypes, + } + ); + } catch (error) { + return res.status(400).json({ + error: + error instanceof Error + ? error.message + : "Die Verteilerdatei ist ungültig.", + }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Verteilung importieren", + command: createDistributionBoardInsertSubtreeProjectCommand( + prepared.snapshot + ), + }); + return res.status(201).json({ + ...result, + distributionBoard: prepared.snapshot.distributionBoard, + circuitList: prepared.snapshot.circuitList, + adjustments: prepared.adjustments, + }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +function safeFilename(name: string) { + return ( + name + .normalize("NFKD") + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "verteiler" + ); +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 5d83ee0..1d91670 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -65,6 +65,10 @@ import { } from "../controllers/external-initial-import.controller.js"; import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js"; +import { + exportDistributionBoard, + importDistributionBoard, +} from "../controllers/distribution-board-transfer.controller.js"; import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js"; export const projectRouter = Router(); @@ -110,6 +114,14 @@ projectRouter.post( "/:projectId/distribution-boards/:distributionBoardId/copy", copyDistributionBoard ); +projectRouter.post( + "/:projectId/distribution-boards/import", + importDistributionBoard +); +projectRouter.get( + "/:projectId/distribution-boards/:distributionBoardId/export", + exportDistributionBoard +); projectRouter.put( "/:projectId/distribution-boards/:distributionBoardId", updateDistributionBoard diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index a8f899c..65ee156 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -98,6 +98,17 @@ export const copyDistributionBoardSchema = z }) .strict(); +// The file itself is checked by parseDistributionBoardTransferEnvelope. +export const importDistributionBoardSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(200).optional(), + transfer: z.unknown().refine((value) => value !== undefined, { + message: "Verteilerdatei fehlt.", + }), + }) + .strict(); + export const deleteDistributionBoardSchema = z .object({ expectedRevision: expectedProjectRevisionSchema, diff --git a/tests/distribution-board-transfer.test.ts b/tests/distribution-board-transfer.test.ts new file mode 100644 index 0000000..c2c32d1 --- /dev/null +++ b/tests/distribution-board-transfer.test.ts @@ -0,0 +1,433 @@ +/** + * Distribution-board export/import: pure import preparation plus the HTTP + * round trip from one project into another against createApp() with an + * in-memory database and synthetic accounts. + */ +import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; +import { after, before, describe, it } from "node:test"; +import type { DistributionBoardSubtreeSnapshot } from "../src/domain/models/distribution-board-subtree-snapshot.model.js"; +import { + createDistributionBoardTransferEnvelope, + parseDistributionBoardTransferEnvelope, + prepareDistributionBoardImport, +} from "../src/domain/models/distribution-board-transfer.model.js"; +import config, { WRITE_HEADER, signIn } from "./security/security.config.ts"; + +type Headers = Record; + +function sourceBoard(): DistributionBoardSubtreeSnapshot { + return { + distributionBoard: { + id: "board-1", + projectId: "project-source", + name: "UV-EG", + floorId: "floor-1", + supplyType: "SV", + simultaneityFactor: 0.8, + feedingDistributionBoardId: "board-main", + feedingCircuitId: null, + boardNumber: "UV-1", + location: "Technik", + fedFrom: null, + remark: null, + }, + circuitList: { + id: "board-1", + projectId: "project-source", + distributionBoardId: "board-1", + name: "UV-EG Stromkreisliste", + }, + sections: [ + { + id: "section-1", + circuitListId: "board-1", + key: "single_phase", + displayName: "Einphasig", + prefix: "-2F1.", + sortOrder: 20, + category: "single_phase", + groupNumber: 1, + }, + ], + components: [], + circuits: [ + { + id: "circuit-1", + circuitListId: "board-1", + sectionId: "section-1", + equipmentIdentifier: "-2F1.1", + displayName: "Steckdose", + sortOrder: 10, + cableType: "NYM-J", + cableCrossSection: "3x2,5", + cableLength: 20, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: null, + status: null, + isReserve: false, + remark: null, + protectionDevice: { + circuitId: "circuit-1", + type: "LS", + ratedCurrentA: 16, + fuseUtilizationCategory: null, + tripCharacteristic: "B", + rcdType: null, + ratedResidualCurrentMa: null, + }, + deviceRows: [ + { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "device-1", + sortOrder: 10, + name: "Steckdose", + displayName: "Steckdose", + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: null, + level: null, + roomId: "room-1", + roomNumberSnapshot: "0.01", + roomNameSnapshot: "Büro", + quantity: 5, + manualQuantity: 2, + powerPerUnit: 0.2, + simultaneityFactor: 1, + cosPhi: 1, + remark: null, + overriddenFields: "[\"displayName\"]", + }, + ], + }, + ], + }; +} + +describe("distribution-board transfer model", () => { + it("imports with new ids and without source-project references", () => { + let nextId = 0; + const envelope = createDistributionBoardTransferEnvelope( + sourceBoard(), + "Quelle", + "2026-10-29T10:00:00.000Z" + ); + const { snapshot, adjustments } = prepareDistributionBoardImport( + parseDistributionBoardTransferEnvelope(JSON.parse(JSON.stringify(envelope))), + { + projectId: "project-target", + name: "UV-EG neu", + voltageSettings: { singlePhaseVoltageV: 220, threePhaseVoltageV: 380 }, + enabledSupplyTypes: ["AV"], + }, + () => `new-${++nextId}` + ); + + const board = snapshot.distributionBoard; + assert.equal(board.projectId, "project-target"); + assert.equal(board.name, "UV-EG neu"); + assert.equal(board.boardNumber, "UV-1"); + assert.equal(board.simultaneityFactor, 0.8); + assert.deepEqual( + [board.floorId, board.supplyType, board.feedingDistributionBoardId], + [null, null, null] + ); + assert.equal(snapshot.circuitList.projectId, "project-target"); + const [circuit] = snapshot.circuits; + assert.ok(circuit.id.startsWith("new-")); + assert.equal(circuit.equipmentIdentifier, "-2F1.1"); + assert.equal(circuit.cableCrossSection, "3x2,5"); + assert.equal(circuit.voltage, 220); + assert.equal(circuit.protectionDevice?.circuitId, circuit.id); + assert.equal(circuit.protectionDevice?.ratedCurrentA, 16); + const [row] = circuit.deviceRows; + assert.ok(row.id.startsWith("new-")); + assert.deepEqual( + { + linkedProjectDeviceId: row.linkedProjectDeviceId, + overriddenFields: row.overriddenFields, + roomId: row.roomId, + roomNumberSnapshot: row.roomNumberSnapshot, + quantity: row.quantity, + manualQuantity: row.manualQuantity, + }, + { + linkedProjectDeviceId: null, + overriddenFields: null, + roomId: null, + roomNumberSnapshot: "0.01", + quantity: 2, + manualQuantity: 2, + } + ); + assert.equal(adjustments.length, 6); + }); + + it("rejects foreign files, unknown fields and broken boards in German", () => { + const envelope = createDistributionBoardTransferEnvelope( + sourceBoard(), + "Quelle", + "2026-10-29T10:00:00.000Z" + ); + assert.throws( + () => parseDistributionBoardTransferEnvelope({ format: "leistungsbilanz.project" }), + /keine Verteiler-Exportdatei/ + ); + assert.throws( + () => parseDistributionBoardTransferEnvelope({ ...envelope, extra: 1 }), + /unbekannte Felder/ + ); + assert.throws( + () => + parseDistributionBoardTransferEnvelope({ + ...envelope, + board: { ...envelope.board, circuits: "kaputt" }, + }), + /Verteiler in der Exportdatei ist ungültig/ + ); + }); +}); + +describe("distribution-board transfer routes", () => { + let server: import("node:http").Server; + let baseUrl = ""; + let user: Headers = {}; + + async function call(method: string, path: string, headers: Headers, body?: unknown) { + return fetch(baseUrl + path, { + method, + headers: { ...headers, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } + + async function json(response: Response, status: number): Promise { + const text = await response.text(); + assert.equal(response.status, status, text); + return JSON.parse(text) as T; + } + + async function createProject(name: string) { + return json<{ id: string }>(await call("POST", "/api/projects", user, { name }), 201); + } + + async function getRevision(projectId: string) { + return ( + await json<{ currentRevision: number }>( + await call("GET", `/api/projects/${projectId}/history`, user), + 200 + ) + ).currentRevision; + } + + async function loadTree(projectId: string) { + const [circuitList] = await json>( + await call("GET", `/api/projects/${projectId}/circuit-lists`, user), + 200 + ); + return json<{ + sections: Array<{ + id: string; + key: string; + circuits: Array<{ + id: string; + equipmentIdentifier: string; + cableType?: string; + deviceRows: Array<{ id: string; powerPerUnit: number }>; + }>; + }>; + }>( + await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitList.id}/tree`, user), + 200 + ); + } + + before(async () => { + const app = await config.createApp(); + server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + user = await signIn(baseUrl, "planer-a"); + }); + + after(() => new Promise((resolve) => server.close(() => resolve()))); + + it("exports a board and imports it into another project as one undoable revision", async () => { + const source = await createProject("Quelle"); + const created = await json<{ distributionBoard: { id: string } }>( + await call("POST", `/api/projects/${source.id}/distribution-boards`, user, { + expectedRevision: 0, + name: "UV-EG", + floorId: null, + supplyType: "AV", + }), + 201 + ); + const section = (await loadTree(source.id)).sections.find( + (entry) => entry.key === "single_phase" + ); + assert.ok(section); + const [circuitList] = await json>( + await call("GET", `/api/projects/${source.id}/circuit-lists`, user), + 200 + ); + await json( + await call("POST", `/api/projects/${source.id}/commands`, user, { + expectedRevision: 1, + command: { + schemaVersion: 1, + type: "circuit.insert", + payload: { + circuit: { + ...sourceBoard().circuits[0], + id: "source-circuit", + circuitListId: circuitList.id, + sectionId: section.id, + cableType: null, + protectionDevice: null, + deviceRows: [ + { + ...sourceBoard().circuits[0].deviceRows[0], + id: "source-row", + circuitId: "source-circuit", + linkedProjectDeviceId: null, + roomId: null, + overriddenFields: null, + quantity: 2, + }, + ], + }, + }, + }, + }), + 200 + ); + // Cell edits of several entities go through the bulk command. + await json( + await call("POST", `/api/projects/${source.id}/commands`, user, { + expectedRevision: 2, + command: { + schemaVersion: 1, + type: "circuit-list.bulk-update", + payload: { + updates: [ + { + schemaVersion: 1, + type: "circuit.update", + payload: { + circuitId: "source-circuit", + changes: [{ field: "cableType", value: "NYM-J" }], + }, + }, + { + schemaVersion: 1, + type: "circuit-device-row.update", + payload: { + rowId: "source-row", + changes: [{ field: "powerPerUnit", value: 0.5 }], + }, + }, + ], + }, + }, + }), + 200 + ); + + const exported = await call( + "GET", + `/api/projects/${source.id}/distribution-boards/${created.distributionBoard.id}/export`, + user + ); + assert.match( + exported.headers.get("content-disposition") ?? "", + /UV-EG\.verteiler\.json/ + ); + const transfer = await json<{ format: string }>(exported, 200); + assert.equal(transfer.format, "leistungsbilanz.distribution-board"); + + const target = await createProject("Ziel"); + const imported = await json<{ adjustments: string[]; distributionBoard: { name: string } }>( + await call("POST", `/api/projects/${target.id}/distribution-boards/import`, user, { + expectedRevision: 0, + name: "UV-EG importiert", + transfer, + }), + 201 + ); + assert.equal(imported.distributionBoard.name, "UV-EG importiert"); + assert.deepEqual(imported.adjustments, []); + const importedCircuit = (await loadTree(target.id)).sections + .flatMap((entry) => entry.circuits) + .find((circuit) => circuit.equipmentIdentifier === "-2F1.1"); + assert.ok(importedCircuit); + assert.notEqual(importedCircuit.id, "source-circuit"); + assert.equal(importedCircuit.cableType, "NYM-J"); + assert.equal(importedCircuit.deviceRows[0].powerPerUnit, 0.5); + assert.equal(await getRevision(target.id), 1); + + await json( + await call("POST", `/api/projects/${target.id}/history/undo`, user, { + expectedRevision: 1, + }), + 200 + ); + assert.deepEqual( + await json(await call("GET", `/api/projects/${target.id}/distribution-boards`, user), 200), + [] + ); + }); + + it("rejects unknown boards, invalid files, stale revisions and anonymous requests", async () => { + const project = await createProject("Fehlerfälle"); + assert.equal( + ( + await call( + "GET", + `/api/projects/${project.id}/distribution-boards/unbekannt/export`, + user + ) + ).status, + 404 + ); + const importPath = `/api/projects/${project.id}/distribution-boards/import`; + const invalid = await call("POST", importPath, user, { + expectedRevision: 0, + transfer: { format: "leistungsbilanz.project" }, + }); + assert.equal(invalid.status, 400); + assert.match((await invalid.json()).error, /keine Verteiler-Exportdatei/); + assert.equal( + (await call("POST", importPath, user, { expectedRevision: 0, transfer: {}, extra: 1 })).status, + 400 + ); + const envelope = createDistributionBoardTransferEnvelope( + sourceBoard(), + "Quelle", + "2026-10-29T10:00:00.000Z" + ); + assert.equal( + (await call("POST", importPath, user, { expectedRevision: 7, transfer: envelope })).status, + 409 + ); + assert.equal( + (await call("POST", importPath, WRITE_HEADER, { expectedRevision: 0, transfer: envelope })).status, + 401 + ); + assert.equal( + ( + await call( + "POST", + "/api/projects/unbekannt/distribution-boards/import", + user, + { expectedRevision: 0, transfer: envelope } + ) + ).status, + 404 + ); + }); +}); diff --git a/tests/security/security.config.ts b/tests/security/security.config.ts index cba0c38..329fa7d 100644 --- a/tests/security/security.config.ts +++ b/tests/security/security.config.ts @@ -124,6 +124,12 @@ const config = { { method: "POST", path: "/api/projects", body: "{kaputt" }, { method: "PUT", path: "/api/projects/{id}", body: {} }, { method: "PUT", path: "/api/projects/{id}", body: "{kaputt" }, + { method: "POST", path: "/api/projects/{id}/distribution-boards/import", body: {} }, + { + method: "POST", + path: "/api/projects/{id}/distribution-boards/import", + body: { expectedRevision: 0, transfer: { format: "fremd" } }, + }, ] as Case[], // Keine Cross-Origin-Freigabe: Browser erreichen die API nur same-origin über den