diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 5064b56..13c7a74 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -41,6 +41,7 @@ import { SwarmControlModule } from './swarm-control/swarm-control.module'; import { WeatherModule } from './weather/weather.module'; import { ProfileModule } from './profile/profile.module'; import { SiteSettingsModule } from './site-settings/site-settings.module'; +import { BackupModule } from './backup/backup.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; @@ -89,6 +90,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; WeatherModule, ProfileModule, SiteSettingsModule, + BackupModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 311177c..29ae03f 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -285,6 +285,15 @@ export const PERMISSION_CATALOG = [ ], 3100, ), + ...group( + 'Backup', + 'backup', + [ + ['export', 'Exportieren'], + ['import', 'Importieren'], + ], + 3200, + ), ] as const satisfies readonly PermissionCatalogEntry[]; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; diff --git a/ApiCore-Backend/src/backup/backup.controller.ts b/ApiCore-Backend/src/backup/backup.controller.ts new file mode 100644 index 0000000..2feb169 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup.controller.ts @@ -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 { + 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); + } +} diff --git a/ApiCore-Backend/src/backup/backup.module.ts b/ApiCore-Backend/src/backup/backup.module.ts new file mode 100644 index 0000000..4a79055 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts new file mode 100644 index 0000000..cd484ee --- /dev/null +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -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 & { + 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; + }, + ], + ]; + 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 }), + }), + ); + }); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts new file mode 100644 index 0000000..54be8ba --- /dev/null +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -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; +} + +export interface BackupImportSummary { + imported: Record; +} + +// 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; + +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 { + const data: Record = {}; + 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 { + 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 = {}; + + 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 }; + } +}