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

+50 -3
View File
@@ -198,6 +198,14 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
75. **Eine verteilte Anschrift zählt nur mit höchstens 10 mm Abstand**: Name und Anschrift des Lebensmittelunternehmers dürfen über mehrere Elemente verteilt sein, wenn der senkrechte Abstand jedes Elements zur bisherigen Gruppe höchstens 10 mm beträgt (symmetrisch, gegen die laufende Ober- und Unterkante der Gruppe; die Lesereihenfolge kommt allein aus den Positionen). Der waagerechte Abstand zählt dort nicht. Kosten bei falscher Annahme: Eine sehr weit auseinandergezogene Anschrift gilt als Lücke; zwei nahe, fremde Elemente mit der passenden Wortfolge gelten als Anschrift. Abschnitt 12.4.
76. **Verwerfen einer Behördenanzeige behält die Dokumente**: `cancel` setzt den Entwurf auf `CANCELLED` und gibt den `openKey` frei; es gibt keine Löschroute (Spec 7), angehängte Dokumente (`AUTHORITY_NOTICE`) bleiben bestehen und durch das Recht der Art geschützt (Annahme 70). Kosten bei falscher Annahme: Wer einen Entwurf „löschen“ erwartet, findet ihn als verworfen in der Liste. Abschnitt 12.8.
77. **Etikett-Rechte: derzeit nur ADMIN (Frage an den Inhaber offen)**: `label-templates:read|create|update|deactivate|print` vergibt keine Migration, nur die Katalog-Synchronisation an die damaligen Systemrollen; in der Test-Datenbank halten sie nur ADMIN. Prüfen und Drucken brauchen zusätzlich `bottling:read`. Offen ist, ob BEEKEEPER (und für `read` STAFF und READONLY) Etiketten ansehen und drucken dürfen; vor dem Rollout die tatsächliche Rollenvergabe in Produktion prüfen. Abschnitt 12.6.
78. **Eine Bestandsänderung meldet nur, sie legt keine Anzeige an**: `compliance.herd-change` schreibt eine Meldung (`HINWEIS`, `COMPLIANCE`) an jeden aktiven Nutzer mit `compliance:manage`, nie einen Entwurf (Spec 5.5). Kosten bei falscher Annahme: Wer einen Entwurf erwartet, legt die Änderungsanzeige selbst an. Abschnitt 12.9.
79. **Die TSK-Erinnerung geht auch bei einem offenen Entwurf**: Existiert für das Jahr nur ein Entwurf, geht die Meldung weiter (Link auf den Entwurf, höchstens eine ungelesene je Nutzer); nur eine gesendete Anzeige hält sie still. Kosten bei falscher Annahme: Eine Erinnerung zu viel, solange der Entwurf nicht gesendet ist. Abschnitt 12.9.
80. **Ein Umschalter der Benachrichtigung wirkt erst beim nächsten Ereignis**: Wer `noticeOnColonyCountChange` oder `noticeOnApiaryChange` einschaltet, bekommt eine bereits bestehende Abweichung erst beim nächsten Herd-Ereignis gemeldet. Kosten bei falscher Annahme: Die Meldung kommt später; die Prüfbereitschaft (12.10) zeigt die Abweichung sofort. Abschnitt 12.9.
81. **Maskierte Aufrufer der Prüfbereitschaft sehen die Zählungen**: Ohne `subjectPermission` bleiben `subjects` leer, `more` und Sätze wie „N Stände liegen im Sperrbezirk“ bleiben sichtbar; Namen, Anschriften und Aktenzeichen stehen nie in Titel und Detail. Kosten bei falscher Annahme: Die Anzahl ist für alle mit `compliance:read` sichtbar. Abschnitt 12.10.
82. **Das TSK-PDF ist A4 quer**: Der Zählnachweis hat viele Spalten (eine je Völkerart plus Summe); im Hochformat blieben der Lage-Spalte etwa 49 pt. Erst- und Änderungsanzeige bleiben Hochformat. Kosten bei falscher Annahme: Das Blatt muss gedreht abgelegt werden. Abschnitt 12.8.
83. **Der Ort im Briefkopf kommt aus der Postleitzahl-Zeile**: Aus „…, 34117 Kassel“ wird „Kassel“; ohne Treffer steht nur das Datum, der Ort bleibt leer (kein `FEHLT`). Kosten bei falscher Annahme: Der Ort fehlt neben dem Datum. Abschnitt 12.8.
84. **Die Summe im Anzeigen-PDF ist das gespeicherte `totalColonies`**: Weicht es von der Summe der Stände ab, steht „Gesamtzahl berichtigt (Summe der Stände: n)“; es wird nicht live neu gezählt. Kosten bei falscher Annahme: Ein veraltetes `totalColonies` erscheint im Ausdruck, bis `recount` im Entwurf läuft. Abschnitt 12.8.
85. **Keine der ersten sieben Prüfregeln ist quittierbar**: Weder Pflicht- noch Prüfen-Punkte dieser Stufe lassen sich wegquittieren (Spec 5.7); die Quittung ist Mechanik für spätere weiche Regeln (Task 13). Kosten bei falscher Annahme: Der Inhaber muss die Ursache beheben statt zu quittieren. Abschnitt 12.10.
## 7. Kubernetes
@@ -395,6 +403,8 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
| `calendar-sync.queen-assignment` | `QueenAssigned` | `enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId)`. |
| `calendar-sync.withdrawal` | `TreatmentRecorded`, `TreatmentCorrected` | `enqueueIn(tx, 'WITHDRAWAL', treatmentId)` (Abschnitt 11.8). |
| `webhooks.forward` | erweitert um `QueenAssigned`, `HoneyBatchRecorded` | `queen.replaced`, `batch.created` (siehe „Webhook-Zustellung“). `HoneyBatchCorrected` sendet nichts. |
| `compliance.herd-change` | `ColonyCreated`, `ColonyMoved`, `ColonyStatusChanged` (lebend ↔ beendet), `ApiaryChanged` | HINWEIS „Bestand geändert …“ (Kategorie `COMPLIANCE`, höchstens eine ungelesene je Nutzer), legt nie eine Anzeige an (Abschnitt 12.9). |
| `compliance.tsk-reference-day` | `TskReferenceDayReached` | TSK-Entwurf (falls keiner offen) und WARNUNG „TSK-Stichtag …“; still bei gesendeter Meldung (Abschnitt 12.9). |
**Worker-Schleife `phenology-weather`** (T4b). Takt 3 600 000 ms, kein Weckkanal (keine Outbox); der erste Durchlauf folgt 10 s nach dem Start des Workers (`WEATHER_FIRST_TICK_DELAY_MS`). Ein Takt ruft `WeatherHistoryService.syncOnce(berlinToday(), signal)` auf und holt die fehlenden Tagesmittel der aktiven Stände mit Koordinaten (Ablauf und Regeln: Abschnitt 11.11). Die Schleife schreibt nur `ApiaryWeatherDay`: kein Ereignis, keine Meldung. Die Loops liegen in einer Karte nach Namen; `WorkerLoop.channel` ist optional. Zu den LISTEN-Kanälen kommt `notification_delivery` (Abschnitt 11.13). Der Test `test/worker-runtime.e2e-spec.ts` ersetzt `WEATHER_HISTORY_SOURCE` durch eine Attrappe, weil die Schleife sonst für jeden Stand der geteilten Test-DB den echten Dienst aufriefe.
@@ -721,7 +731,7 @@ Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von `compliance:read` bzw. `veterinary-medicines:read`; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie `COMPLIANCE`, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk.
**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I). Task 10 bis 19 (Anzeige-PDF, Prüfbereitschaft, Handler und Meldungen, Tagesregeln, Oberfläche) folgen und ergänzen diesen Abschnitt.
**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I). Task 13 bis 19 (weitere Prüfregeln, Oberfläche) folgen und ergänzen diesen Abschnitt.
### 12.1 Schema und Rechte
@@ -819,7 +829,7 @@ Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Com
### 12.8 Behördenpaket I: Einstellungen, Bestandsbild, Anzeigen
`ComplianceModule` (`src/compliance/`, in `AppModule`; der Import im Worker folgt mit Task 11) enthält `ComplianceSettingsService`, `AuthorityNoticesService` und die reinen Helfer `tsk-day.ts`, `herd-snapshot.ts`, `herd-status.ts`. Kein Provider hängt am `PermissionResolverService`; das Modul exportiert beide Services.
`ComplianceModule` (`src/compliance/`, in `AppModule` und seit Task 11 auch in `WorkerModule`, Abschnitt 12.9) enthält `ComplianceSettingsService`, `AuthorityNoticesService` und die reinen Helfer `tsk-day.ts`, `herd-snapshot.ts`, `herd-status.ts`. Kein Provider hängt am `PermissionResolverService`; das Modul exportiert beide Services.
- **Einstellungen.** `GET`/`PATCH /compliance/settings` (`compliance:read` / `compliance:manage`). `update` upsertet, sperrt `ComplianceSettings`, liest `before` danach und schreibt das Audit in derselben Transaktion. Prüfungen (400): `tskReferenceDay` und `tskReportDueDay` als `MM-TT` (02-29 und „1-1“ abgelehnt), `tskExcludedColonyTypes` ohne Doppelte, `netQuantityDigitRules` mit 1 bis 10 Regeln, Obergrenzen streng aufsteigend, nur die letzte ohne Grenze, `minMm` 0,5 bis 20; ein fehlender `maxGrams` wird vor der Prüfung zu `null` normalisiert und so gespeichert. Alle Rechts- und Fristwerte sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“.
- **Bestandsbild** (`herdStatusIn(db, now)`): lebende Völker (`LIVING_COLONY_WHERE`) an aktiven Ständen, bei der TSK-Zählung ohne `tskExcludedColonyTypes`; zwei ungesperrte Abfragen genügen für eine Anzeige. `herdDeviation` vergleicht `baseline.totalColonies` mit der aktuellen Zahl und erkennt neue, aufgegebene und verlegte Stände (Adresse, Ort, Koordinaten); ein Umsetzen zwischen Ständen löst nichts aus. `noticeOnColonyCountChange` und `noticeOnApiaryChange` schalten nur `triggered`, die Tatsachen stehen immer da.
- **Bezugsstand-Regeln.** Bezugsstand ist die jüngste `SENT`-Anzeige der Art `REGISTRATION` oder `HERD_CHANGE`, sortiert nach `sentOn`, dann `createdAt`, `id`; TSK-Zählnachweise zählen nicht. Ohne Bezugsstand ist `deviation = null` (Punkt „Erstanzeige fehlt“). `REGISTRATION` entsteht nur ohne Bezugsstand, `HERD_CHANGE` nur mit („Erst die Erstanzeige erfassen.“, 409); `TSK_REPORT` braucht `year`, andere Arten dürfen keines haben (400). Eine wiedergeöffnete Erstanzeige lässt sich erneut senden.
@@ -827,4 +837,41 @@ Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Com
- **`openKey` und Mutex.** Anlegen, `mark-sent` und `reopen` sperren zuerst `ComplianceSettings` (`singleton`), dann die Anzeige (Reihenfolge der Karte). `openKey` (`REGISTRATION` | `HERD_CHANGE` | `TSK_REPORT:<Jahr>`, nur bei `DRAFT`, nullbar eindeutig) lässt höchstens einen offenen Entwurf je Art und Jahr zu; ein zweiter, auch im Rennen, antwortet 409 `{ code: 'DRAFT_EXISTS', existingId }`. Ein `P2002` ohne erklärenden Entwurf wird unverändert weitergeworfen. Die Prüfung auf einen offenen Entwurf entfällt, wenn die Anzeige mit `sentOn` angelegt wird.
- **`sentOn`-Regeln.** Mit `sentOn` beim Anlegen entsteht die Anzeige gleich als `SENT` ohne `openKey` (Weg „bereits angezeigt am …“ für Altbestand). `reopen` setzt `sentOn = null` und `openKey`, scheitert aber bei vorhandenem Entwurf (409 `DRAFT_EXISTS`). `PATCH` (`totalColonies`, `referenceDate`, `sentOn`, `sentVia`, `reference`, `notes`): `sentOn` auf einem Entwurf ist 400 „Ein Entwurf hat kein Sendedatum.“, das Sendedatum einer `SENT`-Anzeige ist nicht löschbar (400), eine `CANCELLED`-Anzeige nicht änderbar (409); fasst ein Patch `sentOn` an, nimmt `update` zuerst den Settings-Mutex.
- **Verwerfen statt Löschen.** `cancel` verwirft nur einen Entwurf (`CANCELLED`, `openKey` frei); es gibt keine Löschroute (Spec 7), angehängte Dokumente bleiben (Annahme 76). `GET /authority-notices?kind=&year=` liefert höchstens die 200 neuesten Einträge (`take: 200`), eine Seitenaufteilung fehlt noch.
- **Endpunkte.** `GET /authority-notices`, `GET …/:id`, `POST /authority-notices` (`@Idempotent`), `PATCH …/:id`, `POST …/:id/recount|mark-sent|reopen|cancel`; Lesen mit `compliance:read`, Schreiben mit `compliance:manage` (Nutzer ohne die Rechte: 403 auf Liste, Einzelanzeige und Einstellungen). Der PDF-Endpunkt `…/:id/pdf` folgt mit Task 10.
- **Endpunkte.** `GET /authority-notices`, `GET …/:id`, `POST /authority-notices` (`@Idempotent`), `PATCH …/:id`, `POST …/:id/recount|mark-sent|reopen|cancel`; Lesen mit `compliance:read`, Schreiben mit `compliance:manage` (Nutzer ohne die Rechte: 403 auf Liste, Einzelanzeige und Einstellungen). Der PDF-Endpunkt `…/:id/pdf` steht im Absatz „PDFs (Task 10)“ am Ende dieses Abschnitts.
**PDFs (Task 10).** Aufbau: reines Modell `pdf/authority-notice-model.ts` (`buildNoticePdfModel`) getrennt vom Renderer `pdf/authority-notice-pdf.ts` (pdfkit, `PDF_MARGIN`, `drawTable`); `noticeHeaderLines` ist rein und einzeln testbar.
- **Endpunkt.** `GET /authority-notices/:id/pdf` (`compliance:read`) liefert `application/pdf`, `Cache-Control: no-store` und `Content-Disposition: attachment; filename="anzeige-<id>.pdf"` (Dateiname nur mit der Id, auch für TSK-Nachweise). `AuthorityNoticesService.pdf(id)` gibt `{ buffer, fileName }`; eine unbekannte Id ist 404.
- **Inhalt.** Erstanzeige „Anzeige der Bienenhaltung (§ 1a BienSeuchV)“ ohne Spalte „vorher“; Änderungsanzeige „Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)“ mit „vorher“ je Stand, Summenzeile und der Liste „Änderungen seit der Anzeige vom {Datum}“ (Stand neu, aufgegeben, Lage geändert, Zahl der Völker). Lage = Adresse, sonst Koordinaten mit fünf Nachkommastellen, sonst Ort, sonst `FEHLT`.
- **TSK-Zählnachweis in A4 quer.** Titel „Zählnachweis Bienenvölker zum Stichtag {Datum}“, eine Spalte je gezählter Völkerart (feste Reihenfolge) plus „Summe“, Zahlenspalten rechtsbündig, Spaltenbreite aus dem breitesten Wortteil (Mindestbreite 40 pt, Umbruch am Bindestrich), „Lage“ bekommt den Rest. Hinweis „Gezählt am {Tag}“, bei Zählung nach dem Stichtag „…, nicht am Stichtag. Bitte prüfen.“; Fußsatz „Ersetzt nicht die Meldung an die Tierseuchenkasse.“; kein Empfängerblock. Erst- und Änderungsanzeige bleiben Hochformat (Annahme 82). Rutscht die Unterschrift bei vielen Ständen auf Seite 2, ist das gewollt (kein Abschneiden).
- **`FEHLT` statt Weglassen.** Halter, Veterinäramt, TSK-Nummer und die Lage eines Standes druckt der Renderer als `FEHLT`; die Registriernummer als „–“ (Spec 8.1). Fehlt beim Halter nur die Anschrift (oder nur der Name), steht `FEHLT` an dieser Stelle („Imkerei Muster, FEHLT“); nur wenn beides leer ist, ist `holder = null` und der Renderer druckt `FEHLT`.
- **Summen aus dem gespeicherten `totalColonies`.** `totals.current` und `totals.previous` sind beide das gespeicherte `totalColonies` (der Anzeige bzw. der Bezugsanzeige); die Zeilen kommen weiter aus dem Bestandsbild. Weicht die Zahl von der Summe der Stände ab, steht vor den Änderungen „Gesamtzahl berichtigt (Summe der Stände: n)“; die Zeile „Zahl der Völker: a auf b“ nutzt dasselbe `totalColonies`. Das PDF druckt, was in der Anzeige gespeichert ist, keinen Live-Stand (Annahme 84).
- **Richtiger Bezugsstand.** Entwurf: jüngste `SENT`-Anzeige (`findBaselineIn`). Gesendete Anzeige: die davor gesendete (`sentOn` kleiner, bei Gleichstand `createdAt` kleiner, andere Id), damit ein altes PDF nicht gegen einen später gesendeten Stand verglichen wird. Halter aus `SiteSettings`, Empfänger und Nummern aus `ComplianceSettings`. Fußzeile „Stand der Daten {updatedAt} · erstellt mit ApiCore“ (Berliner Tag). Das Briefdatum ist der Berliner Tag von `now`; der Ort daneben kommt aus der Postleitzahl-Zeile der Halteranschrift und bleibt leer, wenn keine gefunden wird (Annahme 83).
- **Geteilter Tabellenhelfer.** `src/export/pdf-table.ts` hat zwei additive Optionen: `PdfColumn.align` (rechtsbündige Zahlenspalten) und `drawTable(…, { boldLastRow })` (fette Summenzeile). Standardverhalten und Kopfwiederholung beim Seitenumbruch bleiben unverändert. Nach `drawTable` setzt der Renderer `doc.x = PDF_MARGIN`, sonst liefen Notizen und Fußzeile im rechten Randstreifen.
- Layout-Tests: 15 Stände mit langen Adressen ergeben eine Hochformat-Seite, Notizen und Fußzeile beginnen bei `PDF_MARGIN`, der TSK-Nachweis hat eine Querformat-MediaBox. Beispiel-PDFs wurden gerendert und angesehen (lesbar, Umlaute korrekt).
### 12.9 Hinweise und Tagesauswertung
Task 11 verbindet die Anzeigen mit dem Ereignis-Rückgrat (Abschnitt 10). `ComplianceModule` samt `ComplianceEventHandlers` ist in `AppModule` **und** `WorkerModule`; `worker-runtime.e2e-spec.ts` und `authority-notices-herd.e2e-spec.ts` prüfen, dass `compliance.herd-change` und `compliance.tsk-reference-day` in beiden Registern stehen.
- **`recordForPermissionOnce`.** `NotificationsService.recordForPermissionOnce(permission, input, tx)` schreibt je Thema (`relatedType` + `relatedId`) und Nutzer höchstens eine **ungelesene** Meldung. Ein Advisory-Lock je Thema (`pg_advisory_xact_lock(hashtextextended('notify-once:<Typ>:<Id>', 0))`) ordnet Prüfung und Schreiben gleichzeitiger Transaktionen hintereinander (e2e: zwei parallele Transaktionen schreiben genau eine Zeile je Nutzer). Bewusst kein `dedupeKey`: Das Thema kommt wieder, sobald die Meldung gelesen ist. Empfänger sind aktive Nutzer mit aktiver Rolle und allen Rechten; `planIn` plant die Telegram-Zustellung in derselben Transaktion (Kategorie `COMPLIANCE`, Vorgabe-Kategorie). Texte unter 600 Zeichen, ohne Anschrift; der Link steht in `link`. Themen: `ComplianceItem` / `herd-change` und `ComplianceItem` / `tsk-report:<Jahr>`.
- **`compliance.herd-change`.** Ereignisse: `ColonyCreated`, `ColonyMoved`, `ColonyEnded`, `ColonyStatusChanged` (nur Wechsel zwischen lebend und beendet) und `ApiaryChanged`. Der Handler berechnet über `herdStatusIn` die Abweichung vom Bezugsstand (außerhalb der Sperre, READ COMMITTED: schlimmstenfalls zu früh oder zu spät gemeldet, nie doppelt). Löst sie aus (`noticeOnColonyCountChange`, `noticeOnApiaryChange`), erhält jeder aktive Nutzer mit `compliance:manage` höchstens eine ungelesene Meldung „Bestand geändert: …“ (Priorität `HINWEIS`, Kategorie `COMPLIANCE`, Link `/compliance`). **Er sendet nur eine Meldung und legt nie eine Anzeige an**, auch keinen Entwurf (Spec 5.5, Annahme 78). Ein Stand-Umzug (`ApiaryChanged` mit Ortswechsel) zählt, ein Umbenennen nicht, ein Umsetzen zwischen Ständen löst nichts aus. Der CSV-Import emittiert `ColonyCreated` (Task 7), damit auch importierte Völker die Meldung auslösen.
- **`compliance.tsk-reference-day`.** Ereignis `TskReferenceDayReached`. Unter der Sperre der Zeile `ComplianceSettings` legt der Handler einen TSK-Entwurf für das Jahr an (`createDraftIn`, 12.8), außer es gibt für das Jahr schon eine gesendete Anzeige oder einen offenen Entwurf. Dazu die Meldung „TSK-Stichtag TT.MM.JJJJ: n Völker gezählt. Bitte prüfen und melden.“ (`WARNUNG`, Kategorie `COMPLIANCE`, Link `/compliance/notices/<id>`). **Die Erinnerung geht auch, wenn nur ein Entwurf existiert** (kein neuer Entwurf, Link auf den vorhandenen, höchstens eine ungelesene je Nutzer); **bei einer gesendeten Anzeige bleibt der Handler still** (Annahme 79).
- **Tagesregel `TskReferenceDayReached`.** Aggregat `Compliance`, `aggregateId tsk:<Jahr>`, Payload `{ year, referenceDay }`, Schlüssel `TskReferenceDayReached:tsk:<Jahr>:<Tag>`. Die Regel prüft den Stichtag des **Jahres des Berliner Laufs und des Vorjahres** mit einem **Nachholfenster von 7 Tagen**: Ein verpasster Lauf wird nachgeholt (Lauf am 05.03. nach verpasstem 03.03. ergibt ein Ereignis, einen Entwurf und eine Meldung je Nutzer). Der Tag ist der Berliner Kalendertag; den Stichtag liest die Regel aus der Zeile `ComplianceSettings` (`tskReferenceDay`). Im Code steht kein Vorgabewert: Fehlt die Zeile, legt `complianceSettingsIn(db)` sie mit dem Schema-Vorgabewert (`01-01`) an; die Migration `20261016100500` sät sie. Zweimal am Stichtag gelaufen heißt: ein Ereignis, ein Entwurf.
- **Umschalter wirkt erst beim nächsten Ereignis.** Schaltet der Inhaber `noticeOnColonyCountChange` oder `noticeOnApiaryChange` von aus auf an und besteht bereits eine Abweichung, kommt die Meldung erst beim nächsten Herd-Ereignis; die Einstellung löst selbst kein Ereignis aus und kein Handler hört auf Einstellungsänderungen (Annahme 80).
- Die e2e-Dateien der Stufe sichern und stellen die geseedete Zeile `ComplianceSettings` wieder her (nie `deleteMany` auf der Tabelle) und räumen nur selbst angelegte Anzeigen ab.
### 12.10 Prüfbereitschaft I
Task 12 liefert den Rahmen der Prüfbereitschaft mit den ersten sieben Regeln; die übrigen elf Regeln folgen mit Task 13.
- **Modul und Worker-Schutz.** `ReadinessModule` (`src/compliance/readiness/`) ist **nur in `AppModule`**. Der Controller hängt am `PermissionResolverService` und darf nicht in den Worker-Graphen. `readiness-worker-graph.spec.ts` läuft über die `imports` des `WorkerModule` und prüft, dass weder `ReadinessModule` noch `ReadinessController` darin liegen.
- **`READINESS_RULES_TOKEN`.** Die Regelliste (`READINESS_RULES` in `rules/index.ts`) wird unter dem Token bereitgestellt; `ReadinessService` und `AcknowledgementsService` bekommen sie per Konstruktor. Unit-Specs übergeben Fake-Regeln direkt, die e2e überschreibt den Provider (`[...READINESS_RULES, softRule]`), ohne die geteilte Liste zu verändern. `rules/index.spec.ts` prüft eindeutige Schlüssel und eindeutige `subjectId` je Regel.
- **Sieben reine Regeln.** Jede Regel ist eine pure Funktion `evaluate(ctx)`: `holder-data`, `food-business-data` (Pflicht), `vet-office` (Prüfen), `registration` (keine `SENT`-Erstanzeige), `herd-change` (Abweichung zum Bezugsstand laut Einstellung, nie ohne Bezugsstand), `tsk-report` (Stichtag erreicht ohne `SENT`-Zählung, „überfällig“ nach `tskReportDueDay`; das Vorjahr bleibt offen, solange sein Entwurf existiert) und `restriction-active` (ein Punkt mit allen gesperrten Ständen). Eine Regel ohne Befund ergibt genau einen erledigten Punkt (`DONE`, `subjectId` `global`). **Keine dieser sieben Regeln ist quittierbar** (Spec 5.7, Annahme 85); die Quittierung ist als Mechanik gebaut (`acknowledgeable`) und für weiche Regeln aus Task 13 gedacht, ein Test mit einer echten quittierbaren Regel gehört dorthin.
- **Live-Auswertung, nur Quittungen gespeichert.** `GET /compliance/readiness` lädt einen Kontext (Stammdaten, Einstellungen, Bestandsstatus, Anzeigen, aktive Sperren, jeweils begrenzt) und führt jede Regel aus; gespeichert wird allein `ComplianceAcknowledgement`. Tage (`today`, Stichtag, „überfällig“) sind Berliner Kalendertage; die Sperre zählt über `isRestrictionActive`, dieselbe Funktion wie der Wächter an den Völker-Pfaden (12.7), damit Anzeige und Sperre nie auseinanderlaufen.
- **Fingerabdruck.** sha256 über kanonisches JSON der für den Befund maßgeblichen Werte; Listen (z. B. `apiariesAdded/Removed/Relocated` bei `herd-change`) werden vorher **sortiert**, damit dieselben Daten in anderer Reihenfolge denselben Abdruck ergeben (Test).
- **Quittung, Rücknahme, veraltet.** Quittiert wird mit Notiz (10 bis 2000 Zeichen); der Fingerabdruck muss zum jetzt berechneten Punkt passen (sonst 409). Eine Quittung gilt nur, solange sie nicht zurückgenommen ist **und** ihr Fingerabdruck noch passt: Eine **zurückgenommene oder veraltete Quittung öffnet den Punkt wieder**. Quittieren und Zurücknehmen schreiben Audit (`CREATE`/`UPDATE` an `ComplianceAcknowledgement`); das Zurücknehmen sperrt die Zeile und setzt `revokedAt` in SQL.
- **Rot und Schwere.** Jeder offene Punkt ist rot; die Schwere steht als Wort („Pflicht“ oder „Prüfen“) daneben. Gesamtstatus `READY`, wenn kein Punkt offen ist, sonst `OPEN`.
- **Höchstens 50 Subjekte.** Betroffene Objekte stehen in `subjects` (höchstens 50), der Rest wird in `more` gezählt.
- **Maskierung je `subjectPermission`.** Titel und Detailtexte enthalten nie Namen, Anschriften, Aktenzeichen oder Notizen. Hat der Aufrufer das Recht der Regel (`subjectPermission`, hier `apiaries:read`) nicht, bleiben `subjects` leer und `more` zählt sie; `summary` wertet ohne Subjekte aus. Der Stammdaten-Punkt nennt nur die Lücke („Name“, „Anschrift“), nie den Wert. **Die Zählungen bleiben sichtbar** („N Stände liegen im Sperrbezirk“), auch maskiert; die Seite verlangt ohnehin `compliance:read`, nur die Namen hängen am Recht (Annahme 81). Die Arzneimittel-Regeln aus Task 13 setzen `subjectPermission: 'veterinary-medicines:read'` und halten Mittel, Packung, Lieferant und Tierarzt aus `detail` und `title` heraus.
- **Lesepfade schreiben nicht.** `loadReadinessContext` liest `SiteSettings` per `findUnique`; fehlt die Zeile, gilt ein leerer In-Memory-Standard (alle Felder `null`, `holder-data` meldet die Lücken), kein Upsert. `ComplianceSettings` kommt aus `herdStatusIn` (12.8), das nur anlegt, wenn die von der Migration gesäte Zeile fehlt.
- **Registrierungs-Bezug separat.** Die jüngste `SENT`-Erstanzeige (nach `sentOn`, `createdAt`, `id`) und der jüngste `DRAFT` werden in **eigenen Abfragen** gelesen, nicht aus einer begrenzten Gesamtliste; nur der TSK-Teil ist auf 100 begrenzt. Der Bezugsstand für `herd-change` kommt unverändert aus `herdStatusIn` (`findBaselineIn`).
- **Endpunkte.** `GET /compliance/readiness`, `GET /compliance/readiness/summary` (`compliance:read`); `POST /compliance/acknowledgements` (`@Idempotent`) und `POST /compliance/acknowledgements/:id/revoke` (`compliance:manage`). BEEKEEPER und ADMIN alles, STAFF und READONLY nur Lesen, API_CLIENT nichts.
- Hinweis für die Oberfläche (Task 14): Die Zuordnung „Schwere → Wort“ und „`OPEN` → rot mit Symbol und Wort“ braucht einen eigenen Test.
+2
View File
@@ -62,6 +62,7 @@ import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
import { ComplianceModule } from './compliance/compliance.module';
import { ReadinessModule } from './compliance/readiness/readiness.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -136,6 +137,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
SystemModule,
VeterinaryMedicinesModule,
ComplianceModule,
ReadinessModule,
],
controllers: [],
providers: [
@@ -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'],
},
],
},
];
},
};
+22 -5
View File
@@ -9,6 +9,13 @@ export const PDF_MARGIN = 36;
export interface PdfColumn {
header: string;
width: number;
/** Numbers read better right-aligned; the default is left. */
align?: 'left' | 'right';
}
export interface DrawTableOptions {
/** Draws the last row in bold, e.g. a totals row. */
boldLastRow?: boolean;
}
export function formatDay(date: Date): string {
@@ -55,11 +62,12 @@ export function drawTable(
doc: PDFKit.PDFDocument,
columns: PdfColumn[],
rows: string[][],
options: DrawTableOptions = {},
): void {
const left = PDF_MARGIN;
const bottomLimit = doc.page.height - PDF_MARGIN - 20;
const drawRow = (cells: string[], bold: boolean) => {
const drawRow = (cells: string[], isHeader: boolean, bold = isHeader) => {
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
const heights = cells.map((cell, index) =>
doc.heightOfString(cell, { width: columns[index].width - 4 }),
@@ -67,18 +75,21 @@ export function drawTable(
const rowHeight = Math.max(...heights, 10) + 4;
if (doc.y + rowHeight > bottomLimit) {
doc.addPage();
if (!bold) {
if (!isHeader) {
drawRow(
columns.map((c) => c.header),
true,
);
doc.font('Helvetica').fontSize(8);
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
}
}
const top = doc.y;
let x = left;
cells.forEach((cell, index) => {
doc.text(cell, x + 2, top + 2, { width: columns[index].width - 4 });
doc.text(cell, x + 2, top + 2, {
width: columns[index].width - 4,
align: columns[index].align ?? 'left',
});
x += columns[index].width;
});
doc
@@ -102,6 +113,12 @@ export function drawTable(
doc.moveDown(0.5);
return;
}
for (const row of rows) drawRow(row, false);
rows.forEach((row, index) =>
drawRow(
row,
false,
options.boldLastRow === true && index === rows.length - 1,
),
);
doc.moveDown(0.5);
}
@@ -43,6 +43,11 @@ interface FakeRows {
varroa?: { id: string; colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
/**
* The ComplianceSettings reference day (MM-TT); null: no settings row.
* Without a value a day far from the other tests' days is used.
*/
tskReferenceDay?: string | null;
/** Stored rule configs by trigger type; others read as the built-in setting. */
rules?: Record<string, object>;
}
@@ -87,6 +92,20 @@ function fakeDb(rows: FakeRows): FakeDb {
})),
),
},
complianceSettings: {
findUnique: jest.fn().mockResolvedValue(
rows.tskReferenceDay === null
? null
: {
id: 'singleton',
tskReferenceDay: rows.tskReferenceDay ?? '10-10',
},
),
// A missing row is created with the schema default.
upsert: jest
.fn()
.mockResolvedValue({ id: 'singleton', tskReferenceDay: '01-01' }),
},
// No stored phenology: every apiary gets the calendar state.
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: {
@@ -154,9 +173,60 @@ describe('daily evaluation rules', () => {
'QueenAged',
'VarroaMeasurementDue',
'InspectionOverdue',
'TskReferenceDayReached',
]);
});
describe('TskReferenceDayReached', () => {
const only = (events: DailyEvent[]) =>
events.filter((event) => event.type === 'TskReferenceDayReached');
it('emits TskReferenceDayReached on the configured day', async () => {
const { db } = fakeDb({ tskReferenceDay: '03-15' });
expect(only(await eventsOn(db, '2027-03-15'))).toEqual([
{
type: 'TskReferenceDayReached',
aggregateType: 'Compliance',
aggregateId: 'tsk:2027',
payload: { year: 2027, referenceDay: '2027-03-15' },
dedupeKey: 'TskReferenceDayReached:tsk:2027:2027-03-15',
},
]);
expect(only(await eventsOn(db, '2027-03-14'))).toEqual([]);
});
it('catches up within 7 days exactly once', async () => {
const { db } = fakeDb({ tskReferenceDay: '03-15' });
const store = await runDaily(db, daysBetween('2027-03-15', '2027-03-22'));
expect(keysOf(store, 'TskReferenceDayReached')).toEqual([
'TskReferenceDayReached:tsk:2027:2027-03-15',
]);
expect(only(await eventsOn(db, '2027-03-23'))).toEqual([]);
});
it("covers the previous year's day in early January", async () => {
const { db } = fakeDb({ tskReferenceDay: '12-30' });
const events = only(await eventsOn(db, '2027-01-03'));
expect(events.map((event) => event.payload)).toEqual([
{ year: 2026, referenceDay: '2026-12-30' },
]);
});
it('creates the settings row with its default 01-01 when it is missing', async () => {
const { db } = fakeDb({ tskReferenceDay: null });
const events = only(await eventsOn(db, '2027-01-04'));
expect(events.map((event) => event.payload)).toEqual([
{ year: 2027, referenceDay: '2027-01-01' },
]);
});
});
describe('withdrawalEndDay', () => {
it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
expect(
@@ -387,7 +457,8 @@ describe('daily evaluation rules', () => {
});
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
const { db, colonyFindMany } = fakeDb({});
// The TSK day lies outside every window this test runs.
const { db, colonyFindMany } = fakeDb({ tskReferenceDay: '03-03' });
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
let assigned = false;
colonyFindMany.mockImplementation(
@@ -1,6 +1,8 @@
import { TaskStatus, Treatment } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { complianceSettingsIn } from '../compliance/herd-status';
import { tskReferenceDayOf } from '../compliance/tsk-day';
import { addDays, berlinDayOf } from '../common/dates';
import {
AnyNewDomainEvent,
@@ -26,7 +28,8 @@ export type DailyEventType =
| 'TaskDue'
| 'QueenAged'
| 'VarroaMeasurementDue'
| 'InspectionOverdue';
| 'InspectionOverdue'
| 'TskReferenceDayReached';
/** An event as the job records it: always with its daily dedupe key. */
export type DailyEvent<T extends DailyEventType = DailyEventType> =
@@ -381,6 +384,32 @@ const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
},
};
/**
* The TSK reference day (ComplianceSettings.tskReferenceDay, MM-TT) of the
* run's year and of the year before: in early January the previous year's
* day can still lie inside the catch-up window. One event per year.
*/
const tskReferenceDay: DailyRule<'TskReferenceDayReached'> = {
type: 'TskReferenceDayReached',
async occurrences(db, _today, { day }) {
// The seeded singleton; a missing row is created with the schema default.
const { tskReferenceDay: monthDay } = await complianceSettingsIn(db);
const runYear = Number(day.slice(0, 4));
return [runYear - 1, runYear].map((year) => {
const referenceDay = tskReferenceDayOf(year, monthDay);
return {
day: referenceDay,
event: {
type: 'TskReferenceDayReached',
aggregateType: 'Compliance',
aggregateId: `tsk:${year}`,
payload: { year, referenceDay },
},
};
});
},
};
/** The rules of the daily evaluation, one row per time-based event type. */
export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
withdrawalEnded,
@@ -388,4 +417,5 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
queenAged,
varroaDue,
inspectionOverdue,
tskReferenceDay,
];
@@ -50,6 +50,12 @@ describe('TimeEventsStep', () => {
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
// A reference day far from the days of these tests.
complianceSettings: {
findUnique: jest
.fn()
.mockResolvedValue({ id: 'singleton', tskReferenceDay: '10-10' }),
},
} as unknown as PrismaService;
step = new TimeEventsStep(
prisma,
@@ -73,6 +79,7 @@ describe('TimeEventsStep', () => {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
});
});
@@ -112,6 +119,7 @@ describe('TimeEventsStep', () => {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
});
});
@@ -151,6 +159,7 @@ describe('TimeEventsStep', () => {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
},
});
expect(recordAll).toHaveBeenCalledWith(tx, [
@@ -330,6 +330,163 @@ describe('NotificationsService', () => {
});
});
describe('recordForPermissionOnce', () => {
const input = {
priority: 'HINWEIS' as const,
category: 'COMPLIANCE' as const,
message: 'Bestand geändert',
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
link: '/compliance',
};
let calls: string[];
let tx: {
$executeRaw: jest.Mock;
user: { findMany: jest.Mock };
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
};
beforeEach(() => {
calls = [];
tx = {
$executeRaw: jest.fn().mockImplementation(() => {
calls.push('lock');
return Promise.resolve(1);
}),
user: {
// Like the database: the id filter narrows the three eligible users.
findMany: jest
.fn()
.mockImplementation(
({ where }: { where: { id?: { in: string[] } } }) =>
Promise.resolve(
['u1', 'u2', 'u3']
.filter((id) => !where.id || where.id.in.includes(id))
.map((id) => ({ id })),
),
),
},
notification: {
findMany: jest.fn().mockImplementation(() => {
calls.push('unread');
return Promise.resolve([{ userId: 'u2' }]);
}),
createManyAndReturn: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
),
},
};
});
it('skips users with an unread notification of the same topic', async () => {
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
'u1',
'u3',
]);
expect(tx.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
read: false,
}) as unknown,
}),
);
});
it('writes again after the user read the earlier one', async () => {
tx.notification.findMany.mockResolvedValue([]);
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(rows).toHaveLength(3);
});
it('takes the advisory lock before reading the unread rows', async () => {
await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(calls).toEqual(['lock', 'unread']);
});
it('stores no dedupe key and the recipient permission', async () => {
await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
const [arg] = tx.notification.createManyAndReturn.mock.calls[0] as [
CreateManyArg,
];
expect(arg.data.map((row) => row.dedupeKey)).toEqual([null, null]);
expect(arg.data[0].requiredPermissions).toEqual(['compliance:manage']);
});
it('plans the Telegram delivery for the new rows only', async () => {
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(tx, rows);
expect(rows).toHaveLength(2);
});
it('writes nothing and plans nothing when everybody has an unread one', async () => {
tx.notification.findMany.mockResolvedValue([
{ userId: 'u1' },
{ userId: 'u2' },
{ userId: 'u3' },
]);
await expect(
service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
),
).resolves.toEqual([]);
expect(tx.notification.createManyAndReturn).not.toHaveBeenCalled();
expect(planner.planIn).not.toHaveBeenCalled();
});
it('throws for an empty permission list instead of broadcasting', async () => {
await expect(
service.recordForPermissionOnce([], input, tx as never),
).rejects.toThrow();
});
});
it('recordForPermission is unchanged: it never reads unread rows', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.recordForPermission('compliance:manage', {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(prisma.notification.findMany).not.toHaveBeenCalled();
});
describe('recordForUsers', () => {
it('skips users missing a required permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
@@ -149,6 +149,53 @@ export class NotificationsService {
return this.fanOut(tx ?? this.prisma, keys, input, undefined);
}
/**
* Like recordForPermission, but a user who still has an unread notification
* of the same topic (relatedType + relatedId) gets no second one. There is
* deliberately no dedupe key: the topic comes back once the user has read
* the earlier notice. An advisory lock per topic serialises the check and
* the write of concurrent transactions; it is held until the caller commits.
*/
async recordForPermissionOnce(
permission: PermissionKey | readonly PermissionKey[],
input: Omit<FanOutNotificationInput, 'dedupeKeyPrefix'> & {
relatedType: string;
relatedId: string;
},
tx: Prisma.TransactionClient,
): Promise<Notification[]> {
const recipientKeys =
typeof permission === 'string' ? [permission] : permission;
if (recipientKeys.length === 0) {
throw new Error('recordForPermissionOnce needs at least one permission');
}
const keys = uniqueSorted([
...recipientKeys,
...(input.requiredPermissions ?? []),
]);
const { relatedType, relatedId } = input;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`notify-once:${relatedType}:${relatedId}`}, 0))`;
const recipients = await this.recipients(tx, keys, undefined);
if (recipients.length === 0) {
return [];
}
const unread = await tx.notification.findMany({
where: {
userId: { in: recipients },
relatedType,
relatedId,
read: false,
},
select: { userId: true },
});
const hasUnread = new Set(unread.map((row) => row.userId));
const fresh = recipients.filter((userId) => !hasUnread.has(userId));
if (fresh.length === 0) {
return [];
}
return this.fanOut(tx, keys, input, fresh);
}
/**
* The own notifications, newest first, at most 100. A row whose
* requiredPermissions the user no longer holds (role changed after the
@@ -5,6 +5,7 @@ import { JobPermissionsModule } from '../auth/permissions/permission-resolver.mo
import { AutomationModule } from '../automation/automation.module';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
import { ComplianceModule } from '../compliance/compliance.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
@@ -41,6 +42,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
AutopilotModule,
PhenologyModule,
WithdrawalModule,
ComplianceModule,
RemindersModule,
TelegramModule,
WorkerRuntimeModule,
@@ -0,0 +1,340 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { berlinToday } from '../src/common/dates';
import { findBaselineIn, herdStatusIn } from '../src/compliance/herd-status';
import { NotificationsService } from '../src/notifications/notifications.service';
import { DomainEventRegistry } from '../src/domain-events/domain-event-registry';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
interface NoticeBody {
id: string;
totalColonies: number;
}
interface ColonyBody {
id: string;
}
/**
* The herd-change notification (T6 task 11): one unread notification per
* permitted user and topic. "Permitted" are the users this file creates with
* compliance:manage; other users of the shared test database do not count.
*/
describe('Authority notices: herd change notification (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = Date.now();
const password = 'SuperSecret123!';
const managerEmails = [
`herd-e2e-manager-a-${suffix}@example.com`,
`herd-e2e-manager-b-${suffix}@example.com`,
];
const staffEmail = `herd-e2e-staff-${suffix}@example.com`;
let managerIds: string[];
let staffId: string;
let managerToken: string;
let apiaryId: string;
let keyCounter = 0;
const colonyIds: string[] = [];
/** The notices this file created: the only ones it deletes. */
const noticeIds: string[] = [];
/** The shared settings row as found; restored in afterAll. */
let savedSettings: {
noticeOnColonyCountChange: boolean;
noticeOnApiaryChange: boolean;
};
const ONCE_TOPIC = `herd-e2e-once-${suffix}`;
const server = () => app.getHttpServer();
const as = (token: string) => ({ Authorization: `Bearer ${token}` });
const nextKey = () =>
`herd-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const create = (email: string, roleKey: string) =>
prisma.user.create({
data: {
email,
passwordHash,
name: roleKey,
role: { connect: { key: roleKey } },
},
});
managerIds = [];
for (const email of managerEmails) {
managerIds.push((await create(email, 'BEEKEEPER')).id);
}
staffId = (await create(staffEmail, 'STAFF')).id;
const settings = await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
savedSettings = {
noticeOnColonyCountChange: settings.noticeOnColonyCountChange,
noticeOnApiaryChange: settings.noticeOnApiaryChange,
};
managerToken = (await login(app, managerEmails[0], password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Herd Stand ${suffix}` } })
).id;
colonyIds.push(
(
await prisma.colony.create({
data: {
code: `H-${suffix}-0`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id,
);
});
beforeEach(async () => {
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { noticeOnColonyCountChange: true, noticeOnApiaryChange: true },
});
await prisma.notification.deleteMany({
where: { userId: { in: [...managerIds, staffId] } },
});
});
afterEach(async () => {
// The shared database holds other suites' notices: only own ids go.
await prisma.authorityNotice.deleteMany({
where: { id: { in: noticeIds.splice(0) } },
});
await prisma.notification.deleteMany({
where: { relatedType: 'ComplianceItem', relatedId: ONCE_TOPIC },
});
});
afterAll(async () => {
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: savedSettings,
});
const userIds = [...managerIds, staffId];
await prisma.notification.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { apiaryId } });
await prisma.domainEvent.deleteMany({ where: { aggregateId: apiaryId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
/** A notice sent today is the baseline: the current herd. */
async function sendBaseline(): Promise<void> {
const kind = (await findBaselineIn(prisma))
? 'HERD_CHANGE'
: 'REGISTRATION';
const response = await request(server())
.post('/api/v1/authority-notices')
.set(as(managerToken))
.set('Idempotency-Key', nextKey())
.send({ kind, sentOn: berlinToday() })
.expect(201);
noticeIds.push((response.body as NoticeBody).id);
}
async function addColony(): Promise<string> {
const response = await request(server())
.post('/api/v1/colonies')
.set(as(managerToken))
.send({
code: `H-${suffix}-${colonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
const { id } = response.body as ColonyBody;
colonyIds.push(id);
await drainDomainEvents(app, [id]);
return id;
}
const unreadOf = (userIds: string[]) =>
prisma.notification.findMany({
where: {
userId: { in: userIds },
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
read: false,
},
select: { userId: true, category: true, priority: true, message: true },
});
it('registers both handlers in the API', () => {
expect(app.get(DomainEventRegistry).handlerIds()).toEqual(
expect.arrayContaining([
'compliance.herd-change',
'compliance.tsk-reference-day',
]),
);
});
it('a new colony changes the herd: after draining one unread notification per permitted user', async () => {
await sendBaseline();
await addColony();
const unread = await unreadOf(managerIds);
expect(unread.map((row) => row.userId).sort()).toEqual(
[...managerIds].sort(),
);
expect(unread[0]).toMatchObject({
category: 'COMPLIANCE',
priority: 'HINWEIS',
});
expect(unread[0].message).toMatch(
/^Bestand geändert: jetzt \d+ Völker an \d+ Ständen\. Angezeigt: \d+ an \d+\. Anzeige ans Veterinäramt prüfen\.$/,
);
});
it('a second colony within one unread notice adds no second notification', async () => {
await sendBaseline();
await addColony();
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', async () => {
await sendBaseline();
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
const created = await request(server())
.post('/api/v1/authority-notices')
.set(as(managerToken))
.set('Idempotency-Key', nextKey())
.send({ kind: 'HERD_CHANGE' })
.expect(201);
const notice = created.body as NoticeBody;
noticeIds.push(notice.id);
await request(server())
.post(`/api/v1/authority-notices/${notice.id}/mark-sent`)
.set(as(managerToken))
.send({})
.expect(201);
await prisma.notification.updateMany({
where: { userId: { in: managerIds } },
data: { read: true },
});
// The sent notice is the new baseline: nothing deviates any more.
const status = await herdStatusIn(prisma, new Date());
expect(status.deviation?.triggered).toBe(false);
expect(await unreadOf(managerIds)).toHaveLength(0);
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('a user without compliance:manage gets no herd notification', async () => {
await sendBaseline();
await addColony();
expect(await unreadOf([staffId])).toHaveLength(0);
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
async function patchApiary(body: Record<string, string>): Promise<void> {
await request(server())
.patch(`/api/v1/apiaries/${apiaryId}`)
.set(as(managerToken))
.send(body)
.expect(200);
await drainDomainEvents(app, [apiaryId]);
}
it('an apiary move (LOCATION_CHANGED) counts', async () => {
await sendBaseline();
await patchApiary({ location: `Neuer Standort ${suffix}` });
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('a rename does not count', async () => {
await sendBaseline();
await patchApiary({ name: `Herd Stand umbenannt ${suffix}` });
expect(await unreadOf(managerIds)).toHaveLength(0);
});
it('noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies', async () => {
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { noticeOnColonyCountChange: false },
});
await sendBaseline();
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(0);
await patchApiary({ location: `Ort wieder neu ${suffix}` });
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('two parallel recordForPermissionOnce transactions write one row per user', async () => {
const notifications = app.get(NotificationsService);
const once = () =>
prisma.$transaction((tx) =>
notifications.recordForPermissionOnce(
'compliance:manage',
{
priority: 'INFO',
category: 'COMPLIANCE',
message: 'Parallel',
relatedType: 'ComplianceItem',
relatedId: ONCE_TOPIC,
link: '/compliance',
},
tx,
),
);
await Promise.all([once(), once()]);
const rows = await prisma.notification.groupBy({
by: ['userId'],
where: {
relatedType: 'ComplianceItem',
relatedId: ONCE_TOPIC,
read: false,
},
_count: { _all: true },
});
expect(rows.length).toBeGreaterThanOrEqual(managerIds.length);
for (const row of rows) {
expect(row._count._all).toBe(1);
}
expect(rows.map((row) => row.userId)).toEqual(
expect.arrayContaining(managerIds),
);
expect(rows.map((row) => row.userId)).not.toContain(staffId);
});
});
@@ -373,4 +373,56 @@ describe('Authority notices (e2e)', () => {
.send({ tskReferenceDay: '01-01' })
.expect(200);
});
it('GET /authority-notices/:id/pdf answers application/pdf with Cache-Control no-store and the file name anzeige-<id>.pdf', async () => {
const created = await request(server())
.post('/api/v1/authority-notices')
.set(as(beekeeperToken))
.set('Idempotency-Key', nextKey())
.send({ kind: 'TSK_REPORT', year: 2027 })
.expect(201);
const id = (created.body as NoticeBody).id;
const response = await request(server())
.get(`/api/v1/authority-notices/${id}/pdf`)
.set(as(staffToken))
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
})
.expect(200);
expect(response.headers['content-type']).toContain('application/pdf');
expect(response.headers['cache-control']).toContain('no-store');
expect(response.headers['content-disposition']).toBe(
`attachment; filename="anzeige-${id}.pdf"`,
);
expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
'%PDF',
);
await request(server())
.get('/api/v1/authority-notices/does-not-exist/pdf')
.set(as(staffToken))
.expect(404);
});
it('the pdf route needs compliance:read', async () => {
const created = await request(server())
.post('/api/v1/authority-notices')
.set(as(beekeeperToken))
.set('Idempotency-Key', nextKey())
.send({ kind: 'REGISTRATION' })
.expect(201);
const id = (created.body as NoticeBody).id;
await request(server())
.get(`/api/v1/authority-notices/${id}/pdf`)
.set(as(noRightsToken))
.expect(403);
await request(server())
.get(`/api/v1/authority-notices/${id}/pdf`)
.expect(401);
});
});
@@ -0,0 +1,477 @@
import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { berlinToday, dayToDate } from '../src/common/dates';
import type {
ReadinessItem,
ReadinessReport,
ReadinessRule,
} from '../src/compliance/readiness/readiness.types';
import {
READINESS_RULES,
READINESS_RULES_TOKEN,
} from '../src/compliance/readiness/rules';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/app.config';
import { THROTTLER_NAMESPACE } from '../src/common/throttler/prisma-throttler.storage';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
const READINESS = '/api/v1/compliance/readiness';
const ACKNOWLEDGEMENTS = '/api/v1/compliance/acknowledgements';
const SOFT_KEY = 'e2e-soft-check';
interface SiteFields {
holderName: string | null;
holderAddress: string | null;
foodBusinessName: string | null;
foodBusinessAddress: string | null;
}
describe('Compliance readiness (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = Date.now();
const password = 'SuperSecret123!';
const tokens: Record<string, string> = {};
let apiaryOnlyRoleId: string;
let apiaryId: string;
let keyCounter = 0;
let softValue = 1;
let originalSite: SiteFields;
let originalVetOffice: string | null | undefined;
// Only rows this file created are ever deleted (the database is shared).
const noticeIds: string[] = [];
const restrictionIds: string[] = [];
const acknowledgementIds: string[] = [];
// A rule that can be acknowledged: the real ones of this task cannot, the
// later ones will. It gives the acknowledgement flow a real database.
const softRule: ReadinessRule = {
key: SOFT_KEY,
area: 'ETIKETTEN',
severity: 'PRUEFEN',
title: 'Testpunkt',
legalBasis: null,
acknowledgeable: true,
evaluate: () => [
{
subjectId: 'global',
detail: 'Bitte prüfen.',
fingerprintParts: { value: softValue },
subjects: [],
},
],
};
const server = () => app.getHttpServer();
const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` });
const nextKey = () =>
`readiness-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
async function report(user = 'beekeeper'): Promise<ReadinessReport> {
const response = await request(server()).get(READINESS).set(as(user));
expect(response.status).toBe(200);
return response.body as ReadinessReport;
}
function itemOf(
body: ReadinessReport,
key: string,
subjectId = 'global',
): ReadinessItem {
const found = body.items.find(
(item) => item.key === key && item.subjectId === subjectId,
);
if (!found) {
throw new Error(`No readiness item ${key}/${subjectId}`);
}
return found;
}
function acknowledge(body: Record<string, string>, user = 'beekeeper') {
return request(server())
.post(ACKNOWLEDGEMENTS)
.set(as(user))
.set('Idempotency-Key', nextKey())
.send(body);
}
async function createUser(
name: string,
roleWhere: { key: string } | { id: string },
) {
const email = `readiness-e2e-${name}-${suffix}@example.com`;
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name,
role: { connect: roleWhere },
},
});
tokens[name] = (await login(app, email, password)).accessToken;
}
// Settings rows are saved in beforeAll and put back in afterAll, never
// deleted: other suites rely on the seeded rows.
async function setSite(data: SiteFields) {
await prisma.siteSettings.upsert({
where: { id: 'singleton' },
update: data,
create: { id: 'singleton', ...data },
});
}
async function setVetOffice(name: string | null) {
await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: { veterinaryOfficeName: name },
create: { id: 'singleton', veterinaryOfficeName: name },
});
}
async function removeOwnRows() {
await prisma.complianceAcknowledgement.deleteMany({
where: { id: { in: acknowledgementIds } },
});
await prisma.authorityNotice.deleteMany({
where: { id: { in: noticeIds } },
});
await prisma.apiaryRestriction.deleteMany({
where: { id: { in: restrictionIds } },
});
noticeIds.length = 0;
restrictionIds.length = 0;
acknowledgementIds.length = 0;
}
beforeAll(async () => {
// The module gets the soft test rule through its token; the shared list
// stays untouched.
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(THROTTLER_NAMESPACE)
.useValue(randomUUID())
.overrideProvider(READINESS_RULES_TOKEN)
.useValue([...READINESS_RULES, softRule])
.compile();
const nest = moduleFixture.createNestApplication<NestExpressApplication>();
configureApp(nest);
await nest.init();
app = nest;
prisma = app.get(PrismaService);
originalVetOffice = (
await prisma.complianceSettings.findUnique({ where: { id: 'singleton' } })
)?.veterinaryOfficeName;
const site = await prisma.siteSettings.findUnique({
where: { id: 'singleton' },
});
originalSite = {
holderName: site?.holderName ?? null,
holderAddress: site?.holderAddress ?? null,
foodBusinessName: site?.foodBusinessName ?? null,
foodBusinessAddress: site?.foodBusinessAddress ?? null,
};
await createUser('beekeeper', { key: 'BEEKEEPER' });
await createUser('staff', { key: 'STAFF' });
await createUser('readonly', { key: 'READONLY' });
await createUser('apiclient', { key: 'API_CLIENT' });
// compliance:read only, without apiaries:read.
const role = await prisma.role.create({
data: { key: `READINESS_E2E_${suffix}`, name: 'Nur Prüfbereitschaft' },
});
apiaryOnlyRoleId = role.id;
const permission = await prisma.permission.findFirstOrThrow({
where: { key: 'compliance:read' },
});
await prisma.rolePermission.create({
data: { roleId: role.id, permissionId: permission.id },
});
await createUser('complianceonly', { id: role.id });
const apiary = await prisma.apiary.create({
data: { name: `Readiness Stand ${suffix}` },
});
apiaryId = apiary.id;
});
beforeEach(async () => {
await removeOwnRows();
softValue = 1;
});
afterAll(async () => {
await removeOwnRows();
await setSite(originalSite);
if (originalVetOffice !== undefined) {
await setVetOffice(originalVetOffice);
}
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
const mine = {
email: {
startsWith: 'readiness-e2e-',
endsWith: `-${suffix}@example.com`,
},
};
const userIds = (
await prisma.user.findMany({ where: mine, select: { id: true } })
).map((user) => user.id);
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.rolePermission.deleteMany({
where: { roleId: apiaryOnlyRoleId },
});
await prisma.role.deleteMany({ where: { id: apiaryOnlyRoleId } });
await app.close();
});
it('without site data and without a SENT registration the Stammdaten and registration items are open and the status is OPEN', async () => {
await setSite({
holderName: null,
holderAddress: null,
foodBusinessName: null,
foodBusinessAddress: null,
});
await setVetOffice(null);
// Rows of other suites stay: the registration follows what exists.
const sentRegistrations = await prisma.authorityNotice.count({
where: { kind: 'REGISTRATION', status: 'SENT' },
});
const body = await report();
for (const key of ['holder-data', 'food-business-data']) {
expect(itemOf(body, key)).toMatchObject({ state: 'OPEN' });
}
expect(itemOf(body, 'registration').state).toBe(
sentRegistrations === 0 ? 'OPEN' : 'DONE',
);
expect(itemOf(body, 'holder-data').subjects[0].missing).toEqual([
'Name',
'Anschrift',
]);
expect(itemOf(body, 'vet-office').state).toBe('OPEN');
expect(body.status).toBe('OPEN');
expect(body.openCount).toBeGreaterThanOrEqual(3);
expect(Number.isNaN(Date.parse(body.generatedAt))).toBe(false);
const summary = await request(server())
.get(`${READINESS}/summary`)
.set(as('beekeeper'));
expect(summary.status).toBe(200);
expect(summary.body).toMatchObject({ status: 'OPEN' });
expect((summary.body as { firstItemKey: string }).firstItemKey).toEqual(
expect.any(String),
);
});
it('complete master data and a SENT registration make those items DONE', async () => {
await setSite({
holderName: 'Imker E2E',
holderAddress: 'Weg 1, 12345 Ort',
foodBusinessName: 'Imkerei E2E',
foodBusinessAddress: 'Weg 2, 12345 Ort',
});
await setVetOffice('Amt E2E');
const sent = await prisma.authorityNotice.create({
data: {
kind: 'REGISTRATION',
status: 'SENT',
referenceDate: dayToDate('2026-01-01'),
countedAt: new Date('2026-01-01T10:00:00.000Z'),
snapshot: { version: 1, takenAt: 'x', apiaries: [], totalLiving: 0 },
totalColonies: 0,
sentOn: dayToDate('2026-01-02'),
},
});
noticeIds.push(sent.id);
const body = await report();
for (const key of [
'holder-data',
'food-business-data',
'vet-office',
'registration',
]) {
expect(itemOf(body, key)).toMatchObject({
state: 'DONE',
subjects: [],
more: 0,
});
}
});
it('READONLY may read the report but gets 403 acknowledging; STAFF gets 403 on acknowledgements; API_CLIENT gets 403 everywhere', async () => {
const payload = {
itemKey: SOFT_KEY,
subjectId: 'global',
fingerprint: 'a'.repeat(64),
note: 'Geprüft und in Ordnung.',
};
expect((await report('readonly')).items.length).toBeGreaterThan(0);
expect((await report('staff')).items.length).toBeGreaterThan(0);
expect((await acknowledge(payload, 'readonly')).status).toBe(403);
expect((await acknowledge(payload, 'staff')).status).toBe(403);
expect(
(
await request(server())
.post(`${ACKNOWLEDGEMENTS}/someid/revoke`)
.set(as('staff'))
).status,
).toBe(403);
for (const path of [READINESS, `${READINESS}/summary`]) {
expect(
(await request(server()).get(path).set(as('apiclient'))).status,
).toBe(403);
}
expect((await acknowledge(payload, 'apiclient')).status).toBe(403);
expect((await request(server()).get(READINESS)).status).toBe(401);
});
it('shows the apiaries of an active restriction only with apiaries:read, but counts them for everyone', async () => {
const created = await prisma.apiaryRestriction.create({
data: {
apiaryId,
disease: 'Faulbrut',
authority: 'Amt Geheim',
startsOn: dayToDate(berlinToday()),
},
});
restrictionIds.push(created.id);
const full = itemOf(await report('beekeeper'), 'restriction-active');
const masked = itemOf(await report('complianceonly'), 'restriction-active');
expect(full.state).toBe('OPEN');
expect(full.subjects.map((subject) => subject.id)).toContain(apiaryId);
expect(masked.state).toBe('OPEN');
expect(masked.subjects).toEqual([]);
expect(masked.more).toBeGreaterThanOrEqual(1);
expect(JSON.stringify(masked)).not.toContain('Readiness Stand');
expect(JSON.stringify(masked)).not.toContain('Amt Geheim');
expect(JSON.stringify(full)).not.toContain('Amt Geheim');
});
it('an ended restriction does not keep the item open', async () => {
const ended = await prisma.apiaryRestriction.create({
data: {
apiaryId,
disease: 'Faulbrut',
startsOn: dayToDate('2020-01-01'),
endsOn: dayToDate('2020-02-01'),
},
});
restrictionIds.push(ended.id);
const body = await report();
expect(
body.items
.find((item) => item.key === 'restriction-active')
?.subjects.map((subject) => subject.id) ?? [],
).not.toContain(apiaryId);
});
describe('acknowledgements', () => {
it('refuses a PFLICHT item, an unknown item and a short note with 400', async () => {
const body = await report();
const holder = itemOf(body, 'holder-data');
const soft = itemOf(body, SOFT_KEY);
const pflicht = await acknowledge({
itemKey: 'holder-data',
subjectId: 'global',
fingerprint: holder.fingerprint,
note: 'Geprüft und in Ordnung.',
});
const unknown = await acknowledge({
itemKey: 'no-such-item',
subjectId: 'global',
fingerprint: soft.fingerprint,
note: 'Geprüft und in Ordnung.',
});
const shortNote = await acknowledge({
itemKey: SOFT_KEY,
subjectId: 'global',
fingerprint: soft.fingerprint,
note: 'kurz',
});
expect(pflicht.status).toBe(400);
expect(unknown.status).toBe(400);
expect(shortNote.status).toBe(400);
});
it('acknowledges with the current fingerprint, reopens on changed data, and a revoke reopens it', async () => {
const before = itemOf(await report(), SOFT_KEY);
const stale = await acknowledge({
itemKey: SOFT_KEY,
subjectId: 'global',
fingerprint: 'b'.repeat(64),
note: 'Geprüft und in Ordnung.',
});
const created = await acknowledge({
itemKey: SOFT_KEY,
subjectId: 'global',
fingerprint: before.fingerprint,
note: 'Geprüft und in Ordnung.',
});
expect(stale.status).toBe(409);
expect(created.status).toBe(201);
const createdItem = created.body as ReadinessItem;
expect(createdItem.state).toBe('ACKNOWLEDGED');
const acknowledgementId = createdItem.acknowledgement!.id;
acknowledgementIds.push(acknowledgementId);
expect(itemOf(await report(), SOFT_KEY)).toMatchObject({
state: 'ACKNOWLEDGED',
acknowledgement: { id: acknowledgementId },
});
expect(
await prisma.auditLog.count({
where: {
entityType: 'ComplianceAcknowledgement',
entityId: acknowledgementId,
action: 'CREATE',
},
}),
).toBe(1);
softValue = 2;
expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN');
softValue = 1;
expect(itemOf(await report(), SOFT_KEY).state).toBe('ACKNOWLEDGED');
const revoke = () =>
request(server())
.post(`${ACKNOWLEDGEMENTS}/${acknowledgementId}/revoke`)
.set(as('beekeeper'));
expect((await revoke()).status).toBe(201);
expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN');
expect((await revoke()).status).toBe(409);
const row = await prisma.complianceAcknowledgement.findUniqueOrThrow({
where: { id: acknowledgementId },
});
expect(row.revokedAt).not.toBeNull();
expect(row.revokedByUserId).not.toBeNull();
});
it('revoking an unknown acknowledgement is a 404', async () => {
const response = await request(server())
.post(`${ACKNOWLEDGEMENTS}/does-not-exist/revoke`)
.set(as('beekeeper'));
expect(response.status).toBe(404);
});
});
});
@@ -4,11 +4,13 @@ import { Prisma } from '../generated/prisma';
import { AutopilotStep } from '../src/autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { ComplianceModule } from '../src/compliance/compliance.module';
import { JobsModule } from '../src/jobs/jobs.module';
import { PhenologyStep } from '../src/phenology/phenology.step';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
import { JobPartialFailure } from '../src/system/job-run.service';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
@@ -18,6 +20,7 @@ const NOTHING_NEW = {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
};
/**
@@ -54,7 +57,8 @@ describe('daily-evaluation (e2e)', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [JobsModule],
// The compliance handlers turn the TSK reference day into a draft.
imports: [JobsModule, ComplianceModule],
}).compile();
app = await moduleRef.init();
prisma = app.get(PrismaService);
@@ -239,6 +243,107 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
});
describe('TSK reference day', () => {
let savedReferenceDay: string;
let permittedUsers: number;
beforeEach(async () => {
// The seeded singleton stays: only its reference day is borrowed.
const saved = await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
savedReferenceDay = saved.tskReferenceDay;
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { tskReferenceDay: '03-03' },
});
permittedUsers = await prisma.user.count({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: 'compliance:manage' } } },
},
},
});
});
afterEach(async () => {
await prisma.notification.deleteMany({
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
});
await prisma.authorityNotice.deleteMany({ where: { year: 2001 } });
await prisma.domainEvent.deleteMany({
where: { aggregateId: 'tsk:2001' },
});
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { tskReferenceDay: savedReferenceDay },
});
});
async function expectOneDraftAndNotices(): Promise<void> {
expect(await eventsOf('tsk:2001')).toEqual([
{
type: 'TskReferenceDayReached',
aggregateType: 'Compliance',
payload: { year: 2001, referenceDay: '2001-03-03' },
dedupeKey: 'TskReferenceDayReached:tsk:2001:2001-03-03',
actorUserId: null,
},
]);
const drafts = await prisma.authorityNotice.findMany({
where: { kind: 'TSK_REPORT', year: 2001 },
});
expect(drafts).toHaveLength(1);
expect(drafts[0]).toMatchObject({
status: 'DRAFT',
openKey: 'TSK_REPORT:2001',
});
const notifications = await prisma.notification.findMany({
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
select: { userId: true, priority: true, category: true, message: true },
});
expect(notifications).toHaveLength(permittedUsers);
expect(new Set(notifications.map((row) => row.userId)).size).toBe(
permittedUsers,
);
for (const row of notifications) {
expect(row).toMatchObject({
priority: 'WARNUNG',
category: 'COMPLIANCE',
});
expect(row.message).toMatch(
/^TSK-Stichtag 03\.03\.2001: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
);
}
}
it('daily evaluation twice on the reference day: one event, one draft', async () => {
const first = await step.run('2001-03-03');
const second = await step.run('2001-03-03');
await drainDomainEvents(app, ['tsk:2001']);
await step.run('2001-03-04');
await drainDomainEvents(app, ['tsk:2001']);
expect(first.TskReferenceDayReached).toBe(1);
expect(second.TskReferenceDayReached).toBe(0);
await expectOneDraftAndNotices();
});
it('catches up a missed reference day on the 5th exactly once', async () => {
const catchUp = await step.run('2001-03-05');
const again = await step.run('2001-03-05');
await drainDomainEvents(app, ['tsk:2001']);
expect(catchUp.TskReferenceDayReached).toBe(1);
expect(again.TskReferenceDayReached).toBe(0);
await expectOneDraftAndNotices();
});
});
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
await step.run('2001-05-20');
@@ -11,6 +11,7 @@ import {
DispatchFilter,
DomainEventDispatcher,
} from '../src/domain-events/domain-event-dispatcher';
import { DomainEventRegistry } from '../src/domain-events/domain-event-registry';
import type { DomainEventEnvelope } from '../src/domain-events/domain-event-types';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
@@ -217,6 +218,15 @@ describe('Worker runtime (e2e)', () => {
);
});
it('registers the compliance handlers in the worker context too', () => {
expect(worker.get(DomainEventRegistry).handlerIds()).toEqual(
expect.arrayContaining([
'compliance.herd-change',
'compliance.tsk-reference-day',
]),
);
});
it('wakes the calendar outbox loop on a calendar_sync notification', async () => {
expect(processOutbox).toHaveBeenCalled();
const before = processOutbox.mock.calls.length;