diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ee2c395..57a70ee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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:`, 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-.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::', 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:`. +- **`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/`). **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:`, Payload `{ year, referenceDay }`, Schlüssel `TskReferenceDayReached:tsk::`. 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. diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 70928a7..dfd6769 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -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: [ diff --git a/ApiCore-Backend/src/compliance/authority-notices-pdf.spec.ts b/ApiCore-Backend/src/compliance/authority-notices-pdf.spec.ts new file mode 100644 index 0000000..0b18669 --- /dev/null +++ b/ApiCore-Backend/src/compliance/authority-notices-pdf.spec.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/ApiCore-Backend/src/compliance/authority-notices.controller.ts b/ApiCore-Backend/src/compliance/authority-notices.controller.ts index 0c2f6f2..17944f6 100644 --- a/ApiCore-Backend/src/compliance/authority-notices.controller.ts +++ b/ApiCore-Backend/src/compliance/authority-notices.controller.ts @@ -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 { + 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() diff --git a/ApiCore-Backend/src/compliance/authority-notices.service.ts b/ApiCore-Backend/src/compliance/authority-notices.service.ts index 2c5bf07..21e1ce5 100644 --- a/ApiCore-Backend/src/compliance/authority-notices.service.ts +++ b/ApiCore-Backend/src/compliance/authority-notices.service.ts @@ -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 { + 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 diff --git a/ApiCore-Backend/src/compliance/compliance.handlers.spec.ts b/ApiCore-Backend/src/compliance/compliance.handlers.spec.ts new file mode 100644 index 0000000..33c4c09 --- /dev/null +++ b/ApiCore-Backend/src/compliance/compliance.handlers.spec.ts @@ -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 = ( + type: T, + aggregateId: string, + payload: Record, +) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope; + +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; + 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); + }); + }); +}); diff --git a/ApiCore-Backend/src/compliance/compliance.handlers.ts b/ApiCore-Backend/src/compliance/compliance.handlers.ts new file mode 100644 index 0000000..9c9e8b6 --- /dev/null +++ b/ApiCore-Backend/src/compliance/compliance.handlers.ts @@ -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 { + if (event.type === 'ColonyStatusChanged') { + const { from, to } = event.payload; + if ( + isLivingColonyStatus(from.status) === isLivingColonyStatus(to.status) + ) { + return; + } + } + // The deviation is computed before any notification lock (READ COMMITTED): + // at worst a notification comes early or late, never twice. + const [{ now }] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`; + const { baseline, current, deviation } = await herdStatusIn(tx, now); + if (!baseline || !deviation?.triggered) { + return; + } + const reported = baseline.snapshot as unknown as HerdSnapshot; + await this.notifications.recordForPermissionOnce( + RECIPIENT_PERMISSION, + { + priority: 'HINWEIS', + category: 'COMPLIANCE', + message: `Bestand geändert: jetzt ${current.totalLiving} Völker an ${current.apiaries.length} Ständen. Angezeigt: ${baseline.totalColonies} an ${reported.apiaries.length}. Anzeige ans Veterinäramt prüfen.`, + relatedType: NOTICE_TOPIC, + relatedId: HERD_CHANGE_TOPIC_ID, + link: '/compliance', + }, + tx, + ); + } + + // One draft per year. A year already sent stays silent; with only an open + // draft nothing new is created, but the reminder is sent (once while + // unread), since the draft may never have been opened. The settings row is + // the mutex. + @OnDomainEvent('TskReferenceDayReached', 'compliance.tsk-reference-day') + async tskReferenceDay( + event: AnyDomainEventEnvelope<'TskReferenceDayReached'>, + tx: Prisma.TransactionClient, + ): Promise { + const { year, referenceDay } = event.payload; + await complianceSettingsIn(tx); + await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID); + const existing = await tx.authorityNotice.findMany({ + where: { kind: 'TSK_REPORT', year, status: { in: ['SENT', 'DRAFT'] } }, + select: { id: true, status: true, totalColonies: true }, + }); + if (existing.some((notice) => notice.status === 'SENT')) { + return; + } + const draft = + existing[0] ?? + (await this.notices.createDraftIn( + tx, + { kind: 'TSK_REPORT', year, referenceDate: dayToDate(referenceDay) }, + null, + )); + await this.notifications.recordForPermissionOnce( + RECIPIENT_PERMISSION, + { + priority: 'WARNUNG', + category: 'COMPLIANCE', + message: `TSK-Stichtag ${germanDay(referenceDay)}: ${draft.totalColonies} Völker gezählt. Bitte prüfen und melden.`, + relatedType: NOTICE_TOPIC, + relatedId: `tsk-report:${year}`, + link: `/compliance/notices/${draft.id}`, + }, + tx, + ); + } +} diff --git a/ApiCore-Backend/src/compliance/compliance.module.ts b/ApiCore-Backend/src/compliance/compliance.module.ts index 44ea696..5746cc8 100644 --- a/ApiCore-Backend/src/compliance/compliance.module.ts +++ b/ApiCore-Backend/src/compliance/compliance.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/compliance/pdf/authority-notice-model.spec.ts b/ApiCore-Backend/src/compliance/pdf/authority-notice-model.spec.ts new file mode 100644 index 0000000..212f60d --- /dev/null +++ b/ApiCore-Backend/src/compliance/pdf/authority-notice-model.spec.ts @@ -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 { + 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 = {}, + 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(); + }); +}); diff --git a/ApiCore-Backend/src/compliance/pdf/authority-notice-model.ts b/ApiCore-Backend/src/compliance/pdf/authority-notice-model.ts new file mode 100644 index 0000000..90268c7 --- /dev/null +++ b/ApiCore-Backend/src/compliance/pdf/authority-notice-model.ts @@ -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; + 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), + }; +} diff --git a/ApiCore-Backend/src/compliance/pdf/authority-notice-pdf.spec.ts b/ApiCore-Backend/src/compliance/pdf/authority-notice-pdf.spec.ts new file mode 100644 index 0000000..eab0e48 --- /dev/null +++ b/ApiCore-Backend/src/compliance/pdf/authority-notice-pdf.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/ApiCore-Backend/src/compliance/pdf/authority-notice-pdf.ts b/ApiCore-Backend/src/compliance/pdf/authority-notice-pdf.ts new file mode 100644 index 0000000..8c763b2 --- /dev/null +++ b/ApiCore-Backend/src/compliance/pdf/authority-notice-pdf.ts @@ -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 { + 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(); + }); +} diff --git a/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.spec.ts b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.spec.ts new file mode 100644 index 0000000..93e57eb --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.spec.ts @@ -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 { + return { + key: 'soft', + area: 'ETIKETTEN', + severity: 'PRUEFEN', + title: 'Weicher Punkt', + legalBasis: null, + acknowledgeable: true, + evaluate: () => [], + ...patch, + }; +} + +function item(patch: Partial = {}): 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 = {}) { + 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, + ); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.ts b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.ts new file mode 100644 index 0000000..1f38b29 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.ts @@ -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 = 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 = NO_PERMISSIONS, + ): Promise { + 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 { + 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, + ); + }); + } +} diff --git a/ApiCore-Backend/src/compliance/readiness/dto/create-acknowledgement.dto.ts b/ApiCore-Backend/src/compliance/readiness/dto/create-acknowledgement.dto.ts new file mode 100644 index 0000000..f8509a6 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/dto/create-acknowledgement.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/compliance/readiness/fingerprint.spec.ts b/ApiCore-Backend/src/compliance/readiness/fingerprint.spec.ts new file mode 100644 index 0000000..2a10d49 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/fingerprint.spec.ts @@ -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')); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/fingerprint.ts b/ApiCore-Backend/src/compliance/readiness/fingerprint.ts new file mode 100644 index 0000000..193e0dd --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/fingerprint.ts @@ -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 = {}; + for (const key of Object.keys(plain).sort()) { + const entry = canonical((plain as Record)[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'); +} diff --git a/ApiCore-Backend/src/compliance/readiness/readiness-context.ts b/ApiCore-Backend/src/compliance/readiness/readiness-context.ts new file mode 100644 index 0000000..63ba1d9 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/readiness-context.ts @@ -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; + +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 { + 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, + }; +} diff --git a/ApiCore-Backend/src/compliance/readiness/readiness-worker-graph.spec.ts b/ApiCore-Backend/src/compliance/readiness/readiness-worker-graph.spec.ts new file mode 100644 index 0000000..3c75dac --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/readiness-worker-graph.spec.ts @@ -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> { + const seen = new Set(); + async function visit(entry: unknown): Promise { + 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); + } + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.controller.ts b/ApiCore-Backend/src/compliance/readiness/readiness.controller.ts new file mode 100644 index 0000000..1e1e9a5 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/readiness.controller.ts @@ -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); + } +} diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.module.ts b/ApiCore-Backend/src/compliance/readiness/readiness.module.ts new file mode 100644 index 0000000..b320cee --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/readiness.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.service.spec.ts b/ApiCore-Backend/src/compliance/readiness/readiness.service.spec.ts new file mode 100644 index 0000000..d646133 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/readiness.service.spec.ts @@ -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 & { 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 { + 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 = {}) { + 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'], + ]); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.service.ts b/ApiCore-Backend/src/compliance/readiness/readiness.service.ts new file mode 100644 index 0000000..fbc2851 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/readiness.service.ts @@ -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, +): { 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, +): 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, + ): Promise { + 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 { + 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> { + const candidates = items.filter( + (item) => item.acknowledgeable && isOpen(item), + ); + const result = new Map(); + 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; + } +} diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.types.ts b/ApiCore-Backend/src/compliance/readiness/readiness.types.ts new file mode 100644 index 0000000..81c797c --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/readiness.types.ts @@ -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; +} diff --git a/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.spec.ts new file mode 100644 index 0000000..2e33e94 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.spec.ts @@ -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, + }); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.ts new file mode 100644 index 0000000..5a9dc06 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.ts @@ -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)], + }, + ]; + }, +}; diff --git a/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.spec.ts new file mode 100644 index 0000000..2469f87 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.spec.ts @@ -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 { + 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, + }); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.ts new file mode 100644 index 0000000..ff73a30 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.ts @@ -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), + }, + ]; + }, +}; diff --git a/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.spec.ts new file mode 100644 index 0000000..c0d8283 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.spec.ts @@ -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'); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.ts new file mode 100644 index 0000000..3b2ed5b --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.ts @@ -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)], + }, + ]; + }, +}; diff --git a/ApiCore-Backend/src/compliance/readiness/rules/index.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/index.spec.ts new file mode 100644 index 0000000..ba16db5 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/index.spec.ts @@ -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 }); + } + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/index.ts b/ApiCore-Backend/src/compliance/readiness/rules/index.ts new file mode 100644 index 0000000..76aa040 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/index.ts @@ -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'); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.spec.ts new file mode 100644 index 0000000..baf2ae7 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.spec.ts @@ -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, + }); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.ts new file mode 100644 index 0000000..7622395 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.ts @@ -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)', + }, + ] + : [], + }, + ]; + }, +}; diff --git a/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.spec.ts new file mode 100644 index 0000000..36f1a27 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.spec.ts @@ -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, + }); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.ts new file mode 100644 index 0000000..5e7461b --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.ts @@ -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(); + 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, + })), + }, + ]; + }, +}; diff --git a/ApiCore-Backend/src/compliance/readiness/rules/rule-helpers.ts b/ApiCore-Backend/src/compliance/readiness/rules/rule-helpers.ts new file mode 100644 index 0000000..81d28a3 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/rule-helpers.ts @@ -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, + }; +} diff --git a/ApiCore-Backend/src/compliance/readiness/rules/rule-spec-helpers.ts b/ApiCore-Backend/src/compliance/readiness/rules/rule-spec-helpers.ts new file mode 100644 index 0000000..6e63b7c --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/rule-spec-helpers.ts @@ -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 { + 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 { + 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 { + 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 & { 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 { + return { + settings: complianceSettings(), + baseline: null, + current: emptySnapshot(), + deviation: null, + ...patch, + }; +} + +export function readinessContext( + patch: Partial = {}, +): ReadinessContext { + return { + now: new Date('2027-03-15T10:00:00.000Z'), + today: '2027-03-15', + site: siteSettings(), + settings: complianceSettings(), + herd: herdStatus(), + notices: [], + restrictions: [], + ...patch, + }; +} diff --git a/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.spec.ts new file mode 100644 index 0000000..f7c7c85 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.spec.ts @@ -0,0 +1,117 @@ +import { + complianceSettings, + notice, + readinessContext, +} from './rule-spec-helpers'; +import { tskReportRule } from './tsk-report.rule'; + +function at(today: string, patch: Parameters[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, + }); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.ts new file mode 100644 index 0000000..22e700b --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.ts @@ -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); + }, +}; diff --git a/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.spec.ts new file mode 100644 index 0000000..e867192 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.spec.ts @@ -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, + }); + }); +}); diff --git a/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.ts new file mode 100644 index 0000000..1120fc1 --- /dev/null +++ b/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.ts @@ -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'], + }, + ], + }, + ]; + }, +}; diff --git a/ApiCore-Backend/src/export/pdf-table.ts b/ApiCore-Backend/src/export/pdf-table.ts index 4e159e0..e1f4795 100644 --- a/ApiCore-Backend/src/export/pdf-table.ts +++ b/ApiCore-Backend/src/export/pdf-table.ts @@ -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); } diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts index 783258a..8ac5485 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts @@ -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; } @@ -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( diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts index c7ccc3e..0938c7f 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts @@ -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 = @@ -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, ]; diff --git a/ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts b/ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts index 91fe764..3e60f93 100644 --- a/ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts +++ b/ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts @@ -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, [ diff --git a/ApiCore-Backend/src/notifications/notifications.service.spec.ts b/ApiCore-Backend/src/notifications/notifications.service.spec.ts index df64b52..aa5bc30 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.spec.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.spec.ts @@ -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' }]); diff --git a/ApiCore-Backend/src/notifications/notifications.service.ts b/ApiCore-Backend/src/notifications/notifications.service.ts index bc5fc80..d5545f4 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.ts @@ -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 & { + relatedType: string; + relatedId: string; + }, + tx: Prisma.TransactionClient, + ): Promise { + 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 diff --git a/ApiCore-Backend/src/worker/worker.module.ts b/ApiCore-Backend/src/worker/worker.module.ts index e3900a9..d733830 100644 --- a/ApiCore-Backend/src/worker/worker.module.ts +++ b/ApiCore-Backend/src/worker/worker.module.ts @@ -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, diff --git a/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts b/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts new file mode 100644 index 0000000..9a64b60 --- /dev/null +++ b/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts @@ -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; + 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 { + 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 { + 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): Promise { + 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); + }); +}); diff --git a/ApiCore-Backend/test/authority-notices.e2e-spec.ts b/ApiCore-Backend/test/authority-notices.e2e-spec.ts index accaad0..440e7a0 100644 --- a/ApiCore-Backend/test/authority-notices.e2e-spec.ts +++ b/ApiCore-Backend/test/authority-notices.e2e-spec.ts @@ -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-.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); + }); }); diff --git a/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts b/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts new file mode 100644 index 0000000..4874c2f --- /dev/null +++ b/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts @@ -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; + let prisma: PrismaService; + const suffix = Date.now(); + const password = 'SuperSecret123!'; + const tokens: Record = {}; + 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 { + 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, 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(); + 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); + }); + }); +}); diff --git a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts index e38ad83..8400376 100644 --- a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts +++ b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts @@ -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 { + 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'); diff --git a/ApiCore-Backend/test/worker-runtime.e2e-spec.ts b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts index 0b928c2..52776c9 100644 --- a/ApiCore-Backend/test/worker-runtime.e2e-spec.ts +++ b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts @@ -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;