feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
Backend Image / build-and-push (push) Successful in 2m15s
Frontend Image / build-and-push (push) Successful in 2m17s

- 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:
Nick Szaunig committed 2026-09-03 13:37:09 +02:00
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 -1
View File
@@ -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;
}