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

@@ -291,6 +291,7 @@ export const PERMISSION_CATALOG = [
[
['export', 'Exportieren'],
['import', 'Importieren'],
['configure', 'Zeitplan konfigurieren'],
],
3200,
),
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -27,4 +27,13 @@ export class AutomationController {
) {
return this.automationService.update(id, dto, actor.id);
}
@RequirePermission('automation:update')
@Post(':id/reset-sequence')
resetSequence(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.automationService.resetSequence(id, actor.id);
}
}
@@ -79,7 +79,7 @@ describe('AutomationService', () => {
const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(6);
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8);
expect(result).toHaveLength(3);
});
@@ -307,4 +307,86 @@ describe('AutomationService', () => {
);
});
});
describe('generateNumber', () => {
it('returns null when the rule is inactive', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: false,
config: { format: 'WH-{YYYY}-{SEQ:4}' },
sequenceValue: 0,
});
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
expect(result).toBeNull();
expect(prisma.automationRule.update).not.toHaveBeenCalled();
});
it('returns null when no format is configured', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: true,
config: {},
sequenceValue: 0,
});
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
expect(result).toBeNull();
});
it('atomically increments sequenceValue and formats the number', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: true,
config: { format: 'WH-{YYYY}-{SEQ:4}' },
sequenceValue: 0,
});
prisma.automationRule.update.mockResolvedValue({ sequenceValue: 7 });
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
expect(prisma.automationRule.update).toHaveBeenCalledWith({
where: { triggerType: 'HONEY_BATCH_NUMBER_FORMAT' },
data: { sequenceValue: { increment: 1 } },
select: { sequenceValue: true },
});
expect(result).toBe(`WH-${new Date().getFullYear()}-0007`);
});
});
describe('resetSequence', () => {
it('sets sequenceValue back to 0 and records an audit log entry', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: true,
config: { format: 'WH-{YYYY}-{SEQ:4}' },
sequenceValue: 12,
});
prisma.automationRule.update.mockResolvedValue({
id: 'rule-4',
sequenceValue: 0,
});
await service.resetSequence('rule-4', 'user-1');
expect(prisma.automationRule.update).toHaveBeenCalledWith({
where: { id: 'rule-4' },
data: { sequenceValue: 0 },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'AutomationRule',
entityId: 'rule-4',
action: 'UPDATE',
userId: 'user-1',
}),
);
});
});
});
@@ -15,6 +15,7 @@ import {
VarroaMeasurement,
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
interface RuleDefaults {
config: Record<string, unknown>;
@@ -48,6 +49,17 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
BOTTLING_LOT_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
};
@Injectable()
@@ -215,6 +227,53 @@ export class AutomationService {
await this.triggerAction(rule, 'STORAGE', item.id);
}
// Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry.
async generateNumber(
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT',
): Promise<string | null> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return null;
}
const config = rule.config as { format?: string } | null;
const format = config?.format;
if (!format) {
return null;
}
// Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent
// batch/bottling creation, no explicit transaction/lock needed.
const updated = await this.prisma.automationRule.update({
where: { triggerType },
data: { sequenceValue: { increment: 1 } },
select: { sequenceValue: true },
});
return formatGeneratedNumber(format, updated.sequenceValue, new Date());
}
async resetSequence(
id: string,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
const rule = await this.prisma.automationRule.update({
where: { id },
data: { sequenceValue: 0 },
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
return rule;
}
private async triggerAction(
rule: AutomationRule,
relatedType: TaskRelatedType,
@@ -0,0 +1,31 @@
import { formatGeneratedNumber } from './number-format';
describe('formatGeneratedNumber', () => {
// Constructed via local-time components (not an ISO string) so the test
// is deterministic regardless of the machine's timezone.
const date = new Date(2026, 2, 7);
it('substitutes year, month, day and a zero-padded sequence', () => {
const result = formatGeneratedNumber('WH-{YYYY}-{SEQ:4}', 7, date);
expect(result).toBe('WH-2026-0007');
});
it('supports {YY}, {MM}, {DD} and an unpadded {SEQ}', () => {
const result = formatGeneratedNumber('{YY}{MM}{DD}-{SEQ}', 3, date);
expect(result).toBe('260307-3');
});
it('does not truncate a sequence wider than the requested padding', () => {
const result = formatGeneratedNumber('L-{SEQ:2}', 12345, date);
expect(result).toBe('L-12345');
});
it('leaves a format with no placeholders unchanged', () => {
const result = formatGeneratedNumber('FIXED-NUMBER', 1, date);
expect(result).toBe('FIXED-NUMBER');
});
});
@@ -0,0 +1,24 @@
// Placeholders: {YYYY} 4-digit year, {YY} 2-digit year, {MM} month (01-12),
// {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence
// zero-padded to N digits. Example matching the lastenheft's own sample
// ("WH-2026-0001"): format "WH-{YYYY}-{SEQ:4}".
export function formatGeneratedNumber(
format: string,
sequence: number,
date: Date,
): string {
const yyyy = String(date.getFullYear());
const yy = yyyy.slice(-2);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return format
.replace(/\{YYYY\}/g, yyyy)
.replace(/\{YY\}/g, yy)
.replace(/\{MM\}/g, mm)
.replace(/\{DD\}/g, dd)
.replace(/\{SEQ:(\d+)\}/g, (_match, width: string) =>
String(sequence).padStart(Number(width), '0'),
)
.replace(/\{SEQ\}/g, String(sequence));
}
@@ -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;
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service';
@Module({
imports: [AutomationModule],
controllers: [BottlingController],
providers: [BottlingService],
})
@@ -3,6 +3,7 @@ import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('BottlingService', () => {
let service: BottlingService;
@@ -43,10 +44,12 @@ describe('BottlingService', () => {
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new BottlingService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
);
});
@@ -6,6 +6,7 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { BottlingBatch } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -25,6 +26,7 @@ export class BottlingService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {}
async findAll(filter: BottlingBatchFilter = {}) {
@@ -69,6 +71,15 @@ export class BottlingService {
);
}
const lotNumber =
dto.lotNumber ??
(await this.automation.generateNumber('BOTTLING_LOT_NUMBER_FORMAT'));
if (!lotNumber) {
throw new BadRequestException(
'Los-/Chargennummer erforderlich (oder Automatisierung "Los-/Chargennummer-Format" aktivieren)',
);
}
const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
async (tx) => {
const created = await tx.bottlingBatch.create({
@@ -80,7 +91,7 @@ export class BottlingService {
jarCount: dto.jarCount,
totalAmount,
bestBeforeDate: new Date(dto.bestBeforeDate),
lotNumber: dto.lotNumber,
lotNumber,
userId: actingUserId,
},
});
@@ -36,7 +36,11 @@ export class CreateBottlingBatchDto {
@IsDateString()
bestBeforeDate!: string;
@ApiProperty()
@ApiPropertyOptional({
description:
'Wird automatisch generiert, falls die Automatisierung "Los-/Chargennummer-Format" aktiv ist',
})
@IsOptional()
@IsString()
lotNumber!: string;
lotNumber?: string;
}
@@ -2,9 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateHoneyBatchDto {
@ApiProperty()
@ApiPropertyOptional({
description:
'Wird automatisch generiert, falls die Automatisierung "Chargennummer-Format" aktiv ist',
})
@IsOptional()
@IsString()
batchNumber!: string;
batchNumber?: string;
@ApiProperty()
@IsString()
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { HoneyBatchesController } from './honey-batches.controller';
import { HoneyBatchesService } from './honey-batches.service';
@Module({
imports: [AutomationModule],
controllers: [HoneyBatchesController],
providers: [HoneyBatchesService],
exports: [HoneyBatchesService],
@@ -3,6 +3,7 @@ import { HoneyBatchesService } from './honey-batches.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('HoneyBatchesService', () => {
let service: HoneyBatchesService;
@@ -60,10 +61,12 @@ describe('HoneyBatchesService', () => {
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new HoneyBatchesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
);
});
@@ -1,7 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
@@ -32,6 +37,7 @@ export class HoneyBatchesService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {}
async findAll(filter: HoneyBatchFilter = {}) {
@@ -54,8 +60,16 @@ export class HoneyBatchesService {
}
async create(dto: CreateHoneyBatchDto, actingUserId: string) {
const batchNumber =
dto.batchNumber ??
(await this.automation.generateNumber('HONEY_BATCH_NUMBER_FORMAT'));
if (!batchNumber) {
throw new BadRequestException(
'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format" aktivieren)',
);
}
const honeyBatch = await this.prisma.honeyBatch.create({
data: { ...dto },
data: { ...dto, batchNumber },
});
await this.auditLog.record({
entityType: 'HoneyBatch',
@@ -11,6 +11,7 @@ export interface PublicSocialLinks {
youtube: string | null;
instagram: string | null;
tiktok: string | null;
website: string | null;
}
export interface PublicBatch {
@@ -89,6 +90,7 @@ export class PublicService {
youtube: settings.youtubeUrl,
instagram: settings.instagramUrl,
tiktok: settings.tiktokUrl,
website: settings.websiteUrl,
},
};
}
@@ -25,4 +25,11 @@ export class UpdateSiteSettingsDto {
@IsUrl({ require_protocol: true })
@MaxLength(300)
tiktokUrl?: string;
@ApiPropertyOptional({ description: 'Leerer String löscht den Link' })
@IsOptional()
@ValidateIf((_, value: string) => value !== '')
@IsUrl({ require_protocol: true })
@MaxLength(300)
websiteUrl?: string;
}
@@ -18,6 +18,7 @@ describe('SiteSettingsService', () => {
youtubeUrl: 'https://youtube.com/@wabenhain',
instagramUrl: null as string | null,
tiktokUrl: null as string | null,
websiteUrl: null as string | null,
updatedAt: new Date('2026-01-01'),
};
@@ -84,6 +85,7 @@ describe('SiteSettingsService', () => {
youtubeUrl: undefined,
instagramUrl: 'https://instagram.com/wabenhain',
tiktokUrl: undefined,
websiteUrl: undefined,
},
});
expect(result.instagramUrl).toBe('https://instagram.com/wabenhain');
@@ -111,6 +113,7 @@ describe('SiteSettingsService', () => {
youtubeUrl: null,
instagramUrl: undefined,
tiktokUrl: undefined,
websiteUrl: undefined,
},
});
});
@@ -127,6 +130,7 @@ describe('SiteSettingsService', () => {
youtubeUrl: undefined,
instagramUrl: undefined,
tiktokUrl: undefined,
websiteUrl: undefined,
},
});
});
@@ -48,6 +48,10 @@ export class SiteSettingsService {
: undefined,
tiktokUrl:
dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined,
websiteUrl:
dto.websiteUrl !== undefined
? emptyToNull(dto.websiteUrl)
: undefined,
},
});
await this.auditLog.record({