feat: Backend-Modul fuer vollstaendiges Daten-Backup (Export/Import)

Neues Modul src/backup/ fuer Backups und Datenuebertragung zwischen
Deployments: GET /backup/export liefert ein JSON-Bundle mit allen
fachlichen Daten (Bienenstaende bis Etikettenvorlagen, ~30 Tabellen),
POST /backup/import spielt so ein Bundle wieder ein.

Bewusst NICHT enthalten: User/Rollen/Rechte (werden ueber die
Benutzerverwaltung gepflegt, Permission-Zeilen werden ohnehin bei
jedem Start aus dem Code neu synchronisiert), Audit-Log (unveraenderliche
Historie), Notifications (fluechtig), Dokumente (nur die DB-Zeile waere
gesichert, nicht die tatsaechliche Datei auf der Festplatte).

Import laeuft in einer Transaktion, Tabellen in Abhaengigkeitsreihenfolge
(z.B. Bienenstand vor Volk, Volk vor Durchsicht). Selbstreferenzen
(Koenigin -> Mutterkoenigin) werden in zwei Durchgaengen aufgeloest,
damit die Reihenfolge im Export keine Rolle spielt. userId-Felder, die
im Zielsystem nicht existieren (z.B. Uebertragung auf ein anderes
Deployment), werden auf den ausfuehrenden Admin umgebogen statt die
Fremdschluessel-Pruefung scheitern zu lassen. AutomationRule wird ueber
triggerType statt id abgeglichen, da diese Zeilen code-fest sind.

Neue Berechtigungen backup:export/backup:import. 8 neue Unit-Tests
fuer die Sonderfaelle (Selbstreferenz, userId-Remapping,
AutomationRule-Abgleich, Audit-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 10:22:15 +02:00
1 parent 390ec32f50
commit 2f223b6941
6 files changed
+896

No files matched your search

@@ -0,0 +1,560 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
const BACKUP_VERSION = 1;
const IMPORT_TRANSACTION_TIMEOUT_MS = 5 * 60 * 1000;
export interface BackupBundle {
version: number;
exportedAt: string;
data: Record<string, unknown[]>;
}
export interface BackupImportSummary {
imported: Record<string, number>;
}
// Excluded on purpose:
// - User/Role/Permission/RolePermission/RefreshToken: access control is
// managed live via the Benutzer & Rollen page, not round-tripped through a
// JSON file - Permission rows are also code-derived (re-synced from the
// catalog on every boot) and would just conflict with that.
// - AuditLog: an immutable historical record, not data to restore.
// - Notification: ephemeral, not worth backing up.
// - Document: only the DB row (filename/path/size) would be backed up, not
// the actual uploaded file bytes on disk - a restored row pointing at a
// file that was never copied is worse than no row at all. File storage
// needs its own (volume-level) backup strategy.
//
// Every model below keeps a `userId`-shaped column pointing at the (excluded)
// User table. On import, an id that doesn't exist in the target database is
// replaced with the id of the admin performing the import - see
// resolveUserId/resolveOptionalUserId.
type Row = Record<string, unknown>;
function asString(row: Row, key: string): string | null {
const value = row[key];
return typeof value === 'string' ? value : null;
}
@Injectable()
export class BackupService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {};
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.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.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.equipment = await this.prisma.equipment.findMany();
data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
data.automationRule = await this.prisma.automationRule.findMany();
data.siteSettings = await this.prisma.siteSettings.findMany();
return {
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
data,
};
}
async importAll(
bundle: BackupBundle,
actingUserId: string,
): Promise<BackupImportSummary> {
if (
typeof bundle !== 'object' ||
bundle === null ||
typeof bundle.data !== 'object' ||
bundle.data === null
) {
throw new BadRequestException('Invalid backup file');
}
const knownUserIds = new Set(
(await this.prisma.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);
const rowsOf = (key: string): Row[] => {
const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : [];
};
const imported: Record<string, number> = {};
await this.prisma.$transaction(
async (tx) => {
for (const row of rowsOf('apiary')) {
await tx.apiary.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.apiary = rowsOf('apiary').length;
// 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: 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: row as never,
});
}
imported.colony = rowsOf('colony').length;
for (const row of rowsOf('queen')) {
if (row.motherQueenId) {
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: {
...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: 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: {
...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: 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: {
...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: {
...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: {
...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: {
...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: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.swarmControlCheck = rowsOf('swarmControlCheck').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: {
...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: {
...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: {
...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: 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: {
...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: 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: 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: 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: {
...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: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
}
imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').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: 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: 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: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
}
imported.bottlingBatch = rowsOf('bottlingBatch').length;
for (const row of rowsOf('equipment')) {
await tx.equipment.upsert({
where: { id: row.id as string },
create: row as never,
update: 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;
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;
},
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
);
await this.auditLog.record({
entityType: 'Backup',
entityId: 'import',
action: 'CREATE',
userId: actingUserId,
afterData: imported,
});
return { imported };
}
}