feat: expand backup to all data and add automatic scheduled backups
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 1m56s

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:
Nick Szaunig committed 2026-09-03 11:33:48 +02:00
1 parent d998f611e2
commit 7b23862a41
13 files changed
+853 -68

No files matched your search

+13
View File
@@ -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
+49
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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')
+2 -1
View File
@@ -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 {}
+259 -31
View File
@@ -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,
}),
);
});
+222 -28
View File
@@ -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 },
);