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