feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
- SiteSettings gains websiteUrl (e.g. online shop link) alongside the
existing social links, editable in Verwaltung > Automatisierung.
- /herkunft/[lot] now shows the apiary's Standort instead of its internal
Name (so multiple apiaries at one location read as one origin), adds
small icons per social link, and surfaces previously-fetched-but-unused
data (Honigsorte, Abfülldatum, stat cards) to make the page a richer
customer-facing info page.
- Backup schedule (path/cron/retention) is now also configurable via the
GUI (Verwaltung > Backup), stored in a new BackupSettings singleton;
an env var always wins over the GUI value per field, and the scheduler
reloads its cron job live when GUI settings change.
- New Automatisierung rules let an admin define a format (e.g.
"WH-{YYYY}-{SEQ:4}") that auto-generates HoneyBatch.batchNumber and
BottlingBatch.lotNumber on creation, with an atomic per-rule sequence
counter and a manual reset. Manual entry still works when the rule is
inactive.
This commit is contained in:
1 parent
7b23862a41
commit
412d033d8d
42 files changed
+1045
-119
No files matched your search
@@ -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.
|
||||
|
||||
+9
@@ -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;
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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() {
|
||||
<Label htmlFor="bottling-lot-number">Los-/Chargennummer</Label>
|
||||
<Input
|
||||
id="bottling-lot-number"
|
||||
required
|
||||
placeholder="Automatisch, falls Automatisierung aktiv"
|
||||
value={lotNumber}
|
||||
onChange={(event) => setLotNumber(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -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() {
|
||||
<Label htmlFor="batch-number">Chargennummer</Label>
|
||||
<Input
|
||||
id="batch-number"
|
||||
required
|
||||
placeholder="Automatisch, falls Automatisierung aktiv"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -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<AutomationTriggerType, string> = {
|
||||
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<AutomationTriggerType, string> = {
|
||||
@@ -33,8 +39,26 @@ const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
|
||||
"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<string, unknown>; 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<string, unknown>; actionTemplate?: string } = {
|
||||
actionTemplate: actionTemplate || undefined,
|
||||
};
|
||||
@@ -75,14 +114,33 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 text-sm">
|
||||
<p className="text-muted-foreground">{TRIGGER_DESCRIPTIONS[rule.triggerType]}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
|
||||
<Input
|
||||
id={`template-${rule.id}`}
|
||||
value={actionTemplate}
|
||||
onChange={(event) => setActionTemplate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{numberFormatRule ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`format-${rule.id}`}>Format</Label>
|
||||
<Input
|
||||
id={`format-${rule.id}`}
|
||||
value={format}
|
||||
placeholder="WH-{YYYY}-{SEQ:4}"
|
||||
onChange={(event) => setFormat(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
|
||||
Nummer, {"{SEQ:4}"} mit führenden Nullen auf 4 Stellen.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aktueller Zähler: {rule.sequenceValue}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
|
||||
<Input
|
||||
id={`template-${rule.id}`}
|
||||
value={actionTemplate}
|
||||
onChange={(event) => setActionTemplate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{rule.triggerType === "VARROA_THRESHOLD" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label>
|
||||
@@ -108,6 +166,17 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
{numberFormatRule ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={resetMutation.isPending}
|
||||
onClick={() => resetMutation.mutate()}
|
||||
>
|
||||
{resetMutation.isPending ? "Wird zurückgesetzt…" : "Zähler zurücksetzen"}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="site-website">Webseite (z. B. Onlineshop)</Label>
|
||||
<Input
|
||||
id="site-website"
|
||||
type="url"
|
||||
placeholder="https://shop.wabenhain.de"
|
||||
value={websiteUrl}
|
||||
onChange={(event) => setWebsiteUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||
</Button>
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const retention = Number(retentionCount);
|
||||
mutation.mutate({
|
||||
storagePath,
|
||||
cronSchedule,
|
||||
retentionCount: retentionCount !== "" && Number.isInteger(retention) ? retention : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-3 border-t border-border/60 pt-4" onSubmit={handleSubmit}>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nur wirksam, solange die zugehörige Umgebungsvariable nicht gesetzt ist - Env hat immer
|
||||
Vorrang.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="backup-path">Pfad</Label>
|
||||
<Input
|
||||
id="backup-path"
|
||||
value={storagePath}
|
||||
disabled={envOverrides.storagePath}
|
||||
onChange={(event) => setStoragePath(event.target.value)}
|
||||
placeholder="/data/backups"
|
||||
/>
|
||||
{envOverrides.storagePath ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Durch BACKUP_STORAGE_PATH vorgegeben.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="backup-cron">Zeitplan (Cron-Ausdruck)</Label>
|
||||
<Input
|
||||
id="backup-cron"
|
||||
value={cronSchedule}
|
||||
disabled={envOverrides.cronSchedule}
|
||||
onChange={(event) => setCronSchedule(event.target.value)}
|
||||
placeholder="0 3 * * *"
|
||||
/>
|
||||
{envOverrides.cronSchedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Durch BACKUP_CRON_SCHEDULE vorgegeben.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="backup-retention">Aufbewahrung (Anzahl Backups)</Label>
|
||||
<Input
|
||||
id="backup-retention"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={retentionCount}
|
||||
disabled={envOverrides.retentionCount}
|
||||
onChange={(event) => setRetentionCount(event.target.value)}
|
||||
placeholder="14"
|
||||
/>
|
||||
{envOverrides.retentionCount ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Durch BACKUP_RETENTION_COUNT vorgegeben.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" variant="outline" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Zeitplan speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<p className="text-muted-foreground">Lädt…</p>
|
||||
) : !status?.enabled ? (
|
||||
<p className="text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||
@@ -176,6 +287,9 @@ function ScheduleSection() {
|
||||
>
|
||||
{runNowMutation.isPending ? "Wird gesichert…" : "Jetzt sichern"}
|
||||
</Button>
|
||||
{settingsQuery.data && status ? (
|
||||
<ScheduleSettingsForm settings={settingsQuery.data} envOverrides={status.envOverrides} />
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
YouTube: Video,
|
||||
Instagram: Camera,
|
||||
TikTok: Music2,
|
||||
Webseite: Globe,
|
||||
};
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium">{value}</dd>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function Stat({ icon: Icon, label, value }: StatProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center">
|
||||
<Icon className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">{value}</span>
|
||||
<span className="text-[0.7rem] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
@@ -65,69 +129,74 @@ export default function PublicBatchPage() {
|
||||
{batch ? (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold">
|
||||
{batch.productName ?? "Honig"}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="font-heading text-2xl font-semibold">
|
||||
{batch.productName ?? "Honig"}
|
||||
</h1>
|
||||
{batch.honeyType ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
||||
<Sparkles className="size-3" />
|
||||
{batch.honeyType}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="font-mono text-sm text-muted-foreground">Charge {batch.lotNumber}</p>
|
||||
</div>
|
||||
|
||||
{batch.productDescription ? (
|
||||
<p className="text-sm text-muted-foreground">{batch.productDescription}</p>
|
||||
) : null}
|
||||
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{apiaryNames ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Herkunft</dt>
|
||||
<dd className="font-medium">{apiaryNames}</dd>
|
||||
</div>
|
||||
</div>
|
||||
{originText ? (
|
||||
<InfoRow icon={MapPin} label="Herkunft" value={originText} />
|
||||
) : null}
|
||||
{harvestMonths ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Geerntet</dt>
|
||||
<dd className="font-medium">{harvestMonths}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<InfoRow icon={CalendarDays} label="Geerntet" value={harvestMonths} />
|
||||
) : null}
|
||||
{batch.extractedDate ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Geschleudert</dt>
|
||||
<dd className="font-medium">{formatDate(batch.extractedDate)}</dd>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{batch.waterContent !== null ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Droplet className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Wassergehalt</dt>
|
||||
<dd className="font-medium">{batch.waterContent} %</dd>
|
||||
</div>
|
||||
</div>
|
||||
<InfoRow
|
||||
icon={Droplet}
|
||||
label="Geschleudert"
|
||||
value={formatDate(batch.extractedDate)}
|
||||
/>
|
||||
) : null}
|
||||
<InfoRow
|
||||
icon={PackageCheck}
|
||||
label="Abgefüllt"
|
||||
value={formatDate(batch.bottledDate)}
|
||||
/>
|
||||
</dl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
|
||||
{batch.waterContent !== null ? (
|
||||
<Stat icon={Droplet} label="Wassergehalt" value={`${batch.waterContent} %`} />
|
||||
) : null}
|
||||
<Stat icon={PackageCheck} label="Gebinde" value={`${batch.jarSize} g`} />
|
||||
<Stat
|
||||
icon={CalendarDays}
|
||||
label="Haltbar bis"
|
||||
value={formatDate(batch.bestBeforeDate)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{socialLinks.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3 border-t border-border pt-3">
|
||||
{socialLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2 border-t border-border pt-4">
|
||||
{socialLinks.map((link) => {
|
||||
const Icon = SOCIAL_LINK_ICONS[link.label] ?? Globe;
|
||||
return (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{link.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -14,3 +14,9 @@ export function updateAutomationRule(
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function resetAutomationRuleSequence(id: string): Promise<AutomationRule> {
|
||||
return apiFetch<AutomationRule>(`/automation-rules/${id}/reset-sequence`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown> | null;
|
||||
actionTemplate: string | null;
|
||||
sequenceValue: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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<BlobDownload> {
|
||||
return apiFetchBlob("/backup/export", "apicore-backup.json");
|
||||
@@ -20,3 +25,16 @@ export function runBackupNow(): Promise<{ filePath: string }> {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function getBackupSettings(): Promise<BackupSettings> {
|
||||
return apiFetch<BackupSettings>("/backup/settings");
|
||||
}
|
||||
|
||||
export function updateBackupSettings(
|
||||
input: UpdateBackupSettingsInput,
|
||||
): Promise<BackupSettings> {
|
||||
return apiFetch<BackupSettings>("/backup/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface CreateBottlingBatchInput {
|
||||
jarSize: number;
|
||||
jarCount: number;
|
||||
bestBeforeDate: string;
|
||||
lotNumber: string;
|
||||
lotNumber?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBottlingBatchInput {
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface Traceability {
|
||||
}
|
||||
|
||||
export interface CreateHoneyBatchInput {
|
||||
batchNumber: string;
|
||||
batchNumber?: string;
|
||||
extractionId: string;
|
||||
honeyType?: string;
|
||||
amount?: number;
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface PublicSocialLinks {
|
||||
youtube: string | null;
|
||||
instagram: string | null;
|
||||
tiktok: string | null;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
export interface PublicBatch {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user