251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
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<ExternalInitialImportProjectCommandStore["execute"]>[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<ExternalInitialImportProjectCommandStore["execute"]>[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<string>();
|
|
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<keyof typeof projected.planningValues>) {
|
|
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 = <T extends { id: string }>(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<string, unknown>;
|
|
return `{${Object.keys(record).sort().map(
|
|
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|