From 7b23862a41b898cbbee0715fdae22c307326b3cc Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 3 Sep 2026 11:33:48 +0200 Subject: [PATCH] feat: expand backup to all data and add automatic scheduled backups Backup export/import now covers Role, User, RolePermission, Notification, AuditLog and Document (with actual file content), not just business data - matching "wirklich alle Daten". Role/User use natural keys (role.key, resource+action) so a bundle restores cleanly into a different database. Adds BackupSchedulerService (@nestjs/schedule) for cron-based automatic backups to a local path (BACKUP_STORAGE_PATH), with retention pruning and a file-mtime guard against duplicate runs across replicas. New GET /backup/schedule and POST /backup/schedule/run-now endpoints, plus a status card and manual trigger in the frontend Backup settings page. --- ApiCore-Backend/.env.example | 13 + ApiCore-Backend/package-lock.json | 49 +++ ApiCore-Backend/package.json | 1 + ApiCore-Backend/src/app.module.ts | 2 + .../src/backup/backup-scheduler.service.ts | 185 +++++++++++ .../src/backup/backup.controller.ts | 21 +- ApiCore-Backend/src/backup/backup.module.ts | 3 +- .../src/backup/backup.service.spec.ts | 290 ++++++++++++++++-- ApiCore-Backend/src/backup/backup.service.ts | 250 +++++++++++++-- .../src/app/(app)/settings/backup/page.tsx | 79 ++++- ApiCore-Frontend/src/lib/backup/api.ts | 14 +- ApiCore-Frontend/src/lib/backup/types.ts | 8 + deploy/k8s/backend/configmap.example.yaml | 6 + 13 files changed, 853 insertions(+), 68 deletions(-) create mode 100644 ApiCore-Backend/src/backup/backup-scheduler.service.ts diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index f7bd3f7..5137926 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -23,6 +23,19 @@ COOKIE_SECURE=false # Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7. DOCUMENTS_STORAGE_PATH="./uploads" +# Automatische Backups (Modul Backup). Ohne BACKUP_STORAGE_PATH bleibt der +# Zeitplan deaktiviert (nur manueller Export/Import ueber die UI verfuegbar). +# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes +# Volume zeigen (wie DOCUMENTS_STORAGE_PATH), sonst sieht jede Replica nur +# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift +# nicht. +BACKUP_STORAGE_PATH="./backups" +# Cron-Ausdruck (Standard: taeglich um 03:00 Uhr). +BACKUP_CRON_SCHEDULE="0 3 * * *" +# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach +# jedem Lauf geloescht (Standard: 14). +BACKUP_RETENTION_COUNT=14 + # Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). # OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index 1b468ab..620b322 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^12.0.1", "@nestjs/swagger": "^11.4.7", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.9.1", @@ -2477,6 +2478,22 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz", + "integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0 || ^12.0.0", + "@nestjs/core": "^11.0.0 || ^12.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -3583,6 +3600,12 @@ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", + "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -5898,6 +5921,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9069,6 +9109,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index e3a1b43..2a45cea 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -29,6 +29,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^12.0.1", "@nestjs/swagger": "^11.4.7", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.9.1", diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 13c7a74..36b085f 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; import { AuditModule } from './audit/audit.module'; @@ -49,6 +50,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + ScheduleModule.forRoot(), ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]), PrismaModule, PermissionsModule, diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.ts new file mode 100644 index 0000000..6b83cab --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.ts @@ -0,0 +1,185 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { BackupService } from './backup.service'; + +const SCHEDULED_JOB_NAME = 'automatic-backup'; +const DEFAULT_CRON_SCHEDULE = '0 3 * * *'; +const DEFAULT_RETENTION_COUNT = 14; +const BACKUP_FILE_PREFIX = 'apicore-backup-'; +const BACKUP_FILE_SUFFIX = '.json'; + +// Guards against two replicas (deployment runs with replicas: 2, no shared +// DB table for scheduling state) both firing the same cron tick: if the most +// recent backup file on this shared/mounted path is younger than this, a +// concurrent replica already handled the tick, so this one skips. +const DUPLICATE_RUN_GUARD_MS = 2 * 60 * 1000; + +export interface BackupScheduleStatus { + enabled: boolean; + path: string | null; + cronSchedule: string | null; + retentionCount: number | null; + lastRunAt: string | null; +} + +function storagePath(): string | null { + const value = process.env.BACKUP_STORAGE_PATH; + return value && value.trim().length > 0 ? value : null; +} + +function cronSchedule(): string { + return process.env.BACKUP_CRON_SCHEDULE ?? DEFAULT_CRON_SCHEDULE; +} + +function retentionCount(): number { + const raw = Number(process.env.BACKUP_RETENTION_COUNT); + return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_RETENTION_COUNT; +} + +@Injectable() +export class BackupSchedulerService implements OnModuleInit { + private readonly logger = new Logger(BackupSchedulerService.name); + + constructor( + private readonly backupService: BackupService, + private readonly schedulerRegistry: SchedulerRegistry, + ) {} + + onModuleInit(): void { + const directory = storagePath(); + if (!directory) { + this.logger.log( + 'BACKUP_STORAGE_PATH not set - automatic backups disabled', + ); + return; + } + + const job = CronJob.from({ + cronTime: cronSchedule(), + onTick: () => { + this.runScheduled(directory).catch((error) => + this.logger.error('Scheduled backup failed', error), + ); + }, + start: true, + }); + this.schedulerRegistry.addCronJob(SCHEDULED_JOB_NAME, job); + this.logger.log( + `Automatic backups enabled: schedule="${cronSchedule()}" path="${directory}" retention=${retentionCount()}`, + ); + } + + async getStatus(): Promise { + const directory = storagePath(); + if (!directory) { + return { + enabled: false, + path: null, + cronSchedule: null, + retentionCount: null, + lastRunAt: null, + }; + } + + const files = await this.listBackupFiles(directory); + const lastRunAt = files[0]?.mtime.toISOString() ?? null; + + return { + enabled: true, + path: directory, + cronSchedule: cronSchedule(), + retentionCount: retentionCount(), + lastRunAt, + }; + } + + // Manual trigger (e.g. "Jetzt sichern" button) - always runs, no + // duplicate-tick guard, since a human explicitly asked for it. + async runNow(): Promise<{ filePath: string }> { + const directory = storagePath(); + if (!directory) { + throw new Error( + 'BACKUP_STORAGE_PATH ist nicht konfiguriert - automatische Backups sind deaktiviert', + ); + } + const filePath = await this.writeBackup(directory); + await this.pruneOldBackups(directory); + return { filePath }; + } + + private async runScheduled(directory: string): Promise { + const files = await this.listBackupFiles(directory); + const mostRecent = files[0]; + if ( + mostRecent && + Date.now() - mostRecent.mtime.getTime() < DUPLICATE_RUN_GUARD_MS + ) { + this.logger.log( + 'Skipping scheduled backup - another replica already wrote one for this tick', + ); + return; + } + + const filePath = await this.writeBackup(directory); + this.logger.log(`Scheduled backup written to ${filePath}`); + await this.pruneOldBackups(directory); + } + + private async writeBackup(directory: string): Promise { + const bundle = await this.backupService.exportAll(); + await fs.mkdir(directory, { recursive: true }); + const timestamp = bundle.exportedAt.replace(/[:.]/g, '-'); + const filePath = path.join( + directory, + `${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`, + ); + await fs.writeFile(filePath, JSON.stringify(bundle, null, 2)); + return filePath; + } + + private async pruneOldBackups(directory: string): Promise { + const files = await this.listBackupFiles(directory); + const toDelete = files.slice(retentionCount()); + await Promise.all( + toDelete.map((file) => + fs + .unlink(file.filePath) + .catch((error) => + this.logger.warn( + `Could not delete old backup ${file.filePath}: ${String(error)}`, + ), + ), + ), + ); + } + + private async listBackupFiles( + directory: string, + ): Promise<{ filePath: string; mtime: Date }[]> { + let entries: string[]; + try { + entries = await fs.readdir(directory); + } catch { + return []; + } + + const backupFiles = entries.filter( + (name) => + name.startsWith(BACKUP_FILE_PREFIX) && + name.endsWith(BACKUP_FILE_SUFFIX), + ); + + const withStats = await Promise.all( + backupFiles.map(async (name) => { + const filePath = path.join(directory, name); + const stat = await fs.stat(filePath); + return { filePath, mtime: stat.mtime }; + }), + ); + + return withStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + } +} diff --git a/ApiCore-Backend/src/backup/backup.controller.ts b/ApiCore-Backend/src/backup/backup.controller.ts index 2feb169..26161f5 100644 --- a/ApiCore-Backend/src/backup/backup.controller.ts +++ b/ApiCore-Backend/src/backup/backup.controller.ts @@ -14,6 +14,10 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { BackupBundle, BackupService } from './backup.service'; +import { + BackupScheduleStatus, + BackupSchedulerService, +} from './backup-scheduler.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 @@ -25,7 +29,22 @@ const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024; @ApiBearerAuth() @Controller('backup') export class BackupController { - constructor(private readonly backupService: BackupService) {} + constructor( + private readonly backupService: BackupService, + private readonly backupSchedulerService: BackupSchedulerService, + ) {} + + @RequirePermission('backup:export') + @Get('schedule') + getSchedule(): Promise { + return this.backupSchedulerService.getStatus(); + } + + @RequirePermission('backup:export') + @Post('schedule/run-now') + runScheduleNow(): Promise<{ filePath: string }> { + return this.backupSchedulerService.runNow(); + } @RequirePermission('backup:export') @Get('export') diff --git a/ApiCore-Backend/src/backup/backup.module.ts b/ApiCore-Backend/src/backup/backup.module.ts index 4a79055..1935dba 100644 --- a/ApiCore-Backend/src/backup/backup.module.ts +++ b/ApiCore-Backend/src/backup/backup.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { BackupController } from './backup.controller'; import { BackupService } from './backup.service'; +import { BackupSchedulerService } from './backup-scheduler.service'; @Module({ controllers: [BackupController], - providers: [BackupService], + providers: [BackupService, BackupSchedulerService], }) export class BackupModule {} diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index cd484ee..7f16e7b 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -3,7 +3,15 @@ import { BackupService } from './backup.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -const ENTITY_KEYS = [ +jest.mock('node:fs/promises', () => ({ + readFile: jest.fn(), + writeFile: jest.fn(), + mkdir: jest.fn(), +})); + +import * as fs from 'node:fs/promises'; + +const BUSINESS_ENTITY_KEYS = [ 'apiary', 'queen', 'colony', @@ -37,32 +45,69 @@ const ENTITY_KEYS = [ 'siteSettings', ] as const; +const ALL_ENTITY_KEYS = [ + 'role', + 'user', + 'rolePermission', + ...BUSINESS_ENTITY_KEYS, + 'document', + 'notification', + 'auditLog', +] as const; + describe('BackupService', () => { let service: BackupService; let prisma: Record & { user: { findMany: jest.Mock }; + rolePermission: { findMany: jest.Mock }; + document: { findMany: jest.Mock }; $transaction: jest.Mock; }; - let tx: Record< - string, - { upsert: jest.Mock; update?: jest.Mock } - >; + let tx: Record & { + permission: { findUnique: jest.Mock }; + user: { upsert: jest.Mock; findMany: jest.Mock }; + }; let auditLog: { record: jest.Mock }; beforeEach(() => { - tx = {}; - for (const key of ENTITY_KEYS) { - tx[key] = { upsert: jest.fn().mockResolvedValue({}) }; + jest.clearAllMocks(); + + tx = {} as never; + for (const key of [ + ...BUSINESS_ENTITY_KEYS, + 'role', + 'user', + 'rolePermission', + 'document', + 'notification', + 'auditLog', + ]) { + tx[key] = { + upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }), + }; } tx.queen.update = jest.fn().mockResolvedValue({}); + tx.permission = { + findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }), + }; + tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]); prisma = { - user: { findMany: jest.fn().mockResolvedValue([{ id: 'user-1' }]) }, - $transaction: jest.fn( - (callback: (txArg: typeof tx) => unknown) => callback(tx), + user: { + findMany: jest.fn().mockResolvedValue([]), + }, + rolePermission: { findMany: jest.fn().mockResolvedValue([]) }, + document: { findMany: jest.fn().mockResolvedValue([]) }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), ), } as never; - for (const key of ENTITY_KEYS) { + for (const key of [ + 'role', + ...BUSINESS_ENTITY_KEYS, + 'notification', + 'auditLog', + ]) { prisma[key] = { findMany: jest.fn().mockResolvedValue([]) }; } @@ -75,29 +120,169 @@ describe('BackupService', () => { }); describe('exportAll', () => { - it('returns a versioned bundle with every entity key', async () => { + it('returns a version 2 bundle with every entity key, including role/user/document', async () => { const bundle = await service.exportAll(); - expect(bundle.version).toBe(1); + expect(bundle.version).toBe(2); expect(typeof bundle.exportedAt).toBe('string'); - for (const key of ENTITY_KEYS) { + for (const key of ALL_ENTITY_KEYS) { expect(bundle.data).toHaveProperty(key); } }); + + it('denormalizes user.roleId into roleKey and strips roleId', async () => { + prisma.user.findMany.mockResolvedValue([ + { + id: 'user-1', + email: 'a@b.de', + roleId: 'role-db-id', + role: { key: 'ADMIN' }, + }, + ]); + + const bundle = await service.exportAll(); + + expect(bundle.data.user).toEqual([ + { id: 'user-1', email: 'a@b.de', roleKey: 'ADMIN' }, + ]); + }); + + it('denormalizes rolePermission into roleKey/resource/action', async () => { + prisma.rolePermission.findMany.mockResolvedValue([ + { + role: { key: 'ADMIN' }, + permission: { resource: 'users', action: 'read' }, + }, + ]); + + const bundle = await service.exportAll(); + + expect(bundle.data.rolePermission).toEqual([ + { roleKey: 'ADMIN', resource: 'users', action: 'read' }, + ]); + }); + + it('embeds document content as base64 and falls back to null when the file is missing', async () => { + prisma.document.findMany.mockResolvedValue([ + { + id: 'doc-1', + storagePath: '/uploads/ok.pdf', + mimeType: 'application/pdf', + }, + { + id: 'doc-2', + storagePath: '/uploads/missing.pdf', + mimeType: 'application/pdf', + }, + ]); + (fs.readFile as jest.Mock).mockImplementation((filePath: string) => + filePath === '/uploads/ok.pdf' + ? Promise.resolve(Buffer.from('hello')) + : Promise.reject(new Error('ENOENT')), + ); + + const bundle = await service.exportAll(); + + const documents = bundle.data.document as { + id: string; + contentBase64: string | null; + }[]; + expect(documents.find((d) => d.id === 'doc-1')?.contentBase64).toBe( + Buffer.from('hello').toString('base64'), + ); + expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull(); + }); }); describe('importAll', () => { it('rejects a bundle with no data object', async () => { await expect( - service.importAll({ version: 1 } as never, 'admin-1'), + service.importAll({ version: 2 } as never, 'admin-1'), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.$transaction).not.toHaveBeenCalled(); }); + it('upserts a role by key and drops the source id on update', async () => { + await service.importAll( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] }, + }, + 'admin-1', + ); + + const [[call]] = tx.role.upsert.mock.calls as [ + [{ where: { key: string }; update: Record }], + ]; + expect(call.where).toEqual({ key: 'ADMIN' }); + expect(call.update).not.toHaveProperty('id'); + }); + + it('resolves a user roleKey to the freshly upserted role id', async () => { + tx.role.upsert = jest.fn().mockResolvedValue({ id: 'role-new-id' }); + + await service.importAll( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + role: [{ key: 'ADMIN', name: 'Admin' }], + user: [{ id: 'user-1', email: 'a@b.de', roleKey: 'ADMIN' }], + }, + }, + 'admin-1', + ); + + const [[call]] = tx.user.upsert.mock.calls as [ + [{ create: { roleId: string } }], + ]; + expect(call.create.roleId).toBe('role-new-id'); + }); + + it('throws when a user references a role key not present in the bundle', async () => { + await expect( + service.importAll( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + user: [{ id: 'user-1', email: 'a@b.de', roleKey: 'UNKNOWN' }], + }, + }, + 'admin-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('resolves rolePermission via permission.findUnique by resource/action and skips unknown permissions', async () => { + tx.permission.findUnique = jest + .fn() + .mockResolvedValueOnce({ id: 'perm-1' }) + .mockResolvedValueOnce(null); + + await service.importAll( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + role: [{ key: 'ADMIN', name: 'Admin' }], + rolePermission: [ + { roleKey: 'ADMIN', resource: 'users', action: 'read' }, + { roleKey: 'ADMIN', resource: 'ghost', action: 'read' }, + ], + }, + }, + 'admin-1', + ); + + expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1); + }); + it('links a self-referencing motherQueenId only after every queen exists', async () => { await service.importAll( { - version: 1, + version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { queen: [ @@ -109,16 +294,12 @@ describe('BackupService', () => { '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' }, @@ -127,15 +308,19 @@ describe('BackupService', () => { }); 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' }]); + tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]); await service.importAll( { - version: 1, + version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { inspection: [ - { id: 'inspection-1', colonyId: 'colony-1', userId: 'ghost-user' }, + { + id: 'inspection-1', + colonyId: 'colony-1', + userId: 'ghost-user', + }, ], }, }, @@ -149,15 +334,19 @@ describe('BackupService', () => { }); it('keeps a required userId that does exist in the target database', async () => { - prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]); + tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]); await service.importAll( { - version: 1, + version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { inspection: [ - { id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user' }, + { + id: 'inspection-1', + colonyId: 'colony-1', + userId: 'known-user', + }, ], }, }, @@ -173,7 +362,7 @@ describe('BackupService', () => { it('leaves an optional userId as null rather than substituting the admin', async () => { await service.importAll( { - version: 1, + version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { stockMovement: [ @@ -193,7 +382,7 @@ describe('BackupService', () => { it('upserts automationRule by triggerType, not id, and drops the source id', async () => { await service.importAll( { - version: 1, + version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { automationRule: [ @@ -220,10 +409,49 @@ describe('BackupService', () => { expect(call.create).not.toHaveProperty('id'); }); + it('decodes document contentBase64, writes it to a fresh path, and skips rows with no content', async () => { + (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + + await service.importAll( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + document: [ + { + id: 'doc-1', + entityType: 'Colony', + mimeType: 'application/pdf', + uploadedByUserId: 'known-user', + contentBase64: Buffer.from('hello').toString('base64'), + }, + { + id: 'doc-2', + entityType: 'Colony', + mimeType: 'application/pdf', + uploadedByUserId: null, + contentBase64: null, + }, + ], + }, + }, + 'admin-1', + ); + + expect(fs.writeFile).toHaveBeenCalledTimes(1); + expect(tx.document.upsert).toHaveBeenCalledTimes(1); + const [[call]] = tx.document.upsert.mock.calls as [ + [{ create: { storagePath: string; contentBase64?: unknown } }], + ]; + expect(call.create.storagePath).toMatch(/colony/); + expect(call.create).not.toHaveProperty('contentBase64'); + }); + it('records an audit log entry with the per-entity import counts', async () => { await service.importAll( { - version: 1, + version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] }, }, @@ -235,7 +463,7 @@ describe('BackupService', () => { entityType: 'Backup', action: 'CREATE', userId: 'admin-1', - afterData: expect.objectContaining({ apiary: 1 }), + afterData: expect.objectContaining({ apiary: 1 }) as unknown, }), ); }); diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index 54be8ba..11be9ee 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -1,9 +1,13 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { ALLOWED_DOCUMENT_MIME_TYPES } from '../documents/document-mime-types'; -const BACKUP_VERSION = 1; -const IMPORT_TRANSACTION_TIMEOUT_MS = 5 * 60 * 1000; +const BACKUP_VERSION = 2; +const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000; export interface BackupBundle { version: number; @@ -16,21 +20,25 @@ export interface BackupImportSummary { } // 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. +// - Permission: code-derived, re-synced from the catalog on every boot - +// including it would just fight that sync. RolePermission references +// permissions by resource+action (not id) instead, resolved against +// whatever the target database's own sync already produced. +// - RefreshToken: live session tokens, meaningless once restored (expired, +// or a stale session that should never be revivable from a backup file). // -// 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. +// Role/User/RolePermission ARE included (v2) - Role is matched by its `key` +// (not id, since a fresh deployment's seeded system roles get a new +// database-generated id every time) and User carries a `roleKey` instead of +// a raw roleId for the same reason. Every other model's `userId`-shaped +// column is resolved against the now-imported User table; an id that still +// doesn't exist there (e.g. a partial/older backup) falls back to the id of +// the admin performing the import. +// +// Document is included with the actual file content (base64), not just the +// DB row - the row alone would point at a path that was never copied. A row +// whose file could not be read at export time is skipped on import rather +// than restored with a dangling path. type Row = Record; function asString(row: Row, key: string): string | null { @@ -38,6 +46,10 @@ function asString(row: Row, key: string): string | null { return typeof value === 'string' ? value : null; } +function documentsStorageRoot(): string { + return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'; +} + @Injectable() export class BackupService { constructor( @@ -47,6 +59,28 @@ export class BackupService { async exportAll(): Promise { const data: Record = {}; + + data.role = await this.prisma.role.findMany(); + const rolePermissions = await this.prisma.rolePermission.findMany({ + include: { + role: { select: { key: true } }, + permission: { select: { resource: true, action: true } }, + }, + }); + data.rolePermission = rolePermissions.map((rp) => ({ + roleKey: rp.role.key, + resource: rp.permission.resource, + action: rp.permission.action, + })); + const users = await this.prisma.user.findMany({ + include: { role: { select: { key: true } } }, + }); + data.user = users.map(({ role, ...rest }) => { + const withoutRoleId: Record = { ...rest }; + delete withoutRoleId.roleId; + return { ...withoutRoleId, roleKey: role.key }; + }); + data.apiary = await this.prisma.apiary.findMany(); data.queen = await this.prisma.queen.findMany(); data.colony = await this.prisma.colony.findMany(); @@ -80,6 +114,26 @@ export class BackupService { data.automationRule = await this.prisma.automationRule.findMany(); data.siteSettings = await this.prisma.siteSettings.findMany(); + const documents = await this.prisma.document.findMany(); + data.document = await Promise.all( + documents.map(async (document) => { + const { storagePath, ...rest } = document; + try { + const contentBase64 = (await fs.readFile(storagePath)).toString( + 'base64', + ); + return { ...rest, contentBase64 }; + } catch { + // File missing on disk - keep the DB row's metadata visible in the + // export, but with no content to restore from. + return { ...rest, contentBase64: null }; + } + }), + ); + + data.notification = await this.prisma.notification.findMany(); + data.auditLog = await this.prisma.auditLog.findMany(); + return { version: BACKUP_VERSION, exportedAt: new Date().toISOString(), @@ -100,18 +154,6 @@ export class BackupService { 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[]) : []; @@ -121,6 +163,86 @@ export class BackupService { await this.prisma.$transaction( async (tx) => { + const roleKeyToId = new Map(); + for (const row of rowsOf('role')) { + const withoutId: Row = { ...row }; + delete withoutId.id; + const upserted = await tx.role.upsert({ + where: { key: row.key as string }, + create: row as never, + update: withoutId as never, + }); + roleKeyToId.set(row.key as string, upserted.id); + } + imported.role = rowsOf('role').length; + + function resolveRoleId(roleKey: string): string { + const id = roleKeyToId.get(roleKey); + if (!id) { + throw new BadRequestException( + `Backup references unknown role "${roleKey}"`, + ); + } + return id; + } + + for (const row of rowsOf('user')) { + const { roleKey, ...rest } = row; + const data = { + ...rest, + roleId: resolveRoleId(roleKey as string), + }; + await tx.user.upsert({ + where: { id: row.id as string }, + create: data as never, + update: data as never, + }); + } + imported.user = rowsOf('user').length; + + for (const row of rowsOf('rolePermission')) { + const roleId = resolveRoleId(row.roleKey as string); + const permission = await tx.permission.findUnique({ + where: { + resource_action: { + resource: row.resource as string, + action: row.action as string, + }, + }, + }); + if (!permission) { + // Permission catalog is code-derived and synced at boot; a + // resource/action from an older export that no longer exists in + // this version of the app is skipped rather than failing the + // whole import. + continue; + } + await tx.rolePermission.upsert({ + where: { + roleId_permissionId: { roleId, permissionId: permission.id }, + }, + create: { roleId, permissionId: permission.id }, + update: {}, + }); + } + imported.rolePermission = rowsOf('rolePermission').length; + + // Built after role/user import so a user id that only exists + // because THIS SAME bundle just created it is still recognised. + const knownUserIds = new Set( + (await tx.user.findMany({ select: { id: true } })).map( + (user) => user.id, + ), + ); + const resolveUserId = (rawUserId: string | null): string => + rawUserId !== null && knownUserIds.has(rawUserId) + ? rawUserId + : actingUserId; + const resolveOptionalUserId = ( + rawUserId: string | null, + ): string | null => + rawUserId === null ? null : resolveUserId(rawUserId); + for (const row of rowsOf('apiary')) { await tx.apiary.upsert({ where: { id: row.id as string }, @@ -543,6 +665,78 @@ export class BackupService { }); } imported.siteSettings = rowsOf('siteSettings').length; + + // Writes the file to disk as part of this loop, before the + // surrounding transaction is known to commit - if a later entity in + // this same import fails and the transaction rolls back, a file + // written here is simply orphaned on disk (wasted space), not a + // correctness problem, since no DB row ends up pointing at it. + let documentCount = 0; + for (const row of rowsOf('document')) { + const contentBase64 = row.contentBase64 as string | null | undefined; + if (!contentBase64) { + continue; + } + const buffer = Buffer.from(contentBase64, 'base64'); + const extension = + ALLOWED_DOCUMENT_MIME_TYPES[row.mimeType as string] ?? ''; + const directory = path.join( + documentsStorageRoot(), + (row.entityType as string).toLowerCase(), + ); + await fs.mkdir(directory, { recursive: true }); + const storagePath = path.join( + directory, + `${crypto.randomUUID()}${extension}`, + ); + await fs.writeFile(storagePath, buffer); + const withoutContent: Row = { ...row }; + delete withoutContent.contentBase64; + const data = { + ...withoutContent, + storagePath, + uploadedByUserId: resolveOptionalUserId( + asString(row, 'uploadedByUserId'), + ), + }; + await tx.document.upsert({ + where: { id: row.id as string }, + create: data as never, + update: data as never, + }); + documentCount += 1; + } + imported.document = documentCount; + + for (const row of rowsOf('notification')) { + await tx.notification.upsert({ + where: { id: row.id as string }, + create: { + ...row, + userId: resolveOptionalUserId(asString(row, 'userId')), + } as never, + update: { + ...row, + userId: resolveOptionalUserId(asString(row, 'userId')), + } as never, + }); + } + imported.notification = rowsOf('notification').length; + + for (const row of rowsOf('auditLog')) { + await tx.auditLog.upsert({ + where: { id: row.id as string }, + create: { + ...row, + userId: resolveOptionalUserId(asString(row, 'userId')), + } as never, + update: { + ...row, + userId: resolveOptionalUserId(asString(row, 'userId')), + } as never, + }); + } + imported.auditLog = rowsOf('auditLog').length; }, { timeout: IMPORT_TRANSACTION_TIMEOUT_MS }, ); diff --git a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx index fc6fd7f..08e1ab4 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx @@ -2,9 +2,9 @@ import { ChangeEvent, useState } from "react"; import { Database } from "lucide-react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { exportBackup, importBackup } from "@/lib/backup/api"; +import { exportBackup, getBackupSchedule, importBackup, runBackupNow } from "@/lib/backup/api"; import { importCsv } from "@/lib/import/api"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; import { Badge } from "@/components/ui/badge"; @@ -76,9 +76,9 @@ function BackupSection() {

