import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { ALLOWED_DOCUMENT_MIME_TYPES, isAllowedDocumentMimeType, } from '../documents/document-mime-types'; import { matchesFileSignature } from '../common/file-signature'; import { appendLegacyFeedNote, feedConditionFromInput, } from '../common/feed-vocabulary'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { DocumentEntityType, Prisma } from '../../generated/prisma'; import { SealedBackup, isSealedBackup, openBackup, sealBackup, } from './backup-crypto'; import { legacyHoneyLedger } from './legacy-honey-ledger'; // 3: production, raw material, stock ledger, number sequence, calendar sync // and backup schedule tables. Older bundles simply lack those sections and // restore them as empty. const BACKUP_VERSION = 3; const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000; export interface BackupBundle { version: number; exportedAt: string; data: Record; } export interface BackupImportSummary { imported: Record; /** * Tables the file carried but an unsealed backup may not write - every * table outside LEGACY_UNSEALED_TABLES. */ skipped: string[]; /** False for an old, unsealed file: restricted and add-only. */ sealed: boolean; } /** * All an unsealed file may write. Its contents cannot be proven to come from * ApiCore (it may be hand-built), so it is limited to the plain beekeeping * master and field records of the old unsealed (v2) format - records an * admin could just as well type in again - and it may only add missing * rows, never rewrite existing ones. * * Sealed-only, and why (any table not listed here is sealed-only too, so * tables added later are safe by default): * - role, user, rolePermission: grant access. * - webhookSubscription, calendarSyncSettings, calendarSyncOutboxEntry: * send data out, or update/delete events in an M365 calendar. * - backupSettings: retention 1 would prune every older backup. * - auditLog: history must not be rewritten. * - automationRule, automationSequence: hold the lot-number counters (a * reset hands out duplicate batch numbers) and the rule thresholds. * - stockItem, stockMovement, batchMovement: running stock and the stock * ledgers - never forged, only booked. * - shopStockRequest: the shop's idempotency keys - a planted one would * swallow a real sale. * - rawMaterialLot, productionBatch, productionInput, * honeyBatchContainerAllocation: newer than the unsealed format, so a * genuine old file never carries them. * - siteSettings: keeper name/address printed on the Bestandsbuch and the * public links. * - document: writes files to the server's disk. * - honeyBatch.initialAmount: an unsealed file may set it (derived from * its bottlings) but never books ledger rows - batchMovement stays sealed * only, so the BOTTLING bookings of an old bundle come with sealed files. * - notification, notificationPreference: messages shown to users as coming * from ApiCore, and settings of a user. */ export const LEGACY_UNSEALED_TABLES = [ 'apiary', 'queen', 'colony', 'colonyApiaryHistory', 'colonyLineage', 'queenEvaluation', 'queenColonyHistory', 'inspection', 'taskTemplate', 'task', 'varroaMeasurement', 'swarmControlCheck', 'winterCheck', 'treatment', 'feeding', 'harvest', 'harvestColony', 'extraction', 'extractionHarvest', 'storageContainer', 'honeyBatch', 'honeyMeasurement', 'honeyBatchRelocation', 'product', 'labelTemplate', 'bottlingBatch', 'equipment', ] as const; // Bundle sections that name a real table - a file's other keys are ignored // for every file and so never reported as skipped. const MODEL_SECTIONS = new Set( Object.values(Prisma.ModelName).map( (model) => model.charAt(0).toLowerCase() + model.slice(1), ), ); /** * Models deliberately left out of the backup, each with its reason. Every * other Prisma model is a section of the bundle, named like its client * delegate (`productionBatch`). The model coverage test in * backup.service.spec.ts fails for a model that is neither, so a new table * cannot silently drop out of the backup. */ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly< Partial> > = { Permission: 'Code-derived catalog, re-synced from the code on every boot. RolePermission rows reference permissions by resource+action instead, resolved against the target database.', RefreshToken: 'Live session tokens - a session must never be revivable from a backup file.', ConsumedToken: 'Replay guard for one-time tokens that expire within minutes, so always stale by the time a backup is restored.', ApiKey: 'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.', IdempotencyRecord: 'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.', DomainEvent: 'Operational outbox - side effects of restored data must never be replayed after a restore.', DomainEventDelivery: 'Per-handler progress of DomainEvent rows, excluded with them.', WebhookDelivery: 'Delivery log and retry queue - restoring it would resend old events to receivers.', JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.", ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.', ApiaryPhenology: 'Derived season state, recomputed by daily-evaluation.', ApiaryWeatherDay: 'Weather cache, refetched by the worker.', }; const DOCUMENT_ENTITY_TYPES = new Set( Object.values(DocumentEntityType), ); // Excluded on purpose: see MODELS_EXCLUDED_FROM_BACKUP. // // Role/User/RolePermission ARE included (v2) - Role is matched by its `key` // (not id, since a fresh deployment's seeded system roles get a new // database-generated id every time) and User carries a `roleKey` instead of // a raw roleId for the same reason. Every other model's `userId`-shaped // column is resolved against the now-imported User table; an id that still // doesn't exist there (e.g. a partial/older backup) falls back to the id of // the admin performing the import. // // Document is included with the actual file content (base64), not just the // DB row - the row alone would point at a path that was never copied. A row // whose file could not be read at export time is skipped on import rather // than restored with a dangling path. type Row = Record; function asString(row: Row, key: string): string | null { const value = row[key]; return typeof value === 'string' ? value : null; } // Bundles written before the feed vocabulary change carry free text in // Inspection.feedStatus: known words map to the enum, the rest goes to notes. function withCurrentFeedVocabulary(row: Row): Row { if (!('feedStatus' in row)) { return row; } const raw = row.feedStatus; // A value that is neither text nor empty cannot map; keep it in the notes. const { feedStatus, notesSuffix } = raw === null || raw === undefined || typeof raw === 'string' ? feedConditionFromInput(raw) : { feedStatus: null, notesSuffix: `Futter (alt): ${JSON.stringify(raw)}`, }; return { ...row, feedStatus, notes: appendLegacyFeedNote(asString(row, 'notes'), notesSuffix), }; } // Bundles written before the notification categories carry none: job failures // (relatedType 'JobRun') and rows without relatedType are system messages, // everything else a suggestion - the rule of migration 11. function withCurrentNotificationCategory(row: Row): Row { if (typeof row.category === 'string') { return row; } const relatedType = asString(row, 'relatedType'); return { ...row, category: relatedType === null || relatedType === 'JobRun' ? 'SYSTEM' : 'SUGGESTION', }; } function documentsStorageRoot(): string { return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'; } @Injectable() export class BackupService { private readonly logger = new Logger(BackupService.name); constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, ) {} /** The export as it leaves the server: encrypted and tamper-evident. */ async exportSealed(): Promise { const bundle = await this.exportAll(); return sealBackup(bundle, bundle.exportedAt); } /** * Restores an uploaded file. A sealed backup is restored completely; an * old unsealed one only add-only and limited to LEGACY_UNSEALED_TABLES. */ async importFile( parsed: unknown, actingUserId: string, ): Promise { if (isSealedBackup(parsed)) { return this.importAll(openBackup(parsed) as BackupBundle, actingUserId, { trusted: true, }); } return this.importAll(parsed as BackupBundle, actingUserId, { trusted: false, }); } /** Imported honey batches whose amount differs from the ledger by more than 0.5 g. */ private async honeyLedgerViolations( tx: Prisma.TransactionClient, ids: string[], ): Promise { if (ids.length === 0) { return []; } const rows = await tx.$queryRaw<{ id: string }[]>(Prisma.sql` SELECT hb."id" FROM "HoneyBatch" hb LEFT JOIN ( SELECT "honeyBatchId", sum("quantity") AS grams FROM "BatchMovement" WHERE "honeyBatchId" = ANY(${ids}) GROUP BY "honeyBatchId" ) m ON m."honeyBatchId" = hb."id" WHERE hb."id" = ANY(${ids}) AND hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.grams, 0) / 1000.0)) > 0.0005 ORDER BY hb."id" `); return rows.map((row) => row.id); } async exportAll(): Promise { const data: Record = {}; data.role = await this.prisma.role.findMany(); const rolePermissions = await this.prisma.rolePermission.findMany({ include: { role: { select: { key: true } }, permission: { select: { resource: true, action: true } }, }, }); data.rolePermission = rolePermissions.map((rp) => ({ roleKey: rp.role.key, resource: rp.permission.resource, action: rp.permission.action, })); const users = await this.prisma.user.findMany({ include: { role: { select: { key: true } } }, }); data.user = users.map(({ role, ...rest }) => { const withoutRoleId: Record = { ...rest }; delete withoutRoleId.roleId; return { ...withoutRoleId, roleKey: role.key }; }); data.apiary = await this.prisma.apiary.findMany(); data.queen = await this.prisma.queen.findMany(); data.colony = await this.prisma.colony.findMany(); data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany(); data.colonyLineage = await this.prisma.colonyLineage.findMany(); data.queenEvaluation = await this.prisma.queenEvaluation.findMany(); data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany(); data.inspection = await this.prisma.inspection.findMany(); data.taskTemplate = await this.prisma.taskTemplate.findMany(); data.suggestion = await this.prisma.suggestion.findMany(); data.task = await this.prisma.task.findMany(); data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany(); data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany(); data.winterCheck = await this.prisma.winterCheck.findMany(); data.phenologyReport = await this.prisma.phenologyReport.findMany(); data.treatment = await this.prisma.treatment.findMany(); data.feeding = await this.prisma.feeding.findMany(); data.harvest = await this.prisma.harvest.findMany(); data.harvestColony = await this.prisma.harvestColony.findMany(); data.extraction = await this.prisma.extraction.findMany(); data.extractionHarvest = await this.prisma.extractionHarvest.findMany(); data.storageContainer = await this.prisma.storageContainer.findMany(); data.honeyBatch = await this.prisma.honeyBatch.findMany(); data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany(); data.honeyBatchRelocation = await this.prisma.honeyBatchRelocation.findMany(); data.honeyBatchContainerAllocation = await this.prisma.honeyBatchContainerAllocation.findMany(); data.stockItem = await this.prisma.stockItem.findMany(); data.stockMovement = await this.prisma.stockMovement.findMany(); data.product = await this.prisma.product.findMany(); data.labelTemplate = await this.prisma.labelTemplate.findMany(); data.bottlingBatch = await this.prisma.bottlingBatch.findMany(); data.rawMaterialLot = await this.prisma.rawMaterialLot.findMany(); data.productionBatch = await this.prisma.productionBatch.findMany(); data.productionInput = await this.prisma.productionInput.findMany(); data.shopStockRequest = await this.prisma.shopStockRequest.findMany(); data.batchMovement = await this.prisma.batchMovement.findMany(); data.equipment = await this.prisma.equipment.findMany(); data.webhookSubscription = await this.prisma.webhookSubscription.findMany(); data.automationRule = await this.prisma.automationRule.findMany(); data.automationSequence = await this.prisma.automationSequence.findMany(); data.siteSettings = await this.prisma.siteSettings.findMany(); data.backupSettings = await this.prisma.backupSettings.findMany(); data.calendarSyncSettings = await this.prisma.calendarSyncSettings.findMany(); data.calendarSyncOutboxEntry = await this.prisma.calendarSyncOutboxEntry.findMany(); const documents = await this.prisma.document.findMany(); data.document = await Promise.all( documents.map(async (document) => { const { storagePath, ...rest } = document; try { const contentBase64 = (await fs.readFile(storagePath)).toString( 'base64', ); return { ...rest, contentBase64 }; } catch { // File missing on disk - keep the DB row's metadata visible in the // export, but with no content to restore from. return { ...rest, contentBase64: null }; } }), ); data.notification = await this.prisma.notification.findMany(); data.notificationPreference = await this.prisma.notificationPreference.findMany(); data.auditLog = await this.prisma.auditLog.findMany(); return { version: BACKUP_VERSION, exportedAt: new Date().toISOString(), data, }; } async importAll( bundle: BackupBundle, actingUserId: string, { trusted }: { trusted: boolean }, ): Promise { if ( typeof bundle !== 'object' || bundle === null || typeof bundle.data !== 'object' || bundle.data === null ) { throw new BadRequestException('Invalid backup file'); } const isAllowedUnsealed = (key: string): boolean => (LEGACY_UNSEALED_TABLES as readonly string[]).includes(key); const skipped = trusted ? [] : Object.keys(bundle.data).filter((key) => { const value = bundle.data[key]; return ( MODEL_SECTIONS.has(key) && !isAllowedUnsealed(key) && Array.isArray(value) && value.length > 0 ); }); const rowsOf = (key: string): Row[] => { if (!trusted && !isAllowedUnsealed(key)) { return []; } const value = bundle.data[key]; return Array.isArray(value) ? (value as Row[]) : []; }; // An unsealed file may add the rows it carries, never rewrite or roll // back ones that already exist here. const updateWith = (data: Row): Row => (trusted ? data : {}); // Document files the restored rows no longer point at - deleted only // once the transaction has committed. const replacedDocumentFiles: string[] = []; const imported: Record = {}; await this.prisma.$transaction( async (tx) => { const roleKeyToId = new Map(); // Roles already in this database resolve too - an unsealed file // brings none of its own. for (const role of await tx.role.findMany({ select: { id: true, key: true }, })) { roleKeyToId.set(role.key, role.id); } for (const row of rowsOf('role')) { const withoutId: Row = { ...row }; delete withoutId.id; const upserted = await tx.role.upsert({ where: { key: row.key as string }, create: row as never, update: withoutId as never, }); roleKeyToId.set(row.key as string, upserted.id); } imported.role = rowsOf('role').length; function resolveRoleId(roleKey: string): string { const id = roleKeyToId.get(roleKey); if (!id) { throw new BadRequestException( `Backup references unknown role "${roleKey}"`, ); } return id; } for (const row of rowsOf('user')) { const { roleKey, ...rest } = row; const data = { ...rest, roleId: resolveRoleId(roleKey as string), }; await tx.user.upsert({ where: { id: row.id as string }, create: data as never, update: data as never, }); } imported.user = rowsOf('user').length; for (const row of rowsOf('rolePermission')) { const roleId = resolveRoleId(row.roleKey as string); const permission = await tx.permission.findUnique({ where: { resource_action: { resource: row.resource as string, action: row.action as string, }, }, }); if (!permission) { // Permission catalog is code-derived and synced at boot; a // resource/action from an older export that no longer exists in // this version of the app is skipped rather than failing the // whole import. continue; } await tx.rolePermission.upsert({ where: { roleId_permissionId: { roleId, permissionId: permission.id }, }, create: { roleId, permissionId: permission.id }, update: {}, }); } const violations = await this.honeyLedgerViolations( tx, rowsOf('honeyBatch').map((row) => row.id as string), ); if (violations.length > 0) { imported.honeyLedgerViolations = violations.length; this.logger.warn( `Honigcharge(n) mit verletzter Mengen-Invariante nach dem Import: ${violations.join(', ')}`, ); } imported.rolePermission = rowsOf('rolePermission').length; // Built after role/user import so a user id that only exists // because THIS SAME bundle just created it is still recognised. const knownUserIds = new Set( (await tx.user.findMany({ select: { id: true } })).map( (user) => user.id, ), ); const resolveUserId = (rawUserId: string | null): string => rawUserId !== null && knownUserIds.has(rawUserId) ? rawUserId : actingUserId; const resolveOptionalUserId = ( rawUserId: string | null, ): string | null => rawUserId === null ? null : resolveUserId(rawUserId); for (const row of rowsOf('apiary')) { await tx.apiary.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.apiary = rowsOf('apiary').length; // Queens already here before this import keep their mother line when // the file is unsealed (add-only, see updateWith). const preexistingQueenIds = new Set( trusted ? [] : ( await tx.queen.findMany({ where: { id: { in: rowsOf('queen').map((row) => row.id as string) }, }, select: { id: true }, }) ).map((queen) => queen.id), ); // Inserted without motherQueenId first (a daughter queen can appear // before her mother in export order) - patched in a second pass // below once every queen row exists. for (const row of rowsOf('queen')) { const withoutMother: Row = { ...row, motherQueenId: null }; await tx.queen.upsert({ where: { id: row.id as string }, create: withoutMother as never, update: updateWith(withoutMother) as never, }); } imported.queen = rowsOf('queen').length; for (const row of rowsOf('colony')) { await tx.colony.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.colony = rowsOf('colony').length; for (const row of rowsOf('queen')) { if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) { await tx.queen.update({ where: { id: row.id as string }, data: { motherQueenId: row.motherQueenId as string }, }); } } for (const row of rowsOf('colonyApiaryHistory')) { await tx.colonyApiaryHistory.upsert({ where: { id: row.id as string }, create: { ...row, movedByUserId: resolveOptionalUserId( asString(row, 'movedByUserId'), ), } as never, update: updateWith({ ...row, movedByUserId: resolveOptionalUserId( asString(row, 'movedByUserId'), ), }) as never, }); } imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length; for (const row of rowsOf('colonyLineage')) { await tx.colonyLineage.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.colonyLineage = rowsOf('colonyLineage').length; for (const row of rowsOf('queenEvaluation')) { await tx.queenEvaluation.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveOptionalUserId(asString(row, 'userId')), }) as never, }); } imported.queenEvaluation = rowsOf('queenEvaluation').length; for (const row of rowsOf('queenColonyHistory')) { await tx.queenColonyHistory.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.queenColonyHistory = rowsOf('queenColonyHistory').length; for (const row of rowsOf('inspection')) { const inspection = { ...withCurrentFeedVocabulary(row), userId: resolveUserId(asString(row, 'userId')), }; await tx.inspection.upsert({ where: { id: row.id as string }, create: inspection as never, update: updateWith(inspection) as never, }); } imported.inspection = rowsOf('inspection').length; for (const row of rowsOf('taskTemplate')) { await tx.taskTemplate.upsert({ where: { id: row.id as string }, create: { ...row, assignedUserId: resolveOptionalUserId( asString(row, 'assignedUserId'), ), createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), } as never, update: updateWith({ ...row, assignedUserId: resolveOptionalUserId( asString(row, 'assignedUserId'), ), createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), }) as never, }); } imported.taskTemplate = rowsOf('taskTemplate').length; // Before the tasks: Task.sourceSuggestionId points at them. After the // colonies: a suggestion may hang on one. for (const row of rowsOf('suggestion')) { const data = { ...row, decidedByUserId: resolveOptionalUserId( asString(row, 'decidedByUserId'), ), }; await tx.suggestion.upsert({ where: { id: row.id as string }, create: data as never, update: updateWith(data) as never, }); } imported.suggestion = rowsOf('suggestion').length; for (const row of rowsOf('task')) { await tx.task.upsert({ where: { id: row.id as string }, create: { ...row, assignedUserId: resolveOptionalUserId( asString(row, 'assignedUserId'), ), createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), } as never, update: updateWith({ ...row, assignedUserId: resolveOptionalUserId( asString(row, 'assignedUserId'), ), createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), }) as never, }); } imported.task = rowsOf('task').length; for (const row of rowsOf('varroaMeasurement')) { await tx.varroaMeasurement.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.varroaMeasurement = rowsOf('varroaMeasurement').length; for (const row of rowsOf('swarmControlCheck')) { await tx.swarmControlCheck.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.swarmControlCheck = rowsOf('swarmControlCheck').length; for (const row of rowsOf('winterCheck')) { await tx.winterCheck.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.winterCheck = rowsOf('winterCheck').length; for (const row of rowsOf('phenologyReport')) { const report = { ...row, userId: resolveUserId(asString(row, 'userId')), }; await tx.phenologyReport.upsert({ where: { id: row.id as string }, create: report as never, update: updateWith(report) as never, }); } imported.phenologyReport = rowsOf('phenologyReport').length; for (const row of rowsOf('treatment')) { await tx.treatment.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.treatment = rowsOf('treatment').length; for (const row of rowsOf('feeding')) { await tx.feeding.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.feeding = rowsOf('feeding').length; for (const row of rowsOf('harvest')) { await tx.harvest.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.harvest = rowsOf('harvest').length; for (const row of rowsOf('harvestColony')) { await tx.harvestColony.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.harvestColony = rowsOf('harvestColony').length; for (const row of rowsOf('extraction')) { await tx.extraction.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.extraction = rowsOf('extraction').length; for (const row of rowsOf('extractionHarvest')) { await tx.extractionHarvest.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.extractionHarvest = rowsOf('extractionHarvest').length; for (const row of rowsOf('storageContainer')) { await tx.storageContainer.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.storageContainer = rowsOf('storageContainer').length; // Bundles from before the honey ledger: initialAmount is derived // like migration 6 does. Only a sealed file also gets the BOTTLING // bookings; an unsealed one may not write ledger rows. const legacyLedger = legacyHoneyLedger( rowsOf('honeyBatch'), rowsOf('bottlingBatch'), ); for (const row of rowsOf('honeyBatch')) { const initialAmount = legacyLedger.initialAmounts.get( row.id as string, ); const batch = !('initialAmount' in row) && initialAmount !== undefined ? { ...row, initialAmount } : row; await tx.honeyBatch.upsert({ where: { id: row.id as string }, create: batch as never, update: updateWith(batch) as never, }); } imported.honeyBatch = rowsOf('honeyBatch').length; for (const row of rowsOf('honeyMeasurement')) { await tx.honeyMeasurement.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), }) as never, }); } imported.honeyMeasurement = rowsOf('honeyMeasurement').length; for (const row of rowsOf('honeyBatchRelocation')) { await tx.honeyBatchRelocation.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveOptionalUserId(asString(row, 'userId')), }) as never, }); } imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length; for (const row of rowsOf('honeyBatchContainerAllocation')) { await tx.honeyBatchContainerAllocation.upsert({ where: { id: row.id as string }, create: row as never, update: row as never, }); } imported.honeyBatchContainerAllocation = rowsOf( 'honeyBatchContainerAllocation', ).length; for (const row of rowsOf('stockItem')) { await tx.stockItem.upsert({ where: { id: row.id as string }, create: row as never, update: row as never, }); } imported.stockItem = rowsOf('stockItem').length; for (const row of rowsOf('stockMovement')) { await tx.stockMovement.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, }); } imported.stockMovement = rowsOf('stockMovement').length; for (const row of rowsOf('product')) { await tx.product.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.product = rowsOf('product').length; for (const row of rowsOf('labelTemplate')) { await tx.labelTemplate.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.labelTemplate = rowsOf('labelTemplate').length; for (const row of rowsOf('bottlingBatch')) { await tx.bottlingBatch.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: updateWith({ ...row, userId: resolveOptionalUserId(asString(row, 'userId')), }) as never, }); } imported.bottlingBatch = rowsOf('bottlingBatch').length; for (const row of rowsOf('rawMaterialLot')) { await tx.rawMaterialLot.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, }); } imported.rawMaterialLot = rowsOf('rawMaterialLot').length; for (const row of rowsOf('productionBatch')) { await tx.productionBatch.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, }); } imported.productionBatch = rowsOf('productionBatch').length; for (const row of rowsOf('productionInput')) { await tx.productionInput.upsert({ where: { id: row.id as string }, create: row as never, update: row as never, }); } imported.productionInput = rowsOf('productionInput').length; for (const row of rowsOf('shopStockRequest')) { await tx.shopStockRequest.upsert({ where: { id: row.id as string }, create: row as never, update: row as never, }); } imported.shopStockRequest = rowsOf('shopStockRequest').length; // After every kind of batch and the shop requests it may reference. for (const row of rowsOf('batchMovement')) { await tx.batchMovement.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, }); } imported.batchMovement = rowsOf('batchMovement').length; // After the batches and bottlings they point at; never rewritten. for (const row of trusted ? legacyLedger.movements : []) { const movement = { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), }; await tx.batchMovement.upsert({ where: { id: row.id as string }, create: movement as never, update: {}, }); } for (const row of rowsOf('equipment')) { await tx.equipment.upsert({ where: { id: row.id as string }, create: row as never, update: updateWith(row) as never, }); } imported.equipment = rowsOf('equipment').length; for (const row of rowsOf('webhookSubscription')) { await tx.webhookSubscription.upsert({ where: { id: row.id as string }, create: { ...row, createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), } as never, update: { ...row, createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), } as never, }); } imported.webhookSubscription = rowsOf('webhookSubscription').length; // Keyed by triggerType (its real unique key), not id: these rows are // fixed, code-known trigger definitions rather than freely created // data, and the target database's own row for the same triggerType // may already exist under a different id. for (const row of rowsOf('automationRule')) { const withoutId: Row = { ...row }; delete withoutId.id; await tx.automationRule.upsert({ where: { triggerType: row.triggerType as never }, create: withoutId as never, update: withoutId as never, }); } imported.automationRule = rowsOf('automationRule').length; // Keyed by triggerType+scope, its primary key (the table has no id). // Restoring the counters keeps the next lot number from colliding // with a restored one. for (const row of rowsOf('automationSequence')) { await tx.automationSequence.upsert({ where: { triggerType_scope: { triggerType: row.triggerType as never, scope: row.scope as string, }, }, create: row as never, update: row as never, }); } imported.automationSequence = rowsOf('automationSequence').length; for (const row of rowsOf('siteSettings')) { const { id, ...withoutId } = row; await tx.siteSettings.upsert({ where: { id: (id as string) ?? 'singleton' }, create: row as never, update: withoutId as never, }); } imported.siteSettings = rowsOf('siteSettings').length; for (const row of rowsOf('backupSettings')) { const { id, ...withoutId } = row; await tx.backupSettings.upsert({ where: { id: (id as string) ?? 'singleton' }, create: row as never, update: withoutId as never, }); } imported.backupSettings = rowsOf('backupSettings').length; for (const row of rowsOf('calendarSyncSettings')) { const { id, ...withoutId } = row; await tx.calendarSyncSettings.upsert({ where: { id: (id as string) ?? 'singleton' }, create: row as never, update: withoutId as never, }); } imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length; // Keyed by sourceType+sourceId, like CalendarSyncService.enqueueIn - // the target database may already track the same source under // another id. graphEventId comes along, so a restored entry keeps // updating its existing M365 event instead of creating a duplicate. for (const row of rowsOf('calendarSyncOutboxEntry')) { const withoutId: Row = { ...row }; delete withoutId.id; await tx.calendarSyncOutboxEntry.upsert({ where: { sourceType_sourceId: { sourceType: row.sourceType as never, sourceId: row.sourceId as string, }, }, create: row as never, update: withoutId as never, }); } imported.calendarSyncOutboxEntry = rowsOf( 'calendarSyncOutboxEntry', ).length; // Writes the file to disk as part of this loop, before the // surrounding transaction is known to commit - if a later entity in // this same import fails and the transaction rolls back, a file // written here is simply orphaned on disk (wasted space), not a // correctness problem, since no DB row ends up pointing at it. let documentCount = 0; for (const row of rowsOf('document')) { const contentBase64 = row.contentBase64 as string | null | undefined; if (!contentBase64) { continue; } const entityType = asString(row, 'entityType'); const mimeType = asString(row, 'mimeType'); if ( !entityType || !DOCUMENT_ENTITY_TYPES.has(entityType) || !mimeType || !isAllowedDocumentMimeType(mimeType) ) { throw new BadRequestException( `Dokument ${String(row.id)} hat einen unzulässigen Typ`, ); } const buffer = Buffer.from(contentBase64, 'base64'); if (!matchesFileSignature(buffer, mimeType)) { throw new BadRequestException( `Dokument ${String(row.id)}: Inhalt passt nicht zum Dateityp`, ); } const extension = ALLOWED_DOCUMENT_MIME_TYPES[mimeType]; const storageRoot = path.resolve(documentsStorageRoot()); const directory = path.join(storageRoot, entityType.toLowerCase()); if (!directory.startsWith(storageRoot + path.sep)) { throw new BadRequestException('Unzulässiger Speicherpfad'); } await fs.mkdir(directory, { recursive: true }); const storagePath = path.join( directory, `${crypto.randomUUID()}${extension}`, ); await fs.writeFile(storagePath, buffer); const previous = await tx.document.findUnique({ where: { id: row.id as string }, select: { storagePath: true }, }); if (previous) { replacedDocumentFiles.push(previous.storagePath); } const withoutContent: Row = { ...row }; delete withoutContent.contentBase64; const data = { ...withoutContent, storagePath, uploadedByUserId: resolveOptionalUserId( asString(row, 'uploadedByUserId'), ), }; await tx.document.upsert({ where: { id: row.id as string }, create: data as never, update: data as never, }); documentCount += 1; } imported.document = documentCount; const activeUserIds = ( await tx.user.findMany({ where: { isActive: true }, select: { id: true }, orderBy: { id: 'asc' }, }) ).map((user) => user.id); let notificationsWritten = 0; let notificationsSkipped = 0; for (const row of rowsOf('notification')) { const withCategory = withCurrentNotificationCategory(row); const rawUserId = asString(row, 'userId'); // A bundle from before the per-user notifications carries // broadcasts (no userId): one copy per active user, as migration 8. // A row of a user this database does not know is skipped - the // userId is mandatory and never falls back to somebody else. const owners = rawUserId === null ? activeUserIds : knownUserIds.has(rawUserId) ? [rawUserId] : []; if (owners.length === 0) { notificationsSkipped += 1; continue; } for (const userId of owners) { const data = { ...withCategory, id: rawUserId === null ? `${row.id as string}:${userId}` : (row.id as string), userId, }; // The unique dedupeKey of a row that lives here under another id // would abort the whole restore: skip that row instead. const dedupeKey = asString(data, 'dedupeKey'); if (dedupeKey !== null) { const holder = await tx.notification.findUnique({ where: { dedupeKey }, select: { id: true }, }); if (holder && holder.id !== data.id) { notificationsSkipped += 1; continue; } } await tx.notification.upsert({ where: { id: data.id }, create: data as never, // A copy made from a broadcast is only added: a repeated // restore must not reset what the user already read. update: (rawUserId === null ? {} : data) as never, }); notificationsWritten += 1; } } imported.notification = notificationsWritten; if (notificationsSkipped > 0) { imported.notificationSkipped = notificationsSkipped; this.logger.warn( `${notificationsSkipped} Meldung(en) übersprungen: Nutzer im Zielsystem unbekannt.`, ); } let preferencesWritten = 0; for (const row of rowsOf('notificationPreference')) { const userId = asString(row, 'userId'); if (userId === null || !knownUserIds.has(userId)) { continue; } await tx.notificationPreference.upsert({ where: { userId }, create: row as never, update: row as never, }); preferencesWritten += 1; } imported.notificationPreference = preferencesWritten; // Append-only: entries already here are never rewritten by a file. for (const row of rowsOf('auditLog')) { await tx.auditLog.upsert({ where: { id: row.id as string }, create: { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, update: {}, }); } imported.auditLog = rowsOf('auditLog').length; }, { timeout: IMPORT_TRANSACTION_TIMEOUT_MS }, ); // Users, roles and grants may have changed underneath every cache. this.permissionResolver.invalidateAll(); await this.auditLog.record({ entityType: 'Backup', entityId: 'import', action: 'CREATE', userId: actingUserId, afterData: { imported, skipped, sealed: trusted }, }); await this.removeReplacedDocumentFiles(replacedDocumentFiles); return { imported, skipped, sealed: trusted }; } /** * Deletes the files restored document rows no longer point at - only now * that the import has committed, only inside the documents root and only * while no row still uses them. Best effort: a file left behind wastes * space, it never breaks a document. */ private async removeReplacedDocumentFiles( storagePaths: string[], ): Promise { const storageRoot = path.resolve(documentsStorageRoot()); for (const storagePath of storagePaths) { const resolved = path.resolve(storagePath); if (!resolved.startsWith(storageRoot + path.sep)) { this.logger.warn( `Ersetzte Dokumentdatei liegt außerhalb von ${storageRoot} und bleibt erhalten: ${resolved}`, ); continue; } if ((await this.prisma.document.count({ where: { storagePath } })) > 0) { continue; } try { await fs.unlink(resolved); } catch (error: unknown) { this.logger.warn( `Ersetzte Dokumentdatei ${resolved} konnte nicht gelöscht werden: ${String(error)}`, ); } } } }