import crypto from "node:crypto"; import { eq } from "drizzle-orm"; import { assertExternalInitialImportProjectCommand, createExternalInitialImportProjectCommand, isEmptyExternalModelState, } from "../../domain/models/external-initial-import-project-command.model.js"; import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js"; import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js"; import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js"; import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; import { createExternalRoomKey, indexExternalObjectsByIfcGuid, projectInitialExternalObject, } from "../../external-model/domain/external-model-matching.js"; import type { AppDatabase } from "../database-context.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelSources } from "../schema/external-model-sources.js"; import { externalRoomMappings } from "../schema/external-room-mappings.js"; import { projectDevices } from "../schema/project-devices.js"; import { rooms } from "../schema/rooms.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { ExternalModelStateRepository } from "./external-model-state.repository.js"; export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore { constructor(private readonly database: AppDatabase) {} execute(input: Parameters[0]) { assertExternalInitialImportProjectCommand(input.command); return executeProjectCommandTransaction(this.database, input, (tx) => { const currentResult = new ExternalModelStateRepository(tx) .getByProject(input.projectId); if (!currentResult.projectExists) throw new Error("Project not found."); const expected = input.command.payload.expected; if (!sameState(currentResult.state, expected)) { throw new Error("External model state changed before initial import."); } const target = input.command.payload.target; if (!isEmptyExternalModelState(target)) { validatePopulatedTarget(tx, input, target); } replaceExternalState(tx, input.projectId, target); const persisted = new ExternalModelStateRepository(tx) .getByProject(input.projectId).state; if (!sameState(persisted, target)) { throw new Error("Persisted external initial import differs from its target."); } return createExternalInitialImportProjectCommand(target, currentResult.state); }); } } function validatePopulatedTarget( database: AppDatabase, input: Parameters[0], target: ExternalModelStateSnapshot ) { const source = target.source!; const batch = target.importBatches[0]; if (source.projectId !== input.projectId) { throw new Error("External initial import belongs to a different project."); } if ( input.source === "user" && batch.appliedProjectRevision !== input.expectedRevision + 1 ) { throw new Error("External initial import revision is invalid."); } const configuration = database .select() .from(externalCsvConfigurations) .where(eq(externalCsvConfigurations.projectId, input.projectId)) .get(); if ( !configuration || configuration.configurationVersion !== batch.configurationVersion || canonicalJson(configuration.configuration) !== canonicalJson(batch.configurationSnapshot) ) { throw new Error("External initial import configuration changed before apply."); } const bytes = decodeCanonicalBase64(batch.originalContentBase64); const sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); if (sha256 !== batch.sha256) { throw new Error("External initial import checksum is invalid."); } const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot); if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) { throw new Error("External initial import document differs from the original bytes."); } const preview = createExternalCsvPreview({ fileName: batch.fileName, bytes, configuration: batch.configurationSnapshot, }); const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects); if (targetByIfcGuid.size !== preview.objects.length) { throw new Error("External initial import must contain every CSV object exactly once."); } const mappingsById = new Map( target.roomMappings.map((mapping) => [mapping.id, mapping]) ); const expectedRoomKeys = new Set(); for (const candidate of preview.objects) { const object = targetByIfcGuid.get(candidate.ifcGuid); if (!object) { throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`); } const projected = projectInitialExternalObject( candidate, batch.configurationSnapshot ); if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) { throw new Error("External initial import source values differ from the CSV."); } for (const field of Object.keys(projected.planningValues) as Array) { if ( !object.overriddenFields.includes(field) && canonicalJson(object.planningValues[field]) !== canonicalJson(projected.planningValues[field]) ) { throw new Error(`External initial planning field ${field} changed without an override.`); } } const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName); if (roomKey === null) { if (object.externalRoomMappingId !== null) { throw new Error("External object without a source room must not use a room mapping."); } } else { expectedRoomKeys.add(roomKey); const mapping = object.externalRoomMappingId === null ? null : mappingsById.get(object.externalRoomMappingId); if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) { throw new Error("External object source room mapping is incomplete."); } } } if ( target.roomMappings.length !== expectedRoomKeys.size || target.roomMappings.some( (mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey) ) ) { throw new Error("External initial import room mappings do not match CSV rooms."); } validateInternalLinks(database, input.projectId, target); } function validateInternalLinks( database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot ) { const roomIds = new Set( database.select({ id: rooms.id }).from(rooms) .where(eq(rooms.projectId, projectId)).all().map((row) => row.id) ); const boardIds = new Set( database.select({ id: distributionBoards.id }).from(distributionBoards) .where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id) ); const deviceIds = new Set( database.select({ id: projectDevices.id }).from(projectDevices) .where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id) ); for (const mapping of target.roomMappings) { if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) { throw new Error("External room mapping references another project."); } if ( mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId) ) { throw new Error("External room mapping distribution board belongs to another project."); } } for (const object of target.objects) { if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) { throw new Error("External object distribution board belongs to another project."); } if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) { throw new Error("External object project device belongs to another project."); } } } function replaceExternalState( database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot ) { database.delete(externalModelSources) .where(eq(externalModelSources.projectId, projectId)).run(); if (target.source === null) return; database.insert(externalModelSources).values(target.source).run(); database.insert(externalImportBatches).values( target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: Buffer.from(originalContentBase64, "base64"), })) ).run(); if (target.roomMappings.length) { database.insert(externalRoomMappings).values(target.roomMappings).run(); } database.insert(externalModelObjects).values(target.objects).run(); } function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) { throw new Error("External initial import bytes are not canonical base64."); } return bytes; } function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) { return canonicalJson(sortState(left)) === canonicalJson(sortState(right)); } function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot { const byId = (entries: readonly T[]) => [...entries].sort((left, right) => left.id.localeCompare(right.id)); return { source: state.source, importBatches: byId(state.importBatches), roomMappings: byId(state.roomMappings), objects: byId(state.objects), }; } function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record; return `{${Object.keys(record).sort().map( (key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}` ).join(",")}}`; } return JSON.stringify(value); }