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
@@ -41,6 +41,7 @@ import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
|||||||
import { WeatherModule } from './weather/weather.module';
|
import { WeatherModule } from './weather/weather.module';
|
||||||
import { ProfileModule } from './profile/profile.module';
|
import { ProfileModule } from './profile/profile.module';
|
||||||
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||||
|
import { BackupModule } from './backup/backup.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
@@ -89,6 +90,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
WeatherModule,
|
WeatherModule,
|
||||||
ProfileModule,
|
ProfileModule,
|
||||||
SiteSettingsModule,
|
SiteSettingsModule,
|
||||||
|
BackupModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -285,6 +285,15 @@ export const PERMISSION_CATALOG = [
|
|||||||
],
|
],
|
||||||
3100,
|
3100,
|
||||||
),
|
),
|
||||||
|
...group(
|
||||||
|
'Backup',
|
||||||
|
'backup',
|
||||||
|
[
|
||||||
|
['export', 'Exportieren'],
|
||||||
|
['import', 'Importieren'],
|
||||||
|
],
|
||||||
|
3200,
|
||||||
|
),
|
||||||
] as const satisfies readonly PermissionCatalogEntry[];
|
] as const satisfies readonly PermissionCatalogEntry[];
|
||||||
|
|
||||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
|
import { BackupBundle, BackupService } from './backup.service';
|
||||||
|
|
||||||
|
// A full backup bundle can be much larger than the 5mb CSV import cap
|
||||||
|
// elsewhere (dozens of tables, potentially years of records, plus embedded
|
||||||
|
// label-template images) - 50mb comfortably covers a real dataset without
|
||||||
|
// raising the app-wide JSON body limit for every other route.
|
||||||
|
const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
@ApiTags('backup')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('backup')
|
||||||
|
export class BackupController {
|
||||||
|
constructor(private readonly backupService: BackupService) {}
|
||||||
|
|
||||||
|
@RequirePermission('backup:export')
|
||||||
|
@Get('export')
|
||||||
|
async export(@Res() res: Response): Promise<void> {
|
||||||
|
const bundle = await this.backupService.exportAll();
|
||||||
|
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(JSON.stringify(bundle, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('backup:import')
|
||||||
|
@Post('import')
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES },
|
||||||
|
fileFilter: (_req, file, callback) => {
|
||||||
|
if (!file.originalname.toLowerCase().endsWith('.json')) {
|
||||||
|
callback(
|
||||||
|
new BadRequestException('Nur JSON-Dateien werden unterstützt'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async import(
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('No file uploaded');
|
||||||
|
}
|
||||||
|
let bundle: BackupBundle;
|
||||||
|
try {
|
||||||
|
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle;
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Datei ist kein gültiges JSON');
|
||||||
|
}
|
||||||
|
return this.backupService.importAll(bundle, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BackupController } from './backup.controller';
|
||||||
|
import { BackupService } from './backup.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BackupController],
|
||||||
|
providers: [BackupService],
|
||||||
|
})
|
||||||
|
export class BackupModule {}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { BackupService } from './backup.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
|
||||||
|
const ENTITY_KEYS = [
|
||||||
|
'apiary',
|
||||||
|
'queen',
|
||||||
|
'colony',
|
||||||
|
'colonyApiaryHistory',
|
||||||
|
'colonyLineage',
|
||||||
|
'queenEvaluation',
|
||||||
|
'queenColonyHistory',
|
||||||
|
'inspection',
|
||||||
|
'taskTemplate',
|
||||||
|
'task',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'swarmControlCheck',
|
||||||
|
'treatment',
|
||||||
|
'feeding',
|
||||||
|
'harvest',
|
||||||
|
'harvestColony',
|
||||||
|
'extraction',
|
||||||
|
'extractionHarvest',
|
||||||
|
'storageContainer',
|
||||||
|
'honeyBatch',
|
||||||
|
'honeyMeasurement',
|
||||||
|
'honeyBatchRelocation',
|
||||||
|
'stockItem',
|
||||||
|
'stockMovement',
|
||||||
|
'product',
|
||||||
|
'labelTemplate',
|
||||||
|
'bottlingBatch',
|
||||||
|
'equipment',
|
||||||
|
'webhookSubscription',
|
||||||
|
'automationRule',
|
||||||
|
'siteSettings',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe('BackupService', () => {
|
||||||
|
let service: BackupService;
|
||||||
|
let prisma: Record<string, { findMany: jest.Mock }> & {
|
||||||
|
user: { findMany: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
};
|
||||||
|
let tx: Record<
|
||||||
|
string,
|
||||||
|
{ upsert: jest.Mock; update?: jest.Mock }
|
||||||
|
>;
|
||||||
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx = {};
|
||||||
|
for (const key of ENTITY_KEYS) {
|
||||||
|
tx[key] = { upsert: jest.fn().mockResolvedValue({}) };
|
||||||
|
}
|
||||||
|
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
prisma = {
|
||||||
|
user: { findMany: jest.fn().mockResolvedValue([{ id: 'user-1' }]) },
|
||||||
|
$transaction: jest.fn(
|
||||||
|
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
||||||
|
),
|
||||||
|
} as never;
|
||||||
|
for (const key of ENTITY_KEYS) {
|
||||||
|
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
service = new BackupService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
auditLog as unknown as AuditLogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportAll', () => {
|
||||||
|
it('returns a versioned bundle with every entity key', async () => {
|
||||||
|
const bundle = await service.exportAll();
|
||||||
|
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
expect(typeof bundle.exportedAt).toBe('string');
|
||||||
|
for (const key of ENTITY_KEYS) {
|
||||||
|
expect(bundle.data).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importAll', () => {
|
||||||
|
it('rejects a bundle with no data object', async () => {
|
||||||
|
await expect(
|
||||||
|
service.importAll({ version: 1 } as never, 'admin-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links a self-referencing motherQueenId only after every queen exists', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
queen: [
|
||||||
|
{ id: 'queen-daughter', motherQueenId: 'queen-mother' },
|
||||||
|
{ id: 'queen-mother', motherQueenId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
// First pass: both queens upserted with motherQueenId stripped, so a
|
||||||
|
// daughter appearing before her mother in the array never violates
|
||||||
|
// the foreign key.
|
||||||
|
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of tx.queen.upsert.mock.calls as {
|
||||||
|
create: { motherQueenId: string | null };
|
||||||
|
}[][]) {
|
||||||
|
expect(call[0].create.motherQueenId).toBeNull();
|
||||||
|
}
|
||||||
|
// Second pass: only the daughter's real motherQueenId is patched back.
|
||||||
|
expect(tx.queen.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.queen.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'queen-daughter' },
|
||||||
|
data: { motherQueenId: 'queen-mother' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remaps a required userId that does not exist in the target database to the importing admin', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||||
|
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
inspection: [
|
||||||
|
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'ghost-user' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||||
|
[{ create: { userId: string } }],
|
||||||
|
];
|
||||||
|
expect(call.create.userId).toBe('admin-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a required userId that does exist in the target database', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||||
|
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
inspection: [
|
||||||
|
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||||
|
[{ create: { userId: string } }],
|
||||||
|
];
|
||||||
|
expect(call.create.userId).toBe('known-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an optional userId as null rather than substituting the admin', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
stockMovement: [
|
||||||
|
{ id: 'movement-1', stockItemId: 'stock-1', userId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.stockMovement.upsert.mock.calls as [
|
||||||
|
[{ create: { userId: string | null } }],
|
||||||
|
];
|
||||||
|
expect(call.create.userId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts automationRule by triggerType, not id, and drops the source id', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
automationRule: [
|
||||||
|
{
|
||||||
|
id: 'source-db-id',
|
||||||
|
triggerType: 'VARROA_THRESHOLD',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.automationRule.upsert.mock.calls as [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
where: { triggerType: string };
|
||||||
|
create: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
expect(call.where).toEqual({ triggerType: 'VARROA_THRESHOLD' });
|
||||||
|
expect(call.create).not.toHaveProperty('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records an audit log entry with the per-entity import counts', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'Backup',
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: 'admin-1',
|
||||||
|
afterData: expect.objectContaining({ apiary: 1 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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