diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index 5137926..ac8710f 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -23,9 +23,12 @@ 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 +# Automatische Backups (Modul Backup). Alle drei Werte sind auch ueber die +# GUI (Verwaltung > Backup) konfigurierbar - eine hier gesetzte Env-Variable +# hat aber IMMER Vorrang vor dem GUI-Wert, pro Feld einzeln. Ohne Pfad +# (weder Env noch GUI) bleibt der Zeitplan deaktiviert (nur manueller +# Export/Import ueber die UI verfuegbar). +# Bei horizontaler Skalierung (>1 Replica) muss der Pfad 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. diff --git a/ApiCore-Backend/prisma/migrations/20260903120000_add_website_url_and_number_automation/migration.sql b/ApiCore-Backend/prisma/migrations/20260903120000_add_website_url_and_number_automation/migration.sql new file mode 100644 index 0000000..89b3fe6 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260903120000_add_website_url_and_number_automation/migration.sql @@ -0,0 +1,9 @@ +-- AlterEnum +ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_BATCH_NUMBER_FORMAT'; +ALTER TYPE "AutomationTriggerType" ADD VALUE 'BOTTLING_LOT_NUMBER_FORMAT'; + +-- AlterTable +ALTER TABLE "AutomationRule" ADD COLUMN "sequenceValue" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "SiteSettings" ADD COLUMN "websiteUrl" TEXT; diff --git a/ApiCore-Backend/prisma/migrations/20260903120100_add_backup_settings/migration.sql b/ApiCore-Backend/prisma/migrations/20260903120100_add_backup_settings/migration.sql new file mode 100644 index 0000000..8defa9c --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260903120100_add_backup_settings/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "BackupSettings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "storagePath" TEXT, + "cronSchedule" TEXT, + "retentionCount" INTEGER, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BackupSettings_pkey" PRIMARY KEY ("id") +); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 29fad7a..0ce37c8 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -1014,6 +1014,8 @@ enum AutomationTriggerType { SWARM_MOOD_HIGH COLONY_HEALTH_CRITICAL STOCK_ITEM_LOW + HONEY_BATCH_NUMBER_FORMAT + BOTTLING_LOT_NUMBER_FORMAT } model AutomationRule { @@ -1022,6 +1024,12 @@ model AutomationRule { active Boolean @default(true) config Json? actionTemplate String? + // Running counter for the number-format rules (HONEY_BATCH_NUMBER_FORMAT / + // BOTTLING_LOT_NUMBER_FORMAT) - unused by the threshold-style rules. Kept as + // a dedicated column (not inside `config`) so it can be incremented with a + // single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`, + // race-safe under concurrent batch/bottling creation. + sequenceValue Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -1038,5 +1046,21 @@ model SiteSettings { youtubeUrl String? instagramUrl String? tiktokUrl String? + websiteUrl String? updatedAt DateTime @updatedAt } + +// --------------------------------------------------------------------------- +// Backup-Einstellungen – GUI-Override für den automatischen Backup-Zeitplan. +// Jede der drei Umgebungsvariablen (BACKUP_STORAGE_PATH/_CRON_SCHEDULE/ +// _RETENTION_COUNT) hat Vorrang vor dem hier gespeicherten Wert - diese Zeile +// greift nur pro Feld, wenn die jeweilige Env-Variable nicht gesetzt ist. +// --------------------------------------------------------------------------- + +model BackupSettings { + id String @id @default("singleton") + storagePath String? + cronSchedule String? + retentionCount Int? + updatedAt DateTime @updatedAt +} diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 29ae03f..84f26aa 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -291,6 +291,7 @@ export const PERMISSION_CATALOG = [ [ ['export', 'Exportieren'], ['import', 'Importieren'], + ['configure', 'Zeitplan konfigurieren'], ], 3200, ), diff --git a/ApiCore-Backend/src/automation/automation.controller.ts b/ApiCore-Backend/src/automation/automation.controller.ts index e1bf2b0..c734ed3 100644 --- a/ApiCore-Backend/src/automation/automation.controller.ts +++ b/ApiCore-Backend/src/automation/automation.controller.ts @@ -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); + } } diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 57dfa24..1cf130e 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -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', + }), + ); + }); + }); }); diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 35a752c..651dc1b 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -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; @@ -48,6 +49,17 @@ const DEFAULT_RULES: Record = { 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 { + 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 { + 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, diff --git a/ApiCore-Backend/src/automation/number-format.spec.ts b/ApiCore-Backend/src/automation/number-format.spec.ts new file mode 100644 index 0000000..23665f3 --- /dev/null +++ b/ApiCore-Backend/src/automation/number-format.spec.ts @@ -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'); + }); +}); diff --git a/ApiCore-Backend/src/automation/number-format.ts b/ApiCore-Backend/src/automation/number-format.ts new file mode 100644 index 0000000..a916819 --- /dev/null +++ b/ApiCore-Backend/src/automation/number-format.ts @@ -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)); +} diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.ts index 6b83cab..509922d 100644 --- a/ApiCore-Backend/src/backup/backup-scheduler.service.ts +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.ts @@ -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 { + await this.reload(); + } + + private async effectiveSettings(): Promise { + 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 { + 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 { - 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 { + 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 { @@ -140,9 +187,12 @@ export class BackupSchedulerService implements OnModuleInit { return filePath; } - private async pruneOldBackups(directory: string): Promise { + private async pruneOldBackups( + directory: string, + retentionCount: number, + ): Promise { const files = await this.listBackupFiles(directory); - const toDelete = files.slice(retentionCount()); + const toDelete = files.slice(retentionCount); await Promise.all( toDelete.map((file) => fs diff --git a/ApiCore-Backend/src/backup/backup-settings.service.spec.ts b/ApiCore-Backend/src/backup/backup-settings.service.spec.ts new file mode 100644 index 0000000..b75c200 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-settings.service.spec.ts @@ -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, + }, + }); + }); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup-settings.service.ts b/ApiCore-Backend/src/backup/backup-settings.service.ts new file mode 100644 index 0000000..a57a2d7 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-settings.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/ApiCore-Backend/src/backup/backup.controller.ts b/ApiCore-Backend/src/backup/backup.controller.ts index 26161f5..663bc51 100644 --- a/ApiCore-Backend/src/backup/backup.controller.ts +++ b/ApiCore-Backend/src/backup/backup.controller.ts @@ -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 { + return this.backupSettingsService.get(); + } + + @RequirePermission('backup:configure') + @Patch('settings') + async updateSettings( + @Body() dto: UpdateBackupSettingsDto, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + 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 { diff --git a/ApiCore-Backend/src/backup/backup.module.ts b/ApiCore-Backend/src/backup/backup.module.ts index 1935dba..a825a05 100644 --- a/ApiCore-Backend/src/backup/backup.module.ts +++ b/ApiCore-Backend/src/backup/backup.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/backup/dto/update-backup-settings.dto.ts b/ApiCore-Backend/src/backup/dto/update-backup-settings.dto.ts new file mode 100644 index 0000000..f95e729 --- /dev/null +++ b/ApiCore-Backend/src/backup/dto/update-backup-settings.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/bottling/bottling.module.ts b/ApiCore-Backend/src/bottling/bottling.module.ts index c9abd12..2df68f1 100644 --- a/ApiCore-Backend/src/bottling/bottling.module.ts +++ b/ApiCore-Backend/src/bottling/bottling.module.ts @@ -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], }) diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index 9464bd4..a6f68bb 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -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, ); }); diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts index b97582b..7684fb7 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -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, }, }); diff --git a/ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts b/ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts index d7d9f74..78004bb 100644 --- a/ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts +++ b/ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts @@ -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; } diff --git a/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts b/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts index 556d0f5..c20debd 100644 --- a/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts +++ b/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts @@ -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() diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.module.ts b/ApiCore-Backend/src/honey-batches/honey-batches.module.ts index 433736c..f37271c 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.module.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.module.ts @@ -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], diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts index 2effcd0..9bc9efe 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts @@ -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, ); }); diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index b8091eb..07de6fa 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -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', diff --git a/ApiCore-Backend/src/public/public.service.ts b/ApiCore-Backend/src/public/public.service.ts index 9517d43..bb7a826 100644 --- a/ApiCore-Backend/src/public/public.service.ts +++ b/ApiCore-Backend/src/public/public.service.ts @@ -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, }, }; } diff --git a/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts index 1ade01b..6bab41f 100644 --- a/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts +++ b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts @@ -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; } diff --git a/ApiCore-Backend/src/site-settings/site-settings.service.spec.ts b/ApiCore-Backend/src/site-settings/site-settings.service.spec.ts index 46f3943..a2e85e2 100644 --- a/ApiCore-Backend/src/site-settings/site-settings.service.spec.ts +++ b/ApiCore-Backend/src/site-settings/site-settings.service.spec.ts @@ -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, }, }); }); diff --git a/ApiCore-Backend/src/site-settings/site-settings.service.ts b/ApiCore-Backend/src/site-settings/site-settings.service.ts index 03de951..6d3e0a9 100644 --- a/ApiCore-Backend/src/site-settings/site-settings.service.ts +++ b/ApiCore-Backend/src/site-settings/site-settings.service.ts @@ -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({ diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx index c5fa545..8ce0348 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx @@ -65,7 +65,7 @@ export default function NewBottlingBatchPage() { jarSize: Number(jarSize), jarCount: Number(jarCount), bestBeforeDate: new Date(bestBeforeDate).toISOString(), - lotNumber, + lotNumber: lotNumber || undefined, }); } @@ -160,7 +160,7 @@ export default function NewBottlingBatchPage() { setLotNumber(event.target.value)} /> diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx index bfdd8a9..c9bcfb5 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx @@ -61,7 +61,7 @@ export function CreateHoneyBatchDialog() { return; } mutation.mutate({ - batchNumber, + batchNumber: batchNumber || undefined, extractionId, honeyType: honeyType || undefined, amount: amount ? Number(amount) : undefined, @@ -80,7 +80,7 @@ export function CreateHoneyBatchDialog() { setBatchNumber(event.target.value)} /> diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 3d42426..a5b62cb 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -3,7 +3,11 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api"; +import { + fetchAutomationRules, + resetAutomationRuleSequence, + updateAutomationRule, +} from "@/lib/automation/api"; import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -18,6 +22,8 @@ const TRIGGER_LABELS: Record = { SWARM_MOOD_HIGH: "Hohe Schwarmstimmung", COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch", STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand", + HONEY_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Honigchargen)", + BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)", }; const TRIGGER_DESCRIPTIONS: Record = { @@ -33,8 +39,26 @@ const TRIGGER_DESCRIPTIONS: Record = { "Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.", STOCK_ITEM_LOW: "Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.", + HONEY_BATCH_NUMBER_FORMAT: + "Wenn aktiv, wird die Chargennummer neuer Honigchargen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.", + BOTTLING_LOT_NUMBER_FORMAT: + "Wenn aktiv, wird die Los-/Chargennummer neuer Abfüllungen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.", }; +const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [ + "HONEY_BATCH_NUMBER_FORMAT", + "BOTTLING_LOT_NUMBER_FORMAT", +]; + +function isNumberFormatRule(rule: AutomationRule): boolean { + return NUMBER_FORMAT_TRIGGER_TYPES.includes(rule.triggerType); +} + +function ruleFormat(rule: AutomationRule): string { + const config = rule.config as { format?: string } | null; + return config?.format ?? ""; +} + function ruleThreshold(rule: AutomationRule): string { const config = rule.config as { thresholdMitesPerDay?: number } | null; return String(config?.thresholdMitesPerDay ?? ""); @@ -44,6 +68,8 @@ function RuleCard({ rule }: { rule: AutomationRule }) { const queryClient = useQueryClient(); const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? ""); const [threshold, setThreshold] = useState(ruleThreshold(rule)); + const [format, setFormat] = useState(ruleFormat(rule)); + const numberFormatRule = isNumberFormatRule(rule); const mutation = useMutation({ mutationFn: (input: { active?: boolean; config?: Record; actionTemplate?: string }) => @@ -55,7 +81,20 @@ function RuleCard({ rule }: { rule: AutomationRule }) { onError: () => toast.error("Regel konnte nicht gespeichert werden"), }); + const resetMutation = useMutation({ + mutationFn: () => resetAutomationRuleSequence(rule.id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["automation-rules"] }); + toast.success("Zähler zurückgesetzt"); + }, + onError: () => toast.error("Zähler konnte nicht zurückgesetzt werden"), + }); + function handleSave() { + if (numberFormatRule) { + mutation.mutate({ config: { format } }); + return; + } const input: { config?: Record; actionTemplate?: string } = { actionTemplate: actionTemplate || undefined, }; @@ -75,14 +114,33 @@ function RuleCard({ rule }: { rule: AutomationRule }) {

{TRIGGER_DESCRIPTIONS[rule.triggerType]}

-
- - setActionTemplate(event.target.value)} - /> -
+ {numberFormatRule ? ( +
+ + setFormat(event.target.value)} + /> +

+ Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende + Nummer, {"{SEQ:4}"} mit führenden Nullen auf 4 Stellen. +

+

+ Aktueller Zähler: {rule.sequenceValue} +

+
+ ) : ( +
+ + setActionTemplate(event.target.value)} + /> +
+ )} {rule.triggerType === "VARROA_THRESHOLD" ? (
@@ -108,6 +166,17 @@ function RuleCard({ rule }: { rule: AutomationRule }) { {mutation.isPending ? "Speichert…" : "Speichern"}
+ {numberFormatRule ? ( + + ) : null}
); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx index 1a34a8d..76a9c37 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx @@ -17,6 +17,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { const [youtubeUrl, setYoutubeUrl] = useState(settings.youtubeUrl ?? ""); const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? ""); const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? ""); + const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? ""); const mutation = useMutation({ mutationFn: updateSiteSettings, @@ -33,6 +34,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { youtubeUrl, instagramUrl, tiktokUrl, + websiteUrl, }); } @@ -74,6 +76,16 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { onChange={(event) => setTiktokUrl(event.target.value)} /> +
+ + setWebsiteUrl(event.target.value)} + /> +
diff --git a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx index 08e1ab4..ee7527a 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx @@ -1,10 +1,19 @@ "use client"; -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, FormEvent, useState } from "react"; import { Database } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { exportBackup, getBackupSchedule, importBackup, runBackupNow } from "@/lib/backup/api"; +import { + exportBackup, + getBackupSchedule, + getBackupSettings, + importBackup, + runBackupNow, + updateBackupSettings, +} from "@/lib/backup/api"; +import { Input } from "@/components/ui/input"; +import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types"; import { importCsv } from "@/lib/import/api"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; import { Badge } from "@/components/ui/badge"; @@ -120,6 +129,104 @@ function formatDateTime(value: string | null): string { return new Date(value).toLocaleString("de-DE"); } +// Mounted only once `settings` has arrived, so its useState initial values +// already reflect the fetched data - matches the pattern already used for +// WebsiteSettingsForm in social-media-tab.tsx. +function ScheduleSettingsForm({ + settings, + envOverrides, +}: { + settings: BackupSettings; + envOverrides: BackupScheduleStatus["envOverrides"]; +}) { + const queryClient = useQueryClient(); + const [storagePath, setStoragePath] = useState(settings.storagePath ?? ""); + const [cronSchedule, setCronSchedule] = useState(settings.cronSchedule ?? ""); + const [retentionCount, setRetentionCount] = useState( + settings.retentionCount !== null ? String(settings.retentionCount) : "", + ); + + const mutation = useMutation({ + mutationFn: updateBackupSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["backup-schedule"] }); + queryClient.invalidateQueries({ queryKey: ["backup-settings"] }); + toast.success("Zeitplan gespeichert"); + }, + onError: () => toast.error("Zeitplan konnte nicht gespeichert werden"), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const retention = Number(retentionCount); + mutation.mutate({ + storagePath, + cronSchedule, + retentionCount: retentionCount !== "" && Number.isInteger(retention) ? retention : undefined, + }); + } + + return ( +
+

+ Nur wirksam, solange die zugehörige Umgebungsvariable nicht gesetzt ist - Env hat immer + Vorrang. +

+
+ + setStoragePath(event.target.value)} + placeholder="/data/backups" + /> + {envOverrides.storagePath ? ( +

+ Durch BACKUP_STORAGE_PATH vorgegeben. +

+ ) : null} +
+
+ + setCronSchedule(event.target.value)} + placeholder="0 3 * * *" + /> + {envOverrides.cronSchedule ? ( +

+ Durch BACKUP_CRON_SCHEDULE vorgegeben. +

+ ) : null} +
+
+ + setRetentionCount(event.target.value)} + placeholder="14" + /> + {envOverrides.retentionCount ? ( +

+ Durch BACKUP_RETENTION_COUNT vorgegeben. +

+ ) : null} +
+ +
+ ); +} + function ScheduleSection() { const queryClient = useQueryClient(); @@ -127,6 +234,10 @@ function ScheduleSection() { queryKey: ["backup-schedule"], queryFn: getBackupSchedule, }); + const settingsQuery = useQuery({ + queryKey: ["backup-settings"], + queryFn: getBackupSettings, + }); const runNowMutation = useMutation({ mutationFn: runBackupNow, @@ -149,8 +260,8 @@ function ScheduleSection() {

Lädt…

) : !status?.enabled ? (

- Automatische Backups sind nicht konfiguriert (BACKUP_STORAGE_PATH fehlt). - Manueller Export/Import bleibt weiterhin möglich. + Automatische Backups sind noch nicht konfiguriert - weder per Umgebungsvariable noch + über die GUI unten. Manueller Export/Import bleibt weiterhin möglich.

) : (
@@ -176,6 +287,9 @@ function ScheduleSection() { > {runNowMutation.isPending ? "Wird gesichert…" : "Jetzt sichern"} + {settingsQuery.data && status ? ( + + ) : null} ); diff --git a/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx index 45d07e0..0e50836 100644 --- a/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx +++ b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx @@ -2,7 +2,19 @@ import { useParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; -import { Hexagon, Droplet, MapPin, CalendarDays, ExternalLink } from "lucide-react"; +import { + Hexagon, + Droplet, + MapPin, + CalendarDays, + PackageCheck, + Sparkles, + Video, + Camera, + Music2, + Globe, + type LucideIcon, +} from "lucide-react"; import { fetchPublicBatch } from "@/lib/public-batch/api"; import { HoneycombPattern } from "@/components/honeycomb-pattern"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -15,6 +27,50 @@ function formatMonthYear(iso: string): string { return new Date(iso).toLocaleDateString("de-DE", { month: "long", year: "numeric" }); } +// lucide-react has no brand icons (YouTube/Instagram/TikTok aren't part of +// its generic icon set) - these are the closest generic stand-ins that still +// read at a glance as "video platform" / "photo platform" / "short clips". +const SOCIAL_LINK_ICONS: Record = { + YouTube: Video, + Instagram: Camera, + TikTok: Music2, + Webseite: Globe, +}; + +interface InfoRowProps { + icon: LucideIcon; + label: string; + value: string; +} + +function InfoRow({ icon: Icon, label, value }: InfoRowProps) { + return ( +
+ +
+
{label}
+
{value}
+
+
+ ); +} + +interface StatProps { + icon: LucideIcon; + label: string; + value: string; +} + +function Stat({ icon: Icon, label, value }: StatProps) { + return ( +
+ + {value} + {label} +
+ ); +} + export default function PublicBatchPage() { const params = useParams<{ lot: string }>(); const lot = decodeURIComponent(params.lot); @@ -28,12 +84,20 @@ export default function PublicBatchPage() { const harvestMonths = batch ? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ") : null; - const apiaryNames = batch ? batch.apiaries.map((a) => a.name).join(", ") : null; + // Standort statt Bienenstand-Name: mehrere Bienenstände am selben Ort + // sollen als ein Herkunftsort erscheinen, nicht als mehrere interne Namen. + // Fällt auf den Namen zurück, falls für einen Bienenstand kein Standort + // hinterlegt ist, damit die Zeile nie leer bleibt. + const originLabels = batch + ? [...new Set(batch.apiaries.map((a) => a.location?.trim() || a.name))] + : []; + const originText = originLabels.join(", "); const socialLinks = batch ? [ { label: "YouTube", url: batch.socialLinks.youtube }, { label: "Instagram", url: batch.socialLinks.instagram }, { label: "TikTok", url: batch.socialLinks.tiktok }, + { label: "Webseite", url: batch.socialLinks.website }, ].filter((link): link is { label: string; url: string } => link.url !== null) : []; @@ -55,7 +119,7 @@ export default function PublicBatchPage() { - + {isLoading ?

Lädt…

: null} {isError ? (

@@ -65,69 +129,74 @@ export default function PublicBatchPage() { {batch ? ( <>

-

- {batch.productName ?? "Honig"} -

+
+

+ {batch.productName ?? "Honig"} +

+ {batch.honeyType ? ( + + + {batch.honeyType} + + ) : null} +

Charge {batch.lotNumber}

+ {batch.productDescription ? (

{batch.productDescription}

) : null} +
- {apiaryNames ? ( -
- -
-
Herkunft
-
{apiaryNames}
-
-
+ {originText ? ( + ) : null} {harvestMonths ? ( -
- -
-
Geerntet
-
{harvestMonths}
-
-
+ ) : null} {batch.extractedDate ? ( -
- -
-
Geschleudert
-
{formatDate(batch.extractedDate)}
-
-
- ) : null} - {batch.waterContent !== null ? ( -
- -
-
Wassergehalt
-
{batch.waterContent} %
-
-
+ ) : null} +
-

- Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde -

+ +
+ {batch.waterContent !== null ? ( + + ) : null} + + +
+ {socialLinks.length > 0 ? ( -
- {socialLinks.map((link) => ( - - - {link.label} - - ))} +
+ {socialLinks.map((link) => { + const Icon = SOCIAL_LINK_ICONS[link.label] ?? Globe; + return ( + + + {link.label} + + ); + })}
) : null} diff --git a/ApiCore-Frontend/src/lib/automation/api.ts b/ApiCore-Frontend/src/lib/automation/api.ts index 057f5b0..e5e6c9d 100644 --- a/ApiCore-Frontend/src/lib/automation/api.ts +++ b/ApiCore-Frontend/src/lib/automation/api.ts @@ -14,3 +14,9 @@ export function updateAutomationRule( body: JSON.stringify(input), }); } + +export function resetAutomationRuleSequence(id: string): Promise { + return apiFetch(`/automation-rules/${id}/reset-sequence`, { + method: "POST", + }); +} diff --git a/ApiCore-Frontend/src/lib/automation/types.ts b/ApiCore-Frontend/src/lib/automation/types.ts index 6990bc3..fe257e0 100644 --- a/ApiCore-Frontend/src/lib/automation/types.ts +++ b/ApiCore-Frontend/src/lib/automation/types.ts @@ -4,7 +4,9 @@ export type AutomationTriggerType = | "FEED_STATUS_CRITICAL" | "SWARM_MOOD_HIGH" | "COLONY_HEALTH_CRITICAL" - | "STOCK_ITEM_LOW"; + | "STOCK_ITEM_LOW" + | "HONEY_BATCH_NUMBER_FORMAT" + | "BOTTLING_LOT_NUMBER_FORMAT"; export interface AutomationRule { id: string; @@ -12,6 +14,7 @@ export interface AutomationRule { active: boolean; config: Record | null; actionTemplate: string | null; + sequenceValue: number; createdAt: string; updatedAt: string; } diff --git a/ApiCore-Frontend/src/lib/backup/api.ts b/ApiCore-Frontend/src/lib/backup/api.ts index ad4dc33..20aa6ce 100644 --- a/ApiCore-Frontend/src/lib/backup/api.ts +++ b/ApiCore-Frontend/src/lib/backup/api.ts @@ -1,5 +1,10 @@ import { apiFetch, apiFetchBlob, apiFetchFormData, BlobDownload } from "@/lib/auth/api-client"; -import { BackupImportSummary, BackupScheduleStatus } from "./types"; +import { + BackupImportSummary, + BackupScheduleStatus, + BackupSettings, + UpdateBackupSettingsInput, +} from "./types"; export function exportBackup(): Promise { return apiFetchBlob("/backup/export", "apicore-backup.json"); @@ -20,3 +25,16 @@ export function runBackupNow(): Promise<{ filePath: string }> { method: "POST", }); } + +export function getBackupSettings(): Promise { + return apiFetch("/backup/settings"); +} + +export function updateBackupSettings( + input: UpdateBackupSettingsInput, +): Promise { + return apiFetch("/backup/settings", { + method: "PATCH", + body: JSON.stringify(input), + }); +} diff --git a/ApiCore-Frontend/src/lib/backup/types.ts b/ApiCore-Frontend/src/lib/backup/types.ts index c9e93e3..d5c08b1 100644 --- a/ApiCore-Frontend/src/lib/backup/types.ts +++ b/ApiCore-Frontend/src/lib/backup/types.ts @@ -8,4 +8,23 @@ export interface BackupScheduleStatus { cronSchedule: string | null; retentionCount: number | null; lastRunAt: string | null; + envOverrides: { + storagePath: boolean; + cronSchedule: boolean; + retentionCount: boolean; + }; +} + +export interface BackupSettings { + id: string; + storagePath: string | null; + cronSchedule: string | null; + retentionCount: number | null; + updatedAt: string; +} + +export interface UpdateBackupSettingsInput { + storagePath?: string; + cronSchedule?: string; + retentionCount?: number; } diff --git a/ApiCore-Frontend/src/lib/bottling/types.ts b/ApiCore-Frontend/src/lib/bottling/types.ts index 41b9946..66476af 100644 --- a/ApiCore-Frontend/src/lib/bottling/types.ts +++ b/ApiCore-Frontend/src/lib/bottling/types.ts @@ -20,7 +20,7 @@ export interface CreateBottlingBatchInput { jarSize: number; jarCount: number; bestBeforeDate: string; - lotNumber: string; + lotNumber?: string; } export interface UpdateBottlingBatchInput { diff --git a/ApiCore-Frontend/src/lib/honey-batches/types.ts b/ApiCore-Frontend/src/lib/honey-batches/types.ts index 26c0c52..89dab36 100644 --- a/ApiCore-Frontend/src/lib/honey-batches/types.ts +++ b/ApiCore-Frontend/src/lib/honey-batches/types.ts @@ -74,7 +74,7 @@ export interface Traceability { } export interface CreateHoneyBatchInput { - batchNumber: string; + batchNumber?: string; extractionId: string; honeyType?: string; amount?: number; diff --git a/ApiCore-Frontend/src/lib/public-batch/types.ts b/ApiCore-Frontend/src/lib/public-batch/types.ts index 918ad5c..dd8f311 100644 --- a/ApiCore-Frontend/src/lib/public-batch/types.ts +++ b/ApiCore-Frontend/src/lib/public-batch/types.ts @@ -7,6 +7,7 @@ export interface PublicSocialLinks { youtube: string | null; instagram: string | null; tiktok: string | null; + website: string | null; } export interface PublicBatch { diff --git a/ApiCore-Frontend/src/lib/site-settings/types.ts b/ApiCore-Frontend/src/lib/site-settings/types.ts index dfb6932..791fd72 100644 --- a/ApiCore-Frontend/src/lib/site-settings/types.ts +++ b/ApiCore-Frontend/src/lib/site-settings/types.ts @@ -3,6 +3,7 @@ export interface SiteSettings { youtubeUrl: string | null; instagramUrl: string | null; tiktokUrl: string | null; + websiteUrl: string | null; updatedAt: string; } @@ -10,4 +11,5 @@ export interface UpdateSiteSettingsInput { youtubeUrl?: string; instagramUrl?: string; tiktokUrl?: string; + websiteUrl?: string; }