feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- Authority notice PDFs: pure model plus renderer, TSK count in A4 landscape, FEHLT for missing particulars, stored totals with correction note; GET /authority-notices/:id/pdf with compliance:read. - Compliance notifications in category COMPLIANCE: herd change and TSK reference day, at most one unread per topic and user (advisory lock); daily rule TskReferenceDayReached with Berlin day and catch-up; handlers in API and worker. - Readiness core: live evaluation of seven pure rules, fingerprinted acknowledgements that reopen when revoked or stale, subject masking, read-only GET paths; READINESS_RULES injectable; kept out of the worker. - e2e hygiene: no unscoped deletes, seeded settings rows saved/restored. - ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
edc193f128
commit
b912d29166
54 files changed
+5300
-14
No files matched your search
+50
-3
@@ -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.
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user