From feec814dc5143097d634c1da79cd00619eddafe9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Tue, 28 Jul 2026 19:36:26 +0200 Subject: [PATCH] Add project transfer controls --- src/app/projects/[projectId]/page.tsx | 65 ++++++++++ ...roject-state-restore-command.repository.ts | 86 +++++++++++- .../components/project-settings-modal.tsx | 122 ++++++++++++++++++ src/frontend/utils/api.ts | 23 ++++ ...t-state-restore-command.repository.test.ts | 65 +++++++++- 5 files changed, 353 insertions(+), 8 deletions(-) diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 596d596..32feec3 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -12,6 +12,7 @@ import { createRoom, deleteProjectDevice, disconnectProjectDeviceRows, + exportProjectTransfer, getProjectDeviceSyncPreview, listCircuitLists, listDistributionBoards, @@ -20,6 +21,7 @@ import { listProjectDevices, listProjects, listRooms, + importProjectTransfer, synchronizeProjectDeviceRows, updateProjectDevice, updateProjectSettings, @@ -349,6 +351,67 @@ export default function ProjectDetailPage() { } } + async function handleExportProject() { + if (!project) { + return; + } + setIsSaving(true); + 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); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Projekt konnte nicht exportiert werden." + ); + } finally { + setIsSaving(false); + } + } + + async function handleImportProject( + transfer: unknown, + mode: "replace" | "duplicate" + ) { + if (!project) { + return; + } + setIsSaving(true); + setError(null); + try { + const result = await importProjectTransfer( + project.id, + mode, + project.currentRevision, + transfer + ); + if (mode === "duplicate" && "projectId" in result) { + window.location.href = `/projects/${result.projectId}`; + return; + } + window.location.reload(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Projektdatei konnte nicht importiert werden." + ); + setIsProjectSettingsOpen(false); + } finally { + setIsSaving(false); + } + } + async function openProjectDeviceSyncPreview(projectDeviceId: string) { if (!projectId) { return; @@ -1015,6 +1078,8 @@ export default function ProjectDetailPage() { setIsProjectSettingsOpen(false)} + onExport={handleExportProject} + onImport={handleImportProject} onSave={handleSaveProjectSettings} project={project} /> diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index 28c6168..a3b2cd4 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -6,7 +6,6 @@ import { } from "../../domain/models/project-state-restore-command.model.js"; import { parseProjectStateSnapshot, - serializeProjectStateSnapshot, type ProjectStateSnapshot, } from "../../domain/models/project-state-snapshot.model.js"; import type { @@ -24,11 +23,11 @@ import { floors } from "../schema/floors.js"; import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js"; import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js"; import { projectDevices } from "../schema/project-devices.js"; +import { projectChangeSets } from "../schema/project-change-sets.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { - hashProjectStatePayload, readProjectStateSnapshot, } from "./project-state-snapshot.persistence.js"; @@ -63,7 +62,8 @@ export class ProjectStateRestoreCommandRepository } if ( current.payloadSha256 !== - input.command.payload.expectedStateSha256 + input.command.payload.expectedStateSha256 && + !matchesHistoryRestoreTarget(tx, input, current.state) ) { throw new ProjectStateConflictError( input.projectId, @@ -75,18 +75,90 @@ export class ProjectStateRestoreCommandRepository const targetState = parseProjectStateSnapshot( input.command.payload.targetState ); - const targetPayloadJson = serializeProjectStateSnapshot(targetState); + replaceProjectState(tx, targetState); + const persistedTarget = readProjectStateSnapshot(tx, input.projectId); + if (!persistedTarget) { + throw new Error("Restored project could not be loaded."); + } const inverse = createProjectStateRestoreCommand( - hashProjectStatePayload(targetPayloadJson), + persistedTarget.payloadSha256, current.state ); - replaceProjectState(tx, targetState); - return inverse; } } +function matchesHistoryRestoreTarget( + database: AppDatabase, + input: ExecuteProjectStateRestoreCommandInput, + currentState: ProjectStateSnapshot +) { + if ( + (input.source !== "undo" && input.source !== "redo") || + !input.historyTargetChangeSetId + ) { + return false; + } + const changeSet = database + .select({ + forwardPayloadJson: projectChangeSets.forwardPayloadJson, + inversePayloadJson: projectChangeSets.inversePayloadJson, + }) + .from(projectChangeSets) + .where(eq(projectChangeSets.id, input.historyTargetChangeSetId)) + .get(); + if (!changeSet) { + return false; + } + try { + const serialized = + input.source === "undo" + ? changeSet.forwardPayloadJson + : changeSet.inversePayloadJson; + const command = JSON.parse(serialized) as { + type?: unknown; + payload?: { targetState?: unknown }; + }; + if (command.type !== "project.restore-state") { + return false; + } + const expectedState = parseProjectStateSnapshot( + command.payload?.targetState + ); + return ( + canonicalProjectState(expectedState) === + canonicalProjectState(currentState) + ); + } catch { + return false; + } +} + +function canonicalProjectState(state: ProjectStateSnapshot) { + return JSON.stringify({ + ...state, + distributionBoards: byId(state.distributionBoards), + circuitLists: byId(state.circuitLists), + circuitSections: byId(state.circuitSections), + circuits: byId( + state.circuits.map((circuit) => ({ + ...circuit, + deviceRows: byId(circuit.deviceRows), + })) + ), + projectDevices: byId(state.projectDevices), + floors: byId(state.floors), + rooms: byId(state.rooms), + }); +} + +function byId(entries: readonly T[]) { + return [...entries].sort((left, right) => + left.id.localeCompare(right.id) + ); +} + function replaceProjectState( database: AppDatabase, state: ProjectStateSnapshot diff --git a/src/frontend/components/project-settings-modal.tsx b/src/frontend/components/project-settings-modal.tsx index e19d130..5073129 100644 --- a/src/frontend/components/project-settings-modal.tsx +++ b/src/frontend/components/project-settings-modal.tsx @@ -16,6 +16,11 @@ export interface ProjectSettingsInput { interface ProjectSettingsModalProps { isSaving: boolean; onClose: () => void; + onExport: () => Promise; + onImport: ( + transfer: unknown, + mode: "replace" | "duplicate" + ) => Promise; onSave: (input: ProjectSettingsInput) => Promise; project: ProjectDto; } @@ -23,6 +28,8 @@ interface ProjectSettingsModalProps { export function ProjectSettingsModal({ isSaving, onClose, + onExport, + onImport, onSave, project, }: ProjectSettingsModalProps) { @@ -45,6 +52,13 @@ export function ProjectSettingsModal({ const [threePhaseVoltageV, setThreePhaseVoltageV] = useState( String(project.threePhaseVoltageV) ); + const [transfer, setTransfer] = useState(null); + const [transferFilename, setTransferFilename] = useState(""); + const [importMode, setImportMode] = useState<"replace" | "duplicate">( + "duplicate" + ); + const [replaceConfirmed, setReplaceConfirmed] = useState(false); + const [fileError, setFileError] = useState(null); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -64,6 +78,21 @@ export function ProjectSettingsModal({ Number(singlePhaseVoltageV) > 0 && Number(threePhaseVoltageV) > 0; + async function handleTransferFile(file: File | undefined) { + setTransfer(null); + setTransferFilename(""); + setFileError(null); + if (!file) { + return; + } + try { + setTransfer(JSON.parse(await file.text()) as unknown); + setTransferFilename(file.name); + } catch { + setFileError("Die ausgewählte Datei enthält kein gültiges JSON."); + } + } + return ( <>
+
+
+

Projekt importieren oder exportieren

+

+ Der Export enthält den vollständigen unterstützten + Projektzustand. Beim Duplizieren bleibt dieses Projekt + unverändert. +

+ + + + void handleTransferFile(event.target.files?.[0]) + } + type="file" + /> + {transferFilename ? ( +
{transferFilename} ist bereit.
+ ) : null} + {fileError ? ( +
{fileError}
+ ) : null} +
+ + +
+ {importMode === "replace" ? ( + + ) : null} + +
diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 5439067..6300c10 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -229,6 +229,29 @@ export function updateProjectSettings( ); } +export function exportProjectTransfer(projectId: string) { + return request(`/api/projects/${projectId}/export`); +} + +export function importProjectTransfer( + projectId: string, + mode: "replace" | "duplicate", + expectedRevision: number, + transfer: unknown +) { + return request< + | { projectId: string; name: string } + | ProjectSettingsCommandResultDto + >(`/api/projects/${projectId}/import`, { + method: "POST", + body: JSON.stringify( + mode === "replace" + ? { mode, expectedRevision, transfer } + : { mode, transfer } + ), + }); +} + export function listDistributionBoards(projectId: string) { return request(`/api/projects/${projectId}/distribution-boards`); } diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index a3fdf8c..e8a26db 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -22,9 +22,9 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/reposi import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; +import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.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"; @@ -37,6 +37,12 @@ import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; import { projects } from "../src/db/schema/projects.js"; +import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; +import { + hashProjectStatePayload, + readProjectStateSnapshot, +} from "../src/db/repositories/project-state-snapshot.persistence.js"; +import { serializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js"; import { rooms } from "../src/db/schema/rooms.js"; import { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; @@ -253,6 +259,63 @@ function seedUpgradeOnlyData(context: DatabaseContext) { } describe("project state restore command", () => { + it("undoes a remapped cross-project import using the persisted target hash", () => { + const context = createTestDatabase(); + try { + const transfers = new ProjectTransferRepository(context.db); + const transfer = transfers.exportProject("project-1"); + assert.ok(transfer); + const duplicate = transfers.importDuplicate(transfer); + const before = readProjectStateSnapshot( + context.db, + duplicate.projectId + ); + assert.ok(before); + const prepared = transfers.prepareReplace( + duplicate.projectId, + transfer + ); + assert.ok(prepared); + const service = createService(context); + const restored = service.executeRestore({ + projectId: duplicate.projectId, + expectedRevision: 0, + description: "Projektimport wiederherstellen", + command: prepared.command, + }); + const legacyExpectedHash = hashProjectStatePayload( + serializeProjectStateSnapshot( + prepared.command.payload.targetState + ) + ); + const storedChangeSet = context.db + .select() + .from(projectChangeSets) + .where(eq(projectChangeSets.id, restored.revision.changeSetId)) + .get(); + assert.ok(storedChangeSet); + const inverse = JSON.parse(storedChangeSet.inversePayloadJson); + inverse.payload.expectedStateSha256 = legacyExpectedHash; + context.db + .update(projectChangeSets) + .set({ inversePayloadJson: JSON.stringify(inverse) }) + .where(eq(projectChangeSets.id, storedChangeSet.id)) + .run(); + const undone = service.undo({ + projectId: duplicate.projectId, + expectedRevision: 1, + }); + assert.equal(undone.revision.revisionNumber, 2); + assert.equal( + readProjectStateSnapshot(context.db, duplicate.projectId) + ?.payloadSha256, + before.payloadSha256 + ); + } finally { + context.close(); + } + }); + it("restores a complete snapshot as a revision and supports undo and redo", () => { const context = createTestDatabase(); try {