leistungsbilanz-ts/src/db/repositories/project-transfer.repository.ts
Grovy311 cd89c4a499 Add buildings above floors and a building structure page
Projects with several buildings could not be structured: floors and rooms
hung directly off the project. Floors can now belong to a building
(migration 0009: table buildings, nullable floors.building_id). Rooms and
distribution boards keep their floor; their building follows from it.
Existing floors stay without a building.

Buildings use the location-structure command store with
project-building.insert/.update/.delete, inverses and persistent Undo/Redo;
deleting a building with assigned floors is rejected. Floor commands carry
buildingId since command version 2. Version 1 floor commands from existing
history predate buildings and are upgraded with buildingId = null before
they are executed and persisted, so forward and inverse keep one version.

Project snapshot schema 7 stores buildings and floor.buildingId; versions
1-6 are upgraded without buildings. Restore and portable transfer include
buildings, and the transfer remaps building ids.

Buildings, floors and rooms move from the project overview to their own
page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume)
that filter each other, and a sidebar entry. Floor selects in the board and
room dialogs are grouped by building. Unchanged dialogs close without a
command. Details in docs/building-structure.md; see issue #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:52:34 +02:00

191 lines
6.4 KiB
TypeScript

import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import {
createProjectTransferEnvelope,
parseProjectTransferEnvelope,
remapProjectState,
} from "../../domain/models/project-transfer.model.js";
import { createProjectStateRestoreCommand } 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 { 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 {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
export class ProjectTransferRepository implements ProjectTransferStore {
constructor(private readonly database: AppDatabase) {}
exportProject(projectId: string) {
const snapshot = readProjectStateSnapshot(this.database, projectId);
if (!snapshot) {
return null;
}
return createProjectTransferEnvelope(
new Date().toISOString(),
snapshot.payloadSha256,
snapshot.state
);
}
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()
);
return {
command: createProjectStateRestoreCommand(
current.payloadSha256,
targetState
),
};
}
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)`
);
this.database.transaction((tx) => insertProjectState(tx, state));
return { projectId, name: state.project.name };
}
private parseVerified(value: unknown) {
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
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.externalCsvConfiguration !== null) {
database
.insert(externalCsvConfigurations)
.values(state.externalCsvConfiguration)
.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);
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,
}))
);
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: 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[]
) {
if (values.length > 0) {
database.insert(table).values(values as never).run();
}
}