- Sichert Bienenstände bis Etikettenvorlagen (Betriebsdaten) in einer JSON-Datei - - für Backups und zur Übertragung auf ein anderes Deployment. Benutzer, Rollen, - Audit-Log und hochgeladene Dokumente sind nicht enthalten. + Sichert wirklich alle Daten - Bienenstände, Völker, Ernten und mehr, dazu Benutzer, + Rollen & Rechte, Audit-Log und hochgeladene Dokumente (inkl. Dateiinhalt) - in + einer JSON-Datei. Für Backups und zur Übertragung auf ein anderes Deployment.

+
+ + ); +} + const ENTITY_LABELS: Record = { apiaries: "Bienenstände", colonies: "Völker", @@ -271,6 +339,7 @@ export default function BackupPage() {
+
diff --git a/ApiCore-Frontend/src/lib/backup/api.ts b/ApiCore-Frontend/src/lib/backup/api.ts index 9e0c7a4..ad4dc33 100644 --- a/ApiCore-Frontend/src/lib/backup/api.ts +++ b/ApiCore-Frontend/src/lib/backup/api.ts @@ -1,5 +1,5 @@ -import { apiFetchBlob, apiFetchFormData, BlobDownload } from "@/lib/auth/api-client"; -import { BackupImportSummary } from "./types"; +import { apiFetch, apiFetchBlob, apiFetchFormData, BlobDownload } from "@/lib/auth/api-client"; +import { BackupImportSummary, BackupScheduleStatus } from "./types"; export function exportBackup(): Promise { return apiFetchBlob("/backup/export", "apicore-backup.json"); @@ -10,3 +10,13 @@ export function importBackup(file: File): Promise { formData.append("file", file); return apiFetchFormData("/backup/import", formData); } + +export function getBackupSchedule(): Promise { + return apiFetch("/backup/schedule"); +} + +export function runBackupNow(): Promise<{ filePath: string }> { + return apiFetch<{ filePath: string }>("/backup/schedule/run-now", { + method: "POST", + }); +} diff --git a/ApiCore-Frontend/src/lib/backup/types.ts b/ApiCore-Frontend/src/lib/backup/types.ts index 4160194..c9e93e3 100644 --- a/ApiCore-Frontend/src/lib/backup/types.ts +++ b/ApiCore-Frontend/src/lib/backup/types.ts @@ -1,3 +1,11 @@ export interface BackupImportSummary { imported: Record; } + +export interface BackupScheduleStatus { + enabled: boolean; + path: string | null; + cronSchedule: string | null; + retentionCount: number | null; + lastRunAt: string | null; +} diff --git a/deploy/k8s/backend/configmap.example.yaml b/deploy/k8s/backend/configmap.example.yaml index ab20212..d6c05b1 100644 --- a/deploy/k8s/backend/configmap.example.yaml +++ b/deploy/k8s/backend/configmap.example.yaml @@ -8,6 +8,12 @@ data: JWT_REFRESH_EXPIRES_IN: "7d" FRONTEND_ORIGIN: "https://apicore.example.com" DOCUMENTS_STORAGE_PATH: "/data/documents" + # Automatische Backups. Ohne BACKUP_STORAGE_PATH bleibt der Zeitplan + # deaktiviert. Bei >1 Replica muss dies auf ein geteiltes Volume zeigen, + # wie DOCUMENTS_STORAGE_PATH. + BACKUP_STORAGE_PATH: "/data/backups" + BACKUP_CRON_SCHEDULE: "0 3 * * *" + BACKUP_RETENTION_COUNT: "14" COOKIE_SECURE: "true" # Entra ID SSO. Issuer ist die v2.0-Metadaten-URL des Tenants, # Redirect-URI muss exakt der im App-Registrierung hinterlegten entsprechen.