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:
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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user