Files
ApiCore/ApiCore-Backend/src/compliance/compliance.handlers.ts
T
Nick SzaunigandClaude Opus 5.5 b912d29166
Backend Image / build-and-push (push) Successful in 4m58s
Frontend Image / build-and-push (push) Successful in 7m57s
feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- 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]>
2026-10-15 14:48:57 +02:00

133 lines
4.7 KiB
TypeScript

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