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

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

No files matched your search

@@ -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,
},
});
});
});
});