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]>
This commit is contained in:
1 parent
edc193f128
commit
b912d29166
54 files changed
+5300
-14
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user