feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
- SiteSettings gains websiteUrl (e.g. online shop link) alongside the
existing social links, editable in Verwaltung > Automatisierung.
- /herkunft/[lot] now shows the apiary's Standort instead of its internal
Name (so multiple apiaries at one location read as one origin), adds
small icons per social link, and surfaces previously-fetched-but-unused
data (Honigsorte, Abfülldatum, stat cards) to make the page a richer
customer-facing info page.
- Backup schedule (path/cron/retention) is now also configurable via the
GUI (Verwaltung > Backup), stored in a new BackupSettings singleton;
an env var always wins over the GUI value per field, and the scheduler
reloads its cron job live when GUI settings change.
- New Automatisierung rules let an admin define a format (e.g.
"WH-{YYYY}-{SEQ:4}") that auto-generates HoneyBatch.batchNumber and
BottlingBatch.lotNumber on creation, with an atomic per-rule sequence
counter and a manual reset. Manual entry still works when the rule is
inactive.
This commit is contained in:
1 parent
7b23862a41
commit
412d033d8d
42 files changed
+1045
-119
No files matched your search
@@ -4,6 +4,7 @@ import { CronJob } from 'cron';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
|
||||
const SCHEDULED_JOB_NAME = 'automatic-backup';
|
||||
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
||||
@@ -23,20 +24,20 @@ export interface BackupScheduleStatus {
|
||||
cronSchedule: string | null;
|
||||
retentionCount: number | null;
|
||||
lastRunAt: string | null;
|
||||
// Which fields are currently pinned by an env var (and therefore not
|
||||
// editable via the GUI form) - env always wins over the GUI-stored value.
|
||||
envOverrides: {
|
||||
storagePath: boolean;
|
||||
cronSchedule: boolean;
|
||||
retentionCount: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
interface EffectiveBackupSettings {
|
||||
path: string | null;
|
||||
cronSchedule: string;
|
||||
retentionCount: number;
|
||||
envOverrides: BackupScheduleStatus['envOverrides'];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -45,20 +46,63 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private readonly backupService: BackupService,
|
||||
private readonly backupSettingsService: BackupSettingsService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const directory = storagePath();
|
||||
if (!directory) {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.reload();
|
||||
}
|
||||
|
||||
private async effectiveSettings(): Promise<EffectiveBackupSettings> {
|
||||
const envPath = process.env.BACKUP_STORAGE_PATH;
|
||||
const hasEnvPath = Boolean(envPath && envPath.trim().length > 0);
|
||||
|
||||
const envCron = process.env.BACKUP_CRON_SCHEDULE;
|
||||
const hasEnvCron = Boolean(envCron && envCron.trim().length > 0);
|
||||
|
||||
const envRetentionRaw = Number(process.env.BACKUP_RETENTION_COUNT);
|
||||
const hasEnvRetention =
|
||||
Number.isInteger(envRetentionRaw) && envRetentionRaw > 0;
|
||||
|
||||
const gui = await this.backupSettingsService.get();
|
||||
|
||||
return {
|
||||
path: hasEnvPath ? (envPath as string) : (gui.storagePath ?? null),
|
||||
cronSchedule: hasEnvCron
|
||||
? (envCron as string)
|
||||
: (gui.cronSchedule ?? DEFAULT_CRON_SCHEDULE),
|
||||
retentionCount: hasEnvRetention
|
||||
? envRetentionRaw
|
||||
: (gui.retentionCount ?? DEFAULT_RETENTION_COUNT),
|
||||
envOverrides: {
|
||||
storagePath: hasEnvPath,
|
||||
cronSchedule: hasEnvCron,
|
||||
retentionCount: hasEnvRetention,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-registers (or tears down) the cron job to match the current effective
|
||||
// settings. Called at boot and again after a GUI settings update, so an
|
||||
// admin's schedule/path/retention change takes effect immediately without
|
||||
// a pod restart.
|
||||
async reload(): Promise<void> {
|
||||
if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) {
|
||||
this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME);
|
||||
}
|
||||
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
this.logger.log(
|
||||
'BACKUP_STORAGE_PATH not set - automatic backups disabled',
|
||||
'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const directory = settings.path;
|
||||
const job = CronJob.from({
|
||||
cronTime: cronSchedule(),
|
||||
cronTime: settings.cronSchedule,
|
||||
onTick: () => {
|
||||
this.runScheduled(directory).catch((error) =>
|
||||
this.logger.error('Scheduled backup failed', error),
|
||||
@@ -68,49 +112,52 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
});
|
||||
this.schedulerRegistry.addCronJob(SCHEDULED_JOB_NAME, job);
|
||||
this.logger.log(
|
||||
`Automatic backups enabled: schedule="${cronSchedule()}" path="${directory}" retention=${retentionCount()}`,
|
||||
`Automatische Backups aktiv: schedule="${settings.cronSchedule}" path="${directory}" retention=${settings.retentionCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getStatus(): Promise<BackupScheduleStatus> {
|
||||
const directory = storagePath();
|
||||
if (!directory) {
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
return {
|
||||
enabled: false,
|
||||
path: null,
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
lastRunAt: null,
|
||||
envOverrides: settings.envOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
const files = await this.listBackupFiles(directory);
|
||||
const files = await this.listBackupFiles(settings.path);
|
||||
const lastRunAt = files[0]?.mtime.toISOString() ?? null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
path: directory,
|
||||
cronSchedule: cronSchedule(),
|
||||
retentionCount: retentionCount(),
|
||||
path: settings.path,
|
||||
cronSchedule: settings.cronSchedule,
|
||||
retentionCount: settings.retentionCount,
|
||||
lastRunAt,
|
||||
envOverrides: settings.envOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
throw new Error(
|
||||
'BACKUP_STORAGE_PATH ist nicht konfiguriert - automatische Backups sind deaktiviert',
|
||||
'Kein Backup-Pfad konfiguriert - weder per Umgebungsvariable noch über die GUI',
|
||||
);
|
||||
}
|
||||
const filePath = await this.writeBackup(directory);
|
||||
await this.pruneOldBackups(directory);
|
||||
const filePath = await this.writeBackup(settings.path);
|
||||
await this.pruneOldBackups(settings.path, settings.retentionCount);
|
||||
return { filePath };
|
||||
}
|
||||
|
||||
private async runScheduled(directory: string): Promise<void> {
|
||||
const settings = await this.effectiveSettings();
|
||||
const files = await this.listBackupFiles(directory);
|
||||
const mostRecent = files[0];
|
||||
if (
|
||||
@@ -125,7 +172,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
const filePath = await this.writeBackup(directory);
|
||||
this.logger.log(`Scheduled backup written to ${filePath}`);
|
||||
await this.pruneOldBackups(directory);
|
||||
await this.pruneOldBackups(directory, settings.retentionCount);
|
||||
}
|
||||
|
||||
private async writeBackup(directory: string): Promise<string> {
|
||||
@@ -140,9 +187,12 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async pruneOldBackups(directory: string): Promise<void> {
|
||||
private async pruneOldBackups(
|
||||
directory: string,
|
||||
retentionCount: number,
|
||||
): Promise<void> {
|
||||
const files = await this.listBackupFiles(directory);
|
||||
const toDelete = files.slice(retentionCount());
|
||||
const toDelete = files.slice(retentionCount);
|
||||
await Promise.all(
|
||||
toDelete.map((file) =>
|
||||
fs
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
|
||||
describe('BackupSettingsService', () => {
|
||||
let service: BackupSettingsService;
|
||||
let prisma: {
|
||||
backupSettings: {
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const existing = {
|
||||
id: 'singleton',
|
||||
storagePath: '/data/backups',
|
||||
cronSchedule: '0 3 * * *',
|
||||
retentionCount: 14,
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
backupSettings: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new BackupSettingsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns the existing singleton row', async () => {
|
||||
prisma.backupSettings.findUnique.mockResolvedValue(existing);
|
||||
|
||||
const result = await service.get();
|
||||
|
||||
expect(result).toEqual(existing);
|
||||
expect(prisma.backupSettings.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the singleton row on first access', async () => {
|
||||
prisma.backupSettings.findUnique.mockResolvedValue(null);
|
||||
prisma.backupSettings.create.mockResolvedValue({
|
||||
id: 'singleton',
|
||||
storagePath: null,
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.get();
|
||||
|
||||
expect(prisma.backupSettings.create).toHaveBeenCalledWith({
|
||||
data: { id: 'singleton' },
|
||||
});
|
||||
expect(result.id).toBe('singleton');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('stores a valid value and records an audit entry', async () => {
|
||||
prisma.backupSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.backupSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
retentionCount: 30,
|
||||
});
|
||||
|
||||
const result = await service.update({ retentionCount: 30 }, 'user-1');
|
||||
|
||||
expect(prisma.backupSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
storagePath: undefined,
|
||||
cronSchedule: undefined,
|
||||
retentionCount: 30,
|
||||
},
|
||||
});
|
||||
expect(result.retentionCount).toBe(30);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'BackupSettings',
|
||||
action: 'UPDATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a string field when given an empty string', async () => {
|
||||
prisma.backupSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.backupSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
storagePath: null,
|
||||
});
|
||||
|
||||
await service.update({ storagePath: '' }, 'user-1');
|
||||
|
||||
expect(prisma.backupSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
storagePath: null,
|
||||
cronSchedule: undefined,
|
||||
retentionCount: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves fields untouched when omitted from the dto', async () => {
|
||||
prisma.backupSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.backupSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update({}, 'user-1');
|
||||
|
||||
expect(prisma.backupSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
storagePath: undefined,
|
||||
cronSchedule: undefined,
|
||||
retentionCount: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { BackupSettings } from '../../generated/prisma';
|
||||
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
|
||||
|
||||
const SINGLETON_ID = 'singleton';
|
||||
|
||||
function emptyToNull(value: string | undefined): string | null {
|
||||
return value === '' ? null : (value ?? null);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BackupSettingsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
async get(): Promise<BackupSettings> {
|
||||
const existing = await this.prisma.backupSettings.findUnique({
|
||||
where: { id: SINGLETON_ID },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return this.prisma.backupSettings.create({ data: { id: SINGLETON_ID } });
|
||||
}
|
||||
|
||||
async update(
|
||||
dto: UpdateBackupSettingsDto,
|
||||
actingUserId: string,
|
||||
): Promise<BackupSettings> {
|
||||
const before = await this.get();
|
||||
const settings = await this.prisma.backupSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
data: {
|
||||
storagePath:
|
||||
dto.storagePath !== undefined
|
||||
? emptyToNull(dto.storagePath)
|
||||
: undefined,
|
||||
cronSchedule:
|
||||
dto.cronSchedule !== undefined
|
||||
? emptyToNull(dto.cronSchedule)
|
||||
: undefined,
|
||||
retentionCount:
|
||||
dto.retentionCount !== undefined ? dto.retentionCount : undefined,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'BackupSettings',
|
||||
entityId: SINGLETON_ID,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: settings,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
Res,
|
||||
UploadedFile,
|
||||
@@ -13,11 +15,14 @@ import type { Response } from 'express';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { BackupSettings } from '../../generated/prisma';
|
||||
import { BackupBundle, BackupService } from './backup.service';
|
||||
import {
|
||||
BackupScheduleStatus,
|
||||
BackupSchedulerService,
|
||||
} from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
|
||||
|
||||
// A full backup bundle can be much larger than the 5mb CSV import cap
|
||||
// elsewhere (dozens of tables, potentially years of records, plus embedded
|
||||
@@ -32,6 +37,7 @@ export class BackupController {
|
||||
constructor(
|
||||
private readonly backupService: BackupService,
|
||||
private readonly backupSchedulerService: BackupSchedulerService,
|
||||
private readonly backupSettingsService: BackupSettingsService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@@ -46,6 +52,23 @@ export class BackupController {
|
||||
return this.backupSchedulerService.runNow();
|
||||
}
|
||||
|
||||
@RequirePermission('backup:configure')
|
||||
@Get('settings')
|
||||
getSettings(): Promise<BackupSettings> {
|
||||
return this.backupSettingsService.get();
|
||||
}
|
||||
|
||||
@RequirePermission('backup:configure')
|
||||
@Patch('settings')
|
||||
async updateSettings(
|
||||
@Body() dto: UpdateBackupSettingsDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<BackupSettings> {
|
||||
const settings = await this.backupSettingsService.update(dto, actor.id);
|
||||
await this.backupSchedulerService.reload();
|
||||
return settings;
|
||||
}
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@Get('export')
|
||||
async export(@Res() res: Response): Promise<void> {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BackupController],
|
||||
providers: [BackupService, BackupSchedulerService],
|
||||
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
|
||||
})
|
||||
export class BackupModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
// An empty string clears the GUI override (stored as null), letting the
|
||||
// built-in default (or the env var, which always wins regardless) take over
|
||||
// again - same "empty string clears" convention as UpdateSiteSettingsDto.
|
||||
export class UpdateBackupSettingsDto {
|
||||
@ApiPropertyOptional({ description: 'Leerer String löscht den GUI-Wert' })
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value: string) => value !== '')
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
storagePath?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Leerer String löscht den GUI-Wert' })
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value: string) => value !== '')
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
cronSchedule?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
retentionCount?: number;
|
||||
}
|
||||
Reference in new issue
Block a user