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]>
1164 lines
42 KiB
TypeScript
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)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|