forked from jappel/leistungsbilanz-ts
261 lines
8.7 KiB
TypeScript
261 lines
8.7 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
createProjectTransferEnvelope,
|
|
parseProjectTransferEnvelope,
|
|
remapEstimateBalances,
|
|
remapProjectState,
|
|
serializeProjectTransferPayload,
|
|
} from "../../domain/models/project-transfer.model.js";
|
|
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
|
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
|
import type { AppDatabase } from "../database-context.js";
|
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
|
import { circuitLists } from "../schema/circuit-lists.js";
|
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
|
import { circuitSections } from "../schema/circuit-sections.js";
|
|
import { circuits } from "../schema/circuits.js";
|
|
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
|
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
|
import { buildings } from "../schema/buildings.js";
|
|
import { floors } from "../schema/floors.js";
|
|
import { projectDevices } from "../schema/project-devices.js";
|
|
import { projects } from "../schema/projects.js";
|
|
import { rooms } from "../schema/rooms.js";
|
|
import { externalModelConfigurations } from "../schema/external-model-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 {
|
|
insertEstimateBalance,
|
|
readEstimateBalances,
|
|
} from "./estimate-balance.persistence.js";
|
|
import {
|
|
hashProjectStatePayload,
|
|
readProjectStateSnapshot,
|
|
} from "./project-state-snapshot.persistence.js";
|
|
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
|
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
|
|
|
export class ProjectTransferRepository implements ProjectTransferStore {
|
|
constructor(private readonly database: AppDatabase) {}
|
|
|
|
exportProject(projectId: string) {
|
|
const snapshot = readProjectStateSnapshot(this.database, projectId);
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
const estimateBalances = readEstimateBalances(this.database, projectId);
|
|
const payloadSha256 = hashProjectStatePayload(
|
|
serializeProjectTransferPayload(snapshot.state, estimateBalances)
|
|
);
|
|
return createProjectTransferEnvelope(
|
|
new Date().toISOString(),
|
|
payloadSha256,
|
|
snapshot.state,
|
|
estimateBalances
|
|
);
|
|
}
|
|
|
|
prepareReplace(projectId: string, value: unknown) {
|
|
const transfer = this.parseVerified(value);
|
|
const current = readProjectStateSnapshot(this.database, projectId);
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
const targetState =
|
|
transfer.projectState.project.id === projectId
|
|
? transfer.projectState
|
|
: remapProjectState(
|
|
transfer.projectState,
|
|
projectId,
|
|
() => crypto.randomUUID()
|
|
);
|
|
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
|
|
const targetEstimateBalances =
|
|
transfer.projectState.project.id === projectId
|
|
? transfer.estimateBalances
|
|
: remapEstimateBalances(
|
|
transfer.estimateBalances,
|
|
projectId,
|
|
() => crypto.randomUUID()
|
|
);
|
|
return {
|
|
command: createProjectTransferRestoreCommand(
|
|
current.payloadSha256,
|
|
targetState,
|
|
currentEstimateBalances,
|
|
targetEstimateBalances
|
|
),
|
|
};
|
|
}
|
|
|
|
importDuplicate(value: unknown) {
|
|
const transfer = this.parseVerified(value);
|
|
const projectId = crypto.randomUUID();
|
|
const state = remapProjectState(
|
|
transfer.projectState,
|
|
projectId,
|
|
() => crypto.randomUUID(),
|
|
`${transfer.projectState.project.name} (Kopie)`
|
|
);
|
|
const estimateBalances = remapEstimateBalances(
|
|
transfer.estimateBalances,
|
|
projectId,
|
|
() => crypto.randomUUID()
|
|
);
|
|
this.database.transaction((tx) => {
|
|
insertProjectState(tx, state);
|
|
for (const balance of estimateBalances) {
|
|
insertEstimateBalance(tx, balance);
|
|
}
|
|
});
|
|
return { projectId, name: state.project.name };
|
|
}
|
|
|
|
private parseVerified(value: unknown) {
|
|
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
|
|
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
|
|
? hashProjectStatePayload(
|
|
isLegacy
|
|
? JSON.stringify(value.projectState)
|
|
: JSON.stringify({
|
|
projectState: value.projectState,
|
|
estimateBalances: value.estimateBalances,
|
|
})
|
|
)
|
|
: null;
|
|
const transfer = parseProjectTransferEnvelope(value);
|
|
const payloadJson = isLegacy
|
|
? serializeProjectStateSnapshot(transfer.projectState)
|
|
: serializeProjectTransferPayload(
|
|
transfer.projectState,
|
|
transfer.estimateBalances
|
|
);
|
|
if (
|
|
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
|
rawPayloadSha256 !== transfer.payloadSha256
|
|
) {
|
|
throw new Error("Project transfer checksum verification failed.");
|
|
}
|
|
return transfer;
|
|
}
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function insertProjectState(
|
|
database: AppDatabase,
|
|
state: ReturnType<typeof remapProjectState>
|
|
) {
|
|
const existing = database
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.id, state.project.id))
|
|
.get();
|
|
if (existing) {
|
|
throw new Error("Project transfer target already exists.");
|
|
}
|
|
database
|
|
.insert(projects)
|
|
.values({ ...state.project, currentRevision: 0 })
|
|
.run();
|
|
if (state.externalModelConfiguration !== null) {
|
|
database
|
|
.insert(externalModelConfigurations)
|
|
.values(state.externalModelConfiguration)
|
|
.run();
|
|
}
|
|
insertMany(database, buildings, state.buildings);
|
|
insertMany(database, floors, state.floors);
|
|
insertMany(database, rooms, state.rooms);
|
|
insertMany(database, projectDevices, state.projectDevices);
|
|
insertMany(
|
|
database,
|
|
distributionBoards,
|
|
state.distributionBoards.map((board) => ({
|
|
...board,
|
|
feedingDistributionBoardId: null,
|
|
feedingCircuitId: null,
|
|
}))
|
|
);
|
|
insertMany(database, circuitLists, state.circuitLists);
|
|
insertMany(database, circuitSections, state.circuitSections);
|
|
insertMany(
|
|
database,
|
|
circuits,
|
|
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
|
...circuit,
|
|
isReserve: circuit.isReserve ? 1 : 0,
|
|
}))
|
|
);
|
|
for (const board of state.distributionBoards) {
|
|
if (
|
|
board.feedingDistributionBoardId === null &&
|
|
board.feedingCircuitId === null
|
|
) {
|
|
continue;
|
|
}
|
|
const updated = database
|
|
.update(distributionBoards)
|
|
.set({
|
|
feedingDistributionBoardId: board.feedingDistributionBoardId,
|
|
feedingCircuitId: board.feedingCircuitId,
|
|
})
|
|
.where(eq(distributionBoards.id, board.id))
|
|
.run();
|
|
if (updated.changes !== 1) {
|
|
throw new Error("Distribution-board feeding reference could not be imported.");
|
|
}
|
|
}
|
|
insertMany(
|
|
database,
|
|
distributionBoardComponents,
|
|
state.distributionBoardComponents
|
|
);
|
|
insertMany(
|
|
database,
|
|
circuitProtectionDevices,
|
|
state.circuitProtectionDevices
|
|
);
|
|
insertMany(
|
|
database,
|
|
distributionBoardComponentProtectionDevices,
|
|
state.distributionBoardComponentProtectionDevices
|
|
);
|
|
insertMany(
|
|
database,
|
|
circuitDeviceRows,
|
|
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
|
);
|
|
if (state.externalModel.source !== null) {
|
|
database.insert(externalModelSources).values(state.externalModel.source).run();
|
|
}
|
|
insertMany(
|
|
database,
|
|
externalImportBatches,
|
|
state.externalModel.importBatches.map(
|
|
({ originalContentBase64, ...batch }) => ({
|
|
...batch,
|
|
originalBytes:
|
|
originalContentBase64 === null
|
|
? null
|
|
: Buffer.from(originalContentBase64, "base64"),
|
|
})
|
|
)
|
|
);
|
|
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
|
|
insertMany(database, externalModelObjects, state.externalModel.objects);
|
|
}
|
|
|
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
|
database: AppDatabase,
|
|
table: TTable,
|
|
values: unknown[]
|
|
) {
|
|
insertManyInBatches(database, table, values);
|
|
}
|