feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
Backend Image / build-and-push (push) Successful in 4m58s
Frontend Image / build-and-push (push) Successful in 7m57s

- Authority notice PDFs: pure model plus renderer, TSK count in A4
  landscape, FEHLT for missing particulars, stored totals with correction
  note; GET /authority-notices/:id/pdf with compliance:read.
- Compliance notifications in category COMPLIANCE: herd change and TSK
  reference day, at most one unread per topic and user (advisory lock);
  daily rule TskReferenceDayReached with Berlin day and catch-up; handlers
  in API and worker.
- Readiness core: live evaluation of seven pure rules, fingerprinted
  acknowledgements that reopen when revoked or stale, subject masking,
  read-only GET paths; READINESS_RULES injectable; kept out of the worker.
- e2e hygiene: no unscoped deletes, seeded settings rows saved/restored.
- ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:48:57 +02:00
1 parent edc193f128
commit b912d29166
54 files changed
+5300 -14

No files matched your search

@@ -0,0 +1,104 @@
import { NotFoundException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuthorityNoticesService } from './authority-notices.service';
function notice(patch: Record<string, unknown> = {}) {
return {
id: 'n1',
kind: 'HERD_CHANGE',
status: 'DRAFT',
year: null,
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
countedAt: new Date('2027-01-02T10:00:00.000Z'),
snapshot: { version: 1, apiaries: [], totalLiving: 0 },
totalColonies: 0,
sentOn: null,
createdAt: new Date('2027-01-02T10:00:00.000Z'),
updatedAt: new Date('2027-01-03T10:00:00.000Z'),
...patch,
};
}
describe('AuthorityNoticesService.pdf', () => {
let service: AuthorityNoticesService;
let prisma: {
authorityNotice: { findUnique: jest.Mock; findFirst: jest.Mock };
siteSettings: { findUnique: jest.Mock };
complianceSettings: { findUnique: jest.Mock };
};
beforeEach(() => {
prisma = {
authorityNotice: {
findUnique: jest.fn().mockResolvedValue(notice()),
findFirst: jest.fn().mockResolvedValue(null),
},
siteSettings: { findUnique: jest.fn().mockResolvedValue(null) },
complianceSettings: {
findUnique: jest.fn().mockResolvedValue({
id: 'singleton',
veterinaryOfficeName: null,
veterinaryOfficeAddress: null,
registrationNumber: null,
tskMemberNumber: null,
}),
},
};
service = new AuthorityNoticesService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
);
});
it('renders a PDF named after the id only, even with every particular missing', async () => {
const result = await service.pdf('n1');
expect(result.fileName).toBe('anzeige-n1.pdf');
expect(result.buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
it('compares a draft change notice with the newest sent notice', async () => {
await service.pdf('n1');
expect(prisma.authorityNotice.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: 'SENT',
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
},
}),
);
});
it('compares a sent change notice with the one sent before it', async () => {
const sentOn = new Date('2027-02-01T00:00:00.000Z');
prisma.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', sentOn }),
);
await service.pdf('n1');
const call = prisma.authorityNotice.findFirst.mock.calls[0] as [
{ where: { id: unknown; OR: unknown[] } },
];
expect(call[0].where.id).toEqual({ not: 'n1' });
expect(call[0].where.OR).toContainEqual({ sentOn: { lt: sentOn } });
});
it('looks for no baseline for a first notice', async () => {
prisma.authorityNotice.findUnique.mockResolvedValue(
notice({ kind: 'REGISTRATION' }),
);
await service.pdf('n1');
expect(prisma.authorityNotice.findFirst).not.toHaveBeenCalled();
});
it('answers 404 for an unknown id', async () => {
prisma.authorityNotice.findUnique.mockResolvedValue(null);
await expect(service.pdf('x')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -6,7 +6,9 @@ import {
Patch,
Post,
Query,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
@@ -36,6 +38,16 @@ export class AuthorityNoticesController {
return this.notices.findOne(id);
}
@RequirePermission('compliance:read')
@Get(':id/pdf')
async pdf(@Param('id') id: string, @Res() res: Response): Promise<void> {
const { buffer, fileName } = await this.notices.pdf(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.send(buffer);
}
@RequirePermission('compliance:manage')
@Idempotent()
@Post()
@@ -18,7 +18,13 @@ import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
import { MarkSentDto } from './dto/mark-sent.dto';
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
import { buildHerdSnapshot, loadHerdApiaryRows } from './herd-snapshot';
import { COMPLIANCE_SETTINGS_ID, findBaselineIn } from './herd-status';
import {
COMPLIANCE_SETTINGS_ID,
complianceSettingsIn,
findBaselineIn,
} from './herd-status';
import { buildNoticePdfModel } from './pdf/authority-notice-model';
import { renderAuthorityNoticePdf } from './pdf/authority-notice-pdf';
const AUDIT_ENTITY = 'AuthorityNotice';
const LIST_LIMIT = 200;
@@ -108,6 +114,60 @@ export class AuthorityNoticesService {
return notice;
}
/**
* The PDF of a notice. A change notice compares with the notice that was
* the baseline when it was (or would be) sent. The file name carries the
* id only, never user input.
*/
async pdf(id: string): Promise<{ buffer: Buffer; fileName: string }> {
const notice = await this.findOne(id);
const [site, settings, baseline] = await Promise.all([
this.prisma.siteSettings.findUnique({ where: { id: 'singleton' } }),
complianceSettingsIn(this.prisma),
notice.kind === 'HERD_CHANGE'
? this.baselineFor(notice)
: Promise.resolve(null),
]);
const model = buildNoticePdfModel({
notice,
baseline,
site: {
holderName: site?.holderName ?? null,
holderAddress: site?.holderAddress ?? null,
},
settings,
now: new Date(),
});
return {
buffer: await renderAuthorityNoticePdf(model),
fileName: `anzeige-${notice.id}.pdf`,
};
}
/** A sent notice compares with the one sent before it, a draft with the newest. */
private baselineFor(
notice: AuthorityNotice,
): Promise<AuthorityNotice | null> {
if (notice.status !== 'SENT' || !notice.sentOn) {
return findBaselineIn(this.prisma);
}
return this.prisma.authorityNotice.findFirst({
where: {
status: 'SENT',
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
id: { not: notice.id },
OR: [
{ sentOn: { lt: notice.sentOn } },
{
sentOn: notice.sentOn,
createdAt: { lt: notice.createdAt },
},
],
},
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
});
}
/**
* Creates a draft, or a SENT notice at once when `sentOn` is given (the
* way to record what was already reported). A second open draft of the
@@ -0,0 +1,286 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import type { NotificationsService } from '../notifications/notifications.service';
import type { AuthorityNoticesService } from './authority-notices.service';
import { ComplianceEventHandlers } from './compliance.handlers';
import { herdStatusIn } from './herd-status';
jest.mock('./herd-status', () => ({
herdStatusIn: jest.fn(),
complianceSettingsIn: jest.fn().mockResolvedValue({ id: 'singleton' }),
}));
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(undefined),
}));
const herdStatus = herdStatusIn as jest.Mock;
const envelope = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
const living = { status: 'ACTIVE', healthStatus: null };
const ended = { status: 'SOLD', healthStatus: null };
const NOW = new Date('2027-03-01T10:00:00Z');
/** A status with a baseline of 10 colonies on 2 apiaries, now 12 on 3. */
const deviating = (triggered = true) => ({
baseline: {
totalColonies: 10,
snapshot: { apiaries: [{ apiaryId: 'a1' }, { apiaryId: 'a2' }] },
},
current: {
totalLiving: 12,
apiaries: [{ apiaryId: 'a1' }, { apiaryId: 'a2' }, { apiaryId: 'a3' }],
},
deviation: { triggered },
});
describe('ComplianceEventHandlers', () => {
let notifications: { recordForPermissionOnce: jest.Mock };
let notices: { createDraftIn: jest.Mock };
let tx: {
$queryRaw: jest.Mock;
authorityNotice: { findMany: jest.Mock };
};
let handlers: ComplianceEventHandlers;
beforeEach(() => {
herdStatus.mockReset().mockResolvedValue(deviating());
notifications = {
recordForPermissionOnce: jest.fn().mockResolvedValue([{ id: 'n1' }]),
};
notices = {
createDraftIn: jest
.fn()
.mockResolvedValue({ id: 'notice-1', totalColonies: 14 }),
};
tx = {
$queryRaw: jest.fn().mockResolvedValue([{ now: NOW }]),
authorityNotice: { findMany: jest.fn().mockResolvedValue([]) },
};
handlers = new ComplianceEventHandlers(
notifications as unknown as NotificationsService,
notices as unknown as AuthorityNoticesService,
);
});
const run = (
event: AnyDomainEventEnvelope<
'ColonyCreated' | 'ColonyStatusChanged' | 'ColonyEnded'
>,
) => handlers.herdChange(event, tx as unknown as Prisma.TransactionClient);
it('registers the two handler ids', () => {
const ids = (method: object) =>
domainEventHandlerMetadata(method).map((entry) => entry.handlerId);
// Metadata sits on the prototype function, not on a bound copy.
const proto = Object.getPrototypeOf(handlers) as Record<string, object>;
expect(ids(proto['herdChange'])).toEqual(['compliance.herd-change']);
expect(ids(proto['tskReferenceDay'])).toEqual([
'compliance.tsk-reference-day',
]);
});
describe('herd-change', () => {
it('herd-change notifies each permitted user once while unread', async () => {
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
expect(herdStatus).toHaveBeenCalledWith(tx, NOW);
expect(notifications.recordForPermissionOnce).toHaveBeenCalledWith(
'compliance:manage',
{
priority: 'HINWEIS',
category: 'COMPLIANCE',
message:
'Bestand geändert: jetzt 12 Völker an 3 Ständen. Angezeigt: 10 an 2. Anzeige ans Veterinäramt prüfen.',
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
link: '/compliance',
},
tx,
);
});
it('ignores a status change within living states and one within ended states', async () => {
await run(
envelope('ColonyStatusChanged', 'c1', { from: living, to: living }),
);
await run(
envelope('ColonyStatusChanged', 'c1', { from: ended, to: ended }),
);
expect(herdStatus).not.toHaveBeenCalled();
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
});
it('handles a status change between living and ended', async () => {
await run(
envelope('ColonyStatusChanged', 'c1', { from: living, to: ended }),
);
await run(
envelope('ColonyStatusChanged', 'c1', { from: ended, to: living }),
);
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(2);
});
it('handles an ended colony', async () => {
await run(envelope('ColonyEnded', 'c1', { status: 'SOLD' }));
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
});
it('does nothing without a baseline or without deviation', async () => {
herdStatus.mockResolvedValueOnce({ ...deviating(), deviation: null });
herdStatus.mockResolvedValueOnce(deviating(false));
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
await run(envelope('ColonyCreated', 'c2', { apiaryId: 'a1' }));
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
expect(notices.createDraftIn).not.toHaveBeenCalled();
});
it('a settings toggle from false to true waits for the next herd event', async () => {
herdStatus.mockResolvedValueOnce(deviating(false));
herdStatus.mockResolvedValueOnce(deviating(true));
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
await run(envelope('ColonyCreated', 'c2', { apiaryId: 'a1' }));
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
});
it('the message uses the exact wording with counts and never an address', async () => {
herdStatus.mockResolvedValue({
...deviating(),
current: {
totalLiving: 1,
apiaries: [{ apiaryId: 'a1', address: 'Hauptstr. 1' }],
},
});
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
const [, input] = notifications.recordForPermissionOnce.mock.calls[0] as [
string,
{ message: string },
];
expect(input.message).toBe(
'Bestand geändert: jetzt 1 Völker an 1 Ständen. Angezeigt: 10 an 2. Anzeige ans Veterinäramt prüfen.',
);
expect(input.message).not.toContain('Hauptstr');
expect(input.message.length).toBeLessThan(600);
});
});
describe('tsk-reference-day', () => {
const tskEvent = envelope('TskReferenceDayReached', 'tsk:2027', {
year: 2027,
referenceDay: '2027-03-15',
});
const runTsk = () =>
handlers.tskReferenceDay(
tskEvent,
tx as unknown as Prisma.TransactionClient,
);
it('tsk handler creates one draft and skips a year already sent', async () => {
await runTsk();
expect(notices.createDraftIn).toHaveBeenCalledWith(
tx,
{
kind: 'TSK_REPORT',
year: 2027,
referenceDate: new Date('2027-03-15T00:00:00.000Z'),
},
null,
);
expect(tx.authorityNotice.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
kind: 'TSK_REPORT',
year: 2027,
}) as unknown,
}),
);
notices.createDraftIn.mockClear();
tx.authorityNotice.findMany.mockResolvedValue([
{ id: 'sent-1', status: 'SENT', totalColonies: 3 },
]);
await runTsk();
expect(notices.createDraftIn).not.toHaveBeenCalled();
});
it('tsk handler with an existing draft creates none but still reminds', async () => {
tx.authorityNotice.findMany.mockResolvedValue([
{ id: 'draft-1', status: 'DRAFT', totalColonies: 9 },
]);
await runTsk();
expect(notices.createDraftIn).not.toHaveBeenCalled();
expect(notifications.recordForPermissionOnce).toHaveBeenCalledWith(
'compliance:manage',
expect.objectContaining({
message:
'TSK-Stichtag 15.03.2027: 9 Völker gezählt. Bitte prüfen und melden.',
link: '/compliance/notices/draft-1',
}),
tx,
);
});
it('tsk handler stays silent when the year was sent', async () => {
tx.authorityNotice.findMany.mockResolvedValue([
{ id: 's', status: 'SENT', totalColonies: 3 },
]);
await runTsk();
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
});
it('tsk handler notifies with WARNUNG and the exact wording', async () => {
await runTsk();
expect(notifications.recordForPermissionOnce).toHaveBeenCalledWith(
'compliance:manage',
{
priority: 'WARNUNG',
category: 'COMPLIANCE',
message:
'TSK-Stichtag 15.03.2027: 14 Völker gezählt. Bitte prüfen und melden.',
relatedType: 'ComplianceItem',
relatedId: 'tsk-report:2027',
link: '/compliance/notices/notice-1',
},
tx,
);
});
it('a second handler run for the same event adds no second draft', async () => {
await runTsk();
// The first run left a draft behind.
tx.authorityNotice.findMany.mockResolvedValue([
{ id: 'notice-1', status: 'DRAFT', totalColonies: 14 },
]);
await runTsk();
// The notification itself is deduplicated by recordForPermissionOnce.
expect(notices.createDraftIn).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,132 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { dayToDate } from '../common/dates';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuthorityNoticesService } from './authority-notices.service';
import type { HerdSnapshot } from './herd-snapshot';
import {
COMPLIANCE_SETTINGS_ID,
complianceSettingsIn,
herdStatusIn,
} from './herd-status';
const RECIPIENT_PERMISSION = 'compliance:manage';
const NOTICE_TOPIC = 'ComplianceItem';
const HERD_CHANGE_TOPIC_ID = 'herd-change';
/** TT.MM.JJJJ for a YYYY-MM-DD day. */
function germanDay(day: string): string {
const [year, month, date] = day.split('-');
return `${date}.${month}.${year}`;
}
/**
* The authority chain (T6): a change of the herd leaves one notification per
* permitted user while the earlier one is unread, and the TSK reference day
* prepares the year's count as a draft. Pure DB writers through the delivery
* transaction; errors propagate so a delivery is retried. No handler creates
* a herd-change notice: the owner reports, the app only reminds.
*/
@Injectable()
export class ComplianceEventHandlers {
constructor(
private readonly notifications: NotificationsService,
private readonly notices: AuthorityNoticesService,
) {}
// A status change only matters when the colony enters or leaves the living
// herd; ColonyEnded always does.
@OnDomainEvent(
[
'ColonyCreated',
'ColonyMoved',
'ColonyEnded',
'ColonyStatusChanged',
'ApiaryChanged',
],
'compliance.herd-change',
)
async herdChange(
event: AnyDomainEventEnvelope<
| 'ColonyCreated'
| 'ColonyMoved'
| 'ColonyEnded'
| 'ColonyStatusChanged'
| 'ApiaryChanged'
>,
tx: Prisma.TransactionClient,
): Promise<void> {
if (event.type === 'ColonyStatusChanged') {
const { from, to } = event.payload;
if (
isLivingColonyStatus(from.status) === isLivingColonyStatus(to.status)
) {
return;
}
}
// The deviation is computed before any notification lock (READ COMMITTED):
// at worst a notification comes early or late, never twice.
const [{ now }] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
const { baseline, current, deviation } = await herdStatusIn(tx, now);
if (!baseline || !deviation?.triggered) {
return;
}
const reported = baseline.snapshot as unknown as HerdSnapshot;
await this.notifications.recordForPermissionOnce(
RECIPIENT_PERMISSION,
{
priority: 'HINWEIS',
category: 'COMPLIANCE',
message: `Bestand geändert: jetzt ${current.totalLiving} Völker an ${current.apiaries.length} Ständen. Angezeigt: ${baseline.totalColonies} an ${reported.apiaries.length}. Anzeige ans Veterinäramt prüfen.`,
relatedType: NOTICE_TOPIC,
relatedId: HERD_CHANGE_TOPIC_ID,
link: '/compliance',
},
tx,
);
}
// One draft per year. A year already sent stays silent; with only an open
// draft nothing new is created, but the reminder is sent (once while
// unread), since the draft may never have been opened. The settings row is
// the mutex.
@OnDomainEvent('TskReferenceDayReached', 'compliance.tsk-reference-day')
async tskReferenceDay(
event: AnyDomainEventEnvelope<'TskReferenceDayReached'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const { year, referenceDay } = event.payload;
await complianceSettingsIn(tx);
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
const existing = await tx.authorityNotice.findMany({
where: { kind: 'TSK_REPORT', year, status: { in: ['SENT', 'DRAFT'] } },
select: { id: true, status: true, totalColonies: true },
});
if (existing.some((notice) => notice.status === 'SENT')) {
return;
}
const draft =
existing[0] ??
(await this.notices.createDraftIn(
tx,
{ kind: 'TSK_REPORT', year, referenceDate: dayToDate(referenceDay) },
null,
));
await this.notifications.recordForPermissionOnce(
RECIPIENT_PERMISSION,
{
priority: 'WARNUNG',
category: 'COMPLIANCE',
message: `TSK-Stichtag ${germanDay(referenceDay)}: ${draft.totalColonies} Völker gezählt. Bitte prüfen und melden.`,
relatedType: NOTICE_TOPIC,
relatedId: `tsk-report:${year}`,
link: `/compliance/notices/${draft.id}`,
},
tx,
);
}
}
@@ -1,14 +1,21 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { AuthorityNoticesController } from './authority-notices.controller';
import { AuthorityNoticesService } from './authority-notices.service';
import { ComplianceEventHandlers } from './compliance.handlers';
import { ComplianceSettingsController } from './compliance-settings.controller';
import { ComplianceSettingsService } from './compliance-settings.service';
// No provider here may inject PermissionResolverService: the worker imports
// this module too (handlers arrive with the notification task).
// this module too (it carries the event handlers).
@Module({
imports: [NotificationsModule],
controllers: [ComplianceSettingsController, AuthorityNoticesController],
providers: [ComplianceSettingsService, AuthorityNoticesService],
providers: [
ComplianceSettingsService,
AuthorityNoticesService,
ComplianceEventHandlers,
],
exports: [ComplianceSettingsService, AuthorityNoticesService],
})
export class ComplianceModule {}
@@ -0,0 +1,275 @@
import type { AuthorityNotice } from '../../../generated/prisma';
import type { HerdApiarySnapshot, HerdSnapshot } from '../herd-snapshot';
import { buildNoticePdfModel } from './authority-notice-model';
function apiary(patch: Partial<HerdApiarySnapshot> = {}): HerdApiarySnapshot {
return {
apiaryId: 'a1',
name: 'Heide',
address: 'Heideweg 1, 34117 Kassel',
location: 'Kassel',
latitude: null,
longitude: null,
livingColonies: 3,
byType: { PRODUCTION_COLONY: 2, NUCLEUS_COLONY: 1 },
...patch,
};
}
function snapshot(apiaries: HerdApiarySnapshot[]): HerdSnapshot {
return {
version: 1,
takenAt: '2027-01-02T10:00:00.000Z',
apiaries,
totalLiving: apiaries.reduce((sum, a) => sum + a.livingColonies, 0),
};
}
function notice(
patch: Partial<AuthorityNotice> = {},
apiaries: HerdApiarySnapshot[] = [apiary()],
): AuthorityNotice {
const snap = snapshot(apiaries);
return {
id: 'n1',
kind: 'REGISTRATION',
status: 'DRAFT',
year: null,
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
countedAt: new Date('2027-01-02T10:00:00.000Z'),
snapshot: snap as never,
totalColonies: snap.totalLiving,
sentOn: null,
sentVia: null,
reference: null,
notes: null,
openKey: 'REGISTRATION',
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date('2027-01-03T09:00:00.000Z'),
...patch,
};
}
const SITE = {
holderName: 'Imkerei Muster',
holderAddress: 'Weg 2, 34117 Kassel',
};
const SETTINGS = {
veterinaryOfficeName: 'Veterinäramt Kassel' as string | null,
veterinaryOfficeAddress: 'Amtsstr. 1, 34117 Kassel' as string | null,
registrationNumber: 'DE-123' as string | null,
tskMemberNumber: 'TSK-9' as string | null,
};
const NOW = new Date('2027-01-05T12:00:00.000Z');
function build(
n: AuthorityNotice,
baseline: AuthorityNotice | null = null,
overrides: {
site?: { holderName: string | null; holderAddress: string | null };
settings?: typeof SETTINGS;
} = {},
) {
return buildNoticePdfModel({
notice: n,
baseline,
site: overrides.site ?? SITE,
settings: overrides.settings ?? SETTINGS,
now: NOW,
});
}
describe('buildNoticePdfModel', () => {
it('keeps an empty veterinary office and an empty holder as null so the renderer prints FEHLT', () => {
const model = build(notice(), null, {
site: { holderName: null, holderAddress: ' ' },
settings: {
...SETTINGS,
veterinaryOfficeName: '',
veterinaryOfficeAddress: null,
},
});
expect(model.holder).toBeNull();
expect(model.office).toEqual({ name: null, address: null });
});
it('the first notice has the heading "Anzeige der Bienenhaltung (§ 1a BienSeuchV)" and no "vorher" column', () => {
const model = build(notice());
expect(model.title).toBe('Anzeige der Bienenhaltung (§ 1a BienSeuchV)');
expect(model.columns).toEqual(['Stand', 'Lage', 'Völker']);
expect(model.rows[0].previous).toBeNull();
expect(model.totals).toEqual({ current: 3, previous: null });
expect(model.changes).toEqual([]);
expect(model.referenceLine).toBe('Stand der Völker am 01.01.2027');
});
it('the change notice shows the previous count per apiary and the totals', () => {
const baseline = notice(
{ kind: 'REGISTRATION', status: 'SENT', sentOn: new Date('2026-06-01') },
[apiary({ livingColonies: 2 })],
);
const model = build(
notice({ kind: 'HERD_CHANGE', openKey: 'HERD_CHANGE' }),
baseline,
);
expect(model.title).toBe(
'Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)',
);
expect(model.columns).toEqual(['Stand', 'Lage', 'Völker', 'vorher']);
expect(model.rows[0]).toMatchObject({
name: 'Heide',
cells: ['3'],
previous: 2,
});
expect(model.totals).toEqual({ current: 3, previous: 2 });
});
it('lists new and abandoned apiaries under "Änderungen seit der Anzeige vom {sentOn}"', () => {
const baseline = notice(
{ kind: 'REGISTRATION', status: 'SENT', sentOn: new Date('2026-06-01') },
[apiary(), apiary({ apiaryId: 'a2', name: 'Alt', livingColonies: 1 })],
);
const model = build(
notice({ kind: 'HERD_CHANGE' }, [
apiary(),
apiary({ apiaryId: 'a3', name: 'Neu', livingColonies: 1 }),
]),
baseline,
);
expect(model.changes[0]).toBe(
'Änderungen seit der Anzeige vom 01.06.2026:',
);
expect(model.changes).toContain('Stand „Neu“ neu');
expect(model.changes).toContain('Stand „Alt“ aufgegeben');
});
it('prints coordinates with five decimals only when there is no address', () => {
const model = build(
notice({}, [
apiary({ apiaryId: 'a1', name: 'Mit', address: 'Weg 1' }),
apiary({
apiaryId: 'a2',
name: 'Ohne',
address: null,
latitude: 51.3127,
longitude: 9.4797,
}),
apiary({
apiaryId: 'a3',
name: 'Nichts',
address: null,
location: null,
}),
]),
);
expect(model.rows.map((r) => r.location)).toEqual([
'Weg 1',
'51.31270, 9.47970',
'FEHLT',
]);
});
it('the TSK model has one column per counted colony type and a sum', () => {
const model = build(
notice({ kind: 'TSK_REPORT', year: 2027, openKey: 'TSK_REPORT:2027' }),
);
expect(model.title).toBe(
'Zählnachweis Bienenvölker zum Stichtag 01.01.2027',
);
expect(model.columns).toEqual([
'Stand',
'Lage',
'Wirtschaftsvolk',
'Ableger',
'Summe',
]);
expect(model.rows[0].cells).toEqual(['2', '1', '3']);
expect(model.tskMemberNumber).toBe('TSK-9');
expect(model.totals).toEqual({ current: 3, previous: null });
});
it('the TSK model says "Gezählt am {countedAt}, nicht am Stichtag. Bitte prüfen." when counted after the reference day', () => {
const late = build(notice({ kind: 'TSK_REPORT', year: 2027 }));
const onTime = build(
notice({
kind: 'TSK_REPORT',
year: 2027,
countedAt: new Date('2026-12-31T23:30:00.000Z'),
}),
);
expect(late.countedNote).toBe(
'Gezählt am 02.01.2027, nicht am Stichtag. Bitte prüfen.',
);
expect(onTime.countedNote).toBe('Gezählt am 01.01.2027');
});
it('the TSK footer says "Ersetzt nicht die Meldung an die Tierseuchenkasse."', () => {
const model = build(notice({ kind: 'TSK_REPORT', year: 2027 }));
expect(model.footer).toContain(
'Ersetzt nicht die Meldung an die Tierseuchenkasse.',
);
expect(model.footer).toContain(
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
);
});
it('the registration footer has no TSK sentence and the TSK number stays out', () => {
const model = build(notice());
expect(model.footer).toEqual([
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
]);
expect(model.tskMemberNumber).toBeNull();
expect(model.countedNote).toBeNull();
});
it('derives the place from the last holder address line and dates the letter in Berlin time', () => {
const model = build(notice());
expect(model.place).toBe('Kassel');
expect(model.dateLine).toBe('Kassel, 05.01.2027');
});
it('prints FEHLT for the address when only the holder name is entered', () => {
const model = build(notice(), null, {
site: { holderName: 'Imkerei Muster', holderAddress: null },
});
expect(model.holder).toBe('Imkerei Muster, FEHLT');
expect(model.place).toBeNull();
expect(model.dateLine).toBe('05.01.2027');
});
it('uses the stored total on both sides and notes a corrected total', () => {
const baseline = notice(
{
kind: 'REGISTRATION',
status: 'SENT',
sentOn: new Date('2026-06-01'),
totalColonies: 5,
},
[apiary({ livingColonies: 2 })],
);
const model = build(
notice({ kind: 'HERD_CHANGE', totalColonies: 7 }),
baseline,
);
expect(model.totals).toEqual({ current: 7, previous: 5 });
expect(model.totalNote).toBe('Gesamtzahl berichtigt (Summe der Stände: 3)');
expect(model.changes).toContain('Zahl der Völker: 5 auf 7');
});
it('has no total note while the total matches the rows', () => {
expect(build(notice()).totalNote).toBeNull();
});
});
@@ -0,0 +1,247 @@
import type {
AuthorityNotice,
AuthorityNoticeKind,
ColonyType,
ComplianceSettings,
SiteSettings,
} from '../../../generated/prisma';
import { berlinDayOf } from '../../common/dates';
import { formatDayDe } from '../../common/format-day';
import type { HerdApiarySnapshot, HerdSnapshot } from '../herd-snapshot';
/** Printed in place of a mandatory particular that is not entered yet. */
export const MISSING = 'FEHLT';
export interface NoticePdfModel {
kind: AuthorityNoticeKind;
title: string;
holder: string | null;
registrationNumber: string | null;
tskMemberNumber: string | null;
office: { name: string | null; address: string | null };
place: string | null;
dateLine: string;
referenceLine: string;
columns: string[];
rows: {
name: string;
location: string;
cells: string[];
previous: number | null;
}[];
totals: { current: number; previous: number | null };
changes: string[];
/** Set when the stored total was corrected and differs from the rows. */
totalNote: string | null;
countedNote: string | null;
footer: string[];
}
export interface NoticePdfInput {
notice: AuthorityNotice;
baseline: AuthorityNotice | null;
site: Pick<SiteSettings, 'holderName' | 'holderAddress'>;
settings: Pick<
ComplianceSettings,
| 'veterinaryOfficeName'
| 'veterinaryOfficeAddress'
| 'registrationNumber'
| 'tskMemberNumber'
>;
now: Date;
}
const TITLE_REGISTRATION = 'Anzeige der Bienenhaltung (§ 1a BienSeuchV)';
const TITLE_HERD_CHANGE = 'Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)';
const TSK_DISCLAIMER = 'Ersetzt nicht die Meldung an die Tierseuchenkasse.';
const COORDINATE_DECIMALS = 5;
/** In display order; the TSK columns follow it. */
const COLONY_TYPES: readonly { type: ColonyType; label: string }[] = [
{ type: 'PRODUCTION_COLONY', label: 'Wirtschaftsvolk' },
{ type: 'NUCLEUS_COLONY', label: 'Ableger' },
{ type: 'YOUNG_COLONY', label: 'Jungvolk' },
{ type: 'CARETAKER_COLONY', label: 'Pflegevolk' },
{ type: 'MATING_NUCLEUS', label: 'Begattungs-einheit' },
{ type: 'RESERVE_COLONY', label: 'Reservevolk' },
];
function clean(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
/** A stored day (midnight UTC) as dd.mm.yyyy. */
function storedDay(date: Date): string {
return formatDayDe(date.toISOString().slice(0, 10), true);
}
/** An instant as the dd.mm.yyyy of its Berlin calendar day. */
function berlinDay(instant: Date): string {
return formatDayDe(berlinDayOf(instant), true);
}
/** The town of a "Street 1, 34117 Town" address, when it has a postcode. */
function placeOf(address: string | null): string | null {
const match = address ? /\b\d{5}\s+([^,\n]+?)\s*$/.exec(address) : null;
return match ? match[1] : null;
}
function locationOf(apiary: HerdApiarySnapshot): string {
const address = clean(apiary.address);
if (address) {
return address;
}
if (apiary.latitude !== null && apiary.longitude !== null) {
return `${apiary.latitude.toFixed(COORDINATE_DECIMALS)}, ${apiary.longitude.toFixed(COORDINATE_DECIMALS)}`;
}
return clean(apiary.location) ?? MISSING;
}
function sameSite(a: HerdApiarySnapshot, b: HerdApiarySnapshot): boolean {
return (
a.address === b.address &&
a.location === b.location &&
a.latitude === b.latitude &&
a.longitude === b.longitude
);
}
function titleOf(notice: AuthorityNotice, hasBaseline: boolean): string {
if (notice.kind === 'TSK_REPORT') {
return `Zählnachweis Bienenvölker zum Stichtag ${storedDay(notice.referenceDate)}`;
}
return notice.kind === 'HERD_CHANGE' && hasBaseline
? TITLE_HERD_CHANGE
: TITLE_REGISTRATION;
}
function changeLines(
baseline: AuthorityNotice,
before: HerdSnapshot,
current: HerdSnapshot,
currentTotal: number,
): string[] {
const old = new Map(before.apiaries.map((a) => [a.apiaryId, a]));
const now = new Map(current.apiaries.map((a) => [a.apiaryId, a]));
const lines = [
`Änderungen seit der Anzeige vom ${baseline.sentOn ? storedDay(baseline.sentOn) : '–'}:`,
];
for (const apiary of current.apiaries) {
const previous = old.get(apiary.apiaryId);
if (!previous) {
lines.push(`Stand „${apiary.name}“ neu`);
} else if (!sameSite(previous, apiary)) {
lines.push(`Stand „${apiary.name}“: Lage geändert`);
}
}
for (const apiary of before.apiaries) {
if (!now.has(apiary.apiaryId)) {
lines.push(`Stand „${apiary.name}“ aufgegeben`);
}
}
if (baseline.totalColonies !== currentTotal) {
lines.push(
`Zahl der Völker: ${baseline.totalColonies} auf ${currentTotal}`,
);
}
return lines.length === 1 ? [...lines, 'keine'] : lines;
}
function tskTypes(snapshot: HerdSnapshot) {
return COLONY_TYPES.filter(({ type }) =>
snapshot.apiaries.some((apiary) => (apiary.byType[type] ?? 0) > 0),
);
}
function footerOf(notice: AuthorityNotice): string[] {
const lines = [
`Stand der Daten ${berlinDay(notice.updatedAt)} · erstellt mit ApiCore`,
];
return notice.kind === 'TSK_REPORT' ? [...lines, TSK_DISCLAIMER] : lines;
}
function countedNoteOf(notice: AuthorityNotice): string | null {
if (notice.kind !== 'TSK_REPORT') {
return null;
}
const counted = `Gezählt am ${berlinDay(notice.countedAt)}`;
const countedDay = berlinDayOf(notice.countedAt);
const referenceDay = notice.referenceDate.toISOString().slice(0, 10);
return countedDay > referenceDay
? `${counted}, nicht am Stichtag. Bitte prüfen.`
: counted;
}
/** Pure: everything the notice PDF prints, without any drawing. */
export function buildNoticePdfModel(input: NoticePdfInput): NoticePdfModel {
const { notice, baseline, site, settings, now } = input;
const current = notice.snapshot as unknown as HerdSnapshot;
const isTsk = notice.kind === 'TSK_REPORT';
const compare = notice.kind === 'HERD_CHANGE' && baseline !== null;
const before = compare
? (baseline.snapshot as unknown as HerdSnapshot)
: null;
const previousOf = (apiaryId: string): number | null =>
before
? (before.apiaries.find((a) => a.apiaryId === apiaryId)?.livingColonies ??
0)
: null;
const types = isTsk ? tskTypes(current) : [];
const columns = isTsk
? ['Stand', 'Lage', ...types.map((t) => t.label), 'Summe']
: ['Stand', 'Lage', 'Völker', ...(compare ? ['vorher'] : [])];
const rows = current.apiaries.map((apiary) => ({
name: apiary.name,
location: locationOf(apiary),
cells: isTsk
? [
...types.map((t) => String(apiary.byType[t.type] ?? 0)),
String(apiary.livingColonies),
]
: [String(apiary.livingColonies)],
previous: previousOf(apiary.apiaryId),
}));
const holderName = clean(site.holderName);
const holderAddress = clean(site.holderAddress);
// One of the two missing is a gap worth printing; both missing is null.
const holder =
holderName || holderAddress
? `${holderName ?? MISSING}, ${holderAddress ?? MISSING}`
: '';
const place = placeOf(clean(site.holderAddress));
const today = berlinDay(now);
return {
kind: notice.kind,
title: titleOf(notice, baseline !== null),
holder: holder || null,
registrationNumber: clean(settings.registrationNumber),
tskMemberNumber: isTsk ? clean(settings.tskMemberNumber) : null,
office: {
name: clean(settings.veterinaryOfficeName),
address: clean(settings.veterinaryOfficeAddress),
},
place,
dateLine: place ? `${place}, ${today}` : today,
referenceLine: `Stand der Völker am ${storedDay(notice.referenceDate)}`,
columns,
rows,
totals: {
current: notice.totalColonies,
previous: compare ? baseline.totalColonies : null,
},
totalNote:
notice.totalColonies === current.totalLiving
? null
: `Gesamtzahl berichtigt (Summe der Stände: ${current.totalLiving})`,
changes:
compare && before
? changeLines(baseline, before, current, notice.totalColonies)
: [],
countedNote: countedNoteOf(notice),
footer: footerOf(notice),
};
}
@@ -0,0 +1,190 @@
import PDFDocument from 'pdfkit';
import { PDF_MARGIN } from '../../export/pdf-table';
import type { NoticePdfModel } from './authority-notice-model';
import {
noticeHeaderLines,
renderAuthorityNoticePdf,
} from './authority-notice-pdf';
function model(patch: Partial<NoticePdfModel> = {}): NoticePdfModel {
return {
kind: 'REGISTRATION',
title: 'Anzeige der Bienenhaltung (§ 1a BienSeuchV)',
holder: 'Imkerei Muster, Weg 2, 34117 Kassel',
registrationNumber: 'DE-123',
tskMemberNumber: null,
office: { name: 'Veterinäramt Kassel', address: 'Amtsstr. 1' },
place: 'Kassel',
dateLine: 'Kassel, 05.01.2027',
referenceLine: 'Stand der Völker am 01.01.2027',
columns: ['Stand', 'Lage', 'Völker'],
rows: [{ name: 'Heide', location: 'Weg 1', cells: ['3'], previous: null }],
totals: { current: 3, previous: null },
changes: [],
totalNote: null,
countedNote: null,
footer: ['Stand der Daten 03.01.2027 · erstellt mit ApiCore'],
...patch,
};
}
describe('noticeHeaderLines', () => {
it('prints FEHLT for an empty veterinary office and an empty holder', () => {
const lines = noticeHeaderLines(
model({ holder: null, office: { name: null, address: null } }),
);
expect(lines.holder).toBe('FEHLT');
expect(lines.office).toEqual(['FEHLT']);
});
it('prints the registration number or a dash, and FEHLT for a missing TSK number', () => {
expect(
noticeHeaderLines(model({ registrationNumber: null })).identifiers,
).toEqual(['Registriernummer: –']);
expect(
noticeHeaderLines(model({ kind: 'TSK_REPORT', tskMemberNumber: null }))
.identifiers,
).toContain('TSK-Mitgliedsnummer: FEHLT');
});
it('leaves out the recipient block for the TSK report', () => {
expect(noticeHeaderLines(model({ kind: 'TSK_REPORT' })).office).toEqual([]);
});
});
describe('renderAuthorityNoticePdf', () => {
it.each([
['REGISTRATION', model()],
[
'HERD_CHANGE',
model({
kind: 'HERD_CHANGE',
columns: ['Stand', 'Lage', 'Völker', 'vorher'],
rows: [{ name: 'Heide', location: 'Weg 1', cells: ['3'], previous: 2 }],
totals: { current: 3, previous: 2 },
changes: [
'Änderungen seit der Anzeige vom 01.06.2026:',
'Stand „Neu“ neu',
],
}),
],
[
'TSK_REPORT',
model({
kind: 'TSK_REPORT',
tskMemberNumber: 'TSK-9',
columns: ['Stand', 'Lage', 'Wirtschaftsvolk', 'Summe'],
rows: [
{
name: 'Heide',
location: 'Weg 1',
cells: ['3', '3'],
previous: null,
},
],
countedNote: 'Gezählt am 02.01.2027, nicht am Stichtag. Bitte prüfen.',
footer: [
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
'Ersetzt nicht die Meldung an die Tierseuchenkasse.',
],
}),
],
])('renders a PDF buffer that starts with %PDF for %s', async (_kind, m) => {
const buffer = await renderAuthorityNoticePdf(m);
expect(buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
it('renders a notice without apiaries', async () => {
const buffer = await renderAuthorityNoticePdf(
model({ rows: [], totals: { current: 0, previous: null } }),
);
expect(buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
});
function pageCount(buffer: Buffer): number {
return (buffer.toString('latin1').match(/\/Type \/Page(?!s)/g) ?? []).length;
}
function manyRows(count: number): NoticePdfModel['rows'] {
return Array.from({ length: count }, (_, index) => ({
name: `Bienenstand Müßiggang-Süd ${index + 1} am Fließgewässer`,
location:
'Äußere Gartenallee 12, Gemarkung Großalmerode, 37247 Großalmerode',
cells: ['8'],
previous: null,
}));
}
describe('notice PDF layout', () => {
let restoreText = (): void => undefined;
afterEach(() => restoreText());
it('keeps 15 apiaries with long addresses on one portrait page, signature included', async () => {
const buffer = await renderAuthorityNoticePdf(
model({ rows: manyRows(15), totals: { current: 120, previous: null } }),
);
expect(pageCount(buffer)).toBe(1);
});
it('prints the notes and the footer at the left margin after the table', async () => {
const calls: { text: string; x: number }[] = [];
type TextFn = (this: { x: number }, ...args: unknown[]) => unknown;
const proto = PDFDocument.prototype as { text: TextFn };
const original = proto.text;
proto.text = function (this: { x: number }, ...args: unknown[]) {
calls.push({
text: String(args[0]),
x: typeof args[1] === 'number' ? args[1] : this.x,
});
return original.apply(this, args);
};
restoreText = () => {
proto.text = original;
};
await renderAuthorityNoticePdf(
model({
rows: manyRows(3),
totalNote: 'Gesamtzahl berichtigt (Summe der Stände: 24)',
changes: [
'Änderungen seit der Anzeige vom 01.06.2026:',
'Stand „A“ neu',
],
}),
);
for (const prefix of [
'Gesamtzahl berichtigt',
'Änderungen seit',
'– Stand',
'Stand der Daten',
]) {
const call = calls.find((c) => c.text.startsWith(prefix));
expect(call?.x).toBe(PDF_MARGIN);
}
});
it('draws the TSK report landscape with room for the location column', async () => {
const buffer = await renderAuthorityNoticePdf(
model({
kind: 'TSK_REPORT',
columns: [
'Stand',
'Lage',
'Wirtschaftsvolk',
'Begattungs-einheit',
'Summe',
],
rows: manyRows(15).map((row) => ({ ...row, cells: ['5', '3', '8'] })),
totals: { current: 120, previous: null },
}),
);
expect(buffer.toString('latin1')).toContain('/MediaBox [0 0 841.89');
});
});
@@ -0,0 +1,193 @@
import PDFDocument from 'pdfkit';
import {
PDF_MARGIN,
PdfColumn,
drawTable,
orDash,
} from '../../export/pdf-table';
import { MISSING, NoticePdfModel } from './authority-notice-model';
const NAME_WIDTH = 110;
const MIN_NUMBER_WIDTH = 40;
const NUMBER_PADDING = 8;
const SIGNATURE_LINE_WIDTH = 200;
export interface NoticeHeaderLines {
holder: string;
identifiers: string[];
/** The addressee block; empty for the TSK report (another recipient). */
office: string[];
}
/** Pure: the header texts, with FEHLT for every mandatory gap. */
export function noticeHeaderLines(model: NoticePdfModel): NoticeHeaderLines {
const isTsk = model.kind === 'TSK_REPORT';
const identifiers = [`Registriernummer: ${orDash(model.registrationNumber)}`];
if (isTsk) {
identifiers.push(
`TSK-Mitgliedsnummer: ${model.tskMemberNumber ?? MISSING}`,
);
}
const office = isTsk
? []
: [
model.office.name ?? MISSING,
...(model.office.address ? [model.office.address] : []),
];
return { holder: model.holder ?? MISSING, identifiers, office };
}
/** Room for the widest word of a header (it may break at a hyphen). */
function numberWidth(doc: PDFKit.PDFDocument, header: string): number {
doc.font('Helvetica-Bold').fontSize(8);
const widest = Math.max(
...header.split('-').map((part) => doc.widthOfString(part)),
);
return Math.max(MIN_NUMBER_WIDTH, Math.ceil(widest) + NUMBER_PADDING);
}
function tableColumns(
doc: PDFKit.PDFDocument,
model: NoticePdfModel,
): PdfColumn[] {
const numeric = model.columns.slice(2).map((header) => ({
header,
width: numberWidth(doc, header),
align: 'right' as const,
}));
const used = numeric.reduce((sum, column) => sum + column.width, 0);
return [
{ header: model.columns[0], width: NAME_WIDTH },
{
header: model.columns[1],
width: doc.page.width - 2 * PDF_MARGIN - NAME_WIDTH - used,
},
...numeric,
];
}
/** Sums of the numeric columns, so the TSK table totals each colony type. */
function totalsRow(model: NoticePdfModel): string[] {
const isTsk = model.kind === 'TSK_REPORT';
if (isTsk) {
const typeCount = model.columns.length - 3;
const sums = Array.from({ length: typeCount }, (_, index) =>
model.rows.reduce((sum, row) => sum + Number(row.cells[index]), 0),
);
return ['Summe', '', ...sums.map(String), String(model.totals.current)];
}
return [
'Summe',
'',
String(model.totals.current),
...(model.totals.previous === null ? [] : [String(model.totals.previous)]),
];
}
function bodyRows(model: NoticePdfModel): string[][] {
const rows = model.rows.map((row) => [
row.name,
row.location,
...row.cells,
...(row.previous === null || model.kind === 'TSK_REPORT'
? []
: [String(row.previous)]),
]);
return rows.length === 0 ? rows : [...rows, totalsRow(model)];
}
function drawHeader(doc: PDFKit.PDFDocument, model: NoticePdfModel): void {
const header = noticeHeaderLines(model);
doc
.font('Helvetica-Bold')
.fontSize(10)
.text(header.holder, PDF_MARGIN, PDF_MARGIN);
doc.font('Helvetica').fontSize(9);
for (const line of header.identifiers) writeLine(doc, line);
if (header.office.length > 0) {
doc.moveDown(1);
doc.text(`An ${header.office[0]}`);
for (const line of header.office.slice(1)) doc.text(` ${line}`);
}
doc.moveDown(0.6);
doc.text(model.dateLine, { align: 'right' });
doc.moveDown(0.6);
doc.font('Helvetica-Bold').fontSize(13).text(model.title);
doc.font('Helvetica').fontSize(9).text(model.referenceLine);
doc.moveDown(0.6);
}
/** A paragraph at the left margin; the table leaves x at its last column. */
function writeLine(doc: PDFKit.PDFDocument, text: string): void {
doc.text(text, PDF_MARGIN, doc.y, {
width: doc.page.width - 2 * PDF_MARGIN,
});
}
function drawNotes(doc: PDFKit.PDFDocument, model: NoticePdfModel): void {
doc.font('Helvetica').fontSize(9);
if (model.totalNote) {
writeLine(doc, model.totalNote);
doc.moveDown(0.5);
}
if (model.changes.length > 0) {
doc.font('Helvetica-Bold');
writeLine(doc, model.changes[0]);
doc.font('Helvetica');
for (const line of model.changes.slice(1)) writeLine(doc, `– ${line}`);
doc.moveDown(0.5);
}
if (model.countedNote) {
writeLine(doc, model.countedNote);
doc.moveDown(0.5);
}
}
function drawSignatureAndFooter(
doc: PDFKit.PDFDocument,
model: NoticePdfModel,
): void {
if (doc.y > doc.page.height - PDF_MARGIN - 110) doc.addPage();
doc.moveDown(3);
const y = doc.y;
doc
.moveTo(PDF_MARGIN, y)
.lineTo(PDF_MARGIN + SIGNATURE_LINE_WIDTH, y)
.lineWidth(0.5)
.strokeColor('#000000')
.stroke();
doc
.font('Helvetica')
.fontSize(8)
.text('Unterschrift', PDF_MARGIN, y + 3);
doc.moveDown(1.5);
doc.fillColor('#555555');
for (const line of model.footer) writeLine(doc, line);
doc.fillColor('#000000');
}
export function renderAuthorityNoticePdf(
model: NoticePdfModel,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
// Seven colony-type columns need the width of a landscape page.
const doc = new PDFDocument({
size: 'A4',
layout: model.kind === 'TSK_REPORT' ? 'landscape' : 'portrait',
margin: PDF_MARGIN,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
drawHeader(doc, model);
drawTable(doc, tableColumns(doc, model), bodyRows(model), {
boldLastRow: true,
});
doc.x = PDF_MARGIN;
drawNotes(doc, model);
drawSignatureAndFooter(doc, model);
doc.end();
});
}
@@ -0,0 +1,249 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { AuditLogService } from '../../audit/audit-log.service';
import { PrismaService } from '../../prisma/prisma.service';
import { lockRowForUpdate } from '../../prisma/row-lock';
import { AcknowledgementsService } from './acknowledgements.service';
import { ReadinessService } from './readiness.service';
import type { ReadinessItem, ReadinessRule } from './readiness.types';
jest.mock('../../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const lockMock = lockRowForUpdate as jest.Mock;
const rules: ReadinessRule[] = [];
const FINGERPRINT = 'a'.repeat(64);
const NOTE = 'Geprüft, alles in Ordnung.';
function rule(patch: Partial<ReadinessRule>): ReadinessRule {
return {
key: 'soft',
area: 'ETIKETTEN',
severity: 'PRUEFEN',
title: 'Weicher Punkt',
legalBasis: null,
acknowledgeable: true,
evaluate: () => [],
...patch,
};
}
function item(patch: Partial<ReadinessItem> = {}): ReadinessItem {
return {
key: 'soft',
area: 'ETIKETTEN',
severity: 'PRUEFEN',
subjectId: 'global',
title: 'Weicher Punkt',
detail: 'Prüfen.',
legalBasis: null,
acknowledgeable: true,
fingerprint: FINGERPRINT,
subjects: [],
more: 0,
state: 'OPEN',
acknowledgement: null,
...patch,
};
}
function dto(patch: Record<string, string> = {}) {
return {
itemKey: 'soft',
subjectId: 'global',
fingerprint: FINGERPRINT,
note: NOTE,
...patch,
};
}
describe('AcknowledgementsService', () => {
let service: AcknowledgementsService;
let readiness: { evaluate: jest.Mock };
let auditLog: { record: jest.Mock };
let tx: {
complianceAcknowledgement: {
create: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
};
$executeRaw: jest.Mock;
};
function reportWith(...items: ReadinessItem[]) {
readiness.evaluate.mockResolvedValue({
status: 'OPEN',
generatedAt: 'x',
openCount: items.length,
items,
});
}
beforeEach(() => {
rules.splice(
0,
rules.length,
rule({}),
rule({ key: 'must', severity: 'PFLICHT', acknowledgeable: false }),
);
lockMock.mockReset().mockResolvedValue(true);
tx = {
complianceAcknowledgement: {
create: jest.fn().mockResolvedValue({
id: 'ack1',
note: NOTE,
createdAt: new Date('2027-03-15T10:00:00.000Z'),
user: { name: 'Imker Eins' },
}),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
},
$executeRaw: jest.fn().mockResolvedValue(1),
};
const prisma = {
$transaction: jest.fn((callback: (client: unknown) => unknown) =>
callback(tx),
),
} as unknown as PrismaService;
readiness = { evaluate: jest.fn() };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new AcknowledgementsService(
prisma,
auditLog as unknown as AuditLogService,
readiness as unknown as ReadinessService,
rules,
);
reportWith(item());
});
it('writes the acknowledgement and the audit entry in one transaction and returns the item acknowledged', async () => {
const result = await service.create(dto(), 'u1');
expect(tx.complianceAcknowledgement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: {
itemKey: 'soft',
subjectId: 'global',
fingerprint: FINGERPRINT,
note: NOTE,
userId: 'u1',
},
}),
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ComplianceAcknowledgement',
entityId: 'ack1',
action: 'CREATE',
userId: 'u1',
}),
tx,
);
expect(result).toMatchObject({
key: 'soft',
state: 'ACKNOWLEDGED',
acknowledgement: {
id: 'ack1',
note: NOTE,
userName: 'Imker Eins',
createdAt: '2027-03-15T10:00:00.000Z',
},
});
});
it('refuses a stale fingerprint with 409', async () => {
await expect(
service.create(dto({ fingerprint: 'b'.repeat(64) }), 'u1'),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.complianceAcknowledgement.create).not.toHaveBeenCalled();
});
it('refuses an item that is not open (gone or done) with 409', async () => {
reportWith(item({ state: 'DONE' }));
await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
reportWith();
await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it('refuses a PFLICHT item and an unknown item key with 400', async () => {
await expect(
service.create(dto({ itemKey: 'must' }), 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.create(dto({ itemKey: 'nope' }), 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(readiness.evaluate).not.toHaveBeenCalled();
});
it('a note under 10 characters is a 400', async () => {
await expect(
service.create(dto({ note: ' kurz ' }), 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.create(dto({ note: 'x'.repeat(2001) }), 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('revoke locks the acknowledgement row, sets revokedAt in SQL and audits', async () => {
const row = {
id: 'ack1',
itemKey: 'soft',
subjectId: 'global',
fingerprint: FINGERPRINT,
note: NOTE,
revokedAt: null,
};
tx.complianceAcknowledgement.findUnique.mockResolvedValue(row);
tx.complianceAcknowledgement.findUniqueOrThrow.mockResolvedValue({
...row,
revokedAt: new Date('2027-03-16T00:00:00.000Z'),
revokedByUserId: 'u2',
});
await service.revoke('ack1', 'u2');
expect(lockMock).toHaveBeenCalledWith(
tx,
'ComplianceAcknowledgement',
'ack1',
);
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ComplianceAcknowledgement',
entityId: 'ack1',
action: 'UPDATE',
userId: 'u2',
beforeData: expect.objectContaining({ revokedAt: null }) as unknown,
}),
tx,
);
});
it('revoke refuses an already revoked acknowledgement and a missing one', async () => {
tx.complianceAcknowledgement.findUnique.mockResolvedValue({
id: 'ack1',
revokedAt: new Date('2027-03-01T00:00:00.000Z'),
});
await expect(service.revoke('ack1', 'u2')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.$executeRaw).not.toHaveBeenCalled();
lockMock.mockResolvedValue(false);
await expect(service.revoke('gone', 'u2')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
@@ -0,0 +1,151 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AuditLogService } from '../../audit/audit-log.service';
import { PrismaService } from '../../prisma/prisma.service';
import { lockRowForUpdate } from '../../prisma/row-lock';
import type { CreateAcknowledgementDto } from './dto/create-acknowledgement.dto';
import { ReadinessService } from './readiness.service';
import type { ReadinessItem, ReadinessRule } from './readiness.types';
import { READINESS_RULES_TOKEN } from './rules';
const AUDIT_ENTITY = 'ComplianceAcknowledgement';
export const MIN_NOTE_LENGTH = 10;
export const MAX_NOTE_LENGTH = 2000;
const NO_PERMISSIONS: ReadonlySet<string> = new Set();
const STALE_MESSAGE = 'Der Punkt hat sich geändert. Bitte neu laden.';
@Injectable()
export class AcknowledgementsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly readiness: ReadinessService,
@Inject(READINESS_RULES_TOKEN)
private readonly rules: readonly ReadinessRule[],
) {}
/**
* Acknowledges one open item as checked. The fingerprint must equal the one
* of the item as it is computed now (409 otherwise). `permissions` only
* decides which subjects the returned item shows.
*/
async create(
dto: CreateAcknowledgementDto,
userId: string,
permissions: ReadonlySet<string> = NO_PERMISSIONS,
): Promise<ReadinessItem> {
const note = dto.note.trim();
if (note.length < MIN_NOTE_LENGTH || note.length > MAX_NOTE_LENGTH) {
throw new BadRequestException(
`Die Notiz braucht ${MIN_NOTE_LENGTH} bis ${MAX_NOTE_LENGTH} Zeichen.`,
);
}
const rule = this.rules.find((entry) => entry.key === dto.itemKey);
if (!rule) {
throw new BadRequestException('Diesen Prüfpunkt gibt es nicht.');
}
if (!rule.acknowledgeable) {
throw new BadRequestException(
'Dieser Prüfpunkt lässt sich nicht quittieren. Er muss behoben werden.',
);
}
const report = await this.readiness.evaluate(new Date(), permissions);
const item = report.items.find(
(entry) => entry.key === dto.itemKey && entry.subjectId === dto.subjectId,
);
if (!item || item.state === 'DONE') {
throw new ConflictException(STALE_MESSAGE);
}
if (item.fingerprint !== dto.fingerprint) {
throw new ConflictException(STALE_MESSAGE);
}
const created = await this.prisma.$transaction(async (tx) => {
const row = await tx.complianceAcknowledgement.create({
data: {
itemKey: item.key,
subjectId: item.subjectId,
fingerprint: item.fingerprint,
note,
userId,
},
select: {
id: true,
note: true,
createdAt: true,
user: { select: { name: true } },
},
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: row.id,
action: 'CREATE',
userId,
afterData: {
itemKey: item.key,
subjectId: item.subjectId,
fingerprint: item.fingerprint,
note,
},
},
tx,
);
return row;
});
return {
...item,
state: 'ACKNOWLEDGED',
acknowledgement: {
id: created.id,
note: created.note,
userName: created.user.name,
createdAt: created.createdAt.toISOString(),
},
};
}
/** Takes an acknowledgement back; the item is open again. */
async revoke(id: string, userId: string): Promise<void> {
await this.prisma.$transaction(async (tx) => {
if (!(await lockRowForUpdate(tx, AUDIT_ENTITY, id))) {
throw new NotFoundException('Die Quittung gibt es nicht.');
}
// Read after the lock: two parallel revokes queue up, the second sees
// the first one's result.
const before = await tx.complianceAcknowledgement.findUnique({
where: { id },
});
if (!before) {
throw new NotFoundException('Die Quittung gibt es nicht.');
}
if (before.revokedAt) {
throw new ConflictException('Die Quittung ist schon zurückgenommen.');
}
await tx.$executeRaw`
UPDATE "ComplianceAcknowledgement"
SET "revokedAt" = now(), "revokedByUserId" = ${userId}
WHERE id = ${id}
`;
const after = await tx.complianceAcknowledgement.findUniqueOrThrow({
where: { id },
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: after,
},
tx,
);
});
}
}
@@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
const KEY_PATTERN = /^[a-z0-9-]+$/;
const SUBJECT_PATTERN = /^[A-Za-z0-9_:.-]+$/;
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
export class CreateAcknowledgementDto {
@ApiProperty({ example: 'harvest-withdrawal' })
@IsString()
@MaxLength(100)
@Matches(KEY_PATTERN)
itemKey!: string;
@ApiProperty({ example: 'global', description: 'subjectId des Punktes' })
@IsString()
@MaxLength(200)
@Matches(SUBJECT_PATTERN)
subjectId!: string;
@ApiProperty({ description: 'Fingerabdruck des Punktes aus der Auswertung' })
@IsString()
@Matches(FINGERPRINT_PATTERN)
fingerprint!: string;
@ApiProperty({ description: 'Notiz, 10 bis 2000 Zeichen' })
@IsString()
@MinLength(10)
@MaxLength(2000)
note!: string;
}
@@ -0,0 +1,41 @@
import { fingerprintOf } from './fingerprint';
describe('fingerprintOf', () => {
it('is stable for the same parts regardless of key order and differs when a value changes', () => {
const a = fingerprintOf({
year: 2027,
ids: ['x', 'y'],
nested: { a: 1, b: 2 },
});
const sameOtherOrder = fingerprintOf({
nested: { b: 2, a: 1 },
ids: ['x', 'y'],
year: 2027,
});
const changedValue = fingerprintOf({
year: 2028,
ids: ['x', 'y'],
nested: { a: 1, b: 2 },
});
expect(a).toMatch(/^[a-f0-9]{64}$/);
expect(sameOtherOrder).toBe(a);
expect(changedValue).not.toBe(a);
});
it('keeps the order of an array and treats dates by their ISO text', () => {
expect(fingerprintOf({ ids: ['x', 'y'] })).not.toBe(
fingerprintOf({ ids: ['y', 'x'] }),
);
expect(fingerprintOf({ at: new Date('2027-01-01T00:00:00.000Z') })).toBe(
fingerprintOf({ at: '2027-01-01T00:00:00.000Z' }),
);
});
it('ignores undefined properties and accepts null and plain values', () => {
expect(fingerprintOf({ a: 1, b: undefined })).toBe(fingerprintOf({ a: 1 }));
expect(fingerprintOf(null)).toMatch(/^[a-f0-9]{64}$/);
expect(fingerprintOf(undefined)).toMatch(/^[a-f0-9]{64}$/);
expect(fingerprintOf(5)).not.toBe(fingerprintOf('5'));
});
});
@@ -0,0 +1,33 @@
import { createHash } from 'node:crypto';
// JSON with sorted keys at every level, so the same facts always give the same
// text. Dates become their ISO text first (toJSON); undefined is dropped.
function canonical(value: unknown): unknown {
const plain: unknown =
value !== null &&
typeof value === 'object' &&
typeof (value as { toJSON?: unknown }).toJSON === 'function'
? (value as { toJSON(): unknown }).toJSON()
: value;
if (Array.isArray(plain)) {
return plain.map((entry) => canonical(entry) ?? null);
}
if (plain !== null && typeof plain === 'object') {
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(plain).sort()) {
const entry = canonical((plain as Record<string, unknown>)[key]);
if (entry !== undefined) {
sorted[key] = entry;
}
}
return sorted;
}
return plain;
}
/** sha256 (hex) of the canonical JSON of `parts`. */
export function fingerprintOf(parts: unknown): string {
return createHash('sha256')
.update(JSON.stringify(canonical(parts)) ?? 'null')
.digest('hex');
}
@@ -0,0 +1,129 @@
import type {
ApiaryRestriction,
AuthorityNotice,
ComplianceSettings,
SiteSettings,
} from '../../../generated/prisma';
import { berlinToday, dayToDate } from '../../common/dates';
import type { DbClient } from '../../prisma/db-client';
import { herdStatusIn, type HerdStatus } from '../herd-status';
const SITE_SETTINGS_ID = 'singleton';
/** TSK reports of this year and the one before matter for the readiness. */
const TSK_LOOKBACK_YEARS = 1;
const MAX_TSK_NOTICES = 100;
const MAX_RESTRICTIONS = 500;
/** A notice without its snapshot (addresses, large): the rules never read it. */
export type ReadinessNotice = Omit<AuthorityNotice, 'snapshot'>;
export type ReadinessRestriction = ApiaryRestriction & {
apiary: { id: string; name: string };
};
/**
* Everything the rules read, loaded once per evaluation. Task 13 adds the
* fields of the other rule groups.
*/
export interface ReadinessContext {
now: Date;
/** The Berlin calendar day of `now` (YYYY-MM-DD). */
today: string;
site: SiteSettings;
settings: ComplianceSettings;
herd: HerdStatus;
notices: ReadinessNotice[];
/** The restrictions active on `today`. */
restrictions: ReadinessRestriction[];
}
/** Before the first save there is no row: every field is empty. */
const EMPTY_SITE_SETTINGS: SiteSettings = {
id: SITE_SETTINGS_ID,
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: null,
holderAddress: null,
foodBusinessName: null,
foodBusinessAddress: null,
originCountry: '',
updatedAt: new Date(0),
};
const NOTICE_COLUMNS = {
id: true,
kind: true,
status: true,
year: true,
referenceDate: true,
countedAt: true,
totalColonies: true,
sentOn: true,
sentVia: true,
reference: true,
notes: true,
openKey: true,
createdByUserId: true,
createdAt: true,
updatedAt: true,
} as const;
/** A read: nothing here writes (the settings rows are seeded by migrations). */
export async function loadReadinessContext(
db: DbClient,
now: Date,
): Promise<ReadinessContext> {
const today = berlinToday(now);
const day = dayToDate(today);
const firstTskYear = Number(today.slice(0, 4)) - TSK_LOOKBACK_YEARS;
const [site, herd, sentRegistration, draftRegistration, tskReports, active] =
await Promise.all([
db.siteSettings.findUnique({ where: { id: SITE_SETTINGS_ID } }),
herdStatusIn(db, now),
// The registrations have their own queries, so an old registration can
// never fall out of the window meant for the TSK reports.
db.authorityNotice.findFirst({
where: { kind: 'REGISTRATION', status: 'SENT' },
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
select: NOTICE_COLUMNS,
}),
db.authorityNotice.findFirst({
where: { kind: 'REGISTRATION', status: 'DRAFT' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: NOTICE_COLUMNS,
}),
db.authorityNotice.findMany({
where: {
kind: 'TSK_REPORT',
status: { not: 'CANCELLED' },
year: { gte: firstTskYear },
},
select: NOTICE_COLUMNS,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: MAX_TSK_NOTICES,
}),
db.apiaryRestriction.findMany({
where: {
startsOn: { lte: day },
OR: [{ endsOn: null }, { endsOn: { gte: day } }],
},
include: { apiary: { select: { id: true, name: true } } },
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
take: MAX_RESTRICTIONS,
}),
]);
const registrations = [sentRegistration, draftRegistration].filter(
(entry): entry is ReadinessNotice => entry !== null,
);
return {
now,
today,
site: site ?? EMPTY_SITE_SETTINGS,
settings: herd.settings,
herd,
notices: [...registrations, ...tskReports],
restrictions: active,
};
}
@@ -0,0 +1,54 @@
import { WorkerModule } from '../../worker/worker.module';
import { ReadinessController } from './readiness.controller';
import { ReadinessModule } from './readiness.module';
// The controller injects the permission resolver: the worker must not load it.
async function reachable(root: unknown): Promise<Set<unknown>> {
const seen = new Set<unknown>();
async function visit(entry: unknown): Promise<void> {
const resolved: unknown = await entry;
if (
resolved === null ||
(typeof resolved !== 'object' && typeof resolved !== 'function')
) {
return;
}
if ('forwardRef' in resolved) {
return visit((resolved as { forwardRef: () => unknown }).forwardRef());
}
if ('module' in resolved) {
const dynamic = resolved as { module: unknown; imports?: unknown[] };
await visit(dynamic.module);
for (const imported of dynamic.imports ?? []) {
await visit(imported);
}
return;
}
if (seen.has(resolved)) {
return;
}
seen.add(resolved);
const imports =
(Reflect.getMetadata('imports', resolved) as unknown[] | undefined) ?? [];
for (const imported of imports) {
await visit(imported);
}
}
await visit(root);
return seen;
}
describe('worker module graph', () => {
it('contains neither the ReadinessModule nor the ReadinessController', async () => {
const modules = await reachable(WorkerModule);
expect(modules.size).toBeGreaterThan(5);
expect(modules).not.toContain(ReadinessModule);
for (const module of modules) {
const controllers =
(Reflect.getMetadata('controllers', module as object) as
unknown[] | undefined) ?? [];
expect(controllers).not.toContain(ReadinessController);
}
});
});
@@ -0,0 +1,56 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
import { PermissionResolverService } from '../../auth/permissions/permission-resolver.service';
import type { AuthenticatedUser } from '../../auth/types/authenticated-user';
import { Idempotent } from '../../common/idempotency/idempotent.decorator';
import { AcknowledgementsService } from './acknowledgements.service';
import { CreateAcknowledgementDto } from './dto/create-acknowledgement.dto';
import { ReadinessService } from './readiness.service';
@ApiTags('compliance')
@ApiBearerAuth()
@Controller('compliance')
export class ReadinessController {
constructor(
private readonly readiness: ReadinessService,
private readonly acknowledgements: AcknowledgementsService,
private readonly permissions: PermissionResolverService,
) {}
@RequirePermission('compliance:read')
@Get('readiness')
async evaluate(@CurrentUser() actor: AuthenticatedUser) {
return this.readiness.evaluate(
new Date(),
await this.permissions.getPermissions(actor.id),
);
}
@RequirePermission('compliance:read')
@Get('readiness/summary')
summary() {
return this.readiness.summary(new Date());
}
@RequirePermission('compliance:manage')
@Idempotent()
@Post('acknowledgements')
async acknowledge(
@Body() dto: CreateAcknowledgementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.acknowledgements.create(
dto,
actor.id,
await this.permissions.getPermissions(actor.id),
);
}
@RequirePermission('compliance:manage')
@Post('acknowledgements/:id/revoke')
revoke(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.acknowledgements.revoke(id, actor.id);
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ComplianceModule } from '../compliance.module';
import { READINESS_RULES, READINESS_RULES_TOKEN } from './rules';
import { AcknowledgementsService } from './acknowledgements.service';
import { ReadinessController } from './readiness.controller';
import { ReadinessService } from './readiness.service';
// Not for WorkerModule: the controller injects the permission resolver (to
// hide the subjects a caller may not see). The services themselves do not.
@Module({
imports: [ComplianceModule],
controllers: [ReadinessController],
providers: [
ReadinessService,
AcknowledgementsService,
{ provide: READINESS_RULES_TOKEN, useValue: READINESS_RULES },
],
exports: [ReadinessService],
})
export class ReadinessModule {}
@@ -0,0 +1,295 @@
import { PrismaService } from '../../prisma/prisma.service';
import { fingerprintOf } from './fingerprint';
import { loadReadinessContext } from './readiness-context';
import { ReadinessService } from './readiness.service';
import type {
ReadinessFinding,
ReadinessRule,
ReadinessSubject,
} from './readiness.types';
import { readinessContext } from './rules/rule-spec-helpers';
jest.mock('./readiness-context', () => ({
loadReadinessContext: jest.fn(),
}));
const NOW = new Date('2027-03-15T10:00:00.000Z');
const loadContextMock = loadReadinessContext as jest.Mock;
const rules: ReadinessRule[] = [];
function fakeRule(
patch: Partial<ReadinessRule> & { findings?: ReadinessFinding[] },
): ReadinessRule {
const { findings = [], ...rest } = patch;
return {
key: 'fake',
area: 'STAMMDATEN',
severity: 'PFLICHT',
title: 'Fake-Regel',
legalBasis: 'Norm',
acknowledgeable: false,
evaluate: () => findings,
...rest,
};
}
function finding(patch: Partial<ReadinessFinding> = {}): ReadinessFinding {
return {
subjectId: 'global',
detail: 'Etwas fehlt.',
fingerprintParts: { v: 1 },
subjects: [],
...patch,
};
}
function subjects(count: number): ReadinessSubject[] {
return Array.from({ length: count }, (_, index) => ({
type: 'APIARY',
id: `a${index}`,
label: `Stand ${index}`,
}));
}
function ack(patch: Record<string, unknown> = {}) {
return {
id: 'ack1',
itemKey: 'fake',
subjectId: 'global',
fingerprint: fingerprintOf({
key: 'fake',
subjectId: 'global',
parts: { v: 1 },
}),
note: 'Geprüft und in Ordnung.',
createdAt: new Date('2027-03-01T08:00:00.000Z'),
revokedAt: null,
user: { name: 'Imker Eins' },
...patch,
};
}
describe('ReadinessService', () => {
let service: ReadinessService;
let findAcknowledgements: jest.Mock;
beforeEach(() => {
rules.splice(0, rules.length);
loadContextMock.mockReset().mockResolvedValue(readinessContext());
findAcknowledgements = jest.fn().mockResolvedValue([]);
const prisma = {
complianceAcknowledgement: { findMany: findAcknowledgements },
} as unknown as PrismaService;
service = new ReadinessService(prisma, rules);
});
it('is READY when no rule has an open finding', async () => {
rules.push(fakeRule({ key: 'a' }), fakeRule({ key: 'b' }));
const report = await service.evaluate(NOW, new Set());
expect(report.status).toBe('READY');
expect(report.openCount).toBe(0);
expect(report.generatedAt).toBe(NOW.toISOString());
});
it('a rule without findings yields one DONE item with subjectId global', async () => {
rules.push(fakeRule({ key: 'a', title: 'Alles da' }));
const report = await service.evaluate(NOW, new Set());
expect(report.items).toEqual([
expect.objectContaining({
key: 'a',
subjectId: 'global',
title: 'Alles da',
state: 'DONE',
subjects: [],
more: 0,
acknowledgement: null,
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/) as unknown,
}),
]);
});
it('overall status is OPEN as long as one item is OPEN, an ACKNOWLEDGED item does not count', async () => {
rules.push(
fakeRule({
key: 'fake',
severity: 'PRUEFEN',
acknowledgeable: true,
findings: [finding()],
}),
);
findAcknowledgements.mockResolvedValue([ack()]);
const acknowledged = await service.evaluate(NOW, new Set());
expect(acknowledged.status).toBe('READY');
expect(acknowledged.openCount).toBe(0);
expect(acknowledged.items[0]).toMatchObject({
state: 'ACKNOWLEDGED',
acknowledgement: {
id: 'ack1',
note: 'Geprüft und in Ordnung.',
userName: 'Imker Eins',
createdAt: '2027-03-01T08:00:00.000Z',
},
});
rules.push(fakeRule({ key: 'other', findings: [finding()] }));
const withOpen = await service.evaluate(NOW, new Set());
expect(withOpen.status).toBe('OPEN');
expect(withOpen.openCount).toBe(1);
});
it('limits subjects to 50 and reports the rest in more', async () => {
rules.push(
fakeRule({
findings: [finding({ subjects: subjects(53), more: 4 })],
}),
);
const [item] = (await service.evaluate(NOW, new Set())).items;
expect(item.subjects).toHaveLength(50);
expect(item.more).toBe(7);
});
it('hides subject labels without the rule subjectPermission and keeps the count', async () => {
rules.push(
fakeRule({
subjectPermission: 'apiaries:read',
findings: [finding({ subjects: subjects(3) })],
}),
);
const hidden = (await service.evaluate(NOW, new Set())).items[0];
const shown = (await service.evaluate(NOW, new Set(['apiaries:read'])))
.items[0];
expect(hidden.subjects).toEqual([]);
expect(hidden.more).toBe(3);
expect(JSON.stringify(hidden)).not.toContain('Stand 0');
expect(shown.subjects).toHaveLength(3);
expect(shown.more).toBe(0);
});
it('summary names the first open PFLICHT item, else the first open item', async () => {
rules.push(
fakeRule({ key: 'done' }),
fakeRule({ key: 'check', severity: 'PRUEFEN', findings: [finding()] }),
fakeRule({ key: 'must', severity: 'PFLICHT', findings: [finding()] }),
);
expect(await service.summary(NOW)).toEqual({
status: 'OPEN',
openCount: 2,
firstItemKey: 'must',
});
rules.splice(2, 1);
expect(await service.summary(NOW)).toEqual({
status: 'OPEN',
openCount: 1,
firstItemKey: 'check',
});
rules.splice(1, 1);
expect(await service.summary(NOW)).toEqual({
status: 'READY',
openCount: 0,
firstItemKey: null,
});
});
it('a revoked acknowledgement reopens the item', async () => {
rules.push(
fakeRule({
key: 'fake',
severity: 'PRUEFEN',
acknowledgeable: true,
findings: [finding()],
}),
);
findAcknowledgements.mockResolvedValue([
ack({ revokedAt: new Date('2027-03-10T00:00:00.000Z') }),
]);
const report = await service.evaluate(NOW, new Set());
expect(report.items[0].state).toBe('OPEN');
expect(report.items[0].acknowledgement).toBeNull();
});
it('a new fingerprint after changed data reopens an acknowledged item', async () => {
const stale = ack();
rules.push(
fakeRule({
key: 'fake',
severity: 'PRUEFEN',
acknowledgeable: true,
findings: [finding({ fingerprintParts: { v: 2 } })],
}),
);
findAcknowledgements.mockResolvedValue([stale]);
const report = await service.evaluate(NOW, new Set());
expect(report.items[0].state).toBe('OPEN');
expect(report.items[0].fingerprint).not.toBe(stale.fingerprint);
});
it('the newest valid acknowledgement wins', async () => {
rules.push(
fakeRule({
key: 'fake',
severity: 'PRUEFEN',
acknowledgeable: true,
findings: [finding()],
}),
);
findAcknowledgements.mockResolvedValue([
ack({ id: 'newer', createdAt: new Date('2027-03-05T00:00:00.000Z') }),
ack({ id: 'older', createdAt: new Date('2027-03-01T00:00:00.000Z') }),
]);
const report = await service.evaluate(NOW, new Set());
expect(report.items[0].acknowledgement?.id).toBe('newer');
});
it('never applies an acknowledgement to a rule that cannot be acknowledged', async () => {
rules.push(
fakeRule({ key: 'fake', acknowledgeable: false, findings: [finding()] }),
);
findAcknowledgements.mockResolvedValue([ack()]);
const report = await service.evaluate(NOW, new Set());
expect(report.items[0].state).toBe('OPEN');
expect(findAcknowledgements).not.toHaveBeenCalled();
});
it('uses the finding title over the rule title and one item per finding', async () => {
rules.push(
fakeRule({
title: 'Regel',
findings: [
finding({ subjectId: '2026', title: 'Zählung 2026' }),
finding({ subjectId: '2027' }),
],
}),
);
const report = await service.evaluate(NOW, new Set());
expect(report.items.map((item) => [item.subjectId, item.title])).toEqual([
['2026', 'Zählung 2026'],
['2027', 'Regel'],
]);
});
});
@@ -0,0 +1,226 @@
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { fingerprintOf } from './fingerprint';
import { loadReadinessContext } from './readiness-context';
import {
GLOBAL_SUBJECT_ID,
MAX_SUBJECTS,
type ReadinessAcknowledgement,
type ReadinessFinding,
type ReadinessItem,
type ReadinessReport,
type ReadinessRule,
type ReadinessSubject,
type ReadinessSummary,
} from './readiness.types';
import { READINESS_RULES_TOKEN } from './rules';
const DONE_DETAIL = 'Nichts offen.';
interface AcknowledgementRow {
id: string;
itemKey: string;
subjectId: string;
fingerprint: string;
note: string;
createdAt: Date;
revokedAt: Date | null;
user: { name: string };
}
function itemId(key: string, subjectId: string): string {
return `${key}\u0000${subjectId}`;
}
/** At most MAX_SUBJECTS subjects; without the right to see them, none. */
function visibleSubjects(
rule: ReadinessRule,
finding: ReadinessFinding,
permissions: ReadonlySet<string>,
): { subjects: ReadinessSubject[]; more: number } {
const hidden =
rule.subjectPermission && !permissions.has(rule.subjectPermission);
if (hidden) {
return {
subjects: [],
more: finding.subjects.length + (finding.more ?? 0),
};
}
return {
subjects: finding.subjects.slice(0, MAX_SUBJECTS),
more:
Math.max(0, finding.subjects.length - MAX_SUBJECTS) + (finding.more ?? 0),
};
}
function openItem(
rule: ReadinessRule,
finding: ReadinessFinding,
permissions: ReadonlySet<string>,
): ReadinessItem {
return {
key: rule.key,
area: rule.area,
severity: rule.severity,
subjectId: finding.subjectId,
title: finding.title ?? rule.title,
detail: finding.detail,
legalBasis: rule.legalBasis,
acknowledgeable: rule.acknowledgeable,
fingerprint: fingerprintOf({
key: rule.key,
subjectId: finding.subjectId,
parts: finding.fingerprintParts,
}),
...visibleSubjects(rule, finding, permissions),
state: 'OPEN',
acknowledgement: null,
};
}
function doneItem(rule: ReadinessRule): ReadinessItem {
return {
key: rule.key,
area: rule.area,
severity: rule.severity,
subjectId: GLOBAL_SUBJECT_ID,
title: rule.title,
detail: DONE_DETAIL,
legalBasis: rule.legalBasis,
acknowledgeable: rule.acknowledgeable,
fingerprint: fingerprintOf({
key: rule.key,
subjectId: GLOBAL_SUBJECT_ID,
parts: null,
}),
subjects: [],
more: 0,
state: 'DONE',
acknowledgement: null,
};
}
function toAcknowledgement(row: AcknowledgementRow): ReadinessAcknowledgement {
return {
id: row.id,
note: row.note,
userName: row.user.name,
createdAt: row.createdAt.toISOString(),
};
}
function isOpen(item: ReadinessItem): boolean {
return item.state === 'OPEN';
}
/**
* The readiness is computed live from the data on every call; only the
* acknowledgements are stored. An acknowledgement counts while it is not
* revoked and its fingerprint still equals the current finding - changed
* data or a revocation reopens the item.
*/
@Injectable()
export class ReadinessService {
constructor(
private readonly prisma: PrismaService,
@Inject(READINESS_RULES_TOKEN)
private readonly rules: readonly ReadinessRule[],
) {}
async evaluate(
now: Date,
permissions: ReadonlySet<string>,
): Promise<ReadinessReport> {
const ctx = await loadReadinessContext(this.prisma, now);
const drafts = this.rules.flatMap((rule) => {
const findings = rule.evaluate(ctx);
return findings.length === 0
? [doneItem(rule)]
: findings.map((finding) => openItem(rule, finding, permissions));
});
const acknowledged = await this.acknowledgementsFor(drafts);
const items = drafts.map((item): ReadinessItem => {
const found = acknowledged.get(itemId(item.key, item.subjectId));
return found && isOpen(item)
? { ...item, state: 'ACKNOWLEDGED', acknowledgement: found }
: item;
});
const openCount = items.filter(isOpen).length;
return {
status: openCount === 0 ? 'READY' : 'OPEN',
generatedAt: now.toISOString(),
openCount,
items,
};
}
/** No names in it: the subjects stay hidden. */
async summary(now: Date): Promise<ReadinessSummary> {
const report = await this.evaluate(now, new Set());
const open = report.items.filter(isOpen);
const first = open.find((item) => item.severity === 'PFLICHT') ?? open[0];
return {
status: report.status,
openCount: report.openCount,
firstItemKey: first?.key ?? null,
};
}
// The valid acknowledgement of each open item that can be acknowledged:
// same item, same subject, same fingerprint, not revoked; the newest wins.
private async acknowledgementsFor(
items: readonly ReadinessItem[],
): Promise<Map<string, ReadinessAcknowledgement>> {
const candidates = items.filter(
(item) => item.acknowledgeable && isOpen(item),
);
const result = new Map<string, ReadinessAcknowledgement>();
if (candidates.length === 0) {
return result;
}
const rows: AcknowledgementRow[] =
await this.prisma.complianceAcknowledgement.findMany({
where: {
revokedAt: null,
OR: candidates.map((item) => ({
itemKey: item.key,
subjectId: item.subjectId,
fingerprint: item.fingerprint,
})),
},
select: {
id: true,
itemKey: true,
subjectId: true,
fingerprint: true,
note: true,
createdAt: true,
revokedAt: true,
user: { select: { name: true } },
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const fingerprints = new Map(
candidates.map((item) => [
itemId(item.key, item.subjectId),
item.fingerprint,
]),
);
const newestFirst = [...rows].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
for (const row of newestFirst) {
const id = itemId(row.itemKey, row.subjectId);
// The query already filters; the same test here keeps a revoked or
// outdated row out whatever the query returns.
if (
row.revokedAt === null &&
fingerprints.get(id) === row.fingerprint &&
!result.has(id)
) {
result.set(id, toAcknowledgement(row));
}
}
return result;
}
}
@@ -0,0 +1,82 @@
import type { PermissionKey } from '../../auth/permissions/permission-catalog';
import type { ReadinessContext } from './readiness-context';
export type ReadinessSeverity = 'PFLICHT' | 'PRUEFEN';
export type ReadinessArea =
'STAMMDATEN' | 'BEHOERDEN' | 'TIERARZNEIMITTEL' | 'ETIKETTEN' | 'ERNTE';
export type ReadinessItemState = 'OPEN' | 'DONE' | 'ACKNOWLEDGED';
/** subjectId of a rule that has one single item (no subject of its own). */
export const GLOBAL_SUBJECT_ID = 'global';
/** The most subjects one item lists; the rest is only counted in `more`. */
export const MAX_SUBJECTS = 50;
export interface ReadinessSubject {
type: string;
id: string;
label: string;
missing?: string[];
/** e.g. the colony of a treatment (link target). */
parentId?: string;
}
export interface ReadinessFinding {
subjectId: string;
/** Overrides the rule title (e.g. the year in it). */
title?: string;
/** One short German sentence, never with a name or an address. */
detail: string;
/** Everything the finding rests on: a change in it reopens an acknowledgement. */
fingerprintParts: unknown;
subjects: ReadinessSubject[];
more?: number;
}
export interface ReadinessRule {
readonly key: string;
readonly area: ReadinessArea;
readonly severity: ReadinessSeverity;
readonly title: string;
readonly legalBasis: string | null;
readonly acknowledgeable: boolean;
/** Without it the caller sees how many subjects there are, not which. */
readonly subjectPermission?: PermissionKey;
evaluate(ctx: ReadinessContext): ReadinessFinding[];
}
export interface ReadinessAcknowledgement {
id: string;
note: string;
userName: string;
createdAt: string;
}
export interface ReadinessItem {
key: string;
area: ReadinessArea;
severity: ReadinessSeverity;
subjectId: string;
title: string;
detail: string;
legalBasis: string | null;
acknowledgeable: boolean;
fingerprint: string;
subjects: ReadinessSubject[];
more: number;
state: ReadinessItemState;
acknowledgement: ReadinessAcknowledgement | null;
}
export interface ReadinessReport {
status: 'READY' | 'OPEN';
generatedAt: string;
openCount: number;
items: ReadinessItem[];
}
export interface ReadinessSummary {
status: 'READY' | 'OPEN';
openCount: number;
firstItemKey: string | null;
}
@@ -0,0 +1,36 @@
import { foodBusinessDataRule } from './food-business-data.rule';
import { readinessContext, siteSettings } from './rule-spec-helpers';
describe('food-business-data rule', () => {
it('is open when the name is empty', () => {
const findings = foodBusinessDataRule.evaluate(
readinessContext({ site: siteSettings({ foodBusinessName: null }) }),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjects[0].missing).toEqual(['Name']);
});
it('is open when the address is empty', () => {
const findings = foodBusinessDataRule.evaluate(
readinessContext({
site: siteSettings({ foodBusinessAddress: ' ' }),
}),
);
expect(findings[0].subjects[0].missing).toEqual(['Anschrift']);
});
it('is done when both are set', () => {
expect(foodBusinessDataRule.evaluate(readinessContext())).toEqual([]);
});
it('is a Pflicht rule without acknowledgement', () => {
expect(foodBusinessDataRule).toMatchObject({
key: 'food-business-data',
area: 'STAMMDATEN',
severity: 'PFLICHT',
acknowledgeable: false,
});
});
});
@@ -0,0 +1,29 @@
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
import { isBlank, siteSettingsSubject } from './rule-helpers';
export const foodBusinessDataRule: ReadinessRule = {
key: 'food-business-data',
area: 'STAMMDATEN',
severity: 'PFLICHT',
title: 'Name und Anschrift des Lebensmittelunternehmers eintragen.',
legalBasis: 'LMIV Art. 8',
acknowledgeable: false,
evaluate: ({ site }): ReadinessFinding[] => {
const missing = [
...(isBlank(site.foodBusinessName) ? ['Name'] : []),
...(isBlank(site.foodBusinessAddress) ? ['Anschrift'] : []),
];
if (missing.length === 0) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail: `Beim Lebensmittelunternehmer fehlt: ${missing.join(' und ')}.`,
fingerprintParts: { missing },
subjects: [siteSettingsSubject(missing)],
},
];
},
};
@@ -0,0 +1,130 @@
import type { HerdDeviation } from '../../herd-snapshot';
import { fingerprintOf } from '../fingerprint';
import { herdChangeRule } from './herd-change.rule';
import {
emptySnapshot,
herdStatus,
notice,
readinessContext,
} from './rule-spec-helpers';
function deviation(patch: Partial<HerdDeviation> = {}): HerdDeviation {
return {
triggered: true,
colonyCountChanged: true,
apiariesAdded: [],
apiariesRemoved: [],
apiariesRelocated: [],
...patch,
};
}
function apiary(id: string, name: string) {
return {
apiaryId: id,
name,
address: null,
location: null,
latitude: null,
longitude: null,
livingColonies: 1,
byType: {},
};
}
describe('herd-change rule', () => {
it('is open when the deviation is triggered', () => {
const findings = herdChangeRule.evaluate(
readinessContext({
herd: herdStatus({
baseline: notice({ totalColonies: 3 }) as never,
current: { ...emptySnapshot(), totalLiving: 5 },
deviation: deviation(),
}),
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
expect(findings[0].detail).toContain('5');
expect(findings[0].detail).toContain('3');
});
it('is done without a baseline: nothing to deviate from', () => {
expect(
herdChangeRule.evaluate(
readinessContext({ herd: herdStatus({ deviation: null }) }),
),
).toEqual([]);
});
it('is done when the deviation is not triggered', () => {
expect(
herdChangeRule.evaluate(
readinessContext({
herd: herdStatus({
baseline: notice() as never,
deviation: deviation({ triggered: false }),
}),
}),
),
).toEqual([]);
});
it('lists the changed apiaries as subjects, removed ones from the baseline snapshot', () => {
const findings = herdChangeRule.evaluate(
readinessContext({
herd: herdStatus({
baseline: {
...notice(),
snapshot: { apiaries: [apiary('gone', 'Alter Stand')] },
},
current: {
...emptySnapshot(),
apiaries: [apiary('new', 'Neuer Stand')],
},
deviation: deviation({
apiariesAdded: ['new'],
apiariesRemoved: ['gone'],
}),
}),
}),
);
expect(findings[0].subjects.map((subject) => subject.label)).toEqual([
'Neuer Stand',
'Alter Stand',
]);
expect(herdChangeRule.subjectPermission).toBe('apiaries:read');
expect(findings[0].detail).not.toContain('Stand');
});
it('gives an equal fingerprint for the same state with permuted lists', () => {
const run = (added: string[], removed: string[], relocated: string[]) =>
herdChangeRule.evaluate(
readinessContext({
herd: herdStatus({
baseline: notice() as never,
deviation: deviation({
apiariesAdded: added,
apiariesRemoved: removed,
apiariesRelocated: relocated,
}),
}),
}),
)[0].fingerprintParts;
expect(fingerprintOf(run(['a', 'b'], ['c', 'd'], ['e', 'f']))).toBe(
fingerprintOf(run(['b', 'a'], ['d', 'c'], ['f', 'e'])),
);
});
it('is a Pflicht rule that cannot be acknowledged', () => {
expect(herdChangeRule).toMatchObject({
key: 'herd-change',
area: 'BEHOERDEN',
severity: 'PFLICHT',
acknowledgeable: false,
});
});
});
@@ -0,0 +1,87 @@
import type { HerdSnapshot } from '../../herd-snapshot';
import type { ReadinessContext } from '../readiness-context';
import type {
ReadinessFinding,
ReadinessRule,
ReadinessSubject,
} from '../readiness.types';
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
function snapshotOf(value: unknown): HerdSnapshot | null {
return value && typeof value === 'object' && 'apiaries' in value
? (value as HerdSnapshot)
: null;
}
function nameOf(
id: string,
...snapshots: (HerdSnapshot | null)[]
): string | null {
for (const snapshot of snapshots) {
const found = snapshot?.apiaries?.find((entry) => entry.apiaryId === id);
if (found) {
return found.name;
}
}
return null;
}
function subjectsOf({ herd }: ReadinessContext): ReadinessSubject[] {
const { deviation, baseline, current } = herd;
if (!deviation) {
return [];
}
const baselineSnapshot = snapshotOf(baseline?.snapshot);
const subjects: ReadinessSubject[] = [];
const add = (ids: string[], reason: string) => {
for (const id of ids) {
subjects.push({
type: 'APIARY',
id,
// Removed apiaries are gone from the current picture.
label: nameOf(id, current, baselineSnapshot) ?? id,
missing: [reason],
});
}
};
add(deviation.apiariesAdded, 'neu');
add(deviation.apiariesRemoved, 'aufgegeben');
add(deviation.apiariesRelocated, 'verlegt');
return subjects;
}
function sorted(ids: readonly string[]): string[] {
return [...ids].sort();
}
export const herdChangeRule: ReadinessRule = {
key: 'herd-change',
area: 'BEHOERDEN',
severity: 'PFLICHT',
title: 'Die Änderung des Bestands beim Veterinäramt anzeigen.',
legalBasis: 'BienSeuchV § 1a (Umfang: Annahme)',
acknowledgeable: false,
subjectPermission: 'apiaries:read',
evaluate: (ctx): ReadinessFinding[] => {
const { baseline, current, deviation } = ctx.herd;
if (!baseline || !deviation?.triggered) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail: `Der Bestand weicht von der letzten Anzeige ab: jetzt ${current.totalLiving} Völker, angezeigt ${baseline.totalColonies}.`,
fingerprintParts: {
baselineId: baseline.id,
totalLiving: current.totalLiving,
totalColonies: baseline.totalColonies,
colonyCountChanged: deviation.colonyCountChanged,
apiariesAdded: sorted(deviation.apiariesAdded),
apiariesRemoved: sorted(deviation.apiariesRemoved),
apiariesRelocated: sorted(deviation.apiariesRelocated),
},
subjects: subjectsOf(ctx),
},
];
},
};
@@ -0,0 +1,51 @@
import { holderDataRule } from './holder-data.rule';
import { readinessContext, siteSettings } from './rule-spec-helpers';
describe('holder-data rule', () => {
it('is open when the holder name is empty', () => {
const findings = holderDataRule.evaluate(
readinessContext({ site: siteSettings({ holderName: ' ' }) }),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
expect(findings[0].subjects[0].missing).toEqual(['Name']);
});
it('is open when the address is empty and lists both gaps when both are empty', () => {
const addressOnly = holderDataRule.evaluate(
readinessContext({ site: siteSettings({ holderAddress: null }) }),
);
const both = holderDataRule.evaluate(
readinessContext({
site: siteSettings({ holderName: null, holderAddress: '' }),
}),
);
expect(addressOnly[0].subjects[0].missing).toEqual(['Anschrift']);
expect(both[0].subjects[0].missing).toEqual(['Name', 'Anschrift']);
});
it('is done when name and address are set', () => {
expect(holderDataRule.evaluate(readinessContext())).toEqual([]);
});
it('is a Pflicht rule that cannot be acknowledged', () => {
expect(holderDataRule).toMatchObject({
key: 'holder-data',
area: 'STAMMDATEN',
severity: 'PFLICHT',
acknowledgeable: false,
});
});
it('never puts the stored name or address into a finding', () => {
const findings = holderDataRule.evaluate(
readinessContext({
site: siteSettings({ holderName: null, holderAddress: 'Geheimweg 7' }),
}),
);
expect(JSON.stringify(findings)).not.toContain('Geheimweg');
});
});
@@ -0,0 +1,29 @@
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
import { isBlank, siteSettingsSubject } from './rule-helpers';
export const holderDataRule: ReadinessRule = {
key: 'holder-data',
area: 'STAMMDATEN',
severity: 'PFLICHT',
title: 'Name und Anschrift des Bienenhalters eintragen.',
legalBasis: 'Art. 108 VO (EU) 2019/6',
acknowledgeable: false,
evaluate: ({ site }): ReadinessFinding[] => {
const missing = [
...(isBlank(site.holderName) ? ['Name'] : []),
...(isBlank(site.holderAddress) ? ['Anschrift'] : []),
];
if (missing.length === 0) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail: `Beim Bienenhalter fehlt: ${missing.join(' und ')}.`,
fingerprintParts: { missing },
subjects: [siteSettingsSubject(missing)],
},
];
},
};
@@ -0,0 +1,41 @@
import { READINESS_RULES } from './index';
import {
complianceSettings,
herdStatus,
notice,
readinessContext,
restriction,
siteSettings,
} from './rule-spec-helpers';
describe('READINESS_RULES', () => {
it('has unique keys', () => {
const keys = READINESS_RULES.map((rule) => rule.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('yields unique subjectIds per rule for a context in which rules are open', () => {
const ctx = readinessContext({
site: siteSettings({ holderName: null, foodBusinessName: null }),
settings: complianceSettings({ veterinaryOfficeName: null }),
herd: herdStatus(),
notices: [
notice({ id: 'old', kind: 'TSK_REPORT', year: 2026, status: 'DRAFT' }),
],
restrictions: [
restriction({ id: 'r1' }),
restriction({ id: 'r2', apiary: { id: 'a2', name: 'Zwei' } }),
],
});
for (const rule of READINESS_RULES) {
const ids = rule.evaluate(ctx).map((finding) => finding.subjectId);
expect({
key: rule.key,
unique: new Set(ids).size === ids.length,
}).toEqual({ key: rule.key, unique: true });
}
});
});
@@ -0,0 +1,22 @@
import type { ReadinessRule } from '../readiness.types';
import { foodBusinessDataRule } from './food-business-data.rule';
import { herdChangeRule } from './herd-change.rule';
import { holderDataRule } from './holder-data.rule';
import { registrationRule } from './registration.rule';
import { restrictionActiveRule } from './restriction-active.rule';
import { tskReportRule } from './tsk-report.rule';
import { vetOfficeRule } from './vet-office.rule';
/** In the order of the report; Task 13 appends the other rules. */
export const READINESS_RULES: readonly ReadinessRule[] = [
holderDataRule,
foodBusinessDataRule,
vetOfficeRule,
registrationRule,
herdChangeRule,
tskReportRule,
restrictionActiveRule,
];
/** Injection token: the module provides the list, tests may override it. */
export const READINESS_RULES_TOKEN = Symbol('READINESS_RULES');
@@ -0,0 +1,53 @@
import { registrationRule } from './registration.rule';
import { notice, readinessContext } from './rule-spec-helpers';
describe('registration rule', () => {
it('is open without any registration', () => {
const findings = registrationRule.evaluate(readinessContext());
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
expect(findings[0].subjects).toEqual([]);
});
it('is open while the registration is only a draft and points to the draft', () => {
const findings = registrationRule.evaluate(
readinessContext({
notices: [notice({ id: 'd1', status: 'DRAFT', sentOn: null })],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjects).toEqual([
expect.objectContaining({ type: 'AUTHORITY_NOTICE', id: 'd1' }),
]);
});
it('ignores a herd change and a tsk report', () => {
const findings = registrationRule.evaluate(
readinessContext({
notices: [
notice({ kind: 'HERD_CHANGE' }),
notice({ kind: 'TSK_REPORT', year: 2027 }),
],
}),
);
expect(findings).toHaveLength(1);
});
it('is done with a SENT registration', () => {
expect(
registrationRule.evaluate(readinessContext({ notices: [notice()] })),
).toEqual([]);
});
it('is a Pflicht rule in the Behörden area', () => {
expect(registrationRule).toMatchObject({
key: 'registration',
area: 'BEHOERDEN',
severity: 'PFLICHT',
acknowledgeable: false,
});
});
});
@@ -0,0 +1,38 @@
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
export const registrationRule: ReadinessRule = {
key: 'registration',
area: 'BEHOERDEN',
severity: 'PFLICHT',
title: 'Die Erstanzeige beim Veterinäramt erfassen.',
legalBasis: 'BienSeuchV § 1a',
acknowledgeable: false,
evaluate: ({ notices }): ReadinessFinding[] => {
const registrations = notices.filter(
(notice) => notice.kind === 'REGISTRATION',
);
if (registrations.some((notice) => notice.status === 'SENT')) {
return [];
}
const draft = registrations.find((notice) => notice.status === 'DRAFT');
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail: draft
? 'Die Erstanzeige ist noch ein Entwurf und nicht als gesendet vermerkt.'
: 'Es gibt noch keine gesendete Erstanzeige.',
fingerprintParts: { draftId: draft?.id ?? null },
subjects: draft
? [
{
type: 'AUTHORITY_NOTICE',
id: draft.id,
label: 'Erstanzeige (Entwurf)',
},
]
: [],
},
];
},
};
@@ -0,0 +1,86 @@
import { restrictionActiveRule } from './restriction-active.rule';
import { readinessContext, restriction } from './rule-spec-helpers';
describe('restriction-active rule', () => {
it('is open with one finding listing each apiary with an active restriction once', () => {
const findings = restrictionActiveRule.evaluate(
readinessContext({
restrictions: [
restriction({ id: 'r1', apiary: { id: 'a1', name: 'Stand Eins' } }),
restriction({ id: 'r2', apiary: { id: 'a1', name: 'Stand Eins' } }),
restriction({ id: 'r3', apiary: { id: 'a2', name: 'Stand Zwei' } }),
],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
expect(findings[0].subjects.map((subject) => subject.label)).toEqual([
'Stand Eins',
'Stand Zwei',
]);
expect(findings[0].subjects[0]).toMatchObject({
type: 'APIARY',
id: 'a1',
});
});
it('is done without a restriction and for one that has ended or not begun', () => {
const ended = restriction({ endsOn: new Date('2027-03-14T00:00:00.000Z') });
const future = restriction({
startsOn: new Date('2027-03-16T00:00:00.000Z'),
});
expect(restrictionActiveRule.evaluate(readinessContext())).toEqual([]);
expect(
restrictionActiveRule.evaluate(
readinessContext({ restrictions: [ended, future] }),
),
).toEqual([]);
});
it('counts the first and the last day as active (Berlin day of the context)', () => {
const lastDay = restriction({
endsOn: new Date('2027-03-15T00:00:00.000Z'),
});
const firstDay = restriction({
id: 'r2',
startsOn: new Date('2027-03-15T00:00:00.000Z'),
});
expect(
restrictionActiveRule.evaluate(
readinessContext({ restrictions: [lastDay] }),
),
).toHaveLength(1);
expect(
restrictionActiveRule.evaluate(
readinessContext({ restrictions: [firstDay] }),
),
).toHaveLength(1);
});
it('keeps names out of the texts, the fingerprint follows the restrictions', () => {
const first = restrictionActiveRule.evaluate(
readinessContext({ restrictions: [restriction({ id: 'r1' })] }),
)[0];
const second = restrictionActiveRule.evaluate(
readinessContext({
restrictions: [restriction({ id: 'r1' }), restriction({ id: 'r2' })],
}),
)[0];
expect(first.detail).not.toContain('Stand Eins');
expect(first.fingerprintParts).not.toEqual(second.fingerprintParts);
expect(restrictionActiveRule.subjectPermission).toBe('apiaries:read');
});
it('is a Pflicht rule that cannot be acknowledged', () => {
expect(restrictionActiveRule).toMatchObject({
key: 'restriction-active',
area: 'BEHOERDEN',
severity: 'PFLICHT',
acknowledgeable: false,
});
});
});
@@ -0,0 +1,43 @@
import { isRestrictionActive } from '../../restricted-zone';
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
export const restrictionActiveRule: ReadinessRule = {
key: 'restriction-active',
area: 'BEHOERDEN',
severity: 'PFLICHT',
title: 'Die Verfügung zum Sperrbezirk prüfen.',
legalBasis: 'BienSeuchV § 10',
acknowledgeable: false,
subjectPermission: 'apiaries:read',
evaluate: ({ restrictions, today }): ReadinessFinding[] => {
// The same Berlin day and the same test as the guard on the colonies.
const active = restrictions.filter((restriction) =>
isRestrictionActive(restriction, today),
);
if (active.length === 0) {
return [];
}
const apiaries = new Map<string, string>();
for (const restriction of active) {
apiaries.set(restriction.apiary.id, restriction.apiary.name);
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail:
apiaries.size === 1
? 'Ein Stand liegt im Sperrbezirk. Ist die Sperre aufgehoben, das Ende eintragen.'
: `${apiaries.size} Stände liegen im Sperrbezirk. Ist eine Sperre aufgehoben, das Ende eintragen.`,
fingerprintParts: {
restrictionIds: active.map((restriction) => restriction.id).sort(),
},
subjects: [...apiaries].map(([id, name]) => ({
type: 'APIARY',
id,
label: name,
})),
},
];
},
};
@@ -0,0 +1,16 @@
import type { ReadinessSubject } from '../readiness.types';
/** True for null, undefined and a text of blanks only. */
export function isBlank(value: string | null | undefined): boolean {
return value == null || value.trim() === '';
}
/** The place to fix the master data: the settings row, with the gaps named. */
export function siteSettingsSubject(missing: string[]): ReadinessSubject {
return {
type: 'SITE_SETTINGS',
id: 'singleton',
label: 'Stammdaten',
missing,
};
}
@@ -0,0 +1,122 @@
import type {
ApiaryRestriction,
ComplianceSettings,
SiteSettings,
} from '../../../../generated/prisma';
import type { HerdSnapshot } from '../../herd-snapshot';
import type { HerdStatus } from '../../herd-status';
import type { ReadinessContext, ReadinessNotice } from '../readiness-context';
/** Builders for the pure rule specs (and for the rules of later tasks). */
export function siteSettings(patch: Partial<SiteSettings> = {}): SiteSettings {
return {
id: 'singleton',
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: 'Halter',
holderAddress: 'Weg 1, 12345 Ort',
foodBusinessName: 'Imkerei',
foodBusinessAddress: 'Weg 2, 12345 Ort',
originCountry: 'Deutschland',
updatedAt: new Date(0),
...patch,
};
}
export function complianceSettings(
patch: Partial<ComplianceSettings> = {},
): ComplianceSettings {
return {
id: 'singleton',
veterinaryOfficeName: 'Veterinäramt',
veterinaryOfficeAddress: null,
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: '01-01',
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [],
updatedAt: new Date(0),
...patch,
};
}
export function emptySnapshot(): HerdSnapshot {
return {
version: 1,
takenAt: '2027-03-15T10:00:00.000Z',
apiaries: [],
totalLiving: 0,
};
}
export function notice(patch: Partial<ReadinessNotice> = {}): ReadinessNotice {
return {
id: 'n1',
kind: 'REGISTRATION',
status: 'SENT',
year: null,
referenceDate: new Date('2026-01-01T00:00:00.000Z'),
countedAt: new Date('2026-01-01T10:00:00.000Z'),
totalColonies: 3,
sentOn: new Date('2026-01-02T00:00:00.000Z'),
sentVia: null,
reference: null,
notes: null,
openKey: null,
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
};
}
export function restriction(
patch: Partial<ApiaryRestriction> & { apiary?: { id: string; name: string } },
): ApiaryRestriction & { apiary: { id: string; name: string } } {
const apiary = patch.apiary ?? { id: 'a1', name: 'Stand Eins' };
return {
id: 'r1',
apiaryId: apiary.id,
disease: 'Faulbrut',
authority: null,
reference: null,
startsOn: new Date('2027-03-01T00:00:00.000Z'),
endsOn: null,
notes: null,
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
apiary,
};
}
export function herdStatus(patch: Partial<HerdStatus> = {}): HerdStatus {
return {
settings: complianceSettings(),
baseline: null,
current: emptySnapshot(),
deviation: null,
...patch,
};
}
export function readinessContext(
patch: Partial<ReadinessContext> = {},
): ReadinessContext {
return {
now: new Date('2027-03-15T10:00:00.000Z'),
today: '2027-03-15',
site: siteSettings(),
settings: complianceSettings(),
herd: herdStatus(),
notices: [],
restrictions: [],
...patch,
};
}
@@ -0,0 +1,117 @@
import {
complianceSettings,
notice,
readinessContext,
} from './rule-spec-helpers';
import { tskReportRule } from './tsk-report.rule';
function at(today: string, patch: Parameters<typeof readinessContext>[0] = {}) {
return readinessContext({ today, ...patch });
}
describe('tsk-report rule', () => {
it('is not open before the reference day of the year', () => {
const findings = tskReportRule.evaluate(
at('2027-03-15', {
settings: complianceSettings({ tskReferenceDay: '06-01' }),
}),
);
expect(findings).toEqual([]);
});
it('is open from the reference day on without a SENT report, one finding per year', () => {
const onTheDay = tskReportRule.evaluate(
at('2027-06-01', {
settings: complianceSettings({ tskReferenceDay: '06-01' }),
}),
);
const afterwards = tskReportRule.evaluate(at('2027-03-15'));
expect(onTheDay.map((finding) => finding.subjectId)).toEqual(['2027']);
expect(afterwards.map((finding) => finding.subjectId)).toEqual(['2027']);
expect(afterwards[0].detail).not.toContain('überfällig');
});
it('is done with a SENT report of that year', () => {
const findings = tskReportRule.evaluate(
at('2027-03-15', {
notices: [notice({ kind: 'TSK_REPORT', year: 2027 })],
}),
);
expect(findings).toEqual([]);
});
it('stays open for a DRAFT and points to it', () => {
const findings = tskReportRule.evaluate(
at('2027-03-15', {
notices: [
notice({
id: 'd1',
kind: 'TSK_REPORT',
year: 2027,
status: 'DRAFT',
sentOn: null,
}),
],
}),
);
expect(findings[0].subjects).toEqual([
expect.objectContaining({ type: 'AUTHORITY_NOTICE', id: 'd1' }),
]);
});
it('says überfällig after the due day', () => {
const settings = complianceSettings({ tskReportDueDay: '03-31' });
const onDueDay = tskReportRule.evaluate(at('2027-03-31', { settings }));
const afterDueDay = tskReportRule.evaluate(at('2027-04-01', { settings }));
expect(onDueDay[0].detail).not.toContain('überfällig');
expect(afterDueDay[0].detail).toContain('überfällig');
expect(afterDueDay[0].fingerprintParts).not.toEqual(
onDueDay[0].fingerprintParts,
);
});
it('keeps the item of last year open while its DRAFT exists, and not otherwise', () => {
const draftOfLastYear = notice({
id: 'old',
kind: 'TSK_REPORT',
year: 2026,
status: 'DRAFT',
sentOn: null,
});
const sentThisYear = notice({ kind: 'TSK_REPORT', year: 2027, id: 'cur' });
const withDraft = tskReportRule.evaluate(
at('2027-03-15', { notices: [draftOfLastYear, sentThisYear] }),
);
const withoutRecord = tskReportRule.evaluate(
at('2027-03-15', { notices: [sentThisYear] }),
);
const sentLastYear = tskReportRule.evaluate(
at('2027-03-15', {
notices: [
notice({ kind: 'TSK_REPORT', year: 2026, id: 'old' }),
sentThisYear,
],
}),
);
expect(withDraft.map((finding) => finding.subjectId)).toEqual(['2026']);
expect(withoutRecord).toEqual([]);
expect(sentLastYear).toEqual([]);
});
it('is a Pflicht rule that cannot be acknowledged', () => {
expect(tskReportRule).toMatchObject({
key: 'tsk-report',
area: 'BEHOERDEN',
severity: 'PFLICHT',
acknowledgeable: false,
});
});
});
@@ -0,0 +1,84 @@
import { addDays, daysBetween } from '../../../common/dates';
import { formatDayDe } from '../../../common/format-day';
import { tskReferenceDayOf } from '../../tsk-day';
import type { ReadinessContext } from '../readiness-context';
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
/**
* The due day of a report year: the first date with the configured MM-TT
* on or after the reference day (a due day before the reference day falls
* into the next year).
*/
function dueDayOf(
year: number,
referenceDay: string,
dueMonthDay: string | null,
): string | null {
if (!dueMonthDay) {
return null;
}
const sameYear = tskReferenceDayOf(year, dueMonthDay);
return sameYear >= referenceDay
? sameYear
: tskReferenceDayOf(year + 1, dueMonthDay);
}
function findingFor(
ctx: ReadinessContext,
year: number,
needsDraft: boolean,
): ReadinessFinding | null {
const { today, settings, notices } = ctx;
const reports = notices.filter(
(notice) => notice.kind === 'TSK_REPORT' && notice.year === year,
);
if (reports.some((notice) => notice.status === 'SENT')) {
return null;
}
const draft = reports.find((notice) => notice.status === 'DRAFT');
// Last year counts only while its draft shows that the count was started.
if (needsDraft && !draft) {
return null;
}
const referenceDay = tskReferenceDayOf(year, settings.tskReferenceDay);
if (daysBetween(referenceDay, today) < 0) {
return null;
}
const dueDay = dueDayOf(year, referenceDay, settings.tskReportDueDay);
const overdue = dueDay !== null && daysBetween(dueDay, today) > 0;
const start = `Die TSK-Zählung ${year} ist noch nicht gemeldet`;
return {
subjectId: String(year),
title: `Die TSK-Zählung ${year} melden.`,
detail:
overdue && dueDay
? `${start} und überfällig seit ${formatDayDe(addDays(dueDay, 1), true)}.`
: `${start}.`,
fingerprintParts: { year, draftId: draft?.id ?? null, overdue },
subjects: draft
? [
{
type: 'AUTHORITY_NOTICE',
id: draft.id,
label: `TSK-Zählung ${year} (Entwurf)`,
},
]
: [],
};
}
export const tskReportRule: ReadinessRule = {
key: 'tsk-report',
area: 'BEHOERDEN',
severity: 'PFLICHT',
title: 'Die TSK-Zählung melden.',
legalBasis: 'Landesrecht Bayern (Annahme)',
acknowledgeable: false,
evaluate: (ctx): ReadinessFinding[] => {
const thisYear = Number(ctx.today.slice(0, 4));
return [
findingFor(ctx, thisYear - 1, true),
findingFor(ctx, thisYear, false),
].filter((finding): finding is ReadinessFinding => finding !== null);
},
};
@@ -0,0 +1,31 @@
import { vetOfficeRule } from './vet-office.rule';
import { complianceSettings, readinessContext } from './rule-spec-helpers';
describe('vet-office rule', () => {
it('is open without an office name', () => {
for (const veterinaryOfficeName of [null, '', ' ']) {
const findings = vetOfficeRule.evaluate(
readinessContext({
settings: complianceSettings({ veterinaryOfficeName }),
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
}
});
it('is done with an office name', () => {
expect(vetOfficeRule.evaluate(readinessContext())).toEqual([]);
});
it('is a Prüfen rule without acknowledgement', () => {
expect(vetOfficeRule).toMatchObject({
key: 'vet-office',
area: 'STAMMDATEN',
severity: 'PRUEFEN',
legalBasis: null,
acknowledgeable: false,
});
});
});
@@ -0,0 +1,32 @@
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
import { isBlank } from './rule-helpers';
export const vetOfficeRule: ReadinessRule = {
key: 'vet-office',
area: 'STAMMDATEN',
severity: 'PRUEFEN',
title: 'Das Veterinäramt als Empfänger eintragen.',
legalBasis: null,
acknowledgeable: false,
evaluate: ({ settings }): ReadinessFinding[] => {
if (!isBlank(settings.veterinaryOfficeName)) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail: 'Der Name des Veterinäramts fehlt in den Einstellungen.',
fingerprintParts: { veterinaryOfficeName: false },
subjects: [
{
type: 'COMPLIANCE_SETTINGS',
id: 'singleton',
label: 'Einstellungen',
missing: ['Name des Veterinäramts'],
},
],
},
];
},
};