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

- SiteSettings gains websiteUrl (e.g. online shop link) alongside the
  existing social links, editable in Verwaltung > Automatisierung.
- /herkunft/[lot] now shows the apiary's Standort instead of its internal
  Name (so multiple apiaries at one location read as one origin), adds
  small icons per social link, and surfaces previously-fetched-but-unused
  data (Honigsorte, Abfülldatum, stat cards) to make the page a richer
  customer-facing info page.
- Backup schedule (path/cron/retention) is now also configurable via the
  GUI (Verwaltung > Backup), stored in a new BackupSettings singleton;
  an env var always wins over the GUI value per field, and the scheduler
  reloads its cron job live when GUI settings change.
- New Automatisierung rules let an admin define a format (e.g.
  "WH-{YYYY}-{SEQ:4}") that auto-generates HoneyBatch.batchNumber and
  BottlingBatch.lotNumber on creation, with an atomic per-rule sequence
  counter and a manual reset. Manual entry still works when the rule is
  inactive.
This commit is contained in:
Nick Szaunig committed 2026-09-03 13:37:09 +02:00
1 parent 7b23862a41
commit 412d033d8d
42 files changed
+1026 -100

No files matched your search

+6 -3
View File
@@ -23,9 +23,12 @@ COOKIE_SECURE=false
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7. # Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
DOCUMENTS_STORAGE_PATH="./uploads" DOCUMENTS_STORAGE_PATH="./uploads"
# Automatische Backups (Modul Backup). Ohne BACKUP_STORAGE_PATH bleibt der # Automatische Backups (Modul Backup). Alle drei Werte sind auch ueber die
# Zeitplan deaktiviert (nur manueller Export/Import ueber die UI verfuegbar). # GUI (Verwaltung > Backup) konfigurierbar - eine hier gesetzte Env-Variable
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes # 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 # Volume zeigen (wie DOCUMENTS_STORAGE_PATH), sonst sieht jede Replica nur
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift # ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
# nicht. # nicht.
@@ -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")
);
+24
View File
@@ -1014,6 +1014,8 @@ enum AutomationTriggerType {
SWARM_MOOD_HIGH SWARM_MOOD_HIGH
COLONY_HEALTH_CRITICAL COLONY_HEALTH_CRITICAL
STOCK_ITEM_LOW STOCK_ITEM_LOW
HONEY_BATCH_NUMBER_FORMAT
BOTTLING_LOT_NUMBER_FORMAT
} }
model AutomationRule { model AutomationRule {
@@ -1022,6 +1024,12 @@ model AutomationRule {
active Boolean @default(true) active Boolean @default(true)
config Json? config Json?
actionTemplate String? 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -1038,5 +1046,21 @@ model SiteSettings {
youtubeUrl String? youtubeUrl String?
instagramUrl String? instagramUrl String?
tiktokUrl 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 updatedAt DateTime @updatedAt
} }
@@ -291,6 +291,7 @@ export const PERMISSION_CATALOG = [
[ [
['export', 'Exportieren'], ['export', 'Exportieren'],
['import', 'Importieren'], ['import', 'Importieren'],
['configure', 'Zeitplan konfigurieren'],
], ],
3200, 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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -27,4 +27,13 @@ export class AutomationController {
) { ) {
return this.automationService.update(id, dto, actor.id); 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(); const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(6); expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8);
expect(result).toHaveLength(3); 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, VarroaMeasurement,
} from '../../generated/prisma'; } from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
interface RuleDefaults { interface RuleDefaults {
config: Record<string, unknown>; config: Record<string, unknown>;
@@ -48,6 +49,17 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: {}, config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.', 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() @Injectable()
@@ -215,6 +227,53 @@ export class AutomationService {
await this.triggerAction(rule, 'STORAGE', item.id); 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( private async triggerAction(
rule: AutomationRule, rule: AutomationRule,
relatedType: TaskRelatedType, 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 fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
const SCHEDULED_JOB_NAME = 'automatic-backup'; const SCHEDULED_JOB_NAME = 'automatic-backup';
const DEFAULT_CRON_SCHEDULE = '0 3 * * *'; const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
@@ -23,20 +24,20 @@ export interface BackupScheduleStatus {
cronSchedule: string | null; cronSchedule: string | null;
retentionCount: number | null; retentionCount: number | null;
lastRunAt: string | 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 { interface EffectiveBackupSettings {
const value = process.env.BACKUP_STORAGE_PATH; path: string | null;
return value && value.trim().length > 0 ? value : null; cronSchedule: string;
} retentionCount: number;
envOverrides: BackupScheduleStatus['envOverrides'];
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;
} }
@Injectable() @Injectable()
@@ -45,20 +46,63 @@ export class BackupSchedulerService implements OnModuleInit {
constructor( constructor(
private readonly backupService: BackupService, private readonly backupService: BackupService,
private readonly backupSettingsService: BackupSettingsService,
private readonly schedulerRegistry: SchedulerRegistry, private readonly schedulerRegistry: SchedulerRegistry,
) {} ) {}
onModuleInit(): void { async onModuleInit(): Promise<void> {
const directory = storagePath(); await this.reload();
if (!directory) { }
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( this.logger.log(
'BACKUP_STORAGE_PATH not set - automatic backups disabled', 'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
); );
return; return;
} }
const directory = settings.path;
const job = CronJob.from({ const job = CronJob.from({
cronTime: cronSchedule(), cronTime: settings.cronSchedule,
onTick: () => { onTick: () => {
this.runScheduled(directory).catch((error) => this.runScheduled(directory).catch((error) =>
this.logger.error('Scheduled backup failed', 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.schedulerRegistry.addCronJob(SCHEDULED_JOB_NAME, job);
this.logger.log( 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> { async getStatus(): Promise<BackupScheduleStatus> {
const directory = storagePath(); const settings = await this.effectiveSettings();
if (!directory) { if (!settings.path) {
return { return {
enabled: false, enabled: false,
path: null, path: null,
cronSchedule: null, cronSchedule: null,
retentionCount: null, retentionCount: null,
lastRunAt: 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; const lastRunAt = files[0]?.mtime.toISOString() ?? null;
return { return {
enabled: true, enabled: true,
path: directory, path: settings.path,
cronSchedule: cronSchedule(), cronSchedule: settings.cronSchedule,
retentionCount: retentionCount(), retentionCount: settings.retentionCount,
lastRunAt, lastRunAt,
envOverrides: settings.envOverrides,
}; };
} }
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no // Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
// duplicate-tick guard, since a human explicitly asked for it. // duplicate-tick guard, since a human explicitly asked for it.
async runNow(): Promise<{ filePath: string }> { async runNow(): Promise<{ filePath: string }> {
const directory = storagePath(); const settings = await this.effectiveSettings();
if (!directory) { if (!settings.path) {
throw new Error( 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); const filePath = await this.writeBackup(settings.path);
await this.pruneOldBackups(directory); await this.pruneOldBackups(settings.path, settings.retentionCount);
return { filePath }; return { filePath };
} }
private async runScheduled(directory: string): Promise<void> { private async runScheduled(directory: string): Promise<void> {
const settings = await this.effectiveSettings();
const files = await this.listBackupFiles(directory); const files = await this.listBackupFiles(directory);
const mostRecent = files[0]; const mostRecent = files[0];
if ( if (
@@ -125,7 +172,7 @@ export class BackupSchedulerService implements OnModuleInit {
const filePath = await this.writeBackup(directory); const filePath = await this.writeBackup(directory);
this.logger.log(`Scheduled backup written to ${filePath}`); 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> { private async writeBackup(directory: string): Promise<string> {
@@ -140,9 +187,12 @@ export class BackupSchedulerService implements OnModuleInit {
return filePath; 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 files = await this.listBackupFiles(directory);
const toDelete = files.slice(retentionCount()); const toDelete = files.slice(retentionCount);
await Promise.all( await Promise.all(
toDelete.map((file) => toDelete.map((file) =>
fs 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 { import {
BadRequestException, BadRequestException,
Body,
Controller, Controller,
Get, Get,
Patch,
Post, Post,
Res, Res,
UploadedFile, UploadedFile,
@@ -13,11 +15,14 @@ import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { BackupSettings } from '../../generated/prisma';
import { BackupBundle, BackupService } from './backup.service'; import { BackupBundle, BackupService } from './backup.service';
import { import {
BackupScheduleStatus, BackupScheduleStatus,
BackupSchedulerService, BackupSchedulerService,
} from './backup-scheduler.service'; } 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 // A full backup bundle can be much larger than the 5mb CSV import cap
// elsewhere (dozens of tables, potentially years of records, plus embedded // elsewhere (dozens of tables, potentially years of records, plus embedded
@@ -32,6 +37,7 @@ export class BackupController {
constructor( constructor(
private readonly backupService: BackupService, private readonly backupService: BackupService,
private readonly backupSchedulerService: BackupSchedulerService, private readonly backupSchedulerService: BackupSchedulerService,
private readonly backupSettingsService: BackupSettingsService,
) {} ) {}
@RequirePermission('backup:export') @RequirePermission('backup:export')
@@ -46,6 +52,23 @@ export class BackupController {
return this.backupSchedulerService.runNow(); 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') @RequirePermission('backup:export')
@Get('export') @Get('export')
async export(@Res() res: Response): Promise<void> { async export(@Res() res: Response): Promise<void> {
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { BackupController } from './backup.controller'; import { BackupController } from './backup.controller';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service'; import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service';
@Module({ @Module({
controllers: [BackupController], controllers: [BackupController],
providers: [BackupService, BackupSchedulerService], providers: [BackupService, BackupSchedulerService, BackupSettingsService],
}) })
export class BackupModule {} 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 { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { BottlingController } from './bottling.controller'; import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
@Module({ @Module({
imports: [AutomationModule],
controllers: [BottlingController], controllers: [BottlingController],
providers: [BottlingService], providers: [BottlingService],
}) })
@@ -3,6 +3,7 @@ import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('BottlingService', () => { describe('BottlingService', () => {
let service: BottlingService; let service: BottlingService;
@@ -43,10 +44,12 @@ describe('BottlingService', () => {
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new BottlingService( service = new BottlingService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService, webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
); );
}); });
@@ -6,6 +6,7 @@ import {
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { BottlingBatch } from '../../generated/prisma'; import { BottlingBatch } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -25,6 +26,7 @@ export class BottlingService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {} ) {}
async findAll(filter: BottlingBatchFilter = {}) { 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( const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
async (tx) => { async (tx) => {
const created = await tx.bottlingBatch.create({ const created = await tx.bottlingBatch.create({
@@ -80,7 +91,7 @@ export class BottlingService {
jarCount: dto.jarCount, jarCount: dto.jarCount,
totalAmount, totalAmount,
bestBeforeDate: new Date(dto.bestBeforeDate), bestBeforeDate: new Date(dto.bestBeforeDate),
lotNumber: dto.lotNumber, lotNumber,
userId: actingUserId, userId: actingUserId,
}, },
}); });
@@ -36,7 +36,11 @@ export class CreateBottlingBatchDto {
@IsDateString() @IsDateString()
bestBeforeDate!: string; bestBeforeDate!: string;
@ApiProperty() @ApiPropertyOptional({
description:
'Wird automatisch generiert, falls die Automatisierung "Los-/Chargennummer-Format" aktiv ist',
})
@IsOptional()
@IsString() @IsString()
lotNumber!: string; lotNumber?: string;
} }
@@ -2,9 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString } from 'class-validator'; import { IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateHoneyBatchDto { export class CreateHoneyBatchDto {
@ApiProperty() @ApiPropertyOptional({
description:
'Wird automatisch generiert, falls die Automatisierung "Chargennummer-Format" aktiv ist',
})
@IsOptional()
@IsString() @IsString()
batchNumber!: string; batchNumber?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { HoneyBatchesController } from './honey-batches.controller'; import { HoneyBatchesController } from './honey-batches.controller';
import { HoneyBatchesService } from './honey-batches.service'; import { HoneyBatchesService } from './honey-batches.service';
@Module({ @Module({
imports: [AutomationModule],
controllers: [HoneyBatchesController], controllers: [HoneyBatchesController],
providers: [HoneyBatchesService], providers: [HoneyBatchesService],
exports: [HoneyBatchesService], exports: [HoneyBatchesService],
@@ -3,6 +3,7 @@ import { HoneyBatchesService } from './honey-batches.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('HoneyBatchesService', () => { describe('HoneyBatchesService', () => {
let service: HoneyBatchesService; let service: HoneyBatchesService;
@@ -60,10 +61,12 @@ describe('HoneyBatchesService', () => {
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new HoneyBatchesService( service = new HoneyBatchesService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService, 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 { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { HoneyBatchStatus } from '../../generated/prisma'; import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto'; import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
@@ -32,6 +37,7 @@ export class HoneyBatchesService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {} ) {}
async findAll(filter: HoneyBatchFilter = {}) { async findAll(filter: HoneyBatchFilter = {}) {
@@ -54,8 +60,16 @@ export class HoneyBatchesService {
} }
async create(dto: CreateHoneyBatchDto, actingUserId: string) { 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({ const honeyBatch = await this.prisma.honeyBatch.create({
data: { ...dto }, data: { ...dto, batchNumber },
}); });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'HoneyBatch', entityType: 'HoneyBatch',
@@ -11,6 +11,7 @@ export interface PublicSocialLinks {
youtube: string | null; youtube: string | null;
instagram: string | null; instagram: string | null;
tiktok: string | null; tiktok: string | null;
website: string | null;
} }
export interface PublicBatch { export interface PublicBatch {
@@ -89,6 +90,7 @@ export class PublicService {
youtube: settings.youtubeUrl, youtube: settings.youtubeUrl,
instagram: settings.instagramUrl, instagram: settings.instagramUrl,
tiktok: settings.tiktokUrl, tiktok: settings.tiktokUrl,
website: settings.websiteUrl,
}, },
}; };
} }
@@ -25,4 +25,11 @@ export class UpdateSiteSettingsDto {
@IsUrl({ require_protocol: true }) @IsUrl({ require_protocol: true })
@MaxLength(300) @MaxLength(300)
tiktokUrl?: string; 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', youtubeUrl: 'https://youtube.com/@wabenhain',
instagramUrl: null as string | null, instagramUrl: null as string | null,
tiktokUrl: null as string | null, tiktokUrl: null as string | null,
websiteUrl: null as string | null,
updatedAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'),
}; };
@@ -84,6 +85,7 @@ describe('SiteSettingsService', () => {
youtubeUrl: undefined, youtubeUrl: undefined,
instagramUrl: 'https://instagram.com/wabenhain', instagramUrl: 'https://instagram.com/wabenhain',
tiktokUrl: undefined, tiktokUrl: undefined,
websiteUrl: undefined,
}, },
}); });
expect(result.instagramUrl).toBe('https://instagram.com/wabenhain'); expect(result.instagramUrl).toBe('https://instagram.com/wabenhain');
@@ -111,6 +113,7 @@ describe('SiteSettingsService', () => {
youtubeUrl: null, youtubeUrl: null,
instagramUrl: undefined, instagramUrl: undefined,
tiktokUrl: undefined, tiktokUrl: undefined,
websiteUrl: undefined,
}, },
}); });
}); });
@@ -127,6 +130,7 @@ describe('SiteSettingsService', () => {
youtubeUrl: undefined, youtubeUrl: undefined,
instagramUrl: undefined, instagramUrl: undefined,
tiktokUrl: undefined, tiktokUrl: undefined,
websiteUrl: undefined,
}, },
}); });
}); });
@@ -48,6 +48,10 @@ export class SiteSettingsService {
: undefined, : undefined,
tiktokUrl: tiktokUrl:
dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined, dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined,
websiteUrl:
dto.websiteUrl !== undefined
? emptyToNull(dto.websiteUrl)
: undefined,
}, },
}); });
await this.auditLog.record({ await this.auditLog.record({
@@ -65,7 +65,7 @@ export default function NewBottlingBatchPage() {
jarSize: Number(jarSize), jarSize: Number(jarSize),
jarCount: Number(jarCount), jarCount: Number(jarCount),
bestBeforeDate: new Date(bestBeforeDate).toISOString(), 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> <Label htmlFor="bottling-lot-number">Los-/Chargennummer</Label>
<Input <Input
id="bottling-lot-number" id="bottling-lot-number"
required placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber} value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)} onChange={(event) => setLotNumber(event.target.value)}
/> />
@@ -61,7 +61,7 @@ export function CreateHoneyBatchDialog() {
return; return;
} }
mutation.mutate({ mutation.mutate({
batchNumber, batchNumber: batchNumber || undefined,
extractionId, extractionId,
honeyType: honeyType || undefined, honeyType: honeyType || undefined,
amount: amount ? Number(amount) : undefined, amount: amount ? Number(amount) : undefined,
@@ -80,7 +80,7 @@ export function CreateHoneyBatchDialog() {
<Label htmlFor="batch-number">Chargennummer</Label> <Label htmlFor="batch-number">Chargennummer</Label>
<Input <Input
id="batch-number" id="batch-number"
required placeholder="Automatisch, falls Automatisierung aktiv"
value={batchNumber} value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)} onChange={(event) => setBatchNumber(event.target.value)}
/> />
@@ -3,7 +3,11 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; 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 { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -18,6 +22,8 @@ const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
SWARM_MOOD_HIGH: "Hohe Schwarmstimmung", SWARM_MOOD_HIGH: "Hohe Schwarmstimmung",
COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch", COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch",
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand", 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> = { const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
@@ -33,8 +39,26 @@ const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
"Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.", "Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.",
STOCK_ITEM_LOW: STOCK_ITEM_LOW:
"Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.", "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 { function ruleThreshold(rule: AutomationRule): string {
const config = rule.config as { thresholdMitesPerDay?: number } | null; const config = rule.config as { thresholdMitesPerDay?: number } | null;
return String(config?.thresholdMitesPerDay ?? ""); return String(config?.thresholdMitesPerDay ?? "");
@@ -44,6 +68,8 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? ""); const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [threshold, setThreshold] = useState(ruleThreshold(rule)); const [threshold, setThreshold] = useState(ruleThreshold(rule));
const [format, setFormat] = useState(ruleFormat(rule));
const numberFormatRule = isNumberFormatRule(rule);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (input: { active?: boolean; config?: Record<string, unknown>; actionTemplate?: string }) => 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"), 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() { function handleSave() {
if (numberFormatRule) {
mutation.mutate({ config: { format } });
return;
}
const input: { config?: Record<string, unknown>; actionTemplate?: string } = { const input: { config?: Record<string, unknown>; actionTemplate?: string } = {
actionTemplate: actionTemplate || undefined, actionTemplate: actionTemplate || undefined,
}; };
@@ -75,6 +114,24 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 text-sm"> <CardContent className="flex flex-col gap-4 text-sm">
<p className="text-muted-foreground">{TRIGGER_DESCRIPTIONS[rule.triggerType]}</p> <p className="text-muted-foreground">{TRIGGER_DESCRIPTIONS[rule.triggerType]}</p>
{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"> <div className="flex flex-col gap-2">
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label> <Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
<Input <Input
@@ -83,6 +140,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
onChange={(event) => setActionTemplate(event.target.value)} onChange={(event) => setActionTemplate(event.target.value)}
/> />
</div> </div>
)}
{rule.triggerType === "VARROA_THRESHOLD" ? ( {rule.triggerType === "VARROA_THRESHOLD" ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label> <Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label>
@@ -108,6 +166,17 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
{mutation.isPending ? "Speichert…" : "Speichern"} {mutation.isPending ? "Speichert…" : "Speichern"}
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
); );
@@ -17,6 +17,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [youtubeUrl, setYoutubeUrl] = useState(settings.youtubeUrl ?? ""); const [youtubeUrl, setYoutubeUrl] = useState(settings.youtubeUrl ?? "");
const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? ""); const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? "");
const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? ""); const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? "");
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const mutation = useMutation({ const mutation = useMutation({
mutationFn: updateSiteSettings, mutationFn: updateSiteSettings,
@@ -33,6 +34,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
youtubeUrl, youtubeUrl,
instagramUrl, instagramUrl,
tiktokUrl, tiktokUrl,
websiteUrl,
}); });
} }
@@ -74,6 +76,16 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
onChange={(event) => setTiktokUrl(event.target.value)} onChange={(event) => setTiktokUrl(event.target.value)}
/> />
</div> </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}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"} {mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button> </Button>
@@ -1,10 +1,19 @@
"use client"; "use client";
import { ChangeEvent, useState } from "react"; import { ChangeEvent, FormEvent, useState } from "react";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; 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 { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -120,6 +129,104 @@ function formatDateTime(value: string | null): string {
return new Date(value).toLocaleString("de-DE"); 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() { function ScheduleSection() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -127,6 +234,10 @@ function ScheduleSection() {
queryKey: ["backup-schedule"], queryKey: ["backup-schedule"],
queryFn: getBackupSchedule, queryFn: getBackupSchedule,
}); });
const settingsQuery = useQuery({
queryKey: ["backup-settings"],
queryFn: getBackupSettings,
});
const runNowMutation = useMutation({ const runNowMutation = useMutation({
mutationFn: runBackupNow, mutationFn: runBackupNow,
@@ -149,8 +260,8 @@ function ScheduleSection() {
<p className="text-muted-foreground">Lädt…</p> <p className="text-muted-foreground">Lädt…</p>
) : !status?.enabled ? ( ) : !status?.enabled ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Automatische Backups sind nicht konfiguriert (BACKUP_STORAGE_PATH fehlt). Automatische Backups sind noch nicht konfiguriert - weder per Umgebungsvariable noch
Manueller Export/Import bleibt weiterhin möglich. über die GUI unten. Manueller Export/Import bleibt weiterhin möglich.
</p> </p>
) : ( ) : (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5"> <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"} {runNowMutation.isPending ? "Wird gesichert…" : "Jetzt sichern"}
</Button> </Button>
{settingsQuery.data && status ? (
<ScheduleSettingsForm settings={settingsQuery.data} envOverrides={status.envOverrides} />
) : null}
</CardContent> </CardContent>
</Card> </Card>
); );
+111 -42
View File
@@ -2,7 +2,19 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; 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 { fetchPublicBatch } from "@/lib/public-batch/api";
import { HoneycombPattern } from "@/components/honeycomb-pattern"; import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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" }); 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() { export default function PublicBatchPage() {
const params = useParams<{ lot: string }>(); const params = useParams<{ lot: string }>();
const lot = decodeURIComponent(params.lot); const lot = decodeURIComponent(params.lot);
@@ -28,12 +84,20 @@ export default function PublicBatchPage() {
const harvestMonths = batch const harvestMonths = batch
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ") ? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
: null; : 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 const socialLinks = batch
? [ ? [
{ label: "YouTube", url: batch.socialLinks.youtube }, { label: "YouTube", url: batch.socialLinks.youtube },
{ label: "Instagram", url: batch.socialLinks.instagram }, { label: "Instagram", url: batch.socialLinks.instagram },
{ label: "TikTok", url: batch.socialLinks.tiktok }, { label: "TikTok", url: batch.socialLinks.tiktok },
{ label: "Webseite", url: batch.socialLinks.website },
].filter((link): link is { label: string; url: string } => link.url !== null) ].filter((link): link is { label: string; url: string } => link.url !== null)
: []; : [];
@@ -55,7 +119,7 @@ export default function PublicBatchPage() {
</div> </div>
</div> </div>
</CardHeader> </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} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-destructive"> <p role="alert" className="text-sm text-destructive">
@@ -65,69 +129,74 @@ export default function PublicBatchPage() {
{batch ? ( {batch ? (
<> <>
<div> <div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-heading text-2xl font-semibold"> <h1 className="font-heading text-2xl font-semibold">
{batch.productName ?? "Honig"} {batch.productName ?? "Honig"}
</h1> </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> <p className="font-mono text-sm text-muted-foreground">Charge {batch.lotNumber}</p>
</div> </div>
{batch.productDescription ? ( {batch.productDescription ? (
<p className="text-sm text-muted-foreground">{batch.productDescription}</p> <p className="text-sm text-muted-foreground">{batch.productDescription}</p>
) : null} ) : null}
<dl className="flex flex-col gap-3 text-sm"> <dl className="flex flex-col gap-3 text-sm">
{apiaryNames ? ( {originText ? (
<div className="flex items-start gap-2.5"> <InfoRow icon={MapPin} label="Herkunft" value={originText} />
<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>
) : null} ) : null}
{harvestMonths ? ( {harvestMonths ? (
<div className="flex items-start gap-2.5"> <InfoRow icon={CalendarDays} label="Geerntet" value={harvestMonths} />
<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>
) : null} ) : null}
{batch.extractedDate ? ( {batch.extractedDate ? (
<div className="flex items-start gap-2.5"> <InfoRow
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" /> icon={Droplet}
<div> label="Geschleudert"
<dt className="text-muted-foreground">Geschleudert</dt> value={formatDate(batch.extractedDate)}
<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>
) : null} ) : null}
<InfoRow
icon={PackageCheck}
label="Abgefüllt"
value={formatDate(batch.bottledDate)}
/>
</dl> </dl>
<p className="text-xs text-muted-foreground">
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde <div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
</p> {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 ? ( {socialLinks.length > 0 ? (
<div className="flex flex-wrap gap-3 border-t border-border pt-3"> <div className="flex flex-wrap gap-2 border-t border-border pt-4">
{socialLinks.map((link) => ( {socialLinks.map((link) => {
const Icon = SOCIAL_LINK_ICONS[link.label] ?? Globe;
return (
<a <a
key={link.label} key={link.label}
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline" 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"
> >
<ExternalLink className="size-4" /> <Icon className="size-4" />
{link.label} {link.label}
</a> </a>
))} );
})}
</div> </div>
) : null} ) : null}
</> </>
@@ -14,3 +14,9 @@ export function updateAutomationRule(
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function resetAutomationRuleSequence(id: string): Promise<AutomationRule> {
return apiFetch<AutomationRule>(`/automation-rules/${id}/reset-sequence`, {
method: "POST",
});
}
+4 -1
View File
@@ -4,7 +4,9 @@ export type AutomationTriggerType =
| "FEED_STATUS_CRITICAL" | "FEED_STATUS_CRITICAL"
| "SWARM_MOOD_HIGH" | "SWARM_MOOD_HIGH"
| "COLONY_HEALTH_CRITICAL" | "COLONY_HEALTH_CRITICAL"
| "STOCK_ITEM_LOW"; | "STOCK_ITEM_LOW"
| "HONEY_BATCH_NUMBER_FORMAT"
| "BOTTLING_LOT_NUMBER_FORMAT";
export interface AutomationRule { export interface AutomationRule {
id: string; id: string;
@@ -12,6 +14,7 @@ export interface AutomationRule {
active: boolean; active: boolean;
config: Record<string, unknown> | null; config: Record<string, unknown> | null;
actionTemplate: string | null; actionTemplate: string | null;
sequenceValue: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
+19 -1
View File
@@ -1,5 +1,10 @@
import { apiFetch, apiFetchBlob, apiFetchFormData, BlobDownload } from "@/lib/auth/api-client"; 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> { export function exportBackup(): Promise<BlobDownload> {
return apiFetchBlob("/backup/export", "apicore-backup.json"); return apiFetchBlob("/backup/export", "apicore-backup.json");
@@ -20,3 +25,16 @@ export function runBackupNow(): Promise<{ filePath: string }> {
method: "POST", 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),
});
}
+19
View File
@@ -8,4 +8,23 @@ export interface BackupScheduleStatus {
cronSchedule: string | null; cronSchedule: string | null;
retentionCount: number | null; retentionCount: number | null;
lastRunAt: string | 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;
} }
+1 -1
View File
@@ -20,7 +20,7 @@ export interface CreateBottlingBatchInput {
jarSize: number; jarSize: number;
jarCount: number; jarCount: number;
bestBeforeDate: string; bestBeforeDate: string;
lotNumber: string; lotNumber?: string;
} }
export interface UpdateBottlingBatchInput { export interface UpdateBottlingBatchInput {
@@ -74,7 +74,7 @@ export interface Traceability {
} }
export interface CreateHoneyBatchInput { export interface CreateHoneyBatchInput {
batchNumber: string; batchNumber?: string;
extractionId: string; extractionId: string;
honeyType?: string; honeyType?: string;
amount?: number; amount?: number;
@@ -7,6 +7,7 @@ export interface PublicSocialLinks {
youtube: string | null; youtube: string | null;
instagram: string | null; instagram: string | null;
tiktok: string | null; tiktok: string | null;
website: string | null;
} }
export interface PublicBatch { export interface PublicBatch {
@@ -3,6 +3,7 @@ export interface SiteSettings {
youtubeUrl: string | null; youtubeUrl: string | null;
instagramUrl: string | null; instagramUrl: string | null;
tiktokUrl: string | null; tiktokUrl: string | null;
websiteUrl: string | null;
updatedAt: string; updatedAt: string;
} }
@@ -10,4 +11,5 @@ export interface UpdateSiteSettingsInput {
youtubeUrl?: string; youtubeUrl?: string;
instagramUrl?: string; instagramUrl?: string;
tiktokUrl?: string; tiktokUrl?: string;
websiteUrl?: string;
} }