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.
This commit is contained in:
1 parent
d998f611e2
commit
7b23862a41
13 files changed
+853
-68
No files matched your search
@@ -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
|
||||
|
||||
Generated
+49
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BackupScheduleStatus> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<BackupScheduleStatus> {
|
||||
return this.backupSchedulerService.getStatus();
|
||||
}
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@Post('schedule/run-now')
|
||||
runScheduleNow(): Promise<{ filePath: string }> {
|
||||
return this.backupSchedulerService.runNow();
|
||||
}
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@Get('export')
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<string, { findMany: jest.Mock }> & {
|
||||
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<string, { upsert: jest.Mock; update?: jest.Mock }> & {
|
||||
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: '[email protected]',
|
||||
roleId: 'role-db-id',
|
||||
role: { key: 'ADMIN' },
|
||||
},
|
||||
]);
|
||||
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.data.user).toEqual([
|
||||
{ id: 'user-1', email: '[email protected]', 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<string, unknown> }],
|
||||
];
|
||||
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: '[email protected]', 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: '[email protected]', 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<BackupBundle> {
|
||||
const data: Record<string, unknown[]> = {};
|
||||
|
||||
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<string, unknown> = { ...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<string, string>();
|
||||
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 },
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user