Files
ApiCore/ApiCore-Backend/src/backup/backup.service.ts
T
Nick SzaunigandClaude Opus 5.5 c01a38835a feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:57:54 +02:00

1164 lines
42 KiB
TypeScript

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 { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType, Prisma } from '../../generated/prisma';
import {
SealedBackup,
isSealedBackup,
openBackup,
sealBackup,
} from './backup-crypto';
// 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<string, unknown[]>;
}
export interface BackupImportSummary {
imported: Record<string, number>;
/**
* 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.
* - notification: messages shown to users as coming from ApiCore.
*/
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<string>(
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<Record<Prisma.ModelName, string>>
> = {
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.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>(
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<string, unknown>;
function asString(row: Row, key: string): string | null {
const value = row[key];
return typeof value === 'string' ? value : null;
}
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<SealedBackup> {
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<BackupImportSummary> {
if (isSealedBackup(parsed)) {
return this.importAll(openBackup(parsed) as BackupBundle, actingUserId, {
trusted: true,
});
}
return this.importAll(parsed as BackupBundle, actingUserId, {
trusted: false,
});
}
async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {};
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<string, unknown> = { ...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.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.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.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<BackupImportSummary> {
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<string, number> = {};
await this.prisma.$transaction(
async (tx) => {
const roleKeyToId = new Map<string, string>();
// 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: {},
});
}
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<string>(
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')) {
await tx.inspection.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.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;
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('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;
for (const row of rowsOf('honeyBatch')) {
await tx.honeyBatch.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) 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;
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.enqueue -
// 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;
for (const row of rowsOf('notification')) {
await tx.notification.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.notification = rowsOf('notification').length;
// 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<void> {
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)}`,
);
}
}
}
}