import { createHash } from "node:crypto"; import { eq } from "drizzle-orm"; import { assertExternalInitialImportProjectCommand, createExternalInitialImportProjectCommand, isEmptyExternalModelState, } from "../../domain/models/external-initial-import-project-command.model.js"; import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js"; import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js"; import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js"; import type { AppDatabase } from "../database-context.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalModelConfigurations } from "../schema/external-model-configurations.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"; import { insertManyInBatches } from "./batched-insert.persistence.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 currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId); if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden."); const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null; const payload = input.command.payload; if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) { throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert."); } if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState); replaceConfiguration(tx, input.projectId, payload.targetConfiguration); replaceState(tx, input.projectId, payload.targetState); const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state; const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null; if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab."); return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state); }); } } function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) { if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt."); const batch = state.importBatches[0]; if (source === "user" && batch.appliedProjectRevision !== expectedRevision + 1) throw new Error("Die Projektrevision des Revit-Erstimports ist ungültig."); if (!same(batch.configurationSnapshot, configuration.configuration)) throw new Error("Die Konfigurationskopie des Importbatches ist ungültig."); const bytes = decodeCanonicalBase64(batch.originalContentBase64!); if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports ist ungültig."); const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all()); const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all()); const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all()); const deviceTypeIds = new Set(configuration.configuration.deviceTypes.map((type) => type.id)); for (const mapping of state.roomMappings) { if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); if (mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); } for (const object of state.objects) { if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); if (object.linkedProjectDeviceId !== null && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); } } function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); if (target) database.insert(externalModelConfigurations).values(target).run(); } function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) { database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run(); if (!target.source) return; database.insert(externalModelSources).values(target.source).run(); insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))); insertManyInBatches(database, externalRoomMappings, target.roomMappings); insertManyInBatches(database, externalModelObjects, target.objects); } function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("Der XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; } function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); } function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); } function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }