From da8115fe1dc94cf197353c491c3637973ef9e36d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Tue, 28 Jul 2026 18:34:57 +0200 Subject: [PATCH] Add portable project transfer API --- docs/current-architecture.md | 10 ++ .../project-transfer.repository.ts | 135 ++++++++++++++ src/domain/models/project-transfer.model.ts | 164 ++++++++++++++++++ src/domain/ports/project-transfer.store.ts | 15 ++ .../composition/project-transfer-store.ts | 4 + .../project-transfer.controller.ts | 100 +++++++++++ src/server/index.ts | 2 +- src/server/routes/project.routes.ts | 6 + .../validation/project-transfer.schemas.ts | 18 ++ tests/project-snapshot.repository.test.ts | 99 +++++++++++ 10 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 src/db/repositories/project-transfer.repository.ts create mode 100644 src/domain/models/project-transfer.model.ts create mode 100644 src/domain/ports/project-transfer.store.ts create mode 100644 src/server/composition/project-transfer-store.ts create mode 100644 src/server/controllers/project-transfer.controller.ts create mode 100644 src/shared/validation/project-transfer.schemas.ts diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 45e44a7..4b40f51 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -226,6 +226,16 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, damit Änderungen weiterhin niemals still synchronisiert werden. +`GET /api/projects/:projectId/export` verpackt denselben vollständigen +Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument +mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format, +Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace` +läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft +rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-, +Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu +zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only- +Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen +innerhalb des unterstützten Laufzeitmodells bleiben erhalten. `distribution-board.insert` versioniert die Anlage einer Verteilung als einen vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen. Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts new file mode 100644 index 0000000..8974bd4 --- /dev/null +++ b/src/db/repositories/project-transfer.repository.ts @@ -0,0 +1,135 @@ +import crypto from "node:crypto"; +import { eq } from "drizzle-orm"; +import { + createProjectTransferEnvelope, + parseProjectTransferEnvelope, + remapProjectState, +} from "../../domain/models/project-transfer.model.js"; +import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js"; +import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitSections } from "../schema/circuit-sections.js"; +import { circuits } from "../schema/circuits.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { floors } from "../schema/floors.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { projects } from "../schema/projects.js"; +import { rooms } from "../schema/rooms.js"; +import { + hashProjectStatePayload, + readProjectStateSnapshot, +} from "./project-state-snapshot.persistence.js"; +import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js"; + +export class ProjectTransferRepository implements ProjectTransferStore { + constructor(private readonly database: AppDatabase) {} + + exportProject(projectId: string) { + const snapshot = readProjectStateSnapshot(this.database, projectId); + if (!snapshot) { + return null; + } + return createProjectTransferEnvelope( + new Date().toISOString(), + snapshot.payloadSha256, + snapshot.state + ); + } + + prepareReplace(projectId: string, value: unknown) { + const transfer = this.parseVerified(value); + const current = readProjectStateSnapshot(this.database, projectId); + if (!current) { + return null; + } + const targetState = + transfer.projectState.project.id === projectId + ? transfer.projectState + : remapProjectState( + transfer.projectState, + projectId, + () => crypto.randomUUID() + ); + return { + command: createProjectStateRestoreCommand( + current.payloadSha256, + targetState + ), + }; + } + + importDuplicate(value: unknown) { + const transfer = this.parseVerified(value); + const projectId = crypto.randomUUID(); + const state = remapProjectState( + transfer.projectState, + projectId, + () => crypto.randomUUID(), + `${transfer.projectState.project.name} (Kopie)` + ); + this.database.transaction((tx) => insertProjectState(tx, state)); + return { projectId, name: state.project.name }; + } + + private parseVerified(value: unknown) { + const transfer = parseProjectTransferEnvelope(value); + const payloadJson = serializeProjectStateSnapshot( + transfer.projectState + ); + if ( + hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 + ) { + throw new Error("Project transfer checksum verification failed."); + } + return transfer; + } +} + +function insertProjectState( + database: AppDatabase, + state: ReturnType +) { + const existing = database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, state.project.id)) + .get(); + if (existing) { + throw new Error("Project transfer target already exists."); + } + database + .insert(projects) + .values({ ...state.project, currentRevision: 0 }) + .run(); + insertMany(database, floors, state.floors); + insertMany(database, rooms, state.rooms); + insertMany(database, projectDevices, state.projectDevices); + insertMany(database, distributionBoards, state.distributionBoards); + insertMany(database, circuitLists, state.circuitLists); + insertMany(database, circuitSections, state.circuitSections); + insertMany( + database, + circuits, + state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({ + ...circuit, + isReserve: circuit.isReserve ? 1 : 0, + })) + ); + insertMany( + database, + circuitDeviceRows, + state.circuits.flatMap((circuit) => circuit.deviceRows) + ); +} + +function insertMany[0]>( + database: AppDatabase, + table: TTable, + values: unknown[] +) { + if (values.length > 0) { + database.insert(table).values(values as never).run(); + } +} diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts new file mode 100644 index 0000000..f65433f --- /dev/null +++ b/src/domain/models/project-transfer.model.ts @@ -0,0 +1,164 @@ +import { + parseProjectStateSnapshot, + projectStateSnapshotSchemaVersion, + type ProjectStateSnapshot, +} from "./project-state-snapshot.model.js"; + +export const projectTransferFormat = "leistungsbilanz.project" as const; +export const projectTransferFormatVersion = 1 as const; + +export interface ProjectTransferEnvelope { + format: typeof projectTransferFormat; + formatVersion: typeof projectTransferFormatVersion; + exportedAtIso: string; + payloadSha256: string; + projectState: ProjectStateSnapshot; +} + +export function createProjectTransferEnvelope( + exportedAtIso: string, + payloadSha256: string, + projectState: ProjectStateSnapshot +): ProjectTransferEnvelope { + return parseProjectTransferEnvelope({ + format: projectTransferFormat, + formatVersion: projectTransferFormatVersion, + exportedAtIso, + payloadSha256, + projectState, + }); +} + +export function parseProjectTransferEnvelope( + value: unknown +): ProjectTransferEnvelope { + if (!isPlainObject(value)) { + throw new Error("Project transfer file must be an object."); + } + if ( + value.format !== projectTransferFormat || + value.formatVersion !== projectTransferFormatVersion + ) { + throw new Error("Project transfer format is not supported."); + } + if ( + typeof value.exportedAtIso !== "string" || + !Number.isFinite(Date.parse(value.exportedAtIso)) + ) { + throw new Error("Project transfer timestamp is invalid."); + } + if ( + typeof value.payloadSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(value.payloadSha256) + ) { + throw new Error("Project transfer checksum is invalid."); + } + const projectState = parseProjectStateSnapshot(value.projectState); + if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) { + throw new Error("Project transfer snapshot version is not supported."); + } + if (Object.keys(value).length !== 5) { + throw new Error("Project transfer file contains unknown fields."); + } + return { + format: projectTransferFormat, + formatVersion: projectTransferFormatVersion, + exportedAtIso: value.exportedAtIso, + payloadSha256: value.payloadSha256, + projectState, + }; +} + +export function remapProjectState( + source: ProjectStateSnapshot, + targetProjectId: string, + createId: () => string, + name: string = source.project.name +): ProjectStateSnapshot { + const boardIds = idMap(source.distributionBoards, createId); + const listIds = idMap(source.circuitLists, createId); + const sectionIds = idMap(source.circuitSections, createId); + const circuitIds = idMap(source.circuits, createId); + const deviceIds = idMap(source.projectDevices, createId); + const floorIds = idMap(source.floors, createId); + const roomIds = idMap(source.rooms, createId); + const rowIds = new Map( + source.circuits.flatMap((circuit) => + circuit.deviceRows.map((row) => [row.id, createId()] as const) + ) + ); + return parseProjectStateSnapshot({ + ...source, + project: { ...source.project, id: targetProjectId, name }, + distributionBoards: source.distributionBoards.map((board) => ({ + ...board, + id: requiredId(boardIds, board.id), + projectId: targetProjectId, + })), + circuitLists: source.circuitLists.map((list) => ({ + ...list, + id: requiredId(listIds, list.id), + projectId: targetProjectId, + distributionBoardId: requiredId(boardIds, list.distributionBoardId), + })), + circuitSections: source.circuitSections.map((section) => ({ + ...section, + id: requiredId(sectionIds, section.id), + circuitListId: requiredId(listIds, section.circuitListId), + })), + circuits: source.circuits.map((circuit) => ({ + ...circuit, + id: requiredId(circuitIds, circuit.id), + circuitListId: requiredId(listIds, circuit.circuitListId), + sectionId: requiredId(sectionIds, circuit.sectionId), + deviceRows: circuit.deviceRows.map((row) => ({ + ...row, + id: requiredId(rowIds, row.id), + circuitId: requiredId(circuitIds, circuit.id), + linkedProjectDeviceId: + row.linkedProjectDeviceId === null + ? null + : requiredId(deviceIds, row.linkedProjectDeviceId), + legacyConsumerId: null, + roomId: + row.roomId === null ? null : requiredId(roomIds, row.roomId), + })), + })), + projectDevices: source.projectDevices.map((device) => ({ + ...device, + id: requiredId(deviceIds, device.id), + projectId: targetProjectId, + })), + floors: source.floors.map((floor) => ({ + ...floor, + id: requiredId(floorIds, floor.id), + projectId: targetProjectId, + })), + rooms: source.rooms.map((room) => ({ + ...room, + id: requiredId(roomIds, room.id), + projectId: targetProjectId, + floorId: + room.floorId === null ? null : requiredId(floorIds, room.floorId), + })), + }); +} + +function idMap( + entries: ReadonlyArray<{ id: string }>, + createId: () => string +) { + return new Map(entries.map((entry) => [entry.id, createId()])); +} + +function requiredId(ids: ReadonlyMap, sourceId: string) { + const targetId = ids.get(sourceId); + if (!targetId) { + throw new Error(`Project transfer reference "${sourceId}" is invalid.`); + } + return targetId; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/project-transfer.store.ts b/src/domain/ports/project-transfer.store.ts new file mode 100644 index 0000000..4467461 --- /dev/null +++ b/src/domain/ports/project-transfer.store.ts @@ -0,0 +1,15 @@ +import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js"; +import type { ProjectTransferEnvelope } from "../models/project-transfer.model.js"; + +export interface PreparedProjectTransferReplace { + command: ProjectStateRestoreCommand; +} + +export interface ProjectTransferStore { + exportProject(projectId: string): ProjectTransferEnvelope | null; + prepareReplace( + projectId: string, + transfer: unknown + ): PreparedProjectTransferReplace | null; + importDuplicate(transfer: unknown): { projectId: string; name: string }; +} diff --git a/src/server/composition/project-transfer-store.ts b/src/server/composition/project-transfer-store.ts new file mode 100644 index 0000000..a9cdb23 --- /dev/null +++ b/src/server/composition/project-transfer-store.ts @@ -0,0 +1,4 @@ +import { db } from "../../db/client.js"; +import { ProjectTransferRepository } from "../../db/repositories/project-transfer.repository.js"; + +export const projectTransferStore = new ProjectTransferRepository(db); diff --git a/src/server/controllers/project-transfer.controller.ts b/src/server/controllers/project-transfer.controller.ts new file mode 100644 index 0000000..a56f3e5 --- /dev/null +++ b/src/server/controllers/project-transfer.controller.ts @@ -0,0 +1,100 @@ +import type { NextFunction, Request, Response } from "express"; +import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; +import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js"; +import { importProjectTransferSchema } from "../../shared/validation/project-transfer.schemas.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { projectTransferStore } from "../composition/project-transfer-store.js"; + +export function exportProjectTransfer(req: Request, res: Response) { + const projectId = getProjectId(req, res); + if (!projectId) { + return; + } + const transfer = projectTransferStore.exportProject(projectId); + if (!transfer) { + return res.status(404).json({ error: "Project not found" }); + } + const filename = safeFilename(transfer.projectState.project.name); + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}.leistungsbilanz.json"` + ); + return res.json(transfer); +} + +export function importProjectTransfer( + req: Request, + res: Response, + next: NextFunction +) { + const projectId = getProjectId(req, res); + if (!projectId) { + return; + } + const parsed = importProjectTransferSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + try { + if (parsed.data.mode === "duplicate") { + return res + .status(201) + .json(projectTransferStore.importDuplicate(parsed.data.transfer)); + } + const prepared = projectTransferStore.prepareReplace( + projectId, + parsed.data.transfer + ); + if (!prepared) { + return res.status(404).json({ error: "Project not found" }); + } + const result = projectCommandService.executeRestore({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Projektimport wiederherstellen", + command: prepared.command, + }); + return res.json(result); + } catch (error) { + 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 ProjectStateConflictError) { + return res + .status(409) + .json({ error: error.message, code: "PROJECT_STATE_CONFLICT" }); + } + if ( + error instanceof Error && + (error.message.startsWith("Project transfer") || + error.message.startsWith("Snapshot")) + ) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +} + +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; +} + +function safeFilename(name: string) { + return ( + name + .normalize("NFKD") + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "projekt" + ); +} diff --git a/src/server/index.ts b/src/server/index.ts index 31e656c..e951a07 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,7 +8,7 @@ import { errorMiddleware } from "./middleware/error.middleware.js"; const app = express(); const port = Number(process.env.PORT || 3000); -app.use(express.json()); +app.use(express.json({ limit: "25mb" })); app.get("/health", (_req, res) => { res.json({ ok: true }); diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index b956dc2..7ebfc11 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -27,12 +27,18 @@ import { listProjectSnapshots, restoreProjectSnapshot, } from "../controllers/project-snapshot.controller.js"; +import { + exportProjectTransfer, + importProjectTransfer, +} from "../controllers/project-transfer.controller.js"; export const projectRouter = Router(); projectRouter.get("/", listProjects); projectRouter.post("/", createProject); projectRouter.get("/:projectId", getProject); +projectRouter.get("/:projectId/export", exportProjectTransfer); +projectRouter.post("/:projectId/import", importProjectTransfer); projectRouter.get("/:projectId/history", getProjectHistory); projectRouter.get("/:projectId/history/revisions", listProjectRevisions); projectRouter.post("/:projectId/commands", executeProjectCommand); diff --git a/src/shared/validation/project-transfer.schemas.ts b/src/shared/validation/project-transfer.schemas.ts new file mode 100644 index 0000000..157c434 --- /dev/null +++ b/src/shared/validation/project-transfer.schemas.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; + +export const importProjectTransferSchema = z.discriminatedUnion("mode", [ + z + .object({ + mode: z.literal("replace"), + expectedRevision: expectedProjectRevisionSchema, + transfer: z.unknown(), + }) + .strict(), + z + .object({ + mode: z.literal("duplicate"), + transfer: z.unknown(), + }) + .strict(), +]); diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index ee480f0..0299069 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -10,6 +10,8 @@ import { } from "../src/db/database-context.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; +import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; +import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; @@ -317,3 +319,100 @@ describe("project snapshot repository", () => { } }); }); + +describe("portable project transfer", () => { + it("exports and atomically duplicates a complete project with new ids", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectTransferRepository(context.db); + const transfer = repository.exportProject("project-1"); + assert.ok(transfer); + const duplicate = repository.importDuplicate(transfer); + assert.notEqual(duplicate.projectId, "project-1"); + assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)"); + + const source = readProjectStateSnapshot(context.db, "project-1"); + const copied = readProjectStateSnapshot( + context.db, + duplicate.projectId + ); + assert.ok(source); + assert.ok(copied); + assert.equal(copied.currentRevision, 0); + assert.equal(copied.state.circuits.length, source.state.circuits.length); + assert.notEqual( + copied.state.circuits[0].id, + source.state.circuits[0].id + ); + assert.equal( + copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId, + copied.state.projectDevices[0].id + ); + assert.equal( + copied.state.circuits[0].deviceRows[0].roomId, + copied.state.rooms[0].id + ); + assert.equal( + copied.state.circuits[0].deviceRows[0].legacyConsumerId, + null + ); + } finally { + context.close(); + } + }); + + it("prepares same-project replacement and rejects damaged payloads", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectTransferRepository(context.db); + const transfer = repository.exportProject("project-1"); + assert.ok(transfer); + const prepared = repository.prepareReplace("project-1", transfer); + assert.ok(prepared); + assert.equal( + prepared.command.payload.targetState.project.id, + "project-1" + ); + assert.throws( + () => + repository.importDuplicate({ + ...transfer, + payloadSha256: "0".repeat(64), + }), + /checksum verification failed/ + ); + assert.equal( + context.db.select().from(projects).all().length, + 1 + ); + } finally { + context.close(); + } + }); + + it("rolls back a duplicate when a late project-state insert fails", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectTransferRepository(context.db); + const transfer = repository.exportProject("project-1"); + assert.ok(transfer); + context.sqlite.exec(` + CREATE TRIGGER fail_imported_device_row + BEFORE INSERT ON circuit_device_rows + BEGIN + SELECT RAISE(ABORT, 'forced project transfer failure'); + END; + `); + assert.throws( + () => repository.importDuplicate(transfer), + /forced project transfer failure/ + ); + assert.equal( + context.db.select().from(projects).all().length, + 1 + ); + } finally { + context.close(); + } + }); +});