- 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]>
133 lines
4.7 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|