import { parseProjectStateSnapshot, projectStateSnapshotSchemaVersion, type ProjectStateSnapshot, } from "./project-state-snapshot.model.js"; import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js"; import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; export const projectTransferFormat = "leistungsbilanz.project" as const; export const projectTransferFormatVersion = 2 as const; const legacyProjectTransferFormatVersion = 1 as const; export interface ProjectTransferEnvelope { format: typeof projectTransferFormat; formatVersion: typeof projectTransferFormatVersion; exportedAtIso: string; payloadSha256: string; projectState: ProjectStateSnapshot; estimateBalances: EstimateBalanceSnapshot[]; } export function createProjectTransferEnvelope( exportedAtIso: string, payloadSha256: string, projectState: ProjectStateSnapshot, estimateBalances: EstimateBalanceSnapshot[] ): ProjectTransferEnvelope { return parseProjectTransferEnvelope({ format: projectTransferFormat, formatVersion: projectTransferFormatVersion, exportedAtIso, payloadSha256, projectState, estimateBalances, }); } 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 !== legacyProjectTransferFormatVersion && 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."); } const estimateBalances = value.formatVersion === legacyProjectTransferFormatVersion ? [] : parseTransferEstimateBalances(value.estimateBalances, projectState.project.id); const expectedFieldCount = value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6; if (Object.keys(value).length !== expectedFieldCount) { throw new Error("Project transfer file contains unknown fields."); } return { format: projectTransferFormat, formatVersion: projectTransferFormatVersion, exportedAtIso: value.exportedAtIso, payloadSha256: value.payloadSha256, projectState, estimateBalances, }; } export function serializeProjectTransferPayload( projectState: ProjectStateSnapshot, estimateBalances: EstimateBalanceSnapshot[] ) { const parsedState = parseProjectStateSnapshot(projectState); return JSON.stringify({ projectState: parsedState, estimateBalances: parseTransferEstimateBalances( estimateBalances, parsedState.project.id ), }); } export function remapEstimateBalances( source: EstimateBalanceSnapshot[], targetProjectId: string, createId: () => string ): EstimateBalanceSnapshot[] { return source.map((balance) => parseEstimateBalanceSnapshot({ ...balance, id: createId(), projectId: targetProjectId, groups: balance.groups.map((group) => ({ ...group, id: createId(), rows: group.rows.map((row) => ({ ...row, id: createId() })), })), }) ); } 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 componentIds = idMap( source.distributionBoardComponents, createId ); const deviceIds = idMap(source.projectDevices, createId); const buildingIds = idMap(source.buildings, 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) ) ); const externalSourceId = source.externalModel.source ? createId() : null; const externalBatchIds = idMap(source.externalModel.importBatches, createId); const externalRoomMappingIds = idMap( source.externalModel.roomMappings, createId ); const externalObjectIds = idMap(source.externalModel.objects, createId); return parseProjectStateSnapshot({ ...source, project: { ...source.project, id: targetProjectId, name }, externalCsvConfiguration: source.externalCsvConfiguration === null ? null : { ...source.externalCsvConfiguration, id: createId(), projectId: targetProjectId, }, externalModel: { source: source.externalModel.source === null ? null : { ...source.externalModel.source, id: externalSourceId!, projectId: targetProjectId, }, importBatches: source.externalModel.importBatches.map((batch) => ({ ...batch, id: requiredId(externalBatchIds, batch.id), projectId: targetProjectId, sourceId: externalSourceId!, })), roomMappings: source.externalModel.roomMappings.map((mapping) => ({ ...mapping, id: requiredId(externalRoomMappingIds, mapping.id), projectId: targetProjectId, sourceId: externalSourceId!, roomId: mapping.roomId === null ? null : requiredId(roomIds, mapping.roomId), defaultDistributionBoardId: mapping.defaultDistributionBoardId === null ? null : requiredId(boardIds, mapping.defaultDistributionBoardId), })), objects: source.externalModel.objects.map((object) => ({ ...object, id: requiredId(externalObjectIds, object.id), projectId: targetProjectId, sourceId: externalSourceId!, lastSeenImportBatchId: requiredId( externalBatchIds, object.lastSeenImportBatchId ), lastAcceptedImportBatchId: requiredId( externalBatchIds, object.lastAcceptedImportBatchId ), externalRoomMappingId: object.externalRoomMappingId === null ? null : requiredId( externalRoomMappingIds, object.externalRoomMappingId ), distributionBoardId: object.distributionBoardId === null ? null : requiredId(boardIds, object.distributionBoardId), linkedProjectDeviceId: object.linkedProjectDeviceId === null ? null : requiredId(deviceIds, object.linkedProjectDeviceId), circuitDeviceRowId: object.circuitDeviceRowId === null ? null : requiredId(rowIds, object.circuitDeviceRowId), })), }, distributionBoards: source.distributionBoards.map((board) => ({ ...board, id: requiredId(boardIds, board.id), projectId: targetProjectId, feedingDistributionBoardId: board.feedingDistributionBoardId === null ? null : requiredId(boardIds, board.feedingDistributionBoardId), feedingCircuitId: board.feedingCircuitId === null ? null : requiredId(circuitIds, board.feedingCircuitId), floorId: board.floorId === null ? null : requiredId(floorIds, board.floorId), })), 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), })), distributionBoardComponents: source.distributionBoardComponents.map((component) => ({ ...component, id: requiredId(componentIds, component.id), circuitListId: requiredId( listIds, component.circuitListId ), sectionId: component.sectionId === null ? null : requiredId(sectionIds, component.sectionId), })), circuitProtectionDevices: source.circuitProtectionDevices.map( (protection) => ({ ...protection, circuitId: requiredId(circuitIds, protection.circuitId), }) ), distributionBoardComponentProtectionDevices: source.distributionBoardComponentProtectionDevices.map( (protection) => ({ ...protection, componentId: requiredId( componentIds, protection.componentId ), }) ), 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), roomId: row.roomId === null ? null : requiredId(roomIds, row.roomId), })), })), projectDevices: source.projectDevices.map((device) => ({ ...device, id: requiredId(deviceIds, device.id), projectId: targetProjectId, })), buildings: source.buildings.map((building) => ({ ...building, id: requiredId(buildingIds, building.id), projectId: targetProjectId, })), floors: source.floors.map((floor) => ({ ...floor, id: requiredId(floorIds, floor.id), projectId: targetProjectId, buildingId: floor.buildingId === null ? null : requiredId(buildingIds, floor.buildingId), })), 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 parseTransferEstimateBalances( value: unknown, projectId: string ): EstimateBalanceSnapshot[] { if (!Array.isArray(value)) { throw new Error("Project transfer estimate balances must be an array."); } const balances = value.map(parseEstimateBalanceSnapshot); if (balances.some((balance) => balance.projectId !== projectId)) { throw new Error("Project transfer estimate balance belongs to another project."); } assertUniqueIds(balances.map((balance) => balance.id), "estimate balance"); assertUniqueIds( balances.flatMap((balance) => balance.groups.map((group) => group.id)), "estimate group" ); assertUniqueIds( balances.flatMap((balance) => balance.groups.flatMap((group) => group.rows.map((row) => row.id)) ), "estimate row" ); return balances; } function assertUniqueIds(ids: string[], label: string) { if (new Set(ids).size !== ids.length) { throw new Error(`Project transfer contains a duplicate ${label} id.`); } } function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); }