Add portable project transfer API

This commit is contained in:
2026-07-28 18:34:57 +02:00
parent 5fa2a701dc
commit da8115fe1d
10 changed files with 552 additions and 1 deletions
+10
View File
@@ -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.
@@ -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<typeof remapProjectState>
) {
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<TTable extends Parameters<AppDatabase["insert"]>[0]>(
database: AppDatabase,
table: TTable,
values: unknown[]
) {
if (values.length > 0) {
database.insert(table).values(values as never).run();
}
}
+164
View File
@@ -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<string, string>, 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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -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 };
}
@@ -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);
@@ -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"
);
}
+1 -1
View File
@@ -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 });
+6
View File
@@ -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);
@@ -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(),
]);
+99
View File
@@ -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();
}
});
});