feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9609
-315
No files matched your search
+73
-31
@@ -28,11 +28,11 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
|
||||
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
||||
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
|
||||
| `SuggestionsModule` | Vorschläge (Modell, API, Aufräumen) und Ketten-Handler, Abschnitt 11 | Tasks, Notifications |
|
||||
| `AutopilotModule` | Saison-Autopilot (A1–A3), „Die nächsten Schritte“, „Heute“ | Season, Suggestions |
|
||||
| `AutopilotModule` | Saison-Autopilot (A1–A10), „Die nächsten Schritte“, „Heute“ | Season, Suggestions |
|
||||
| `SeasonModule` | Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin | – |
|
||||
| `PhenologyModule` | Saisonzustand je Stand (GTS, Phase, Kalibrierung), Job-Schritt `phenology`, Abschnitt 11.11 | Season, Weather |
|
||||
| `PhenologyModule` | Saisonzustand je Stand (GTS, Phase, Kalibrierung), Job-Schritt `phenology`, Meldungen (API, Neuberechnung, Handler), Abschnitt 11.11 | Season, Weather, Notifications |
|
||||
| `WeatherHistoryModule` | Wetterquelle (`WeatherHistorySource`) und Rückholdienst für die Worker-Schleife `phenology-weather`, Abschnitt 11.11 | – |
|
||||
| `TelegramModule` | `TelegramClient` (nur Worker, noch nicht eingebunden), Abschnitt 11.13 | – |
|
||||
| `TelegramModule` | `TelegramClient`, `TelegramSender` (Zustellung, nur Worker), Abschnitt 11.13 | Notifications |
|
||||
| `WithdrawalModule` | Wartezeit-Prüfung bei Ernte, Abfüllung und Behandlung, Meldungen | Notifications |
|
||||
| `RemindersModule` | Erinnerung je Aufgabe, Morgenübersicht | Notifications, Season, Autopilot |
|
||||
|
||||
@@ -151,17 +151,21 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
33. **Futter-Vokabular**: `Inspection.feedStatus` ist `ColonyCondition` wie auf der Volkskarte (`EXCELLENT` Reichlich, `GOOD` Ausreichend, `AVERAGE` Mittel, `WEAK` Niedrig, `CRITICAL` Kritisch). Die Migration bildet den alten Freitext (getrimmt auf Leerzeichen, Tab, CR, LF; ohne Groß-/Kleinschreibung) ab: „ausreichend“ → `GOOD`, „niedrig“ → `WEAK`, „kritisch“ → `CRITICAL`; leer → `NULL`; alles andere → `NULL` und `Futter (alt): <Text>` wird nach einem Zeilenumbruch an `notes` gehängt (der Lauf meldet die Zahl der verschobenen Texte per `RAISE NOTICE`). Dieselbe Abbildung gilt für den CSV-Völkerimport (unbekannter Text landet in den Notizen, die Zeile trägt die Meldung „Futterangabe … nicht erkannt, in die Notizen übernommen.“) und für alte Backup-Bundles (ein Wert, der kein Text ist, wird `NULL` mit Notiz `Futter (alt): <JSON>`). Die API akzeptiert bei `POST/PATCH /inspections` die Enum-Namen in jeder Schreibweise und die drei alten Wörter (so bleiben Einträge der Offline-Warteschlange alter Clients sendbar); leerer Text wird `NULL`, anderer Freitext 400. Die Regel `FEED_STATUS_CRITICAL` feuert bei `feedStatus = CRITICAL`; der Webhook `inspection.created` trägt `feedStatus` als Enum-Wert (der Shop nutzt das Ereignis nicht).
|
||||
34. **Volksstärke in besetzten Waben**: `Colony.occupiedFrames` und `Inspection.colonyStrength` zählen besetzte Waben, 0 bis 40 (CHECK in der Datenbank; 400 „Höchstens 40 besetzte Waben.“). Das alte `Colony.strength` (1–10) bleibt unverändert, wird in Swagger als veraltet markiert und nie umgerechnet; die Oberfläche zeigt es nur, wenn es gespeichert ist, als „Volksstärke (alt)“. Vor dem Ausrollen listet der Kopf der Migration `20261014100700_t4_occupied_frames` die Abfrage nach `Inspection.colonyStrength > 40`.
|
||||
35. **Übernahme Durchsicht → Volkskarte**: Siehe Abschnitt 11.10. Annahme: Das Brutbild wird zum Brutzustand der Volkskarte (5 `EXCELLENT`, 4 `GOOD`, 3 `AVERAGE`, 2 `WEAK`, 1 `CRITICAL`); diese Zuordnung ist nicht aus dem Lastenheft abgeleitet.
|
||||
36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). Der Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor `SALWEIDE` noch `WINTER` des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über `SeasonService.statesFor`, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in `ApiaryPhenology (apiaryId, year)`; der Job-Schritt `phenology` und jede Meldung oder Rücknahme berechnen ihn neu (`PhenologyStateService.recomputeIn`, unter Zeilensperre auf `Apiary`). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer zurückgezogenen Meldung (`allowBackward`). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11.
|
||||
36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). Der Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor `SALWEIDE` noch `WINTER` des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über `SeasonService.statesFor`, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in `ApiaryPhenology (apiaryId, year)`; der Job-Schritt `phenology` und jede Meldung oder Rücknahme berechnen ihn neu (`PhenologyStateService.recomputeIn`, unter Zeilensperre auf `Apiary`). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer Korrektur oder Rücknahme einer Meldung (`allowBackward`, gesetzt nur vom Handler bei `PhenologyCorrected`; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).
|
||||
37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis).
|
||||
38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft).
|
||||
39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`.
|
||||
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. Die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit steuern in dieser Lieferung nichts in der App; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken erst auf die Telegram-Zustellung (T4c).
|
||||
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).
|
||||
41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen.
|
||||
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
|
||||
43. **Pflanzenentwicklung: Tabellen und Fremdschlüssel**: Abweichend vom Entwurf (Spec 4.2) verweisen `ApiaryPhenology.apiaryId` und `ApiaryWeatherDay.apiaryId` mit `ON DELETE CASCADE` auf `Apiary` (abgeleitete Daten und Cache, keine Waisen, ein Restore von Ständen wird nie blockiert). `PhenologyReport.apiaryId` verweist mit `ON DELETE RESTRICT` auf `Apiary`: Der Prisma-Standard für eine optionale Relation, `SetNull`, machte aus einer Meldung still eine Meldung „für alle Stände“. Die Migration `20261015100000_t4_phenology` vergibt `phenology:read`, `phenology:create` und `phenology:update` (Gruppe „Pflanzenentwicklung“) an alle Systemrollen; `read` geht an Rollen, die `colonies:read` halten, `create` und `update` an Rollen mit `inspections:create`, jeweils nur Nicht-Systemrollen. Die Migration legt die Rechte selbst an, weil der Katalog-Abgleich beim Start neue Rechte nur für Zeilen vergibt, die er selbst anlegt (`PermissionCatalogSyncService`).
|
||||
44. **Bewegungsbuch der Honigcharge**: `HoneyBatch.amount` bleibt der laufende Rest in kg, `HoneyBatch.initialAmount` (kg) ist die Anfangsmenge. `BatchMovement.quantity` ist bei Honig eine vorzeichenbehaftete Menge in Gramm mit `honeyBatchId`. Invariante: `amount = initialAmount + Summe(quantity) / 1000`, auf das Gramm gerundet, nie unter 0, Toleranz 0,5 g. Eine Charge ohne `amount` und `initialAmount` wird nicht gebucht. Jede `BatchMovement` gehört zu genau einer Quelle aus Abfüllung, Herstellungscharge, Rohstoff-Charge und Honigcharge (CHECK `BatchMovement_exactly_one_batch`, `num_nonnulls(…) = 1`). Rückrechnung für Altbestand (Migration `20261015100100_t4_honey_ledger`, Import alter Bundles): `initialAmount = amount + Summe(Gramm der Abfüllungen) / 1000`, je Abfüllung eine Buchung `BOTTLING` mit `sourceBottlingBatchId`, Datum und Benutzer der Abfüllung (Grammwerte halb aufgerundet, `floor(x·1000 + 0,5)`). Frühere Handkorrekturen von `amount` stecken damit in `initialAmount`. Die Migration endet mit einer Prüfung (`RAISE NOTICE` mit Zählern, `RAISE EXCEPTION` bei verletzter Invariante, dann Rollback). Stand: Die Laufzeit schreibt `initialAmount` und die Buchungen noch nicht (Task 29); bis dahin folgt `amount` der Invariante nur für migrierte und wiederhergestellte Daten.
|
||||
44. **Bewegungsbuch der Honigcharge**: `HoneyBatch.amount` bleibt der laufende Rest in kg, `HoneyBatch.initialAmount` (kg) ist die Anfangsmenge. `BatchMovement.quantity` ist bei Honig eine vorzeichenbehaftete Menge in Gramm mit `honeyBatchId`. Invariante: `amount = initialAmount + Summe(quantity) / 1000`, auf das Gramm gerundet, nie unter 0, Toleranz 0,5 g. Eine Charge ohne `amount` und `initialAmount` wird nicht gebucht. Jede `BatchMovement` gehört zu genau einer Quelle aus Abfüllung, Herstellungscharge, Rohstoff-Charge und Honigcharge (CHECK `BatchMovement_exactly_one_batch`, `num_nonnulls(…) = 1`). Rückrechnung für Altbestand (Migration `20261015100100_t4_honey_ledger`, Import alter Bundles): `initialAmount = amount + Summe(Gramm der Abfüllungen) / 1000`, je Abfüllung eine Buchung `BOTTLING` mit `sourceBottlingBatchId`, Datum und Benutzer der Abfüllung (Grammwerte halb aufgerundet, `floor(x·1000 + 0,5)`). Frühere Handkorrekturen von `amount` stecken damit in `initialAmount`. Die Migration endet mit einer Prüfung (`RAISE NOTICE` mit Zählern, `RAISE EXCEPTION` bei verletzter Invariante, dann Rollback). Laufzeit: `HoneyBatchLedgerService` (`src/honey-batches/honey-batch-ledger.service.ts`) ist der einzige Schreiber von `HoneyBatch.amount` (Wächter `honey-batch-amount-writes.spec.ts`). Buchungen laufen in der Transaktion des Aufrufers unter `lockRowForUpdate('HoneyBatch')`: `bookIn` (vorzeichenbehaftete Gramm; `amount` wird aus `initialAmount + Summe(quantity) / 1000` auf das Gramm neu berechnet, nie unter 0), `setInitialIn` (erste Menge: `amount` und `initialAmount` zugleich, keine Buchung) und `clearIn` (Menge entfernen, nur ohne Buchungen, sonst 400 „Die Menge wird ueber das Bewegungsbuch gefuehrt. Bitte eine Korrektur buchen.“). Eine Abbuchung braucht genug Rest (Toleranz 0,5 g), sonst 400 (Abfüllung: „Abfuellmenge uebersteigt die verfuegbare Menge der Honigcharge“, sonst „Die Menge kann nicht unter 0 sinken.“). Gerundet wird überall gleich, `floor(x·1000 + 0,5)` Gramm, auch bei Mengen, die Nutzer eingeben (12,3456 kg werden 12,346 kg). Eine Abfüllung anlegen bucht `BOTTLING` negativ mit `sourceBottlingBatchId`, Ändern die Differenz (bei Chargenwechsel zurück auf die alte, von der neuen), Entfernen `BOTTLING` positiv mit der Notiz „Abfuellung entfernt“ (ohne Quelle: die Abfüllung wird danach gelöscht). `PATCH amount` bucht `CORRECTION` (neu minus aktuell; `initialAmount` bleibt, Startmenge und Summe der Buchungen weichen danach ab); der erste Wert einer Charge ohne Menge (`null` → Wert) setzt `initialAmount`. Eine Charge ohne `initialAmount` bucht nichts, die Abfüllung setzt trotzdem den Status `BOTTLED`. Eine Abfüllung, die dem Buch nie etwas entnommen hat (Heuristik `drewFromLedger`: keine `BOTTLING`-Buchung mit `honeyBatchId` und ihrer `sourceBottlingBatchId`, etwa weil die Charge beim Abfüllen noch keine Menge hatte), bucht bei Änderung und Entfernung nichts zurück; wird sie in eine andere Charge verschoben, entnimmt sie erst dort. Das folgt aus den Buchungen und ist kein gespeichertes Merkmal. Sperrreihenfolge je Pfad: Abfüllung → Honigcharge (zwei Chargen in einer Anweisung, aufsteigend; die Abfüllung sperrt die Charge schon vor dem Anlegen), Schleuderung → Honigcharge. API des Buchs: Abschnitt 11.12.
|
||||
45. **Honig-Kette: Vorschläge, Warnungen, nie sperren**: Vorbelegungen (Menge, Honigsorte, Wassergehalt) sind Vorschläge mit Übernehmen-Knopf, nie stillschweigend eingetragen. Plausibilitätswarnungen (`EXTRACTION_EXCEEDS_HARVEST`, `BATCHES_EXCEED_EXTRACTION`, Hinweis `HARVEST_NET_MISSING`) und die Wartezeitwarnung sperren nie (kein 4xx). Die Wartezeitwarnung `WITHDRAWAL_ACTIVE` bekommt jeder, der eine Schleuderung erfasst oder vorbelegt, auch ohne `treatments:read` (Lebensmittelsicherheit vor der Lese-Trennung). Die Vorbelegungs-Endpunkte hängen an den Anlege-Rechten (`extractions:create`, `honey-batches:create`): Sie liefern dieselben Daten wie der Anlege-Ablauf. Die Honigsorte kommt aus den Ernten, weil `Extraction` kein Sortenfeld hat. Abschnitt 11.12.
|
||||
46. **Telegram: Token nur im Worker, Zustellung über Outbox**: Der Bot-Token (`TELEGRAM_BOT_TOKEN`) liegt nur in der Umgebung des Workers (optionales Secret `apicore-telegram`); der API-Pod kennt nur `TELEGRAM_BOT_USERNAME` für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über `NotificationDelivery`, nie aus Anfragen, Handlern oder Jobs. Abschnitt 11.13.
|
||||
46. **Telegram: Token nur im Worker, Zustellung über Outbox**: Der Bot-Token (`TELEGRAM_BOT_TOKEN`) liegt nur in der Umgebung des Workers (optionales Secret `apicore-telegram`); der API-Pod kennt nur `TELEGRAM_BOT_USERNAME` für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über `NotificationDelivery`, nie aus Anfragen, Handlern oder Jobs; die Zustellung läuft in der Schleife `telegram-send` (Abschnitt 11.13). Der Sender nimmt genau ein Worker-Replikat an (`replicas: 1`): Chat-Abstand, Senderate und die Pausen nach 401 und 429 liegen im Speicher des Pods, mit mehreren Pods hätte jeder seine eigenen. Die SQL-Regel „eine Zustellung je Nutzer und Batch“ und Telegrams 429-Antworten schützen den Bot auch dann. Ohne `FRONTEND_ORIGIN` geht die Nachricht außerhalb der Entwicklung (`NODE_ENV` leer oder `development`) ohne Link hinaus (ein Warn-Log je Pod), nie mit einem localhost-Link. Abschnitt 11.13.
|
||||
47. **Pflanzen-Meldungen, Neuberechnung und Saisonwechsel**: Rechte: `phenology:read` (Liste), `phenology:create` (Melden), `phenology:update` (Bearbeiten und Zurückziehen); `GET /phenology/state` braucht `apiaries:read` und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); `apiaryId` leer (`""`) ergibt 400, `null` heißt „alle Stände“. Wer `phenology:update` hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. `PhenologyReported` und `PhenologyCorrected` (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. `PhenologyCorrected` trägt `previousApiaryId` (beim Zurückziehen gleich `apiaryId`): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite `null` (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler `phenology.recompute` rechnet für den späteren von Berliner Tag des Ereignisses (`occurredAt`) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (kein Schlüssel), und `phenology.recompute-apiary` rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. **Bewusste Abweichung von der Spec:** Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. `SeasonPhaseChanged` bleibt für den Autopiloten.
|
||||
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
|
||||
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
|
||||
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), mit einer Ausnahme (Entscheidung zu Task 27): `Apiary` wird vor `Colony` gesperrt. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Karte im Code führt `Apiary` weiterhin weiter hinten (Stand dieses Dokuments): Die Ausnahme steht als Regel hier, nicht in der Karte.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
@@ -272,7 +276,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
|
||||
| `TELEGRAM_BOT_USERNAME` | ConfigMap, API und Worker | Benutzername des Bots (ohne `@`, 5–32 Zeichen, `^[A-Za-z][A-Za-z0-9_]{4,31}$`), erzeugt den Deep Link; ungültig = nicht gesetzt, mit Warnung in jeder Rolle |
|
||||
| `TELEGRAM_BOT_TOKEN` | Secret `apicore-telegram`, nur Worker | Bot-Token von BotFather. Der API-Pod darf das Secret nicht mounten. Fehlt er, ist Telegram aus; ein Benutzername ohne Token oder umgekehrt warnt im Log |
|
||||
|
||||
Die Manifeste verdrahten das Telegram-Secret noch nicht (Tasks 35/36).
|
||||
Die Manifeste verdrahten das Telegram-Secret noch nicht; ohne Token läuft die Worker-Schleife `telegram-send` nicht (Abschnitt 10).
|
||||
|
||||
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
||||
|
||||
@@ -318,17 +322,19 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
|
||||
**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
|
||||
|
||||
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden.
|
||||
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen mit einer Ausnahme: `Apiary` kommt vor `Colony` (Annahme 50).
|
||||
|
||||
**Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.
|
||||
|
||||
**Neue und geänderte Ereignisse.** `ColonySplit { parentColonyId, lineageId, queenSource | null, splitOn }` (Aggregat: das neue Volk) schreibt `ColoniesService.split` in der Teil-Transaktion. `QueenAssigned { colonyId, historyId, assignedOn, previousColonyId | null }` (Aggregat: die Königin) schreibt `QueensService.assignColony` nach dem Sperren beider Völker (eine Anweisung, aufsteigend nach id) und der Königin; ein geänderter vorheriger Stand zwischen Vorab-Lesung und Sperre ergibt 409. `ColonyEnded` trägt jetzt `{ status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `InspectionOverdue`, `VarroaMeasurementDue` und `QueenAged` haben zusätzliche, optionale Payload-Felder (Abschnitt „Jobs“, `daily-evaluation`).
|
||||
|
||||
**Ereignisse der Honig-Kette und der Pflanzenentwicklung (T4b).** Sieben Typen kommen in den Katalog (`domain-event-types.ts`), Aggregattypen `Extraction`, `HoneyBatch`, `PhenologyReport` und `Apiary` dazu:
|
||||
**Ereignisse der Honig-Kette und der Pflanzenentwicklung (T4b).** Sieben Typen kommen in den Katalog (`domain-event-types.ts`), Aggregattypen `Extraction`, `HoneyBatch`, `PhenologyReport` und `Apiary` dazu; die Lieferung der Meldungs-API und der Autopilot-Regeln A4–A10 ergänzt `PhenologyRecomputeRequested` und `WinterCheckRecorded` (Aggregattyp `WinterCheck`):
|
||||
- `ExtractionRecorded` und `ExtractionCorrected` (Aggregat `Extraction`, Payload `harvestIds`, bei der Korrektur nach der Änderung) schreibt `ExtractionsService` in der Transaktion des Schreibens, zusammen mit dem Audit-Eintrag.
|
||||
- `HoneyBatchRecorded` und `HoneyBatchCorrected` (Aggregat `HoneyBatch`, Payload `extractionId`) schreibt `HoneyBatchesService` ebenso. `update` sperrt zuerst die Zeile und liest „vorher“ danach (Abschnitt 11.12).
|
||||
- `PhenologyReported` und `PhenologyCorrected` (Aggregat `PhenologyReport`) stehen im Katalog; die Meldungs-API schreibt sie in einer späteren Lieferung (Abschnitt 11.11).
|
||||
- `SeasonPhaseChanged` (Aggregat `Apiary`, Payload `apiaryId`, `phase`, `segment`, `previousPhase`, `previousSegment`, `source`) schreibt `PhenologyStateService`, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über `dailyDedupeKey`. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Einen Handler für `SeasonPhaseChanged` gibt es noch nicht.
|
||||
- `PhenologyReported` und `PhenologyCorrected` (Aggregat `PhenologyReport`) schreibt `PhenologyService` mit Datensatz und Audit-Eintrag in einer Transaktion: Anlegen → `PhenologyReported`, Bearbeiten und Zurückziehen → `PhenologyCorrected` mit `previousApiaryId` (Annahme 47, Abschnitt 11.11). Ändert ein Bearbeiten nichts, entsteht kein Ereignis.
|
||||
- `SeasonPhaseChanged` (Aggregat `Apiary`, Payload `apiaryId`, `phase`, `segment`, `previousPhase`, `previousSegment`, `source`) schreibt `PhenologyStateService`, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über `dailyDedupeKey`. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Der Handler `autopilot.apiary-sync` hört darauf; eine Einzelmeldung je Saisonwechsel gibt es bewusst nicht (Annahme 47).
|
||||
- `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (Aggregat `Apiary`, ohne Schlüssel) schreibt der Handler `phenology.recompute` je aktivem Stand, wenn eine Meldung für alle Stände gilt; `phenology.recompute-apiary` rechnet damit einen Stand je Zustellung.
|
||||
- `WinterCheckRecorded { colonyId }` (Aggregat `WinterCheck`) schreiben `WinterChecksService.create` und `update` in ihrer Transaktion (ein Verschieben auf ein anderes Volk meldet auch das alte); `autopilot.colony-sync` löst damit A9/A10 sofort auf.
|
||||
`Apiary`, `PhenologyReport`, `Extraction` und `HoneyBatch` stehen am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `NotificationDelivery` steht ganz zuletzt (Abschnitt 11.13).
|
||||
|
||||
**Handler-Übersicht (T4).** Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über `tx` schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (`lockRowForUpdate`; fehlt es, ist nichts zu tun), Status über `tx` neu lesen (beendet: nichts), Regel über `readRule(tx, …)` lesen (abgeschaltet: nichts vorschlagen).
|
||||
@@ -343,7 +349,10 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
| `chains.queen-cells` | `InspectionRecorded` | Erledigt zuerst offene Ketten-Vorschläge (Notiz „Mit Durchsicht erledigt“): `SWARM_CHECK` bei Weiselzellen alle, sonst die bis zum Durchsichtstag fälligen; `QUEEN_ACCEPTANCE` (fällig bis Durchsichtstag) nur bei `queenSeen` oder `eggsPresent`. Bei `queenCellsPresent = true` und aktiver Regel `QUEEN_CELLS_SWARM_CHECK`: PLAN `SWARM_CHECK` (`inspections:read`), Anker Durchsicht-ID, fällig Durchsichtstag + `dueInDays` (7). |
|
||||
| `chains.queen-acceptance` | `QueenAssigned` | Regel `QUEEN_ACCEPTANCE_CHECK` aktiv: PLAN `QUEEN_ACCEPTANCE` (`queens:read`), Anker `historyId`, fällig `assignedOn` + `dueInDays` (7). |
|
||||
| `chains.split-schedule` | `ColonySplit` | Regel `SPLIT_SCHEDULE` aktiv und `queenSource` gesetzt: `RAISE_OWN` und `QUEEN_CELL` → PLAN `SPLIT_PLAN` (`colonies:read`), `QUEEN` → PLAN `QUEEN_ACCEPTANCE` (`queens:read`); Anker `lineageId`, Plan = Schritte der Variante ab `splitOn` (Abschnitt 11.6). |
|
||||
| `autopilot.colony-sync` | 19 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` | Ruft `AutopilotService.syncColonyIn(tx, colonyId, berlinToday())` je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei `ColonySplit` neues und Muttervolk, bei `QueenAssigned` Volk und vorheriges Volk; Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). |
|
||||
| `autopilot.colony-sync` | 20 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue`, `WinterCheckRecorded` | Betrifft ein Ereignis mehrere Völker, sperrt der Handler sie zuerst in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend); dann ruft er `AutopilotService.syncColonyIn(tx, colonyId, berlinToday())` je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei `ColonySplit` neues und Muttervolk, bei `QueenAssigned` Volk und vorheriges Volk, bei `WinterCheckRecorded` das Volk der Prüfung (A9/A10 lösen sich sofort auf); Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). |
|
||||
| `autopilot.apiary-sync` | `SeasonPhaseChanged` | Gleicht alle lebenden Völker des Stands ab (`AutopilotService.syncApiaryIn(tx, apiaryId, berlinToday())`): Stand sperren, Völker lesen, alle in einer Anweisung sperren (Id aufsteigend), Regeln und Saison einmal lesen, dann je Volk `syncColonyIn`. Ergänzt `autopilot.colony-sync`; es entsteht höchstens ein Ereignis je Stand und Tag, spätere Wechsel fängt der tägliche Schritt `autopilot` auf (Annahmen 48, 50). |
|
||||
| `phenology.recompute` | `PhenologyReported`, `PhenologyCorrected` | Berechnet den Saisonzustand der betroffenen Stände in der Zustell-Transaktion neu (`PhenologyStateService.recomputeIn`, am späteren von Ereignistag und heute); bei `PhenologyCorrected` darf die Phase zurückfallen (`allowBackward`). Betroffen: Stand und, bei einer Korrektur, `previousApiaryId`; gilt eine Seite für alle Stände, schreibt er je aktivem Stand ein `PhenologyRecomputeRequested`. Fehler gehen als Wiederholung durch (Annahme 47). |
|
||||
| `phenology.recompute-apiary` | `PhenologyRecomputeRequested` | Rechnet genau einen Stand neu; ein nicht mehr aktiver oder gelöschter Stand wird übersprungen. |
|
||||
| `withdrawal.harvest-check` | `HarvestRecorded`, `HarvestCorrected` | Prüft Wartezeit-Konflikte der Ernte und schreibt die Meldung (Abschnitt 11.8). |
|
||||
| `withdrawal.bottling-check` | `BatchBottled`, `BottlingCorrected` | Wie oben für die Honigcharge der Abfüllung; bei `removed: true` nichts. |
|
||||
| `withdrawal.treatment-check` | `TreatmentRecorded`, `TreatmentCorrected` | Prüft Ernten des Volkes, die das (nachträglich erfasste oder verlängerte) Wartezeitfenster treffen. |
|
||||
@@ -354,7 +363,11 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
| `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. |
|
||||
|
||||
**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. Die LISTEN-Kanäle bleiben unverändert (`notification_delivery` kommt mit der Telegram-Zustellung, 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.
|
||||
**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.
|
||||
|
||||
**Zustellplanung.** Kein Handler: `NotificationsService.write` plant nach dem Einfügen der Meldungen (dieselbe Transaktion; ohne Aufrufer-Transaktion öffnet es eine eigene, damit Meldungen und Zustellungen zusammen bestehen) über den `NotificationDeliveryPlanner` je neu eingefügter Meldung höchstens eine `NotificationDelivery` für Telegram und weckt den Worker per `pg_notify('notification_delivery', '')`, nur wenn Zeilen entstanden sind. Eine wiederholte Meldung (gleicher `dedupeKey`) plant nichts. Regeln: Annahme 49, Abschnitt 11.13.
|
||||
|
||||
**Worker-Schleife `telegram-send`.** Takt 5 000 ms und auf `notification_delivery`; sie ist nur registriert, wenn `TELEGRAM_BOT_TOKEN` gültig gesetzt ist (`TelegramClient.isConfigured()`). Ein Takt ruft `TelegramSender.releaseStaleClaims()` und dann `drain(stopping.signal)` auf. Mit `telegram-updates` (Task 36, noch offen) ist sie der einzige Netzzugriff zur Bot-API. Rechte kommen aus dem globalen `JobPermissionsModule` des Workers, `TelegramModule` importiert kein `PermissionsModule`.
|
||||
|
||||
### Webhook-Zustellung
|
||||
|
||||
@@ -376,14 +389,15 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
|
||||
**`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit.
|
||||
|
||||
**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PENDING`- und `PROCESSING`-Einträge bleiben immer stehen.
|
||||
**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PROCESSING`-Einträge bleiben immer stehen, `PENDING` ebenso, mit der einen Ausnahme der Telegram-Zustellungen unten.
|
||||
- Läufe, die seit mehr als 6 h `RUNNING` sind (`JOB_RUN_STALE_HOURS`, weit über jeder `activeDeadlineSeconds`), werden `FAILED` mit `error: 'aborted (pod ended without result)'`; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert.
|
||||
- Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (`IdempotencyCleanupService`; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen).
|
||||
- `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten.
|
||||
- `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen.
|
||||
- `JobRun`-Zeilen nach 90 Tagen.
|
||||
- `NotificationDelivery` (nach `createdAt`, auch Antworten des Bots ohne Nutzer): `DELIVERED` und `SKIPPED` nach 30 Tagen, `FAILED` nach 90 Tagen, `PENDING` nach 7 Tagen (`PENDING_DELIVERY_MAX_AGE_DAYS`; eine so alte Zustellung ist hängen geblieben, sie wird mit Warn-Log samt Zahl gelöscht). `TelegramLinkToken` 30 Tage nach Ablauf.
|
||||
|
||||
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`).
|
||||
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`, `notificationDeliveries.done`, `notificationDeliveries.failed`, `notificationDeliveries.pending`, `telegramLinkTokens`).
|
||||
|
||||
**`task-templates`** (täglich 05:00) ruft `TasksService.generateFromTemplates()` auf und meldet `{ created }`. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor `GET /tasks`.
|
||||
|
||||
@@ -399,7 +413,7 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
|
||||
- Handler: `TaskDue` → `notifications.task-due`; `WithdrawalEnded` → `notifications.withdrawal-ended` und `autopilot.colony-sync`; `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` → `autopilot.colony-sync`.
|
||||
- **`autopilot`** (`AutopilotStep`): lädt Regeln und Saisonzustände einmal vorab (`preloadFor`) und gleicht dann jedes lebende Volk in einer eigenen Transaktion ab (`syncColonyIn`, Abschnitt 11.5). Zähler `autopilotColonies`, `suggestionsOpened`, `suggestionsResolved`. Scheitert ein Volk, läuft der Schritt weiter und endet mit `JobPartialFailure`.
|
||||
- **`suggestions-expiry`**: `OPEN` mit `expiresAt` vor heute → `EXPIRED`, gibt den `dedupeKey` frei. Zähler `suggestionsExpired`; zweimal am selben Tag ändert nichts.
|
||||
- **`digest`**: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler `digestsWritten`; ein Wiederholungslauf am selben Tag schreibt 0.
|
||||
- **`digest`**: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler `digestsWritten` und, nur wenn mindestens eine Übersicht geschrieben wurde, `digestWindowEnd` (Beginn des Schritts, ISO-Zeit; Zeitfenster der Saison-Zeile, Abschnitt 11.9); ein Wiederholungslauf am selben Tag schreibt 0 und merkt kein Fensterende.
|
||||
|
||||
**`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“.
|
||||
|
||||
@@ -413,7 +427,7 @@ Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:m
|
||||
|
||||
## 11. Logik-Ketten und Saison-Autopilot (T4a bis T4c)
|
||||
|
||||
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie das Telegram-Fundament (11.13). Noch offen sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`), die Meldungs-API der Pflanzenphasen, die Autopilot-Regeln A4–A10, die Buchungen des Bewegungsbuchs zur Laufzeit (Task 29) und die Telegram-Zustellung (Tasks 35/36).
|
||||
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13). Noch offen sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`) und das Verknüpfen und Abfragen von Telegram (Task 36).
|
||||
|
||||
### 11.1 Vorschläge
|
||||
|
||||
@@ -421,7 +435,7 @@ Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensc
|
||||
|
||||
**PLAN und DO.** `PLAN` hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus `PLAN_STEP_TITLES`, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. `DO` ist genau eine Aufgabe und braucht ein Datum (eigenes oder `dueDate` des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus `renderSuggestion` mit dem `actionTemplate` der Regel. Die Aufgabe entsteht über `TasksService.createInTx` (Audit, `TaskCreated`, danach Kalender und Webhook). `sourceInspectionId` wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter `FOR SHARE` geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein `assignedUserId`, der nicht existiert oder inaktiv ist: 400 mit `issues`.
|
||||
|
||||
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference).
|
||||
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference; Ausnahme: `Apiary` vor `Colony`, Annahme 50).
|
||||
|
||||
| von | nach | Auslöser |
|
||||
|---|---|---|
|
||||
@@ -478,17 +492,24 @@ Quelle der Grenzen ist die LWG Veitshöchheim; die Zahlen stehen als Bänder in
|
||||
|
||||
### 11.5 Saison-Autopilot und „Die nächsten Schritte“
|
||||
|
||||
**Abgleich.** `AutopilotService.syncColonyIn(tx, colonyId, today)` (`AutopilotModule`) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (`loadAutopilotInputIn`) und die Regeln (`SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `VARROA_THRESHOLD` nur `recheckAfterDays`, `PHENOLOGY`). Ein beendetes Volk wird übersprungen (das Aufräumen ist `suggestions.colony-ended`). Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren `dedupeKey` nicht mehr vorkommt, werden `RESOLVED` („Bedingung entfällt“); bei gleichem Schlüssel frischt `refreshIn` Belege, Fälligkeit, Priorität und `reasonCode` auf; ein neuer Kandidat geht über `proposeIn` (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; `rules.A<n> = false` schaltet eine Regel ab, `INSPECTION_INTERVAL.active = false` nur A1. Der Handler `autopilot.colony-sync` und der Job-Schritt `autopilot` rufen ihn auf (Abschnitte 10 und „Jobs“).
|
||||
**Abgleich.** `AutopilotService.syncColonyIn(tx, colonyId, today)` (`AutopilotModule`) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (`loadAutopilotInputIn`) und die Regeln (`SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `VARROA_THRESHOLD` nur `recheckAfterDays`, `PHENOLOGY`). Ein beendetes Volk wird übersprungen (das Aufräumen ist `suggestions.colony-ended`). Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren `dedupeKey` nicht mehr vorkommt, werden `RESOLVED` („Bedingung entfällt“); bei gleichem Schlüssel frischt `refreshIn` Belege, Fälligkeit, Priorität und `reasonCode` auf; ein neuer Kandidat geht über `proposeIn` (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; `rules.A<n> = false` schaltet eine Regel ab, `INSPECTION_INTERVAL.active = false` nur A1. Der Handler `autopilot.colony-sync` und der Job-Schritt `autopilot` rufen ihn auf (Abschnitte 10 und „Jobs“). Wechselt ein Stand seine Saison (`SeasonPhaseChanged`), gleicht `autopilot.apiary-sync` alle seine lebenden Völker ab (`syncApiaryIn`, Sperrreihenfolge Stand, dann alle Völker in einer Anweisung: Annahme 50). Eine Winterprüfung (`WinterCheckRecorded`) gleicht das betroffene Volk sofort ab, sodass A9/A10 ohne Warten auf den Tagesjob verschwinden.
|
||||
|
||||
**Regeln A1–A3** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`; A4–A10 folgen später):
|
||||
**Regeln A1–A10** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`, Annahme 48):
|
||||
|
||||
| Nr | Absicht | Wann | Anker | Fälligkeit, Priorität |
|
||||
|---|---|---|---|---|
|
||||
| A1 | `INSPECT` | Abschnitt ≠ `WINTERRUHE`, `inspectionDueOn` ≤ heute + 2 (`INSPECTION_DUE`, bzw. `INSPECTION_FIRST_OF_YEAR`) | ID der letzten Durchsicht oder `none` | `inspectionDueOn`; überfällig = `HIGH`, sonst `NORMAL` |
|
||||
| A2 | `VARROA_MEASURE` | `varroaMeasurementDue` ≤ heute (`VARROA_MEASURE_DUE`, im Winterfenster `VARROA_WINTER_MEASURE`) | ID der letzten Messung oder `winter-<Jahr>` | Fälligkeitstag; wird von A3 unterdrückt, solange A3 gilt |
|
||||
| A3 | `VARROA_RECHECK` | Behandlungsende + `recheckAfterDays` ≤ heute, keine Messung nach dem Ende, Ende höchstens 60 Tage her (Annahme 38); `requiredPermission` `varroa:read` | Behandlungs-ID | Tag, ab dem die Bedingung gilt |
|
||||
| A4 | `SUMMER_TREATMENT` (`PLAN`) | Abschnitt `SPAETSOMMER`, keine Behandlung seit Abschnittsbeginn (`SUMMER_TREATMENT_DUE`) | Jahr | heute; `HIGH` |
|
||||
| A5 | `FEED` (`DO`) | `SPAETSOMMER` oder `HERBST`, keine Fütterung seit Beginn von `SPAETSOMMER` (`AUTUMN_FEED_MISSING`); in `HERBST` gilt der Kalendertag von `TRACHTENDE`, höchstens der Beginn von `HERBST` (Annahme 48) | Jahr | heute |
|
||||
| A6 | `FEED` (`DO`) | nicht `WINTERRUHE`; Futter der letzten Durchsicht `WEAK` oder `CRITICAL`, sonst Volkskarte `CRITICAL` (`FEED_LOW`, Beleg `source` `INSPECTION` oder `COLONY_CARD`); keine Fütterung seit der Durchsicht (ohne Durchsicht: seit dem späteren von Anlagetag und heute − 60 Tage) | ID der Durchsicht oder `none` | heute |
|
||||
| A7 | `HONEY_SUPER` (`DO`) | Phase ab `KIRSCHE` bis vor `LINDE` (Reihenfolge `PHENOLOGY_PHASES`), `colonyStrength` einer Durchsicht ab `springSince` des Jahres (sonst ab 1. Januar) ≥ `honeySuperMinFrames` (`HONEY_SUPER_CHECK`) | Jahr | heute |
|
||||
| A8 | `QUEEN_REPLACE` (`PLAN`) | `SCHWARMZEIT` oder `SOMMER`, Königin gealtert (`isQueenAged`, `queenMaxAgeYears`; `QUEEN_OLD`) | `<Königin>-<Jahr>` | heute |
|
||||
| A9 | `WINTER_CHECK` (`DO`) | `HERBST`, kein `WinterCheck` `AUTUMN` mit `winterYear` = Jahr (`WINTER_CHECK_AUTUMN_MISSING`) | `<Jahr>-AUTUMN` | heute |
|
||||
| A10 | `WINTER_CHECK` (`DO`) | `FRUEHJAHR`, Einwinterung des Vorwinters vorhanden, `SPRING` fehlt (`WINTER_CHECK_SPRING_MISSING`) | `<Jahr−1>-SPRING` | heute |
|
||||
|
||||
Als „letzte Behandlung“ zählt das späteste Ende (`endDate ?? date`); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf `expiresAt` siehe Annahme 38.
|
||||
Als „letzte Behandlung“ zählt das späteste Ende (`endDate ?? date`); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf `expiresAt` siehe Annahme 38; alle Regeln sind einzeln über `config.rules` schaltbar. Der Grund von A4 und A5 nennt die Quelle der Phase aus `SeasonState.phaseSource` (gemeldet, geschätzt, Kalender).
|
||||
|
||||
**Nächste Schritte.** `NextStepsService` liefert `GET /colonies/:id/next-steps` (`ColonyNextSteps`, höchstens `maxStepsPerColony` = 3) und `GET /autopilot/today` (`AutopilotToday`: dringlichster Schritt über alle lebenden Völker mit demselben Vergleich, fällige Durchsichten je Stand, Zahl offener Vorschläge, Abschnitt je Stand); beide `colonies:read`, 404 bei unbekanntem Volk. Die Zahl der Abfragen ist unabhängig von der Zahl der Völker.
|
||||
- Schritte = offene Aufgaben (`OPEN`, `IN_PROGRESS`) plus sichtbare offene Vorschläge, nur mit `tasks:read` (ohne: keine Schritte). Ein offener Vorschlag entfällt, wenn für das Volk eine offene Aufgabe derselben Absicht besteht.
|
||||
@@ -528,11 +549,13 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
|
||||
|
||||
### 11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht
|
||||
|
||||
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` wird nie geschrieben. Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
|
||||
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
|
||||
|
||||
**Erinnerung je Aufgabe.** Handler `notifications.task-due` (`RemindersModule`, von `AppModule`, `WorkerModule` und `JobsModule` importiert) schreibt zu `TaskDue` je Empfänger eine Meldung (HINWEIS, `TASK_DUE`, Link `/tasks`, Schlüssel `task-due:<Aufgabe>:<dueOn>:<userId>`): „Heute fällig: <Titel>“, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: <Titel>“. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit `tasks:complete`; immer nur mit `tasks:read` und eingeschaltetem `taskDueEnabled` (fehlende Einstellung = an). Nur offene Aufgaben (`OPEN`, `IN_PROGRESS`); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen.
|
||||
|
||||
**Morgenübersicht.** Der Schritt `digest` (`DigestService.runFor`) schreibt je aktivem Nutzer mit aktiver Rolle und `digestEnabled` höchstens eine Meldung je Tag (INFO, `DIGEST`, Link `/dashboard`, Schlüssel `digest:<Tag>:<userId>`). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (`tasks:read`), „Durchsicht fällig: n Völker am Stand …“ (`colonies:read` und `inspections:read`), „n Vorschläge warten.“ (`tasks:read`, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (`apiaries:read`, nur am Tag des Abschnittsbeginns). Ohne Zeile keine Übersicht. `requiredPermissions` der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird `FAILED`.
|
||||
**Morgenübersicht.** Der Schritt `digest` (`DigestService.runFor`) schreibt je aktivem Nutzer mit aktiver Rolle und `digestEnabled` höchstens eine Meldung je Tag (INFO, `DIGEST`, Link `/dashboard`, Schlüssel `digest:<Tag>:<userId>`). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (`tasks:read`), „Durchsicht fällig: n Völker am Stand …“ (`colonies:read` und `inspections:read`), „n Vorschläge warten.“ (`tasks:read`, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (`apiaries:read`, Saison-Zeile, siehe unten). Ohne Zeile keine Übersicht. `requiredPermissions` der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird `FAILED`.
|
||||
|
||||
**Saison-Zeile.** Saisonwechsel kommen nur hier vor, nie als Einzelmeldung (bewusste Abweichung von der Spec, Annahme 47). Die Zeile entsteht aus den `SeasonPhaseChanged`-Ereignissen mit `occurredAt` im Fenster `[von, bis)`: `bis` ist der Beginn des Schritts, `von` das `digestWindowEnd` des jüngsten abgeschlossenen `daily-evaluation`-Laufs (`SUCCEEDED` oder `FAILED`, unter den letzten 10), der eines gemerkt hat, sonst `bis` − 24 Stunden. Der nächste Lauf beginnt damit, wo der vorige aufhörte; jeder Wechsel steht genau einmal da, auch einer, den eine Meldung tagsüber nach 04:30 ausgelöst hat. Ein Lauf, der keine Übersicht schrieb (Wiederholung am selben Tag, kein Inhalt), merkt kein Fensterende, sodass das nächste Fenster weiter zurückreicht. Je Stand werden die Ereignisse zusammengezogen (erster vorheriger bis letzter Abschnitt); reine Phasenwechsel, der erste Zustand eines Jahres (kein vorheriger Abschnitt) und Wechsel ohne Nettoänderung ergeben keine Zeile, ebenso Stände ohne lebende Völker. Ein Wechsel vorwärts lautet „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“, ein Rückschritt (zurückgezogene Meldung; `HERBST` → `WINTERRUHE` zählt als vorwärts) neutral „Stand {Name}: wieder {Abschnitt}.“. Bekannte Ränder: Ein manueller Wiederholungslauf am selben Tag schreibt nichts, ein Ereignis, das nach `bis` committet, aber davor gestempelt ist (eine über 04:30 offene Transaktion), kann fehlen; beides ist selten und hingenommen.
|
||||
|
||||
**Job-Ausfall.** Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (Abschnitt 10, Jobs) einmal je Job und Berliner Tag (Kategorie `SYSTEM`).
|
||||
|
||||
@@ -546,7 +569,7 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
|
||||
|
||||
### 11.11 Pflanzenentwicklung und Wetter
|
||||
|
||||
Stand: Schema, Wetterquelle, Berechnung und Job-Schritt sind fertig. Die API, mit der Nutzer Phasen melden und zurückziehen, kommt in einer späteren Lieferung; die Ereignisse `PhenologyReported` und `PhenologyCorrected` stehen schon im Katalog (Abschnitt 10).
|
||||
Stand: Schema, Wetterquelle, Berechnung, Job-Schritt und die Meldungs-API (Melden, Bearbeiten, Zurückziehen mit Neuberechnung) sind fertig.
|
||||
|
||||
**Tabellen** (Migration `20261015100000_t4_phenology`, Annahme 43). `PhenologyReport` hält eine gemeldete Phase (Stand oder alle Stände, Datum, Rücknahme per `revokedAt`; Index `(apiaryId, phase, date)`). `ApiaryPhenology (apiaryId, year)` ist der abgeleitete Saisonzustand. `ApiaryWeatherDay` ist der Wetter-Cache (Tagesmittel, `source` `forecast` oder `archive`, `fetchedAt`). Rechte `phenology:read`, `phenology:create`, `phenology:update` (Gruppe „Pflanzenentwicklung“). Backup: Abschnitt 8.
|
||||
|
||||
@@ -562,11 +585,13 @@ Stand: Schema, Wetterquelle, Berechnung und Job-Schritt sind fertig. Die API, mi
|
||||
- Je Durchlauf ein Log mit `points`, `daysWritten`, `failedPoints`, `endedEarly`.
|
||||
- Der Job `cleanup` löscht `ApiaryWeatherDay` vor dem 1. Januar von (Berliner Jahr − 4); Ergebniszähler `weatherDays`.
|
||||
|
||||
**Berechnung.** Annahme 36 beschreibt Phase, GTS, Kalibrierung und Rückfall. Der Job-Schritt `phenology` (Abschnitt 10) und künftig jede Meldung oder Rücknahme rufen `PhenologyStateService.recomputeIn`. Die Funktion sperrt zuerst die Zeile `Apiary` und liest dann die gespeicherte Zeile, lädt die Meldungen der letzten 3 Jahre (Filter in SQL: eigener Stand oder alle Stände, nicht zurückgezogen) und das Wetter des Jahres sowie früherer Jahre mit Kalibrierungsmeldung, kalibriert die Schwellen und schreibt `ApiaryPhenology` per Upsert. Die Kalibrierung eines Vorjahres nimmt die GTS bis zum Vortag des ersten Meldetags, also das, was der Morgen jenes Tages gezeigt hätte. `recomputeAll` liest Regel und Meldungen einmal vorab und rechnet dann je aktivem Stand in einer eigenen Transaktion; ein gescheiterter Stand wird gezählt und geloggt, der Lauf geht weiter. Eine nach dem Vorab-Lesen zurückgezogene Meldung sieht nur die Neuberechnung dieser Meldung selbst. `ApiaryPhenology` speichert keine Notiz, kein `reportedOn` und kein `springSince`; `seasonStateFromRow` (`src/phenology/phenology-row.ts`) leitet sie ab (Notiz aus den Koordinaten, `reportedOn` leer). Die gespeicherte GTS ist nur gesetzt, wenn sie nutzbar war. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen, damit ein Rollout mitten in der Saison kein falsches `SeasonPhaseChanged` erzeugt. Rücknahme-Handler (später) müssen `recomputeIn` mit `allowBackward: true` aufrufen.
|
||||
**Berechnung.** Annahme 36 beschreibt Phase, GTS, Kalibrierung und Rückfall. Der Job-Schritt `phenology` (Abschnitt 10) und künftig jede Meldung oder Rücknahme rufen `PhenologyStateService.recomputeIn`. Die Funktion sperrt zuerst die Zeile `Apiary` und liest dann die gespeicherte Zeile, lädt die Meldungen der letzten 3 Jahre (Filter in SQL: eigener Stand oder alle Stände, nicht zurückgezogen) und das Wetter des Jahres sowie früherer Jahre mit Kalibrierungsmeldung, kalibriert die Schwellen und schreibt `ApiaryPhenology` per Upsert. Die Kalibrierung eines Vorjahres nimmt die GTS bis zum Vortag des ersten Meldetags, also das, was der Morgen jenes Tages gezeigt hätte. `recomputeAll` liest Regel und Meldungen einmal vorab und rechnet dann je aktivem Stand in einer eigenen Transaktion; ein gescheiterter Stand wird gezählt und geloggt, der Lauf geht weiter. Eine nach dem Vorab-Lesen zurückgezogene Meldung sieht nur die Neuberechnung dieser Meldung selbst. `ApiaryPhenology` speichert keine Notiz, kein `reportedOn` und kein `springSince`; `seasonStateFromRow` (`src/phenology/phenology-row.ts`) leitet sie ab (Notiz aus den Koordinaten, `reportedOn` leer). Die gespeicherte GTS ist nur gesetzt, wenn sie nutzbar war. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen, damit ein Rollout mitten in der Saison kein falsches `SeasonPhaseChanged` erzeugt. Der Handler bei `PhenologyCorrected` ruft `recomputeIn` mit `allowBackward: true` auf.
|
||||
|
||||
**Meldungen** (`PhenologyModule`, Annahme 47). Routen: `GET /phenology/state` (`apiaries:read`; je aktivem Stand Zustand ohne Koordinaten, `reportedOn` nur bei Quelle `REPORT` der früheste Meldetag der geltenden Phase), `GET /phenology/reports?year=` (`phenology:read`, Standard laufendes Jahr, auch zurückgezogene mit `revokedAt`, Stand- und Nutzername), `POST /phenology/reports` (`phenology:create`, `@Idempotent()`; Phase, Datum `JJJJ-MM-TT`, `apiaryId` oder `null` für alle, Notiz höchstens 500 Zeichen), `PATCH /phenology/reports/:id` (`phenology:update`; `null` auf Phase oder Datum: 400, auf Stand oder Notiz: leert), `POST /phenology/reports/:id/revoke` (`phenology:update`, `@Idempotent()`, schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (`lockRowForUpdate`, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler `phenology.recompute` und `phenology.recompute-apiary` (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9).
|
||||
|
||||
### 11.12 Honig-Kette und Bewegungsbuch
|
||||
|
||||
Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung und der Webhook sind fertig. Die Laufzeit-Buchungen des Bewegungsbuchs (Anfangsmenge und Abfüllbuchungen beim Anlegen und Abfüllen) kommen mit Task 29; bis dahin schreibt kein Code `initialAmount` oder Buchungen zur Laufzeit.
|
||||
Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook und die Laufzeit-Buchungen des Bewegungsbuchs sind fertig. Offen ist die Bestätigung beim Wassergehalt der Abfüllung (409, `acknowledgedWarnings`).
|
||||
|
||||
**Ereignisse.** `ExtractionRecorded`/`ExtractionCorrected` und `HoneyBatchRecorded`/`HoneyBatchCorrected` entstehen in der Schreib-Transaktion mit dem Audit-Eintrag (Abschnitt 10). Der Webhook `batch.created` kommt über `webhooks.forward` aus `HoneyBatchRecorded`; `HoneyBatchCorrected` sendet nichts, wie vorher beim Ändern.
|
||||
|
||||
@@ -580,20 +605,22 @@ Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung und de
|
||||
**Plausibilitätswarnungen** (`src/honey-batches/honey-plausibility.ts`; Antwort `warnings` bei `POST` und `PATCH`, nie 4xx; Toleranz 0,5 g; Zahlen deutsch formatiert):
|
||||
- `EXTRACTION_EXCEEDS_HARVEST` (WARNUNG): Menge der Schleuderung größer als die Summe der Nettogewichte; nur wenn alle Nettogewichte bekannt sind.
|
||||
- `HARVEST_NET_MISSING` (HINWEIS): mindestens eine Ernte ohne Nettogewicht, ein Vergleich ist nicht möglich.
|
||||
- `BATCHES_EXCEED_EXTRACTION` (WARNUNG): Summe der Anfangsmengen der Chargen größer als die Menge der Schleuderung. Solange die Laufzeit `initialAmount` nicht schreibt, zählt eine Charge ohne `initialAmount` mit ihrem aktuellen `amount` (Rückfall in `HoneyBatchesService.batchesOf`; Task 29 entfernt ihn); Chargen ohne `amount` zählen nicht.
|
||||
- `BATCHES_EXCEED_EXTRACTION` (WARNUNG): Summe der Anfangsmengen der Chargen größer als die Menge der Schleuderung. Eine Charge ohne `initialAmount` zählt nicht (auch nicht für den Rest der Vorbelegung); der frühere Rückfall auf `amount` ist entfernt.
|
||||
- `WITHDRAWAL_ACTIVE` bei der Schleuderung: Der Handler `withdrawal.extraction-check` und die Antwort prüfen über `WithdrawalCheckService.conflictsForExtraction`. Die Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`. Die Meldung nutzt denselben Schlüssel wie Ernte und Abfüllung (Abschnitt 11.8), ein Konflikt wird also nur einmal gemeldet.
|
||||
|
||||
**Bewegungsbuch.** Annahme 44. Schema und Rückrechnung: Migration `20261015100100_t4_honey_ledger`, `BatchMovement.honeyBatchId` (Fremdschlüssel `Restrict`, Index) und `sourceBottlingBatchId` (Fremdschlüssel `SetNull`, Index). Die Invariante `amount = initialAmount + Summe(quantity) / 1000` prüfen die Migration und der Import alter Bundles (Abschnitt 8) mit einer SQL-Abfrage (`INVARIANT_SQL` im Test-Helfer `test/utils/honey-ledger.ts`). Haltbarkeit: `Product.shelfLifeMonths` (Ganzzahl, optional; Migration `20261015100200_t4_product_shelf_life`).
|
||||
**Bewegungsbuch.** Annahme 44. Schema und Rückrechnung: Migration `20261015100100_t4_honey_ledger`, `BatchMovement.honeyBatchId` (Fremdschlüssel `Restrict`, Index) und `sourceBottlingBatchId` (Fremdschlüssel `SetNull`, Index). Die Invariante `amount = initialAmount + Summe(quantity) / 1000` prüfen die Migration und der Import alter Bundles (Abschnitt 8) mit einer SQL-Abfrage (`INVARIANT_SQL` im Test-Helfer `test/utils/honey-ledger.ts`). Haltbarkeit: `Product.shelfLifeMonths` (Ganzzahl, optional; Migration `20261015100200_t4_product_shelf_life`). Laufzeit-Buchungen: Annahme 44.
|
||||
|
||||
**API des Buchs.** `GET /honey-batches/:id/movements` (`honey-batches:read`; 404 für eine unbekannte Charge) antwortet `{ items, hasMore }` mit höchstens 200 Buchungen, neueste zuerst (`id`, `type`, `quantityGrams`, `reason`, `notes`, `sourceBottlingBatchId`, `createdAt`, `userId`). `POST /honey-batches/:id/movements` (`honey-batches:update`, `@Idempotent()`) nimmt `{ type: 'WITHDRAWAL' | 'CORRECTION', deltaKg, reason?, notes? }` (`deltaKg` endlich, ungleich 0, Betrag höchstens 100 000; Notiz höchstens 500 Zeichen) und antwortet mit der Buchung. Eine `WITHDRAWAL` braucht einen Grund („Bitte einen Grund angeben.“) und eine negative Menge („Eine Entnahme braucht eine negative Menge.“); bei einer `CORRECTION` ist ein Grund erlaubt und wird gespeichert. Eine Charge ohne erfasste Menge ergibt 400 „Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.“; ein widersprüchlicher Bestand (`initialAmount` gesetzt, `amount` leer) 409. Die Buchung schreibt Audit `HoneyBatch UPDATE` und `HoneyBatchCorrected`. Die Oberfläche zeigt die Startmenge getrennt von der Liste der Buchungen und liest `.items`.
|
||||
|
||||
**Antwort-Form.** Antworten von `POST/PATCH /extractions` und `/honey-batches` tragen `warnings`. Eine Bestätigung (`acknowledgedWarnings`, 409) gibt es hier nicht; sie gehört zur Abfüllung.
|
||||
|
||||
### 11.13 Telegram
|
||||
|
||||
Stand: Schema, Client und Prüfung beim Start sind fertig. Die Zustellung (Verknüpfen, Senden, Abfragen) kommt mit den Tasks 35 und 36; `TelegramModule` ist noch von keinem Modul importiert, und `NotificationPreference.telegramEnabled` wird nie geschrieben (Abschnitt 11.9).
|
||||
Stand: Schema, Client, Prüfung beim Start, Planung und Senden sind fertig (`TelegramModule` importieren `WorkerRuntimeModule` und `WorkerModule`). Das Verknüpfen eines Chats und das Abfragen von Nachrichten (Task 36) fehlen noch: Es gibt noch keinen Weg, eine `TelegramLink` anzulegen. `NotificationPreference.telegramEnabled` setzt bisher nur der Sender, beim Lösen der Verbindung auf aus (Abschnitt 11.9).
|
||||
|
||||
**Tabellen** (Migration `20261015110000_t4_telegram`). Enums `NotificationChannel` und `NotificationDeliveryStatus`. `TelegramLink` (Chat-Bindung eines Nutzers; eindeutig je Chat), `TelegramLinkToken` (Einmal-Token zum Verknüpfen), `NotificationDelivery` (Zustell-Warteschlange), `TelegramPollState` (genau eine Zeile `singleton`, per CHECK `id = 'singleton'`). Alle Fremdschlüssel auf `User` laufen mit `ON DELETE CASCADE`, ebenso `Notification.deliveries`. Keine dieser Tabellen ist Teil des Backups (Abschnitt 8).
|
||||
|
||||
**`NotificationDelivery`.** Eindeutig je `(notificationId, channel)`, Index auf `notificationId`. Der CHECK `NotificationDelivery_target_check` verlangt entweder Meldung und Nutzer oder Vorlage und Chat. Zustellungen mit Vorlage haben `chatId` und kein `userId`; sie entfernt die Nutzer-Kaskade nicht. Sie braucht eine eigene Aufräumregel nach Alter für Endzustände (`DELIVERED`, `SKIPPED`, `FAILED`), die Tasks 35/36 im Job `cleanup` ergänzen.
|
||||
**`NotificationDelivery`.** Eindeutig je `(notificationId, channel)`, Index auf `notificationId`. Der CHECK `NotificationDelivery_target_check` verlangt entweder Meldung und Nutzer oder Vorlage und Chat. Zustellungen mit Vorlage haben `chatId` und kein `userId`; sie entfernt die Nutzer-Kaskade nicht. Ihre Aufräumregeln nach Alter stehen im Job `cleanup` (Abschnitt 10).
|
||||
|
||||
**Dritte Outbox.** `NotificationDelivery` nutzt `src/common/outbox/outbox-claims.ts` wie Ereignisse, Webhooks und Kalender: `claimBatch`, `settleSuccess`, `settleFailure`, `releaseStaleClaims`. Neu sind `deferClaim` (zurück auf `PENDING` nach einer Wartezeit, Versuche unverändert, Wartezeit höchstens 24 h) und `settleSkipped` (`SKIPPED` mit Grund, höchstens 200 Zeichen). Beide sind Compare-and-Set auf `id` + `claimedAt` und nutzen die DB-Uhr. Weckkanal `notification_delivery` (`worker-wakeup.ts`); `NotificationDelivery` steht in der Sperr-Karte ganz zuletzt.
|
||||
|
||||
@@ -606,3 +633,18 @@ Stand: Schema, Client und Prüfung beim Start sind fertig. Die Zustellung (Verkn
|
||||
- `assertTelegramToken(role, env, logger?)` (`src/runtime/startup-checks.ts`) prüft nur die Rollen `worker` und `all`. Ein fehlender Token ist in Ordnung. Ein fehlerhafter (in Produktion auch ein Platzhalter wie `change-me…`) wirft nie: Fehler-Log ohne Wert, Telegram bleibt aus. Ein Benutzername ohne Token oder umgekehrt warnt nur im Log.
|
||||
- `TELEGRAM_BOT_USERNAME` verliert ein führendes `@` und muss `^[A-Za-z][A-Za-z0-9_]{4,31}$` erfüllen, sonst gilt er als nicht gesetzt (`telegramBotAvailable()` falsch) mit Warnung.
|
||||
- Der Wächter `no-network-outside-loops.spec.ts` hält fest, dass das Netzwerk nur in den erlaubten Dateien und nur aus Schleifen genutzt wird (Abschnitt 9).
|
||||
|
||||
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE` und `DIGEST`; `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
|
||||
|
||||
**Texte** (`src/telegram/telegram-texts.ts`). `TELEGRAM_TEMPLATES` hält die festen Texte, `renderTelegramText` baut daraus den Klartext: ohne Details nur „Neue Meldung in ApiCore.“ und Link, Standardlink `/dashboard`, ein Schrägstrich am Ende von `FRONTEND_ORIGIN` wird toleriert. Höchstens 600 Zeichen nach Codepunkten; die Meldung wird mit Auslassungspunkten gekürzt, der Link bleibt ganz, Steuer- und unsichtbare Zeichen entfallen. Ohne `FRONTEND_ORIGIN` entfällt der Link (Annahme 46).
|
||||
|
||||
**Senden** (`TelegramSender`, Schleife `telegram-send`, Abschnitt 10). Ein Durchlauf übernimmt höchstens 25 Zustellungen (`claimBatch`, älteste zuerst). Je Batch geht höchstens eine Zustellung je Nutzer bzw. Chat hinaus, die übrigen kommen 1,1 s später (`deferClaim`); ein Speicher je Chat (10 s) hält auch aufeinanderfolgende Durchläufe und Bot-Antworten auseinander, und zwischen zwei Sendungen liegen mindestens 40 ms (25 pro Sekunde). `drain` wartet nach einer Runde mit zurückgehaltenen Nachrichten 1,1 s, damit ein Schwall für eine Person nicht auf den nächsten 5-s-Takt wartet; ein Batch ist auf 120 s begrenzt (`TELEGRAM_BATCH_BUDGET_MS`), danach gehen die übrigen ohne Fehlversuch zurück, ebenso beim Herunterfahren. Vor jeder Sendung prüft eine SQL-Abfrage mit frisch gelesenen Rechten (`PermissionResolverService.getFreshPermissions`) die Gründe aus Annahme 49 in dieser Reihenfolge: `user inactive`, `telegram disabled`, `not linked`, `category off`, `permission revoked`, `stale`, `task already done`, dazu `notification gone` und `unknown template`; ein Treffer ergibt `SKIPPED` mit Grund. Antworten des Bots (`templateCode`) überspringen alle Prüfungen. Die Frische rechnet in SQL (Berliner Tag bzw. 24 Stunden gegen `now()`).
|
||||
|
||||
**Antworten von Telegram.**
|
||||
- 200: `DELIVERED`.
|
||||
- 429: `deferClaim` um `retry_after` (ohne Angabe 5 s), kein Fehlversuch; der Sender pausiert für den ganzen Pod bis dahin (`pausedUntil`, der Rest des Batches geht zurück).
|
||||
- 403 und 400 „chat not found“ (Bot blockiert, Chat gelöscht): Bei einer Nutzer-Zustellung sperrt eine Transaktion `NotificationPreference` (`lockRowForUpdate`), löscht die `TelegramLink` mit `userId` und `chatId` und schaltet nur dann `telegramEnabled` aus und schreibt die Meldung „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ (`SYSTEM`, HINWEIS), wenn die Verbindung noch auf diesen Chat zeigt; die Zustellung wird `SKIPPED` („chat unavailable“). Antworten des Bots werden nur `SKIPPED`.
|
||||
- 401 (Token ungültig): `deferClaim` um 15 min, der Sender pausiert 15 min (monotone Uhr, kein Claim), und `system:manage` bekommt einmal je Berliner Tag „Telegram: Bot-Token ungültig.“ (Schlüsselpräfix `telegram-token-invalid:<Tag>`). Scheitert die Meldung, wird das nur geloggt.
|
||||
- Alles andere (5xx, Zeitüberschreitung, Netzfehler, sonstige 4xx, unerwartete Ausnahmen): `settleFailure` mit `loggableError(...).message`, Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`).
|
||||
|
||||
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
|
||||
@@ -94,6 +94,59 @@ describe('PermissionResolverService', () => {
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('getFreshPermissions', () => {
|
||||
it('the permission check reads fresh, not the 30 s cache', async () => {
|
||||
prisma.user.findUnique.mockResolvedValueOnce(
|
||||
activeUserWithPermissions('tasks:read'),
|
||||
);
|
||||
await resolver.getPermissions('user-1');
|
||||
// The right is revoked on another replica: this process has no
|
||||
// invalidateUser call, the cache still holds the old set.
|
||||
prisma.user.findUnique.mockResolvedValueOnce(
|
||||
activeUserWithPermissions('colonies:read'),
|
||||
);
|
||||
|
||||
const fresh = await resolver.getFreshPermissions('user-1');
|
||||
|
||||
expect([...fresh]).toEqual(['colonies:read']);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fills the cache for getPermissions', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(
|
||||
activeUserWithPermissions('tasks:read'),
|
||||
);
|
||||
|
||||
await resolver.getFreshPermissions('user-1');
|
||||
const cached = await resolver.getPermissions('user-1');
|
||||
|
||||
expect([...cached]).toEqual(['tasks:read']);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is empty for an inactive user, an inactive role and a missing user', async () => {
|
||||
prisma.user.findUnique.mockResolvedValueOnce({
|
||||
isActive: false,
|
||||
role: {
|
||||
status: 'ACTIVE',
|
||||
permissions: [{ permission: { key: 'tasks:read' } }],
|
||||
},
|
||||
});
|
||||
prisma.user.findUnique.mockResolvedValueOnce({
|
||||
isActive: true,
|
||||
role: {
|
||||
status: 'INACTIVE',
|
||||
permissions: [{ permission: { key: 'tasks:read' } }],
|
||||
},
|
||||
});
|
||||
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
expect((await resolver.getFreshPermissions('u1')).size).toBe(0);
|
||||
expect((await resolver.getFreshPermissions('u2')).size).toBe(0);
|
||||
expect((await resolver.getFreshPermissions('u3')).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTokenValid', () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
|
||||
@@ -37,6 +37,19 @@ export class PermissionResolverService {
|
||||
return entry.permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The permissions read fresh (one primary-key lookup), never from the
|
||||
* cache, and stored as the cache entry. For a process that does not hear
|
||||
* invalidateUser of the API pods (the worker): a right revoked there must
|
||||
* stop mattering at once, not after the 30 s cache window. Empty for an
|
||||
* inactive user or role.
|
||||
*/
|
||||
async getFreshPermissions(userId: string): Promise<ReadonlySet<string>> {
|
||||
const entry = await this.loadEntry(userId);
|
||||
this.cache.set(userId, entry);
|
||||
return entry.permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always read fresh (one primary-key lookup) and stored as the cache
|
||||
* entry the PermissionsGuard then uses: a deactivation, role change or
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
import { lateSummerStart, loadAutopilotInputIn } from './autopilot-input';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
const season = calendarSeasonState(
|
||||
@@ -55,7 +55,13 @@ describe('loadAutopilotInputIn', () => {
|
||||
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
|
||||
]);
|
||||
|
||||
const input = await loadAutopilotInputIn(db as never, 'c1', season);
|
||||
const input = await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(input?.lastTreatment).toEqual({
|
||||
id: 'old',
|
||||
@@ -74,7 +80,13 @@ describe('loadAutopilotInputIn', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await loadAutopilotInputIn(db as never, 'c1', season);
|
||||
await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
|
||||
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
|
||||
@@ -82,9 +94,119 @@ describe('loadAutopilotInputIn', () => {
|
||||
});
|
||||
|
||||
it('has no last treatment without treatments', async () => {
|
||||
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
|
||||
const input = await loadAutopilotInputIn(
|
||||
tx([]) as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(input?.lastTreatment).toBeNull();
|
||||
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
|
||||
});
|
||||
|
||||
it('counts feedings and treatments from the real start of SPAETSOMMER', async () => {
|
||||
const lateSummer = {
|
||||
...season,
|
||||
segment: 'SPAETSOMMER' as const,
|
||||
segmentSince: '2026-07-25',
|
||||
};
|
||||
const db = tx([]);
|
||||
|
||||
await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
lateSummer,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(db.feeding.count).toHaveBeenLastCalledWith({
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
|
||||
});
|
||||
expect(db.treatment.count).toHaveBeenCalledWith({
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
|
||||
});
|
||||
});
|
||||
|
||||
it('counts nothing for late summer outside SPAETSOMMER and HERBST', async () => {
|
||||
const db = tx([]);
|
||||
|
||||
const input = await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
{ ...season, segment: 'SOMMER' },
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(input?.feedingsSinceLateSummer).toBe(0);
|
||||
expect(input?.treatmentsSinceLateSummer).toBe(0);
|
||||
expect(db.treatment.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('without an inspection counts feedings from the later of creation and the last 60 days', async () => {
|
||||
const db = tx([]);
|
||||
|
||||
await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
// created 2026-04-01, today 2026-08-20: 60 days back wins
|
||||
expect(db.feeding.count).toHaveBeenNthCalledWith(1, {
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-06-21') } },
|
||||
});
|
||||
const young = tx([]);
|
||||
young.colony.findUnique.mockResolvedValue({
|
||||
id: 'c1',
|
||||
code: 'B3',
|
||||
status: 'ACTIVE',
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
apiaryId: 'api-1',
|
||||
createdAt: d('2026-08-10'),
|
||||
feedStatus: null,
|
||||
currentQueen: null,
|
||||
});
|
||||
await loadAutopilotInputIn(
|
||||
young as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
expect(young.feeding.count).toHaveBeenNthCalledWith(1, {
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-08-10') } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('lateSummerStart', () => {
|
||||
const state = (
|
||||
segment: 'SPAETSOMMER' | 'HERBST' | 'SOMMER',
|
||||
segmentSince: string,
|
||||
) => ({ ...season, year: 2026, segment, segmentSince });
|
||||
|
||||
it('is the start of the segment in SPAETSOMMER', () => {
|
||||
expect(lateSummerStart(state('SPAETSOMMER', '2026-07-25'), '08-01')).toBe(
|
||||
'2026-07-25',
|
||||
);
|
||||
});
|
||||
|
||||
it('is the calendar day of TRACHTENDE in HERBST, at the latest the start of HERBST', () => {
|
||||
expect(lateSummerStart(state('HERBST', '2026-09-15'), '08-01')).toBe(
|
||||
'2026-08-01',
|
||||
);
|
||||
expect(lateSummerStart(state('HERBST', '2026-07-20'), '08-01')).toBe(
|
||||
'2026-07-20',
|
||||
);
|
||||
});
|
||||
|
||||
it('is null in every other segment', () => {
|
||||
expect(lateSummerStart(state('SOMMER', '2026-06-20'), '08-01')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,35 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import type { AutopilotInput } from './autopilot.types';
|
||||
|
||||
/**
|
||||
* Until Task 27 passes the real start of the late summer, the feedings and
|
||||
* treatments of A4-A6 are counted from this day of the year (the built-in
|
||||
* calendar day of TRACHTENDE).
|
||||
*/
|
||||
const LATE_SUMMER_FALLBACK_START = '08-01';
|
||||
|
||||
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
|
||||
|
||||
function lateSummerStart(season: SeasonState): string | null {
|
||||
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST'
|
||||
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
|
||||
: null;
|
||||
/** Without an inspection a feeding counts for A6 if it is at most this old. */
|
||||
const FEEDING_LOOKBACK_DAYS = 60;
|
||||
|
||||
/**
|
||||
* The day SPAETSOMMER began, for the feedings and treatments of A4/A5. In
|
||||
* SPAETSOMMER that is the real start of the segment. The stored state keeps
|
||||
* only the start of the current segment, so in HERBST it is the calendar day
|
||||
* of TRACHTENDE (`calendarTrachtende`, MM-DD), at the latest the start of
|
||||
* HERBST.
|
||||
*
|
||||
* TODO: store the real start as `lateSummerSince` on ApiaryPhenology (needs
|
||||
* a migration) so HERBST no longer depends on the calendar day.
|
||||
*/
|
||||
export function lateSummerStart(
|
||||
season: SeasonState,
|
||||
calendarTrachtende: string,
|
||||
): string | null {
|
||||
if (season.segment === 'SPAETSOMMER') {
|
||||
return season.segmentSince;
|
||||
}
|
||||
if (season.segment !== 'HERBST') {
|
||||
return null;
|
||||
}
|
||||
const calendarDay = `${season.year}-${calendarTrachtende}`;
|
||||
return calendarDay < season.segmentSince ? calendarDay : season.segmentSince;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +42,8 @@ export async function loadAutopilotInputIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
season: SeasonState,
|
||||
calendarTrachtende: string,
|
||||
today: string,
|
||||
): Promise<AutopilotInput | null> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
@@ -86,13 +102,18 @@ export async function loadAutopilotInputIn(
|
||||
(await tx.varroaMeasurement.count({
|
||||
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
|
||||
})) > 0;
|
||||
// Feedings that answer a low feed status: since the inspection; without
|
||||
// one only recent ones, from the colony's creation at the earliest.
|
||||
const createdOn = berlinDayOf(colony.createdAt);
|
||||
const lookbackFrom = addDays(today, -FEEDING_LOOKBACK_DAYS);
|
||||
const feedingsSince =
|
||||
inspection === null
|
||||
? dayToDate(createdOn > lookbackFrom ? createdOn : lookbackFrom)
|
||||
: inspection.date;
|
||||
const feedingsSinceInspection = await tx.feeding.count({
|
||||
where: {
|
||||
colonyId,
|
||||
...(inspection === null ? {} : { date: { gte: inspection.date } }),
|
||||
},
|
||||
where: { colonyId, date: { gte: feedingsSince } },
|
||||
});
|
||||
const lateSummer = lateSummerStart(season);
|
||||
const lateSummer = lateSummerStart(season, calendarTrachtende);
|
||||
const sinceLateSummer =
|
||||
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
|
||||
const feedingsSinceLateSummer =
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import type {
|
||||
PhaseSourceKey,
|
||||
PhenologyPhaseKey,
|
||||
SeasonSegmentKey,
|
||||
SeasonState,
|
||||
} from '../season/season-types';
|
||||
import { evaluateAutopilot } from './autopilot-rules';
|
||||
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
|
||||
|
||||
@@ -62,6 +68,12 @@ const inspectionOn = (date: string) => ({
|
||||
queenCellsPresent: null,
|
||||
});
|
||||
|
||||
/** Treated and fed in late summer: keeps A4 and A5 out of the A3 tests. */
|
||||
const LATE_SUMMER_DONE = {
|
||||
treatmentsSinceLateSummer: 1,
|
||||
feedingsSinceLateSummer: 1,
|
||||
};
|
||||
|
||||
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
|
||||
|
||||
describe('autopilot rules', () => {
|
||||
@@ -227,6 +239,7 @@ describe('autopilot rules', () => {
|
||||
lastInspection: inspectionOn('2026-08-10'),
|
||||
lastVarroa: { id: 'var_3', date: '2026-08-01' },
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
@@ -256,6 +269,8 @@ describe('autopilot rules', () => {
|
||||
input(early, {
|
||||
lastInspection: inspectionOn('2026-08-10'),
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
...LATE_SUMMER_DONE,
|
||||
}),
|
||||
configs(),
|
||||
early,
|
||||
@@ -268,6 +283,8 @@ describe('autopilot rules', () => {
|
||||
input(late, {
|
||||
lastInspection: inspectionOn('2026-08-15'),
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
...LATE_SUMMER_DONE,
|
||||
varroaSinceLastTreatmentEnd: true,
|
||||
}),
|
||||
configs(),
|
||||
@@ -286,6 +303,7 @@ describe('autopilot rules', () => {
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-08-05'),
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
}),
|
||||
cfg,
|
||||
today,
|
||||
@@ -370,3 +388,437 @@ describe('autopilot rules', () => {
|
||||
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autopilot rules A4-A10', () => {
|
||||
/** A stored season state; the helper keeps the tests free of calendar maths. */
|
||||
function inSeason(
|
||||
segment: SeasonSegmentKey,
|
||||
phase: PhenologyPhaseKey | null,
|
||||
segmentSince: string,
|
||||
phaseSource: PhaseSourceKey = 'REPORT',
|
||||
): SeasonState {
|
||||
return {
|
||||
apiaryId: 'api_1',
|
||||
year: Number(segmentSince.slice(0, 4)),
|
||||
phase,
|
||||
phaseSource,
|
||||
reportedOn: null,
|
||||
segment,
|
||||
segmentSince,
|
||||
springSince: `${segmentSince.slice(0, 4)}-03-20`,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
note: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps A1 and A2 quiet: a fresh inspection and measurement. */
|
||||
function quiet(today: string, patch: Partial<AutopilotInput>) {
|
||||
return input(today, {
|
||||
lastInspection: inspectionOn(today),
|
||||
lastVarroa: { id: 'var_1', date: today },
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
const only = (rule: string, candidates: { rule: string }[]) =>
|
||||
candidates.filter((candidate) => candidate.rule === rule);
|
||||
|
||||
it('A4: SPAETSOMMER without treatment since segment start -> SUMMER_TREATMENT, PLAN, HIGH, anchor year', () => {
|
||||
const today = '2026-08-05';
|
||||
const result = evaluateAutopilot(
|
||||
quiet(today, {
|
||||
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(only('A4', result)).toEqual([
|
||||
{
|
||||
rule: 'A4',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'SUMMER_TREATMENT',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'SUMMER_TREATMENT_DUE',
|
||||
evidence: {
|
||||
colonyId: 'col_1',
|
||||
phase: 'TRACHTENDE',
|
||||
phaseSource: 'REPORT',
|
||||
since: '2026-07-25',
|
||||
reportedOn: null,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'HIGH',
|
||||
anchor: '2026',
|
||||
requiredPermission: 'treatments:read',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('A4: a treatment since the segment start, another segment or the rule off -> nothing', () => {
|
||||
const today = '2026-08-05';
|
||||
const season = inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25');
|
||||
const a4 = (candidates: { rule: string }[]) => only('A4', candidates);
|
||||
expect(
|
||||
a4(
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season, treatmentsSinceLateSummer: 1 }),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
a4(
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season: inSeason('SOMMER', 'LINDE', '2026-06-20') }),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
a4(
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season }),
|
||||
configs({
|
||||
rules: { ...configs().autopilot.config.rules, A4: false },
|
||||
}),
|
||||
today,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('A5: SPAETSOMMER or HERBST without feeding since late summer -> FEED, DO, NORMAL, anchor year', () => {
|
||||
for (const [segment, since, today] of [
|
||||
['SPAETSOMMER', '2026-07-25', '2026-08-05'],
|
||||
['HERBST', '2026-09-15', '2026-09-20'],
|
||||
] as const) {
|
||||
const result = evaluateAutopilot(
|
||||
quiet(today, { season: inSeason(segment, 'TRACHTENDE', since) }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(only('A5', result)).toMatchObject([
|
||||
{
|
||||
rule: 'A5',
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||
priority: 'NORMAL',
|
||||
anchor: '2026',
|
||||
dueDate: today,
|
||||
requiredPermission: 'feedings:read',
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('A5 and A6 never fire in WINTERRUHE; A5 is quiet after a feeding', () => {
|
||||
const today = '2026-12-05';
|
||||
const winter = quiet(today, {
|
||||
season: inSeason('WINTERRUHE', 'WINTER', '2026-11-15'),
|
||||
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
|
||||
lastInspection: { ...inspectionOn('2026-11-01'), feedStatus: 'CRITICAL' },
|
||||
});
|
||||
expect(
|
||||
evaluateAutopilot(winter, configs(), today).filter((c) =>
|
||||
['A5', 'A6'].includes(c.rule),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
const augustToday = '2026-08-05';
|
||||
const fed = quiet(augustToday, {
|
||||
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||
feedingsSinceLateSummer: 1,
|
||||
});
|
||||
expect(only('A5', evaluateAutopilot(fed, configs(), augustToday))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('A6 anchors on the inspection: WEAK or CRITICAL feed of the last inspection, no feeding since', () => {
|
||||
const today = '2026-04-10';
|
||||
const base: Partial<AutopilotInput> = {
|
||||
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'WEAK' },
|
||||
lastVarroa: { id: 'var_1', date: today },
|
||||
};
|
||||
const result = only(
|
||||
'A6',
|
||||
evaluateAutopilot(input(today, base), configs(), today),
|
||||
);
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'FEED_LOW',
|
||||
anchor: 'insp_9',
|
||||
priority: 'NORMAL',
|
||||
dueDate: today,
|
||||
requiredPermission: 'inspections:read',
|
||||
evidence: {
|
||||
colonyId: 'col_1',
|
||||
inspectionId: 'insp_9',
|
||||
inspectedOn: '2026-04-03',
|
||||
condition: 'WEAK',
|
||||
source: 'INSPECTION',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const fedSince = input(today, { ...base, feedingsSinceInspection: 1 });
|
||||
expect(only('A6', evaluateAutopilot(fedSince, configs(), today))).toEqual(
|
||||
[],
|
||||
);
|
||||
const good = input(today, {
|
||||
...base,
|
||||
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'GOOD' },
|
||||
});
|
||||
expect(only('A6', evaluateAutopilot(good, configs(), today))).toEqual([]);
|
||||
});
|
||||
|
||||
it('A6: a CRITICAL colony card counts too, anchored on the inspection or none', () => {
|
||||
const today = '2026-04-10';
|
||||
const critical = input(today, {
|
||||
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
|
||||
lastInspection: inspectionOn('2026-04-03'),
|
||||
});
|
||||
expect(
|
||||
only('A6', evaluateAutopilot(critical, configs(), today)),
|
||||
).toMatchObject([
|
||||
{
|
||||
anchor: 'insp_9',
|
||||
evidence: { condition: 'CRITICAL', source: 'COLONY_CARD' },
|
||||
},
|
||||
]);
|
||||
const noInspection = { ...critical, lastInspection: null };
|
||||
expect(
|
||||
only('A6', evaluateAutopilot(noInspection, configs(), today)),
|
||||
).toMatchObject([{ anchor: 'none' }]);
|
||||
});
|
||||
|
||||
it('A7: KIRSCHE and 9 frames -> HONEY_SUPER; 7 frames -> nothing; LINDE -> nothing', () => {
|
||||
const today = '2026-04-28';
|
||||
const withFrames = (frames: number | null, phase: PhenologyPhaseKey) =>
|
||||
only(
|
||||
'A7',
|
||||
evaluateAutopilot(
|
||||
quiet(today, {
|
||||
season: inSeason('SCHWARMZEIT', phase, '2026-04-15', 'GTS'),
|
||||
lastInspection: { ...inspectionOn(today), colonyStrength: frames },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
);
|
||||
|
||||
expect(withFrames(9, 'KIRSCHE')).toMatchObject([
|
||||
{
|
||||
rule: 'A7',
|
||||
intent: 'HONEY_SUPER',
|
||||
mode: 'DO',
|
||||
reasonCode: 'HONEY_SUPER_CHECK',
|
||||
anchor: '2026',
|
||||
priority: 'NORMAL',
|
||||
dueDate: today,
|
||||
requiredPermission: 'inspections:read',
|
||||
evidence: {
|
||||
colonyId: 'col_1',
|
||||
inspectionId: 'insp_9',
|
||||
frames: 9,
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(withFrames(8, 'APFEL')).toHaveLength(1);
|
||||
expect(withFrames(7, 'KIRSCHE')).toEqual([]);
|
||||
expect(withFrames(null, 'KIRSCHE')).toEqual([]);
|
||||
expect(withFrames(9, 'SALWEIDE')).toEqual([]);
|
||||
expect(withFrames(9, 'LINDE')).toEqual([]);
|
||||
});
|
||||
|
||||
it('A7 ignores an inspection from before the spring of this year', () => {
|
||||
const today = '2026-04-28';
|
||||
// inSeason puts springSince on 2026-03-20
|
||||
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||
const withInspection = (date: string) =>
|
||||
only(
|
||||
'A7',
|
||||
evaluateAutopilot(
|
||||
quiet(today, {
|
||||
season,
|
||||
lastInspection: { ...inspectionOn(date), colonyStrength: 12 },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
);
|
||||
|
||||
expect(withInspection('2025-09-10')).toEqual([]);
|
||||
expect(withInspection('2026-03-19')).toEqual([]);
|
||||
expect(withInspection('2026-03-20')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('A7 honours honeySuperMinFrames and a missing inspection', () => {
|
||||
const today = '2026-04-28';
|
||||
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||
const nine = quiet(today, {
|
||||
season,
|
||||
lastInspection: { ...inspectionOn(today), colonyStrength: 9 },
|
||||
});
|
||||
expect(
|
||||
only(
|
||||
'A7',
|
||||
evaluateAutopilot(nine, configs({ honeySuperMinFrames: 10 }), today),
|
||||
),
|
||||
).toEqual([]);
|
||||
const none = input(today, { season, lastInspection: null });
|
||||
expect(only('A7', evaluateAutopilot(none, configs(), today))).toEqual([]);
|
||||
});
|
||||
|
||||
it('A8: hatchYear 2024 in 2026 SCHWARMZEIT -> QUEEN_REPLACE, PLAN, anchor queen-year', () => {
|
||||
const today = '2026-05-12';
|
||||
const aged = quiet(today, {
|
||||
season: inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15'),
|
||||
queen: { id: 'q_12', hatchYear: 2024 },
|
||||
});
|
||||
expect(only('A8', evaluateAutopilot(aged, configs(), today))).toMatchObject(
|
||||
[
|
||||
{
|
||||
rule: 'A8',
|
||||
intent: 'QUEEN_REPLACE',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'QUEEN_OLD',
|
||||
anchor: 'q_12-2026',
|
||||
priority: 'NORMAL',
|
||||
requiredPermission: 'queens:read',
|
||||
evidence: { colonyId: 'col_1', queenId: 'q_12', hatchYear: 2024 },
|
||||
},
|
||||
],
|
||||
);
|
||||
const sommerDay = '2026-07-01';
|
||||
const sommer = {
|
||||
...aged,
|
||||
season: inSeason('SOMMER', 'LINDE', '2026-06-20'),
|
||||
};
|
||||
expect(
|
||||
only('A8', evaluateAutopilot(sommer, configs(), sommerDay)),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('A8: young queen, unknown hatch year, other segment or queenMaxAgeYears 3 -> nothing', () => {
|
||||
const today = '2026-05-12';
|
||||
const swarm = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||
const a8 = (
|
||||
queen: AutopilotInput['queen'],
|
||||
over?: object,
|
||||
season = swarm,
|
||||
) =>
|
||||
only(
|
||||
'A8',
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season, queen }),
|
||||
configs(over),
|
||||
today,
|
||||
),
|
||||
);
|
||||
expect(a8({ id: 'q', hatchYear: 2025 })).toEqual([]);
|
||||
expect(a8({ id: 'q', hatchYear: null })).toEqual([]);
|
||||
expect(a8(null)).toEqual([]);
|
||||
expect(a8({ id: 'q', hatchYear: 2024 }, { queenMaxAgeYears: 3 })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
a8(
|
||||
{ id: 'q', hatchYear: 2024 },
|
||||
undefined,
|
||||
inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('A9 and A10 follow the WinterCheck rows', () => {
|
||||
const autumnDay = '2026-09-20';
|
||||
const autumn = (winterChecks: AutopilotInput['winterChecks']) =>
|
||||
only(
|
||||
'A9',
|
||||
evaluateAutopilot(
|
||||
quiet(autumnDay, {
|
||||
season: inSeason('HERBST', 'HERBST', '2026-09-15'),
|
||||
winterChecks,
|
||||
}),
|
||||
configs(),
|
||||
autumnDay,
|
||||
),
|
||||
);
|
||||
expect(autumn([])).toMatchObject([
|
||||
{
|
||||
rule: 'A9',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||
anchor: '2026-AUTUMN',
|
||||
priority: 'NORMAL',
|
||||
dueDate: autumnDay,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
evidence: { colonyId: 'col_1', winterYear: 2026 },
|
||||
},
|
||||
]);
|
||||
expect(autumn([{ winterYear: 2026, season: 'AUTUMN' }])).toEqual([]);
|
||||
// The previous winter does not count for this one.
|
||||
expect(autumn([{ winterYear: 2025, season: 'AUTUMN' }])).toHaveLength(1);
|
||||
|
||||
const springDay = '2026-03-25';
|
||||
const spring = (winterChecks: AutopilotInput['winterChecks']) =>
|
||||
only(
|
||||
'A10',
|
||||
evaluateAutopilot(
|
||||
quiet(springDay, {
|
||||
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
winterChecks,
|
||||
}),
|
||||
configs(),
|
||||
springDay,
|
||||
),
|
||||
);
|
||||
expect(spring([{ winterYear: 2025, season: 'AUTUMN' }])).toMatchObject([
|
||||
{
|
||||
rule: 'A10',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
|
||||
anchor: '2025-SPRING',
|
||||
dueDate: springDay,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
evidence: { colonyId: 'col_1', winterYear: 2025, phase: 'SALWEIDE' },
|
||||
},
|
||||
]);
|
||||
// No wintering-in last autumn -> nothing to wind out; done -> nothing.
|
||||
expect(spring([])).toEqual([]);
|
||||
expect(
|
||||
spring([
|
||||
{ winterYear: 2025, season: 'AUTUMN' },
|
||||
{ winterYear: 2025, season: 'SPRING' },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the order A1...A10', () => {
|
||||
const today = '2026-08-05';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, {
|
||||
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||
lastInspection: inspectionOn('2026-07-01'),
|
||||
lastVarroa: { id: 'var_0', date: '2026-06-01' },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2', 'A4', 'A5']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AutopilotRuleId } from '../automation/rule-config';
|
||||
import { addDays, daysBetween } from '../common/dates';
|
||||
import { inspectionDueOn } from '../season/inspection-due';
|
||||
import { isQueenAged } from '../season/queen-age';
|
||||
import { PHENOLOGY_PHASES } from '../season/season-types';
|
||||
import { varroaMeasurementDue } from '../season/varroa-due';
|
||||
import type {
|
||||
AutopilotCandidate,
|
||||
@@ -127,13 +129,245 @@ const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** A4-A10 follow with Task 27. */
|
||||
export const AUTOPILOT_RULES: Readonly<
|
||||
Partial<Record<AutopilotRuleId, AutopilotRule>>
|
||||
> = {
|
||||
/** The phase window of A7: from KIRSCHE up to, not including, LINDE. */
|
||||
const HONEY_SUPER_FROM_PHASE = PHENOLOGY_PHASES.indexOf('KIRSCHE');
|
||||
const HONEY_SUPER_UNTIL_PHASE = PHENOLOGY_PHASES.indexOf('LINDE');
|
||||
|
||||
/** The season facts the texts of A4, A5, A7 and A10 quote. */
|
||||
function seasonEvidence(input: AutopilotInput): {
|
||||
phase: string | null;
|
||||
phaseSource: string | null;
|
||||
} {
|
||||
return {
|
||||
phase: input.season.phase,
|
||||
phaseSource: input.season.phaseSource,
|
||||
};
|
||||
}
|
||||
|
||||
/** A4: the honey flow is over and nothing was treated since. */
|
||||
const a4SummerTreatment: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
if (season.segment !== 'SPAETSOMMER' || input.treatmentsSinceLateSummer > 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A4',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'SUMMER_TREATMENT',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'SUMMER_TREATMENT_DUE',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
...seasonEvidence(input),
|
||||
since: season.segmentSince,
|
||||
reportedOn: season.reportedOn,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'HIGH',
|
||||
anchor: String(season.year),
|
||||
requiredPermission: 'treatments:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A5: late summer or autumn and no feeding since the honey flow ended. */
|
||||
const a5AutumnFeeding: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
if (
|
||||
(season.segment !== 'SPAETSOMMER' && season.segment !== 'HERBST') ||
|
||||
input.feedingsSinceLateSummer > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A5',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
...seasonEvidence(input),
|
||||
since: season.segmentSince,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: String(season.year),
|
||||
requiredPermission: 'feedings:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A6: low feed at the last inspection (or a critical colony card), not fed since. */
|
||||
const a6LowFeed: AutopilotRule = (input, _configs, today) => {
|
||||
if (
|
||||
input.season.segment === 'WINTERRUHE' ||
|
||||
input.feedingsSinceInspection > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const { lastInspection, colony } = input;
|
||||
const inspectionFeed = lastInspection?.feedStatus ?? null;
|
||||
const isInspectionLow =
|
||||
lastInspection !== null &&
|
||||
(inspectionFeed === 'WEAK' || inspectionFeed === 'CRITICAL');
|
||||
if (!isInspectionLow && colony.feedStatus !== 'CRITICAL') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A6',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'FEED_LOW',
|
||||
evidence: {
|
||||
colonyId: colony.id,
|
||||
inspectionId: lastInspection?.id ?? null,
|
||||
inspectedOn: isInspectionLow ? lastInspection.date : null,
|
||||
condition: isInspectionLow ? inspectionFeed : 'CRITICAL',
|
||||
source: isInspectionLow ? 'INSPECTION' : 'COLONY_CARD',
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: lastInspection?.id ?? 'none',
|
||||
requiredPermission: 'inspections:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A7: the cherry blooms and the colony covers enough frames for a honey super. */
|
||||
const a7HoneySuper: AutopilotRule = (input, configs, today) => {
|
||||
const phaseIndex =
|
||||
input.season.phase === null
|
||||
? -1
|
||||
: PHENOLOGY_PHASES.indexOf(input.season.phase);
|
||||
// Only an inspection of this season says how strong the colony is now.
|
||||
const seasonStart = input.season.springSince ?? `${input.season.year}-01-01`;
|
||||
const inspection =
|
||||
input.lastInspection !== null && input.lastInspection.date >= seasonStart
|
||||
? input.lastInspection
|
||||
: null;
|
||||
const frames = inspection?.colonyStrength ?? null;
|
||||
if (
|
||||
phaseIndex < HONEY_SUPER_FROM_PHASE ||
|
||||
phaseIndex >= HONEY_SUPER_UNTIL_PHASE ||
|
||||
frames === null ||
|
||||
frames < configs.autopilot.config.honeySuperMinFrames
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A7',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'HONEY_SUPER',
|
||||
mode: 'DO',
|
||||
reasonCode: 'HONEY_SUPER_CHECK',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
inspectionId: inspection?.id ?? null,
|
||||
frames,
|
||||
...seasonEvidence(input),
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: String(input.season.year),
|
||||
requiredPermission: 'inspections:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A8: the queen has reached the age the beekeeper plans to replace her at. */
|
||||
const a8QueenReplace: AutopilotRule = (input, configs, today) => {
|
||||
const { queen, season } = input;
|
||||
if (
|
||||
queen === null ||
|
||||
(season.segment !== 'SCHWARMZEIT' && season.segment !== 'SOMMER') ||
|
||||
!isQueenAged(
|
||||
queen.hatchYear,
|
||||
today,
|
||||
configs.autopilot.config.queenMaxAgeYears,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A8',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'QUEEN_REPLACE',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'QUEEN_OLD',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
queenId: queen.id,
|
||||
hatchYear: queen.hatchYear,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: `${queen.id}-${season.year}`,
|
||||
requiredPermission: 'queens:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A9: autumn and no wintering-in entry for the winter that begins now. */
|
||||
const a9WinteringIn: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
const isRecorded = input.winterChecks.some(
|
||||
(check) => check.winterYear === season.year && check.season === 'AUTUMN',
|
||||
);
|
||||
if (season.segment !== 'HERBST' || isRecorded) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A9',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||
evidence: { colonyId: input.colony.id, winterYear: season.year },
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: `${season.year}-AUTUMN`,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A10: spring, wintered in last autumn, not yet wintered out. */
|
||||
const a10WinteringOut: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
const winterYear = season.year - 1;
|
||||
const has = (kind: 'AUTUMN' | 'SPRING') =>
|
||||
input.winterChecks.some(
|
||||
(check) => check.winterYear === winterYear && check.season === kind,
|
||||
);
|
||||
if (season.segment !== 'FRUEHJAHR' || !has('AUTUMN') || has('SPRING')) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A10',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
winterYear,
|
||||
...seasonEvidence(input),
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: `${winterYear}-SPRING`,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
};
|
||||
};
|
||||
|
||||
export const AUTOPILOT_RULES: Readonly<Record<AutopilotRuleId, AutopilotRule>> =
|
||||
{
|
||||
A1: a1Inspection,
|
||||
A2: a2VarroaMeasure,
|
||||
A3: a3VarroaRecheck,
|
||||
A4: a4SummerTreatment,
|
||||
A5: a5AutumnFeeding,
|
||||
A6: a6LowFeed,
|
||||
A7: a7HoneySuper,
|
||||
A8: a8QueenReplace,
|
||||
A9: a9WinteringIn,
|
||||
A10: a10WinteringOut,
|
||||
};
|
||||
|
||||
const RULE_ORDER: readonly AutopilotRuleId[] = [
|
||||
@@ -164,11 +398,10 @@ export function evaluateAutopilot(
|
||||
}
|
||||
const candidates: AutopilotCandidate[] = [];
|
||||
for (const id of RULE_ORDER) {
|
||||
const rule = AUTOPILOT_RULES[id];
|
||||
if (rule === undefined || !configs.autopilot.config.rules[id]) {
|
||||
if (!configs.autopilot.config.rules[id]) {
|
||||
continue;
|
||||
}
|
||||
const candidate = rule(input, configs, today);
|
||||
const candidate = AUTOPILOT_RULES[id](input, configs, today);
|
||||
if (candidate !== null) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ import type {
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { AutopilotEventHandlers } from './autopilot.handlers';
|
||||
import type { AutopilotService } from './autopilot.service';
|
||||
|
||||
jest.mock('../prisma/row-lock');
|
||||
const lockRows = jest.mocked(lockRowsForUpdate);
|
||||
|
||||
const EXPECTED_TYPES: DomainEventType[] = [
|
||||
'InspectionRecorded',
|
||||
'InspectionCorrected',
|
||||
@@ -27,6 +31,7 @@ const EXPECTED_TYPES: DomainEventType[] = [
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
'WinterCheckRecorded',
|
||||
];
|
||||
|
||||
function event(
|
||||
@@ -48,13 +53,17 @@ function event(
|
||||
}
|
||||
|
||||
describe('AutopilotEventHandlers', () => {
|
||||
let autopilot: { syncColonyIn: jest.Mock };
|
||||
let autopilot: { syncColonyIn: jest.Mock; syncApiaryIn: jest.Mock };
|
||||
let tx: { task: { findUnique: jest.Mock } };
|
||||
let handlers: AutopilotEventHandlers;
|
||||
|
||||
beforeEach(() => {
|
||||
lockRows.mockReset();
|
||||
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
|
||||
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
|
||||
autopilot = {
|
||||
syncColonyIn: jest.fn().mockResolvedValue({}),
|
||||
syncApiaryIn: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
tx = { task: { findUnique: jest.fn() } };
|
||||
handlers = new AutopilotEventHandlers(
|
||||
autopilot as unknown as AutopilotService,
|
||||
@@ -93,6 +102,7 @@ describe('AutopilotEventHandlers', () => {
|
||||
['TreatmentCorrected', { colonyId: 'c1' }],
|
||||
['FeedingRecorded', { colonyId: 'c1' }],
|
||||
['FeedingCorrected', { colonyId: 'c1' }],
|
||||
['WinterCheckRecorded', { colonyId: 'c1' }],
|
||||
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
|
||||
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
|
||||
] as [DomainEventType, object][])(
|
||||
@@ -130,6 +140,26 @@ describe('AutopilotEventHandlers', () => {
|
||||
expect(synced()).toEqual(['c1', 'c2', 'c3']);
|
||||
});
|
||||
|
||||
it('locks all colonies of an event in one statement before syncing any', async () => {
|
||||
await run(
|
||||
event('HarvestRecorded', 'h1', {
|
||||
apiaryId: 'a',
|
||||
colonyIds: ['c3', 'c1', 'c2'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['c1', 'c2', 'c3']);
|
||||
expect(lockRows.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
autopilot.syncColonyIn.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('takes no extra lock for a single colony', async () => {
|
||||
await run(event('InspectionRecorded', 'i1', { colonyId: 'c1' }));
|
||||
|
||||
expect(lockRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('syncs the new and the parent colony of a split', async () => {
|
||||
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
|
||||
|
||||
@@ -178,4 +208,54 @@ describe('AutopilotEventHandlers', () => {
|
||||
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
describe('apiarySync', () => {
|
||||
it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => {
|
||||
const entries = domainEventHandlerMetadata(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
AutopilotEventHandlers.prototype.apiarySync,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].handlerId).toBe('autopilot.apiary-sync');
|
||||
expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']);
|
||||
});
|
||||
|
||||
// One event per apiary and day (dailyDedupeKey): a change later the same
|
||||
// day is caught by the daily autopilot step.
|
||||
it('syncs the apiary of the payload for the Berlin day', async () => {
|
||||
await handlers.apiarySync(
|
||||
event('SeasonPhaseChanged', 'api-1', {
|
||||
apiaryId: 'api-1',
|
||||
phase: 'KIRSCHE',
|
||||
segment: 'SCHWARMZEIT',
|
||||
previousPhase: 'SALWEIDE',
|
||||
previousSegment: 'FRUEHJAHR',
|
||||
source: 'REPORT',
|
||||
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
||||
tx as unknown as Prisma.TransactionClient,
|
||||
);
|
||||
|
||||
expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1);
|
||||
expect(autopilot.syncApiaryIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'api-1',
|
||||
'2026-05-12',
|
||||
);
|
||||
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets errors propagate so the delivery is retried', async () => {
|
||||
autopilot.syncApiaryIn.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
handlers.apiarySync(
|
||||
event('SeasonPhaseChanged', 'api-1', {
|
||||
apiaryId: 'api-1',
|
||||
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
||||
tx as unknown as Prisma.TransactionClient,
|
||||
),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { AutopilotService } from './autopilot.service';
|
||||
|
||||
const COLONY_SYNC_EVENTS = [
|
||||
@@ -28,6 +29,7 @@ const COLONY_SYNC_EVENTS = [
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
'WinterCheckRecorded',
|
||||
] as const satisfies readonly DomainEventType[];
|
||||
|
||||
type ColonySyncEvent = AnyDomainEventEnvelope<
|
||||
@@ -61,6 +63,7 @@ async function colonyIdsOf(
|
||||
case 'FeedingRecorded':
|
||||
case 'FeedingCorrected':
|
||||
case 'WithdrawalEnded':
|
||||
case 'WinterCheckRecorded':
|
||||
case 'QueenAged':
|
||||
return [event.payload.colonyId];
|
||||
case 'HarvestRecorded':
|
||||
@@ -85,7 +88,9 @@ async function colonyIdsOf(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the autopilot suggestions of the colonies an event touches.
|
||||
* Reconciles the autopilot suggestions of the colonies an event touches
|
||||
* (autopilot.colony-sync) and, for a season change, of all colonies of the
|
||||
* apiary (autopilot.apiary-sync).
|
||||
* Pure DB writer through the delivery transaction; the colonies are synced
|
||||
* in id order, so two deliveries never lock them the other way round.
|
||||
*/
|
||||
@@ -104,9 +109,31 @@ export class AutopilotEventHandlers {
|
||||
if (colonyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (colonyIds.length > 1) {
|
||||
// All colonies in one statement before any sync, like syncApiaryIn.
|
||||
await lockRowsForUpdate(tx, 'Colony', colonyIds);
|
||||
}
|
||||
const today = berlinToday();
|
||||
for (const colonyId of colonyIds) {
|
||||
await this.autopilot.syncColonyIn(tx, colonyId, today);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A season change (new phase or segment) alters the rules for every colony
|
||||
* of the apiary: reconcile them all. There is one such event per apiary and
|
||||
* day (dailyDedupeKey); a change later the same day is caught by the daily
|
||||
* autopilot step.
|
||||
*/
|
||||
@OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync')
|
||||
async apiarySync(
|
||||
event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
await this.autopilot.syncApiaryIn(
|
||||
tx,
|
||||
event.payload.apiaryId,
|
||||
berlinToday(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
readRule,
|
||||
ruleConfigOf,
|
||||
} from '../automation/rule-config';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import type { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
@@ -19,6 +19,7 @@ jest.mock('../automation/rule-config', () => ({
|
||||
jest.mock('./autopilot-input');
|
||||
|
||||
const lockRow = jest.mocked(lockRowForUpdate);
|
||||
const lockRows = jest.mocked(lockRowsForUpdate);
|
||||
const readRuleMock = jest.mocked(readRule);
|
||||
const loadInput = jest.mocked(loadAutopilotInputIn);
|
||||
|
||||
@@ -73,7 +74,7 @@ function input(lastInspectedOn: string | null): AutopilotInput {
|
||||
describe('AutopilotService', () => {
|
||||
let tx: {
|
||||
$connect?: undefined;
|
||||
colony: { findUnique: jest.Mock };
|
||||
colony: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
suggestion: { findMany: jest.Mock };
|
||||
};
|
||||
let suggestions: {
|
||||
@@ -92,6 +93,7 @@ describe('AutopilotService', () => {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
@@ -301,7 +303,7 @@ describe('AutopilotService', () => {
|
||||
expect(result.opened).toBe(1);
|
||||
expect(readRuleMock).not.toHaveBeenCalled();
|
||||
expect(seasons.statesFor).not.toHaveBeenCalled();
|
||||
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
|
||||
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season, '08-01', TODAY);
|
||||
});
|
||||
|
||||
it('preloadFor reads the four rules and the seasons once', async () => {
|
||||
@@ -330,4 +332,66 @@ describe('AutopilotService', () => {
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
describe('syncApiaryIn', () => {
|
||||
it('syncs every living colony of the apiary in id order with one read of rules and seasons', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
|
||||
loadInput.mockResolvedValue(input(null));
|
||||
readRuleMock.mockClear();
|
||||
|
||||
const result = await service.syncApiaryIn(tx as never, 'api-1', TODAY);
|
||||
|
||||
expect(tx.colony.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
apiaryId: 'api-1',
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(lockRow.mock.calls.map((call) => call.slice(1))).toEqual([
|
||||
['Apiary', 'api-1'],
|
||||
['Colony', 'col-1'],
|
||||
['Colony', 'col-2'],
|
||||
]);
|
||||
expect(readRuleMock).toHaveBeenCalledTimes(4);
|
||||
expect(seasons.statesFor).toHaveBeenCalledTimes(1);
|
||||
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
|
||||
expect(result).toEqual({ opened: 2, refreshed: 0, resolved: 0 });
|
||||
});
|
||||
|
||||
it('locks the apiary, then all its colonies in one statement, before any colony is synced', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
|
||||
loadInput.mockResolvedValue(input(null));
|
||||
|
||||
await service.syncApiaryIn(tx as never, 'api-1', TODAY);
|
||||
|
||||
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['col-1', 'col-2']);
|
||||
const apiaryLock = lockRow.mock.invocationCallOrder[0];
|
||||
const listing = tx.colony.findMany.mock.invocationCallOrder[0];
|
||||
const colonyLock = lockRows.mock.invocationCallOrder[0];
|
||||
const firstSync = lockRow.mock.invocationCallOrder[1];
|
||||
expect(apiaryLock).toBeLessThan(listing);
|
||||
expect(listing).toBeLessThan(colonyLock);
|
||||
expect(colonyLock).toBeLessThan(firstSync);
|
||||
});
|
||||
|
||||
it('does nothing and reads nothing for an apiary without living colonies', async () => {
|
||||
readRuleMock.mockClear();
|
||||
|
||||
expect(await service.syncApiaryIn(tx as never, 'api-1', TODAY)).toEqual({
|
||||
opened: 0,
|
||||
refreshed: 0,
|
||||
resolved: 0,
|
||||
});
|
||||
expect(readRuleMock).not.toHaveBeenCalled();
|
||||
expect(seasons.statesFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a client that is not a transaction', async () => {
|
||||
await expect(
|
||||
service.syncApiaryIn({ $connect: jest.fn() } as never, 'api-1', TODAY),
|
||||
).rejects.toThrow('AutopilotService.syncApiaryIn');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import {
|
||||
isLivingColonyStatus,
|
||||
LIVING_COLONY_WHERE,
|
||||
} from '../colonies/colony-status';
|
||||
import { addDays } from '../common/dates';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
@@ -113,7 +116,13 @@ export class AutopilotService {
|
||||
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
|
||||
return NOTHING;
|
||||
}
|
||||
const input = await loadAutopilotInputIn(tx, colonyId, season);
|
||||
const input = await loadAutopilotInputIn(
|
||||
tx,
|
||||
colonyId,
|
||||
season,
|
||||
configs.phenology.calendarFallback.TRACHTENDE,
|
||||
today,
|
||||
);
|
||||
if (input === null) {
|
||||
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
|
||||
return NOTHING;
|
||||
@@ -173,6 +182,51 @@ export class AutopilotService {
|
||||
return { opened, refreshed, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles every living colony of the apiary, for a season change.
|
||||
* Lock order: the apiary row, then (after reading the list under that
|
||||
* lock) all its colonies in ONE statement in ascending id order, before any
|
||||
* colony is synced. A concurrent single-colony or multi-colony sync takes
|
||||
* its colony locks the same way, so no two syncs wait for each other while
|
||||
* the proposals lock suggestion and rule rows. Rules and season are read
|
||||
* once for all colonies. About ten queries per colony run in this one
|
||||
* transaction.
|
||||
*/
|
||||
async syncApiaryIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
today: string,
|
||||
): Promise<AutopilotSyncResult> {
|
||||
assertTransactionClient(tx, 'AutopilotService.syncApiaryIn');
|
||||
if (!(await lockRowForUpdate(tx, 'Apiary', apiaryId))) {
|
||||
return NOTHING;
|
||||
}
|
||||
const colonies = await tx.colony.findMany({
|
||||
where: { apiaryId, ...LIVING_COLONY_WHERE },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (colonies.length === 0) {
|
||||
return NOTHING;
|
||||
}
|
||||
await lockRowsForUpdate(
|
||||
tx,
|
||||
'Colony',
|
||||
colonies.map((colony) => colony.id),
|
||||
);
|
||||
const preload = await this.preloadFor(tx, [apiaryId], today);
|
||||
let total = NOTHING;
|
||||
for (const { id } of colonies) {
|
||||
const result = await this.syncColonyIn(tx, id, today, preload);
|
||||
total = {
|
||||
opened: total.opened + result.opened,
|
||||
refreshed: total.refreshed + result.refreshed,
|
||||
resolved: total.resolved + result.resolved,
|
||||
};
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
|
||||
return {
|
||||
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { HoneyBatchesModule } from '../honey-batches/honey-batches.module';
|
||||
import { StockModule } from '../stock/stock.module';
|
||||
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
|
||||
import { BottlingController } from './bottling.controller';
|
||||
import { BottlingService } from './bottling.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule, StockModule, WithdrawalModule],
|
||||
imports: [
|
||||
AutomationModule,
|
||||
HoneyBatchesModule,
|
||||
StockModule,
|
||||
WithdrawalModule,
|
||||
],
|
||||
controllers: [BottlingController],
|
||||
providers: [BottlingService],
|
||||
})
|
||||
|
||||
@@ -11,18 +11,10 @@ import { AutomationService } from '../automation/automation.service';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
|
||||
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
|
||||
|
||||
type AmountChange = number | { increment?: number; decrement?: number };
|
||||
|
||||
function applyAmountChange(
|
||||
current: number | null,
|
||||
change: AmountChange | undefined,
|
||||
): number | null {
|
||||
if (change === undefined) return current;
|
||||
if (typeof change === 'number') return change;
|
||||
if (current === null) return null;
|
||||
return current + (change.increment ?? 0) - (change.decrement ?? 0);
|
||||
}
|
||||
import {
|
||||
HoneyBatchLedgerService,
|
||||
type LedgerBooking,
|
||||
} from '../honey-batches/honey-batch-ledger.service';
|
||||
|
||||
/**
|
||||
* What one bottling write records in a single recordAll - so both share a
|
||||
@@ -78,12 +70,10 @@ describe('BottlingService', () => {
|
||||
update: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
honeyBatch: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
};
|
||||
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
|
||||
batchMovement: { count: jest.Mock };
|
||||
};
|
||||
let ledger: { bookIn: jest.Mock };
|
||||
let stock: {
|
||||
remainingByBottling: jest.Mock;
|
||||
stockChangedEvent: jest.Mock;
|
||||
@@ -101,9 +91,11 @@ describe('BottlingService', () => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn(() => {
|
||||
steps.push('lock');
|
||||
return Promise.resolve([]);
|
||||
return Promise.resolve([{ id: 'row' }]);
|
||||
}),
|
||||
product: { findUnique: jest.fn() },
|
||||
// By default a bottling has drawn from the ledger (own BOTTLING booking).
|
||||
batchMovement: { count: jest.fn().mockResolvedValue(1) },
|
||||
bottlingBatch: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
@@ -115,47 +107,50 @@ describe('BottlingService', () => {
|
||||
const row = honeyBatches.get(where.id);
|
||||
return Promise.resolve(row ? { id: where.id, ...row } : null);
|
||||
}),
|
||||
// Only the status is written here; the amount belongs to the ledger.
|
||||
update: jest.fn(
|
||||
({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: { amount?: AmountChange; status?: string };
|
||||
data: { status?: string };
|
||||
}) => {
|
||||
const row = honeyBatches.get(where.id) ?? { amount: null };
|
||||
honeyBatches.set(where.id, {
|
||||
amount: applyAmountChange(row.amount, data.amount),
|
||||
...row,
|
||||
status: data.status ?? row.status,
|
||||
});
|
||||
return Promise.resolve({});
|
||||
},
|
||||
),
|
||||
updateMany: jest.fn(
|
||||
({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string; amount?: { gte?: number } };
|
||||
data: { amount: AmountChange; status?: string };
|
||||
}) => {
|
||||
const row = honeyBatches.get(where.id);
|
||||
const minimum = where.amount?.gte;
|
||||
if (
|
||||
!row ||
|
||||
row.amount === null ||
|
||||
(minimum !== undefined && row.amount < minimum)
|
||||
) {
|
||||
return Promise.resolve({ count: 0 });
|
||||
},
|
||||
};
|
||||
// The ledger as the movement book keeps it, reduced to the rest: a
|
||||
// negative booking needs enough rest, an untracked batch stays untracked.
|
||||
ledger = {
|
||||
bookIn: jest.fn((_tx: unknown, booking: LedgerBooking) => {
|
||||
const row = honeyBatches.get(booking.honeyBatchId);
|
||||
if (!row) {
|
||||
return Promise.reject(new NotFoundException('Honey batch not found'));
|
||||
}
|
||||
honeyBatches.set(where.id, {
|
||||
amount: applyAmountChange(row.amount, data.amount),
|
||||
status: data.status ?? row.status,
|
||||
});
|
||||
return Promise.resolve({ count: 1 });
|
||||
},
|
||||
if (row.amount === null) {
|
||||
return Promise.resolve({ amountKg: null, movementId: null });
|
||||
}
|
||||
if (booking.deltaKg < 0 && row.amount < -booking.deltaKg - 0.0005) {
|
||||
return Promise.reject(
|
||||
new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
const amountKg = Math.max(
|
||||
0,
|
||||
Math.round((row.amount + booking.deltaKg) * 1000) / 1000,
|
||||
);
|
||||
honeyBatches.set(booking.honeyBatchId, { ...row, amount: amountKg });
|
||||
return Promise.resolve({ amountKg, movementId: 'm1' });
|
||||
}),
|
||||
};
|
||||
prisma = {
|
||||
honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
|
||||
@@ -202,6 +197,7 @@ describe('BottlingService', () => {
|
||||
automation as unknown as AutomationService,
|
||||
stock as unknown as StockService,
|
||||
withdrawalCheck as unknown as WithdrawalCheckService,
|
||||
ledger as unknown as HoneyBatchLedgerService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -237,6 +233,12 @@ describe('BottlingService', () => {
|
||||
id: 'h1',
|
||||
amount: 5,
|
||||
});
|
||||
honeyBatches.set('h1', { amount: 5 });
|
||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
totalAmount: 10,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
@@ -327,14 +329,19 @@ describe('BottlingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a bottling when the stored amount shrank after the first check', async () => {
|
||||
// The pre-check saw 25 kg, but a concurrent bottling already took most
|
||||
// of it - the conditional booking inside the transaction must catch that.
|
||||
it('refuses a bottling when the rest under the lock is too small and writes nothing', async () => {
|
||||
// The first read saw 25 kg, but a concurrent bottling already took most
|
||||
// of it - the ledger decides under the batch lock.
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
|
||||
id: 'h1',
|
||||
amount: 25,
|
||||
});
|
||||
honeyBatches.set('h1', { amount: 3 });
|
||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
totalAmount: 10,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
@@ -349,9 +356,66 @@ describe('BottlingService', () => {
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(3);
|
||||
expect(events.recordAll).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create books the bottling in the ledger against the bottling it created', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
|
||||
honeyBatches.set('h1', { amount: 25 });
|
||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
totalAmount: 5,
|
||||
});
|
||||
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
|
||||
|
||||
await service.create(
|
||||
{
|
||||
honeyBatchId: 'h1',
|
||||
date: '2026-01-01',
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
lotNumber: 'L-1',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg: -5,
|
||||
type: 'BOTTLING',
|
||||
sourceBottlingBatchId: 'b1',
|
||||
actingUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('a batch without a recorded amount is bottled without a booking and marked BOTTLED', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
|
||||
honeyBatches.set('h1', { amount: null });
|
||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
totalAmount: 5,
|
||||
});
|
||||
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
|
||||
|
||||
await service.create(
|
||||
{
|
||||
honeyBatchId: 'h1',
|
||||
date: '2026-01-01',
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
lotNumber: 'L-1',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(honeyBatches.get('h1')).toEqual({ amount: null, status: 'BOTTLED' });
|
||||
});
|
||||
|
||||
it('update writes an audit log entry with before and after data in its transaction', async () => {
|
||||
@@ -417,7 +481,7 @@ describe('BottlingService', () => {
|
||||
tx,
|
||||
);
|
||||
// Nothing moved: the stale honey batch is never touched.
|
||||
expect(tx.honeyBatch.updateMany).not.toHaveBeenCalled();
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update records BottlingCorrected and StockChanged in its transaction', async () => {
|
||||
@@ -554,6 +618,7 @@ describe('BottlingService', () => {
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
});
|
||||
honeyBatches.set('h1', { amount: 0 });
|
||||
|
||||
await expect(
|
||||
service.update('b1', { jarCount: 40 }, 'user-1'),
|
||||
@@ -687,6 +752,184 @@ describe('BottlingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('ledger bookings', () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 2 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
};
|
||||
|
||||
it('a smaller jar count gives the difference back as a BOTTLING booking', async () => {
|
||||
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
|
||||
await service.update('b1', { jarCount: 6 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg: 2,
|
||||
type: 'BOTTLING',
|
||||
sourceBottlingBatchId: 'b1',
|
||||
actingUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('books nothing when the amount of the bottling stays the same', async () => {
|
||||
lockedRows(before, { ...before, lotNumber: 'L-2' });
|
||||
|
||||
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a change of the honey batch gives back to the old one and takes from the new one', async () => {
|
||||
lockedRows(before, { ...before, honeyBatchId: 'h2' });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
honeyBatches.set('h2', { amount: 8 });
|
||||
|
||||
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
|
||||
|
||||
expect(
|
||||
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
|
||||
([, booking]) => booking,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg: 5,
|
||||
type: 'BOTTLING',
|
||||
sourceBottlingBatchId: 'b1',
|
||||
actingUserId: 'user-1',
|
||||
},
|
||||
{
|
||||
honeyBatchId: 'h2',
|
||||
deltaKg: -5,
|
||||
type: 'BOTTLING',
|
||||
sourceBottlingBatchId: 'b1',
|
||||
actingUserId: 'user-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('locks the bottling, then both honey batches in one statement, before it books', async () => {
|
||||
lockedRows(before, { ...before, honeyBatchId: 'h2' });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
honeyBatches.set('h2', { amount: 8 });
|
||||
|
||||
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
ledger.bookIn.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('a failed take from the new honey batch leaves the bottling row unchanged', async () => {
|
||||
lockedRows(before, { ...before, honeyBatchId: 'h2' });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
honeyBatches.set('h2', { amount: 1 });
|
||||
|
||||
await expect(
|
||||
service.update('b1', { honeyBatchId: 'h2' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create locks the honey batch before it writes the bottling', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
|
||||
honeyBatches.set('h1', { amount: 25 });
|
||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
totalAmount: 5,
|
||||
});
|
||||
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
|
||||
|
||||
await service.create(
|
||||
{
|
||||
honeyBatchId: 'h1',
|
||||
date: '2026-01-01',
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
lotNumber: 'L-1',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.bottlingBatch.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('a bottling that never drew from the ledger books nothing when it changes', async () => {
|
||||
tx.batchMovement.count.mockResolvedValue(0);
|
||||
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
|
||||
await service.update('b1', { jarCount: 6 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
expect(tx.batchMovement.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
honeyBatchId: 'h1',
|
||||
sourceBottlingBatchId: 'b1',
|
||||
type: 'BOTTLING',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('moving a bottling that never drew from the ledger only takes from the new batch', async () => {
|
||||
tx.batchMovement.count.mockResolvedValue(0);
|
||||
lockedRows(before, { ...before, honeyBatchId: 'h2' });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
honeyBatches.set('h2', { amount: 8 });
|
||||
|
||||
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
|
||||
|
||||
expect(
|
||||
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
|
||||
([, booking]) => [booking.honeyBatchId, booking.deltaKg],
|
||||
),
|
||||
).toEqual([['h2', -5]]);
|
||||
});
|
||||
|
||||
it('removing a bottling that never drew from the ledger gives nothing back', async () => {
|
||||
tx.batchMovement.count.mockResolvedValue(0);
|
||||
lockedRows(before);
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
|
||||
await service.remove('b1', 'user-1');
|
||||
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
expect(tx.bottlingBatch.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('remove books the amount back with the note "Abfüllung entfernt"', async () => {
|
||||
lockedRows(before);
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
|
||||
await service.remove('b1', 'user-1');
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg: 5,
|
||||
type: 'BOTTLING',
|
||||
notes: 'Abfüllung entfernt',
|
||||
actingUserId: 'user-1',
|
||||
});
|
||||
// The booking is made while the bottling still exists (FK); the delete
|
||||
// follows.
|
||||
expect(ledger.bookIn.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.bottlingBatch.delete.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdrawal warnings', () => {
|
||||
const conflict = {
|
||||
harvestId: 'harvest-1',
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
@@ -15,6 +15,7 @@ import { StockService } from '../stock/stock.service';
|
||||
import { withWarnings } from '../common/warnings/plausibility-warning';
|
||||
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { HoneyBatchLedgerService } from '../honey-batches/honey-batch-ledger.service';
|
||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
||||
|
||||
@@ -27,82 +28,14 @@ export interface BottlingBatchFilter {
|
||||
honeyBatchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amounts are kept in kilograms with gram precision. Rounding on every write
|
||||
* keeps a honey batch's remaining amount from drifting when a bottling batch
|
||||
* is corrected or removed and its amount is given back.
|
||||
*/
|
||||
/** Amounts are kept in kilograms with gram precision. */
|
||||
function roundKg(amount: number): number {
|
||||
return Math.round(amount * 1000) / 1000;
|
||||
}
|
||||
|
||||
const INSUFFICIENT_AMOUNT_MESSAGE =
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
|
||||
* database - not a value read earlier - decides whether enough is left.
|
||||
* A batch without a recorded amount (null) is not tracked and stays null.
|
||||
*/
|
||||
async function takeFromHoneyBatch(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
const current = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (current.amount === null) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { status: 'BOTTLED' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Tolerance of half a gram absorbs float noise from earlier writes.
|
||||
const booked = await tx.honeyBatch.updateMany({
|
||||
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
|
||||
data: { amount: { decrement: amount }, status: 'BOTTLED' },
|
||||
});
|
||||
if (booked.count === 0) {
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
await normalizeAmount(tx, honeyBatchId);
|
||||
}
|
||||
|
||||
async function giveBackToHoneyBatch(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
await tx.honeyBatch.updateMany({
|
||||
where: { id: honeyBatchId, amount: { not: null } },
|
||||
data: { amount: { increment: amount } },
|
||||
});
|
||||
await normalizeAmount(tx, honeyBatchId);
|
||||
}
|
||||
|
||||
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
|
||||
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
|
||||
const row = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true },
|
||||
});
|
||||
if (row?.amount == null) return;
|
||||
const normalized = Math.max(0, roundKg(row.amount));
|
||||
if (normalized !== row.amount) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: normalized },
|
||||
});
|
||||
}
|
||||
}
|
||||
const REMOVED_NOTE = 'Abfüllung entfernt';
|
||||
|
||||
/**
|
||||
* Bottlings of a honey batch. Every write records its audit entry and its
|
||||
@@ -119,8 +52,59 @@ export class BottlingService {
|
||||
private readonly automation: AutomationService,
|
||||
private readonly stock: StockService,
|
||||
private readonly withdrawalCheck: WithdrawalCheckService,
|
||||
private readonly ledger: HoneyBatchLedgerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Books a bottling's honey in the movement book of its honey batch: minus
|
||||
* for a bottling taking honey, plus for giving it back. The ledger locks
|
||||
* the batch and decides whether the rest is enough.
|
||||
*/
|
||||
private async book(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
deltaKg: number,
|
||||
actingUserId: string,
|
||||
source: { sourceBottlingBatchId: string } | { notes: string },
|
||||
): Promise<void> {
|
||||
await this.ledger.bookIn(tx, {
|
||||
honeyBatchId,
|
||||
deltaKg,
|
||||
type: 'BOTTLING',
|
||||
actingUserId,
|
||||
...source,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this bottling took honey out of the batch's ledger. A bottling
|
||||
* made while the batch had no recorded amount has no BOTTLING booking of
|
||||
* its own: later changes or its removal must not give back or take
|
||||
* anything the ledger never lost.
|
||||
*/
|
||||
private async drewFromLedger(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
bottlingId: string,
|
||||
): Promise<boolean> {
|
||||
const bookings = await tx.batchMovement.count({
|
||||
where: {
|
||||
honeyBatchId,
|
||||
sourceBottlingBatchId: bottlingId,
|
||||
type: 'BOTTLING',
|
||||
},
|
||||
});
|
||||
return bookings > 0;
|
||||
}
|
||||
|
||||
/** A bottled batch changes its status, whether or not its amount is kept. */
|
||||
private async markBottled(tx: Tx, honeyBatchId: string): Promise<void> {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { status: 'BOTTLED' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The bottling as the response body plus the withdrawal warnings of the
|
||||
* harvests behind its honey batch. Bottling inside a window is written all
|
||||
@@ -195,6 +179,7 @@ export class BottlingService {
|
||||
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
|
||||
const honeyBatch = await this.prisma.honeyBatch.findUnique({
|
||||
where: { id: dto.honeyBatchId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!honeyBatch) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
@@ -204,9 +189,6 @@ export class BottlingService {
|
||||
}
|
||||
|
||||
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
|
||||
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
const lotNumber =
|
||||
dto.lotNumber ??
|
||||
@@ -218,11 +200,11 @@ export class BottlingService {
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Booked against the stored amount inside the transaction, never
|
||||
// against the value read above: two bottlings submitted at the same
|
||||
// time (double tap, two devices) must not both pass the check and
|
||||
// overwrite each other's deduction.
|
||||
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
|
||||
// The honey batch first (like update and remove, which hold the
|
||||
// bottling and then the batch): the ledger books under this lock.
|
||||
if (!(await lockRowForUpdate(tx, 'HoneyBatch', dto.honeyBatchId))) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const created = await tx.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: dto.honeyBatchId,
|
||||
@@ -236,6 +218,14 @@ export class BottlingService {
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
// Booked in the ledger under the batch lock, never against a value read
|
||||
// earlier: two bottlings submitted at the same time (double tap, two
|
||||
// devices) must not both pass the check. A refusal rolls the bottling
|
||||
// back with it.
|
||||
await this.book(tx, dto.honeyBatchId, -totalAmount, actingUserId, {
|
||||
sourceBottlingBatchId: created.id,
|
||||
});
|
||||
await this.markBottled(tx, dto.honeyBatchId);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -289,39 +279,51 @@ export class BottlingService {
|
||||
const jarCount = dto.jarCount ?? before.jarCount;
|
||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||
|
||||
const targetHoneyBatch = movedToOtherHoneyBatch
|
||||
? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } })
|
||||
: before.honeyBatch;
|
||||
if (!targetHoneyBatch) {
|
||||
if (movedToOtherHoneyBatch) {
|
||||
// Both batches in one statement in ascending id order, so two
|
||||
// bottlings moving the other way cannot wait for each other.
|
||||
await lockRowsForUpdate(tx, 'HoneyBatch', [
|
||||
before.honeyBatchId,
|
||||
honeyBatchId,
|
||||
]);
|
||||
const target = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!target) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
|
||||
// Early, friendly check; the booking below is still conditional on
|
||||
// the stored amount (takeFromHoneyBatch).
|
||||
const availableOnTarget =
|
||||
targetHoneyBatch.amount === null
|
||||
? null
|
||||
: roundKg(
|
||||
targetHoneyBatch.amount +
|
||||
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
|
||||
);
|
||||
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
const amountChanged =
|
||||
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
|
||||
if (amountChanged) {
|
||||
// Give the previous amount back first, then take the corrected one -
|
||||
// a correction never double-charges the source batch.
|
||||
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
||||
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
|
||||
}
|
||||
const tracked = await this.drewFromLedger(tx, before.honeyBatchId, id);
|
||||
if (movedToOtherHoneyBatch) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { status: 'BOTTLED' },
|
||||
// Give the previous amount back first (only if it was taken), then
|
||||
// take the corrected one - a correction never double-charges.
|
||||
if (tracked) {
|
||||
await this.book(
|
||||
tx,
|
||||
before.honeyBatchId,
|
||||
before.totalAmount,
|
||||
actingUserId,
|
||||
{ sourceBottlingBatchId: id },
|
||||
);
|
||||
}
|
||||
await this.book(tx, honeyBatchId, -totalAmount, actingUserId, {
|
||||
sourceBottlingBatchId: id,
|
||||
});
|
||||
} else if (amountChanged && tracked) {
|
||||
await this.book(
|
||||
tx,
|
||||
honeyBatchId,
|
||||
roundKg(before.totalAmount - totalAmount),
|
||||
actingUserId,
|
||||
{ sourceBottlingBatchId: id },
|
||||
);
|
||||
}
|
||||
if (amountChanged) {
|
||||
await this.markBottled(tx, honeyBatchId);
|
||||
}
|
||||
await tx.bottlingBatch.update({
|
||||
where: { id },
|
||||
@@ -387,8 +389,18 @@ export class BottlingService {
|
||||
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
|
||||
);
|
||||
}
|
||||
// Booked first, while the bottling still exists; deleting it then
|
||||
// clears the reference of the booking (ON DELETE SET NULL).
|
||||
if (await this.drewFromLedger(tx, before.honeyBatchId, id)) {
|
||||
await this.book(
|
||||
tx,
|
||||
before.honeyBatchId,
|
||||
before.totalAmount,
|
||||
actingUserId,
|
||||
{ notes: REMOVED_NOTE },
|
||||
);
|
||||
}
|
||||
await tx.bottlingBatch.delete({ where: { id } });
|
||||
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'BottlingBatch',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-
|
||||
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
|
||||
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
|
||||
import { UpdateNotificationSettingsDto } from '../../notifications/dto/update-notification-settings.dto';
|
||||
import { UpdatePhenologyReportDto } from '../../phenology/dto/update-phenology-report.dto';
|
||||
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
|
||||
import { UpdateProductDto } from '../../products/dto/update-product.dto';
|
||||
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
|
||||
@@ -210,6 +211,12 @@ const contracts: Record<string, NullContract> = {
|
||||
notNull: ['name', 'status', 'kind', 'shopCategory'],
|
||||
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
|
||||
},
|
||||
// apiaryId: null widens the report to all apiaries; notes: null clears them.
|
||||
UpdatePhenologyReportDto: {
|
||||
dto: UpdatePhenologyReportDto,
|
||||
notNull: ['phase', 'date'],
|
||||
clearable: ['apiaryId', 'notes'],
|
||||
},
|
||||
UpdateProductionBatchDto: {
|
||||
dto: UpdateProductionBatchDto,
|
||||
notNull: ['lotNumber', 'date', 'quantity'],
|
||||
|
||||
@@ -104,11 +104,22 @@ export interface DomainEventPayloads {
|
||||
phase: PhenologyPhase;
|
||||
reportedOn: string;
|
||||
};
|
||||
// previousApiaryId: the scope before a correction (null = all apiaries).
|
||||
PhenologyCorrected: {
|
||||
apiaryId: string | null;
|
||||
previousApiaryId: string | null;
|
||||
phase: PhenologyPhase;
|
||||
reportedOn: string;
|
||||
};
|
||||
// Aggregate Apiary; internal follow-up of a report for all apiaries: one
|
||||
// apiary is recomputed per delivery.
|
||||
PhenologyRecomputeRequested: {
|
||||
apiaryId: string;
|
||||
allowBackward: boolean;
|
||||
day: string;
|
||||
};
|
||||
// Aggregate WinterCheck: a wintering-in or -out entry was written or changed.
|
||||
WinterCheckRecorded: { colonyId: string };
|
||||
// Aggregate Apiary; time-based (dedupeKey per apiary and day).
|
||||
SeasonPhaseChanged: {
|
||||
apiaryId: string;
|
||||
@@ -155,7 +166,9 @@ const CATALOG: Record<DomainEventType, true> = {
|
||||
HoneyBatchCorrected: true,
|
||||
PhenologyReported: true,
|
||||
PhenologyCorrected: true,
|
||||
PhenologyRecomputeRequested: true,
|
||||
SeasonPhaseChanged: true,
|
||||
WinterCheckRecorded: true,
|
||||
};
|
||||
|
||||
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
||||
@@ -178,6 +191,7 @@ export type AggregateType =
|
||||
| 'Extraction'
|
||||
| 'HoneyBatch'
|
||||
| 'PhenologyReport'
|
||||
| 'WinterCheck'
|
||||
| 'Apiary';
|
||||
|
||||
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
|
||||
|
||||
describe('domain event catalog', () => {
|
||||
it('lists every event type exactly once', () => {
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(32);
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(34);
|
||||
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
|
||||
expect(DOMAIN_EVENT_TYPES).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -199,7 +199,9 @@ describe('domain event catalog', () => {
|
||||
'HoneyBatchCorrected',
|
||||
'PhenologyReported',
|
||||
'PhenologyCorrected',
|
||||
'PhenologyRecomputeRequested',
|
||||
'SeasonPhaseChanged',
|
||||
'WinterCheckRecorded',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
NotEquals,
|
||||
} from 'class-validator';
|
||||
import { WithdrawalReason } from '../../../generated/prisma';
|
||||
|
||||
/** The limit of one booking, in kg: far above any batch, below float noise. */
|
||||
export const MAX_MOVEMENT_KG = 100000;
|
||||
|
||||
export const MOVEMENT_TYPES = ['WITHDRAWAL', 'CORRECTION'] as const;
|
||||
export type ManualMovementType = (typeof MOVEMENT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* A manual booking in the movement book of a honey batch. Bottlings book
|
||||
* themselves; this is for loose goods, samples, breakage (WITHDRAWAL, minus)
|
||||
* and for weighing again (CORRECTION, plus or minus).
|
||||
*/
|
||||
export class CreateHoneyBatchMovementDto {
|
||||
@ApiProperty({ enum: MOVEMENT_TYPES })
|
||||
@IsIn(MOVEMENT_TYPES)
|
||||
type!: ManualMovementType;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Änderung der Restmenge in kg mit Vorzeichen, nicht 0 (Entnahme: negativ)',
|
||||
})
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
@NotEquals(0)
|
||||
@Min(-MAX_MOVEMENT_KG)
|
||||
@Max(MAX_MOVEMENT_KG)
|
||||
deltaKg!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: WithdrawalReason,
|
||||
description: 'Pflicht bei einer Entnahme',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WithdrawalReason)
|
||||
reason?: WithdrawalReason;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
// HoneyBatch.amount is the running rest of the movement book (spec 7.7): only
|
||||
// HoneyBatchLedgerService writes it. A second writer would break the
|
||||
// invariant amount = initialAmount + sum(quantity) / 1000.
|
||||
//
|
||||
// The regexes see an `amount` inside the call itself. A spread such as
|
||||
// `data: { ...dto }` is invisible to them; honey-batches.service.spec.ts
|
||||
// covers that case.
|
||||
const SRC = join(__dirname, '..');
|
||||
const LEDGER = 'src/honey-batches/honey-batch-ledger.service.ts';
|
||||
|
||||
function productionSources(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return productionSources(path);
|
||||
}
|
||||
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function writersOf(...patterns: RegExp[]): string[] {
|
||||
return productionSources(SRC)
|
||||
.filter((file) => {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
return patterns.some((pattern) => pattern.test(source));
|
||||
})
|
||||
.map((file) => `src/${relative(SRC, file).split(sep).join('/')}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe('honey batch amount writes', () => {
|
||||
it('honey batch amount is written only by the ledger', () =>
|
||||
expect(
|
||||
writersOf(
|
||||
/honeyBatch\.(update|updateMany|upsert)\([^)]*amount/s,
|
||||
/UPDATE "HoneyBatch"[^;]*"amount"/,
|
||||
),
|
||||
).toEqual([LEDGER]));
|
||||
|
||||
it('the guard sees a write: the ledger itself is found', () =>
|
||||
expect(writersOf(/honeyBatch\.update\(\{[^)]*data: \{ amount/s)).toContain(
|
||||
LEDGER,
|
||||
));
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface BatchState {
|
||||
amount: number | null;
|
||||
initialAmount: number | null;
|
||||
/** Sum of the booked grams so far. */
|
||||
grams: number;
|
||||
}
|
||||
|
||||
describe('HoneyBatchLedgerService', () => {
|
||||
let service: HoneyBatchLedgerService;
|
||||
let state: BatchState;
|
||||
let exists: boolean;
|
||||
let steps: string[];
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
|
||||
batchMovement: { aggregate: jest.Mock; create: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
batchMovement: { findMany: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state = { amount: 25, initialAmount: 25, grams: 0 };
|
||||
exists = true;
|
||||
steps = [];
|
||||
tx = {
|
||||
$queryRaw: jest.fn(() => {
|
||||
steps.push('lock');
|
||||
return Promise.resolve(exists ? [{ id: 'h1' }] : []);
|
||||
}),
|
||||
honeyBatch: {
|
||||
findUnique: jest.fn(() => {
|
||||
steps.push('read');
|
||||
return Promise.resolve({
|
||||
amount: state.amount,
|
||||
initialAmount: state.initialAmount,
|
||||
});
|
||||
}),
|
||||
update: jest.fn(({ data }: { data: Partial<BatchState> }) => {
|
||||
steps.push('write');
|
||||
state = { ...state, ...data };
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
},
|
||||
batchMovement: {
|
||||
aggregate: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
_sum: { quantity: state.grams },
|
||||
_count: { _all: state.grams === 0 ? 0 : 1 },
|
||||
}),
|
||||
),
|
||||
create: jest.fn(({ data }: { data: { quantity: number } }) => {
|
||||
state = { ...state, grams: state.grams + data.quantity };
|
||||
return Promise.resolve({ id: 'm1', ...data });
|
||||
}),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
honeyBatch: { findUnique: jest.fn() },
|
||||
batchMovement: { findMany: jest.fn() },
|
||||
};
|
||||
service = new HoneyBatchLedgerService(prisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
const book = (
|
||||
deltaKg: number,
|
||||
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL' = 'BOTTLING',
|
||||
extra: Record<string, unknown> = {},
|
||||
) =>
|
||||
service.bookIn(tx as never, {
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg,
|
||||
type,
|
||||
actingUserId: 'user-1',
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('books in grams and rounds the amount to the gram', async () => {
|
||||
const result = await book(-5.0004, 'BOTTLING', {
|
||||
sourceBottlingBatchId: 'b1',
|
||||
});
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
honeyBatchId: 'h1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -5000,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: 'b1',
|
||||
userId: 'user-1',
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ amountKg: 20, movementId: 'm1' });
|
||||
expect(state.amount).toBe(20);
|
||||
});
|
||||
|
||||
it('rounds half a gram up with floor(x*1000+0.5), like the migration', async () => {
|
||||
await book(0.0125, 'CORRECTION');
|
||||
await book(-0.0125, 'CORRECTION');
|
||||
|
||||
const quantities = (
|
||||
tx.batchMovement.create.mock.calls as [{ data: { quantity: number } }][]
|
||||
).map(([arg]) => arg.data.quantity);
|
||||
expect(quantities).toEqual([13, -12]);
|
||||
});
|
||||
|
||||
it('books exactly the rest when it is off the gram grid, and ends at 0', async () => {
|
||||
state = { amount: 3.0004, initialAmount: 3.0004, grams: 0 };
|
||||
|
||||
const result = await book(-3.0004);
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ quantity: -3000 }) as unknown,
|
||||
});
|
||||
expect(result.amountKg).toBe(0);
|
||||
expect(state.grams).toBe(-3000);
|
||||
});
|
||||
|
||||
it('a reason on a correction is stored', async () => {
|
||||
await book(1, 'CORRECTION', { reason: 'SONSTIGES' });
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ reason: 'SONSTIGES' }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the batch before it reads it', async () => {
|
||||
await book(-1);
|
||||
|
||||
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
|
||||
});
|
||||
|
||||
it('refuses a booking larger than the rest with the German message', async () => {
|
||||
state = { amount: 3, initialAmount: 25, grams: -22000 };
|
||||
|
||||
await expect(book(-6)).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
),
|
||||
);
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a booking within half a gram of the rest', async () => {
|
||||
state = { amount: 3, initialAmount: 25, grams: -22000 };
|
||||
|
||||
const result = await book(-3.0004);
|
||||
|
||||
expect(result.amountKg).toBe(0);
|
||||
});
|
||||
|
||||
it('a correction or withdrawal below zero says the amount cannot go below 0', async () => {
|
||||
state = { amount: 1, initialAmount: 1, grams: 0 };
|
||||
|
||||
await expect(book(-2, 'CORRECTION')).rejects.toThrow(
|
||||
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
|
||||
);
|
||||
await expect(book(-2, 'WITHDRAWAL')).rejects.toThrow(
|
||||
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives back with a plus booking and a note', async () => {
|
||||
state = { amount: 20, initialAmount: 25, grams: -5000 };
|
||||
|
||||
const result = await book(5, 'BOTTLING', { notes: 'Abfüllung entfernt' });
|
||||
|
||||
expect(result.amountKg).toBe(25);
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
quantity: 5000,
|
||||
notes: 'Abfüllung entfernt',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('a correction books the difference new minus current', async () => {
|
||||
state = { amount: 20, initialAmount: 25, grams: -5000 };
|
||||
|
||||
await book(22.5 - 20, 'CORRECTION', { notes: 'Nachgewogen' });
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
type: 'CORRECTION',
|
||||
quantity: 2500,
|
||||
notes: 'Nachgewogen',
|
||||
}) as unknown,
|
||||
});
|
||||
expect(state.amount).toBe(22.5);
|
||||
});
|
||||
|
||||
it('a withdrawal carries its reason', async () => {
|
||||
await book(-1, 'WITHDRAWAL', { reason: 'EIGENBEDARF' });
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ reason: 'EIGENBEDARF' }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the amount from the ledger sum, never from a stored value that drifted', async () => {
|
||||
state = { amount: 9.9, initialAmount: 10, grams: -2000 };
|
||||
|
||||
const result = await book(-1);
|
||||
|
||||
expect(result.amountKg).toBe(7);
|
||||
});
|
||||
|
||||
it('books nothing for a batch without initialAmount', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
const result = await book(-5);
|
||||
|
||||
expect(result).toEqual({ amountKg: null, movementId: null });
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('books nothing for a delta below one gram', async () => {
|
||||
const result = await book(0.0003);
|
||||
|
||||
expect(result).toEqual({ amountKg: 25, movementId: null });
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 for a missing batch', async () => {
|
||||
exists = false;
|
||||
|
||||
await expect(book(-1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('refuses the root client', async () => {
|
||||
await expect(
|
||||
service.bookIn({ $connect: jest.fn() } as never, {
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg: -1,
|
||||
type: 'BOTTLING',
|
||||
actingUserId: null,
|
||||
}),
|
||||
).rejects.toThrow('needs the transaction client');
|
||||
});
|
||||
|
||||
describe('setInitialIn', () => {
|
||||
it('sets amount and initialAmount together, without a booking', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
await service.setInitialIn(tx as never, 'h1', 12.5);
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 12.5, initialAmount: 12.5 },
|
||||
});
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rounds the amount to the gram, like a booking', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
await service.setInitialIn(tx as never, 'h1', 12.3456);
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 12.346, initialAmount: 12.346 },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a batch that already has an initial amount', async () => {
|
||||
await expect(
|
||||
service.setInitialIn(tx as never, 'h1', 12.5),
|
||||
).rejects.toThrow('initialAmount already set');
|
||||
});
|
||||
|
||||
it('refuses a negative or non-finite amount', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
await expect(service.setInitialIn(tx as never, 'h1', -1)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
service.setInitialIn(tx as never, 'h1', Number.NaN),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearIn', () => {
|
||||
it('removes amount and initialAmount while nothing is booked', async () => {
|
||||
await service.clearIn(tx as never, 'h1');
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: null, initialAmount: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses with the ledger message once there are bookings', async () => {
|
||||
state = { ...state, grams: -5000 };
|
||||
|
||||
await expect(service.clearIn(tx as never, 'h1')).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
|
||||
),
|
||||
);
|
||||
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movements', () => {
|
||||
it('lists the bookings as views, newest first', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
|
||||
const createdAt = new Date('2026-10-01T10:00:00Z');
|
||||
prisma.batchMovement.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'm1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -5000,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: 'b1',
|
||||
createdAt,
|
||||
userId: 'user-1',
|
||||
shopRequestId: null,
|
||||
apiKeyId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.movements('h1');
|
||||
|
||||
expect(prisma.batchMovement.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { honeyBatchId: 'h1' },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: 201,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'm1',
|
||||
type: 'BOTTLING',
|
||||
quantityGrams: -5000,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: 'b1',
|
||||
createdAt,
|
||||
userId: 'user-1',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 200 bookings and says there are more', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
|
||||
const row = (i: number) => ({
|
||||
id: `m${i}`,
|
||||
type: 'BOTTLING',
|
||||
quantity: -1,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: null,
|
||||
createdAt: new Date(),
|
||||
userId: null,
|
||||
});
|
||||
prisma.batchMovement.findMany.mockResolvedValue(
|
||||
Array.from({ length: 201 }, (_, i) => row(i)),
|
||||
);
|
||||
|
||||
const result = await service.movements('h1');
|
||||
|
||||
expect(result.items).toHaveLength(200);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reads one booking through the given client', async () => {
|
||||
const createdAt = new Date('2026-10-01T10:00:00Z');
|
||||
const db = {
|
||||
batchMovement: {
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue({
|
||||
id: 'm2',
|
||||
type: 'WITHDRAWAL',
|
||||
quantity: -1500,
|
||||
reason: 'BRUCH',
|
||||
notes: null,
|
||||
sourceBottlingBatchId: null,
|
||||
createdAt,
|
||||
userId: 'user-1',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const view = await service.movementView(db as never, 'm2');
|
||||
|
||||
expect(db.batchMovement.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: 'm2' },
|
||||
});
|
||||
expect(view).toMatchObject({
|
||||
id: 'm2',
|
||||
quantityGrams: -1500,
|
||||
reason: 'BRUCH',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown batch', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.movements('x')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Prisma,
|
||||
type BatchMovementType,
|
||||
type WithdrawalReason,
|
||||
} from '../../generated/prisma';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
|
||||
/** Half a gram in kg: the tolerance of the ledger (spec 4.6). */
|
||||
const TOLERANCE_KG = 0.0005;
|
||||
const GRAMS_PER_KG = 1000;
|
||||
|
||||
export const INSUFFICIENT_AMOUNT_MESSAGE =
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
|
||||
export const BELOW_ZERO_MESSAGE = 'Die Menge kann nicht unter 0 sinken.';
|
||||
export const LEDGER_MANAGED_MESSAGE =
|
||||
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.';
|
||||
|
||||
export interface LedgerBooking {
|
||||
honeyBatchId: string;
|
||||
deltaKg: number;
|
||||
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL';
|
||||
reason?: WithdrawalReason | null;
|
||||
notes?: string | null;
|
||||
sourceBottlingBatchId?: string | null;
|
||||
actingUserId: string | null;
|
||||
}
|
||||
|
||||
export interface LedgerResult {
|
||||
/** The rest after the booking; null for a batch whose amount is not kept. */
|
||||
amountKg: number | null;
|
||||
/** null when nothing was booked. */
|
||||
movementId: string | null;
|
||||
}
|
||||
|
||||
export interface HoneyBatchMovementView {
|
||||
id: string;
|
||||
type: BatchMovementType;
|
||||
quantityGrams: number;
|
||||
reason: WithdrawalReason | null;
|
||||
notes: string | null;
|
||||
sourceBottlingBatchId: string | null;
|
||||
createdAt: Date;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
function toView(row: {
|
||||
id: string;
|
||||
type: BatchMovementType;
|
||||
quantity: number;
|
||||
reason: WithdrawalReason | null;
|
||||
notes: string | null;
|
||||
sourceBottlingBatchId: string | null;
|
||||
createdAt: Date;
|
||||
userId: string | null;
|
||||
}): HoneyBatchMovementView {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
quantityGrams: row.quantity,
|
||||
reason: row.reason,
|
||||
notes: row.notes,
|
||||
sourceBottlingBatchId: row.sourceBottlingBatchId,
|
||||
createdAt: row.createdAt,
|
||||
userId: row.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/** One rounding for every gram: floor(x * 1000 + 0.5), as in the migration. */
|
||||
function toGrams(kg: number): number {
|
||||
return Math.floor(kg * GRAMS_PER_KG + 0.5);
|
||||
}
|
||||
|
||||
/** A kg amount on the gram grid. */
|
||||
export function roundToGram(kg: number): number {
|
||||
return toGrams(kg) / GRAMS_PER_KG;
|
||||
}
|
||||
|
||||
/** Bookings returned by `movements`; more are reported, not sent. */
|
||||
export const MOVEMENTS_PAGE_SIZE = 200;
|
||||
|
||||
/** The rest implied by the ledger, on the gram grid, never below 0. */
|
||||
function restFromLedger(initialAmountKg: number, grams: number): number {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((initialAmountKg + grams / GRAMS_PER_KG) * GRAMS_PER_KG) /
|
||||
GRAMS_PER_KG,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The movement book of a honey batch (spec 4.6, 7.7): the ONE place that
|
||||
* writes HoneyBatch.amount. Invariant: amount = initialAmount + sum(quantity)
|
||||
* / 1000, rounded to the gram, never below 0. A batch without initialAmount
|
||||
* is not booked (its amount is not recorded). Every method runs in the
|
||||
* caller's transaction, so the booking commits or rolls back together with
|
||||
* the business write behind it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HoneyBatchLedgerService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Books a change of the rest under the batch row lock. A negative delta
|
||||
* needs enough rest (half a gram of tolerance), a positive one gives back.
|
||||
* The stored amount is set from the ledger sum, so it cannot drift.
|
||||
*/
|
||||
async bookIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
booking: LedgerBooking,
|
||||
): Promise<LedgerResult> {
|
||||
assertTransactionClient(tx, 'HoneyBatchLedgerService.bookIn');
|
||||
const { honeyBatchId } = booking;
|
||||
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const batch = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true, initialAmount: true },
|
||||
});
|
||||
if (batch === null) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (batch.initialAmount === null) {
|
||||
return { amountKg: null, movementId: null };
|
||||
}
|
||||
let grams = toGrams(booking.deltaKg);
|
||||
if (grams === 0) {
|
||||
return { amountKg: batch.amount, movementId: null };
|
||||
}
|
||||
const rest = batch.amount ?? 0;
|
||||
if (grams < 0 && rest < -grams / GRAMS_PER_KG - TOLERANCE_KG) {
|
||||
throw new BadRequestException(
|
||||
booking.type === 'BOTTLING'
|
||||
? INSUFFICIENT_AMOUNT_MESSAGE
|
||||
: BELOW_ZERO_MESSAGE,
|
||||
);
|
||||
}
|
||||
// Within the tolerance a booking takes exactly the rest on the gram
|
||||
// grid, so the sum of the bookings and the amount stay equal.
|
||||
const restGrams = toGrams(rest);
|
||||
if (grams < 0 && -grams > restGrams) {
|
||||
grams = -restGrams;
|
||||
}
|
||||
if (grams === 0) {
|
||||
return { amountKg: batch.amount, movementId: null };
|
||||
}
|
||||
const booked = await tx.batchMovement.aggregate({
|
||||
where: { honeyBatchId },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const amountKg = restFromLedger(
|
||||
batch.initialAmount,
|
||||
(booked._sum.quantity ?? 0) + grams,
|
||||
);
|
||||
const movement = await tx.batchMovement.create({
|
||||
data: {
|
||||
honeyBatchId,
|
||||
type: booking.type,
|
||||
quantity: grams,
|
||||
reason: booking.reason ?? null,
|
||||
notes: booking.notes ?? null,
|
||||
sourceBottlingBatchId: booking.sourceBottlingBatchId ?? null,
|
||||
userId: booking.actingUserId,
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: amountKg },
|
||||
});
|
||||
return { amountKg, movementId: movement.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* The first amount of a batch that had none: it becomes the rest and the
|
||||
* initial amount at once, without a booking. For a new batch with an amount
|
||||
* and for the PATCH from "not recorded" to a value.
|
||||
*/
|
||||
async setInitialIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
honeyBatchId: string,
|
||||
amountKg: number,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'HoneyBatchLedgerService.setInitialIn');
|
||||
if (!Number.isFinite(amountKg) || amountKg < 0) {
|
||||
throw new BadRequestException('Die Menge muss eine Zahl ab 0 sein.');
|
||||
}
|
||||
const batch = await this.lockedBatch(tx, honeyBatchId);
|
||||
const rounded = roundToGram(amountKg);
|
||||
if (batch.initialAmount !== null) {
|
||||
throw new Error(
|
||||
`HoneyBatchLedgerService.setInitialIn: initialAmount already set for ${honeyBatchId}`,
|
||||
);
|
||||
}
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: rounded, initialAmount: rounded },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the amount away again ("not recorded"). Only while nothing is
|
||||
* booked: with bookings the amount is corrected, not erased.
|
||||
*/
|
||||
async clearIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
honeyBatchId: string,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'HoneyBatchLedgerService.clearIn');
|
||||
const batch = await this.lockedBatch(tx, honeyBatchId);
|
||||
if (batch.initialAmount === null) {
|
||||
return;
|
||||
}
|
||||
const booked = await tx.batchMovement.aggregate({
|
||||
where: { honeyBatchId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
if (booked._count._all > 0) {
|
||||
throw new BadRequestException(LEDGER_MANAGED_MESSAGE);
|
||||
}
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: null, initialAmount: null },
|
||||
});
|
||||
}
|
||||
|
||||
/** The bookings of one batch, newest first. */
|
||||
async movements(honeyBatchId: string): Promise<{
|
||||
items: HoneyBatchMovementView[];
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const batch = await this.prisma.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (batch === null) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const rows = await this.prisma.batchMovement.findMany({
|
||||
where: { honeyBatchId },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: MOVEMENTS_PAGE_SIZE + 1,
|
||||
});
|
||||
return {
|
||||
items: rows.slice(0, MOVEMENTS_PAGE_SIZE).map(toView),
|
||||
hasMore: rows.length > MOVEMENTS_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
/** One booking, read through `db` (a transaction sees its own writes). */
|
||||
async movementView(
|
||||
db: DbClient,
|
||||
movementId: string,
|
||||
): Promise<HoneyBatchMovementView> {
|
||||
return toView(
|
||||
await db.batchMovement.findUniqueOrThrow({ where: { id: movementId } }),
|
||||
);
|
||||
}
|
||||
|
||||
private async lockedBatch(
|
||||
tx: Prisma.TransactionClient,
|
||||
honeyBatchId: string,
|
||||
) {
|
||||
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const batch = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true, initialAmount: true },
|
||||
});
|
||||
if (batch === null) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
@@ -15,19 +15,25 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
import { HoneyBatchPrefillQuery } from './dto/honey-batch-prefill.query';
|
||||
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@ApiTags('honey-batches')
|
||||
@ApiBearerAuth()
|
||||
@Controller('honey-batches')
|
||||
export class HoneyBatchesController {
|
||||
constructor(private readonly honeyBatchesService: HoneyBatchesService) {}
|
||||
constructor(
|
||||
private readonly honeyBatchesService: HoneyBatchesService,
|
||||
private readonly ledger: HoneyBatchLedgerService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get()
|
||||
@@ -50,6 +56,24 @@ export class HoneyBatchesController {
|
||||
return this.honeyBatchesService.findById(id);
|
||||
}
|
||||
|
||||
// The movement book: every change of the rest, newest first.
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get(':id/movements')
|
||||
movements(@Param('id') id: string) {
|
||||
return this.ledger.movements(id);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:update')
|
||||
@Idempotent()
|
||||
@Post(':id/movements')
|
||||
createMovement(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHoneyBatchMovementDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.honeyBatchesService.createMovement(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get(':id/traceability')
|
||||
traceability(@Param('id') id: string) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { HoneyBatchesController } from './honey-batches.controller';
|
||||
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule],
|
||||
controllers: [HoneyBatchesController],
|
||||
providers: [HoneyBatchesService],
|
||||
exports: [HoneyBatchesService],
|
||||
providers: [HoneyBatchesService, HoneyBatchLedgerService],
|
||||
exports: [HoneyBatchesService, HoneyBatchLedgerService],
|
||||
})
|
||||
export class HoneyBatchesModule {}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
|
||||
describe('HoneyBatchesService', () => {
|
||||
let service: HoneyBatchesService;
|
||||
@@ -35,6 +40,13 @@ describe('HoneyBatchesService', () => {
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let automation: { generateNumber: jest.Mock };
|
||||
let ledger: {
|
||||
bookIn: jest.Mock;
|
||||
setInitialIn: jest.Mock;
|
||||
clearIn: jest.Mock;
|
||||
movements: jest.Mock;
|
||||
movementView: jest.Mock;
|
||||
};
|
||||
|
||||
const extraction = {
|
||||
id: 'extraction-1',
|
||||
@@ -95,11 +107,19 @@ describe('HoneyBatchesService', () => {
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { record: jest.fn() };
|
||||
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
ledger = {
|
||||
bookIn: jest.fn().mockResolvedValue({ amountKg: 20, movementId: 'm1' }),
|
||||
setInitialIn: jest.fn().mockResolvedValue(undefined),
|
||||
clearIn: jest.fn().mockResolvedValue(undefined),
|
||||
movements: jest.fn().mockResolvedValue([]),
|
||||
movementView: jest.fn(),
|
||||
};
|
||||
service = new HoneyBatchesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
automation as unknown as AutomationService,
|
||||
events as unknown as DomainEventsService,
|
||||
ledger as unknown as HoneyBatchLedgerService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -172,7 +192,7 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 6, amount: 6 },
|
||||
{ id: 'batch-1', initialAmount: null, amount: 8 },
|
||||
{ id: 'batch-1', initialAmount: 8, amount: 8 },
|
||||
]);
|
||||
|
||||
const result = await service.create(dto, 'user-1');
|
||||
@@ -181,6 +201,53 @@ describe('HoneyBatchesService', () => {
|
||||
'BATCHES_EXCEED_EXTRACTION',
|
||||
]);
|
||||
});
|
||||
|
||||
it('create writes amount and initialAmount together, through the ledger', async () => {
|
||||
await service.create({ ...dto, amount: 20 }, 'user-1');
|
||||
|
||||
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
|
||||
[{ data: Record<string, unknown> }],
|
||||
];
|
||||
expect(createArg.data).not.toHaveProperty('amount');
|
||||
expect(createArg.data).not.toHaveProperty('initialAmount');
|
||||
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 20);
|
||||
});
|
||||
|
||||
it('the audit entry of a new batch shows the amount the ledger set', async () => {
|
||||
const withAmount = { ...honeyBatch, amount: 20, initialAmount: 20 };
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(withAmount);
|
||||
|
||||
await service.create({ ...dto, amount: 20 }, 'user-1');
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'CREATE', afterData: withAmount }),
|
||||
prisma,
|
||||
);
|
||||
expect(ledger.setInitialIn.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
prisma.honeyBatch.findUniqueOrThrow.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('create without an amount books nothing', async () => {
|
||||
await service.create(dto, 'user-1');
|
||||
|
||||
expect(ledger.setInitialIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a batch without initialAmount does not count against the extraction', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: 10,
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 6, amount: 6 },
|
||||
{ id: 'batch-1', initialAmount: null, amount: 8 },
|
||||
]);
|
||||
|
||||
const result = await service.create(dto, 'user-1');
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('traceability flattens the extraction, harvest, and colony chain', async () => {
|
||||
@@ -561,6 +628,225 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update of the amount (movement book)', () => {
|
||||
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
});
|
||||
});
|
||||
|
||||
function dataOfUpdate(): Record<string, unknown> {
|
||||
const [[arg]] = prisma.honeyBatch.update.mock.calls as [
|
||||
[{ data: Record<string, unknown> }],
|
||||
];
|
||||
return arg.data;
|
||||
}
|
||||
|
||||
it('update never passes amount to Prisma, even when the dto carries it', async () => {
|
||||
await service.update(
|
||||
'batch-1',
|
||||
{ amount: 22, honeyType: 'Waldhonig' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(dataOfUpdate()).toEqual({ honeyType: 'Waldhonig' });
|
||||
});
|
||||
|
||||
it('books a CORRECTION for the difference new minus current', async () => {
|
||||
await service.update('batch-1', { amount: 22.5 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
|
||||
honeyBatchId: 'batch-1',
|
||||
deltaKg: 2.5,
|
||||
type: 'CORRECTION',
|
||||
actingUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds the new amount to the gram before it takes the difference', async () => {
|
||||
await service.update('batch-1', { amount: 22.5004 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
expect.objectContaining({ deltaKg: 2.5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('answers 409 for a batch with an initial amount but no rest', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
initialAmount: 25,
|
||||
amount: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('batch-1', { amount: 22 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('books nothing when the amount stays the same', async () => {
|
||||
await service.update('batch-1', { amount: 20 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the current rest under the batch lock, then books', async () => {
|
||||
await service.update('batch-1', { amount: 22 }, 'user-1');
|
||||
|
||||
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
reads[reads.length - 1],
|
||||
);
|
||||
expect(reads[reads.length - 1]).toBeLessThan(
|
||||
ledger.bookIn.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the first amount of a batch that had none without a booking', async () => {
|
||||
const untracked = { ...honeyBatch, initialAmount: null, amount: null };
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(untracked);
|
||||
|
||||
await service.update('batch-1', { amount: 12 }, 'user-1');
|
||||
|
||||
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 12);
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('amount null goes to the ledger, which refuses it once bookings exist', async () => {
|
||||
await service.update('batch-1', { amount: null as never }, 'user-1');
|
||||
|
||||
expect(ledger.clearIn).toHaveBeenCalledWith(prisma, 'batch-1');
|
||||
expect(prisma.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the 400 of the ledger on', async () => {
|
||||
ledger.clearIn.mockRejectedValue(new BadRequestException('x'));
|
||||
|
||||
await expect(
|
||||
service.update('batch-1', { amount: null as never }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMovement', () => {
|
||||
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
|
||||
ledger.movementView.mockResolvedValue({
|
||||
id: 'm1',
|
||||
type: 'WITHDRAWAL',
|
||||
quantityGrams: -1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('books a withdrawal with its reason, with audit and event in the transaction', async () => {
|
||||
const result = await service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'WITHDRAWAL', deltaKg: -1, reason: 'EIGENBEDARF' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
|
||||
honeyBatchId: 'batch-1',
|
||||
deltaKg: -1,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'EIGENBEDARF',
|
||||
notes: null,
|
||||
actingUserId: 'user-1',
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: 'batch-1',
|
||||
action: 'UPDATE',
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
expect.objectContaining({
|
||||
type: 'HoneyBatchCorrected',
|
||||
aggregateId: 'batch-1',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ id: 'm1', quantityGrams: -1000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('a withdrawal needs a reason and a negative amount', async () => {
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'WITHDRAWAL', deltaKg: -1 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('Bitte einen Grund angeben.'));
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException('Eine Entnahme braucht eine negative Menge.'),
|
||||
);
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a correction may carry a reason and stores it', async () => {
|
||||
await service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'CORRECTION', deltaKg: 1, reason: 'SONSTIGES' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
expect.objectContaining({ type: 'CORRECTION', reason: 'SONSTIGES' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a batch whose amount is not recorded', async () => {
|
||||
ledger.bookIn.mockResolvedValue({ amountKg: null, movementId: null });
|
||||
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'CORRECTION', deltaKg: 1 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.',
|
||||
),
|
||||
);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown batch', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'x',
|
||||
{ type: 'CORRECTION', deltaKg: 1 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefill', () => {
|
||||
it('proposes the rest of the extraction with its honey type and water content', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
|
||||
@@ -15,8 +15,14 @@ import {
|
||||
withWarnings,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import { HoneyBatchStatus } from '../../generated/prisma';
|
||||
import { HoneyBatchStatus, type Prisma } from '../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
|
||||
import {
|
||||
HoneyBatchLedgerService,
|
||||
roundToGram,
|
||||
type HoneyBatchMovementView,
|
||||
} from './honey-batch-ledger.service';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
@@ -59,6 +65,7 @@ export class HoneyBatchesService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly ledger: HoneyBatchLedgerService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: HoneyBatchFilter = {}) {
|
||||
@@ -82,19 +89,14 @@ export class HoneyBatchesService {
|
||||
|
||||
/**
|
||||
* The batches of one extraction with the amount they started with. A batch
|
||||
* the ledger does not know yet (no initialAmount) counts with its current
|
||||
* amount: it is the best known start, and the rest can only be lower.
|
||||
* without initialAmount (its amount was never recorded) does not count.
|
||||
*/
|
||||
private async batchesOf(db: DbClient, extractionId: string) {
|
||||
const batches = await db.honeyBatch.findMany({
|
||||
return db.honeyBatch.findMany({
|
||||
where: { extractionId },
|
||||
select: { id: true, initialAmount: true, amount: true },
|
||||
select: { id: true, initialAmount: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return batches.map((batch) => ({
|
||||
id: batch.id,
|
||||
initialAmount: batch.initialAmount ?? batch.amount,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The batches of the extraction against its amount. Never blocks. */
|
||||
@@ -127,8 +129,18 @@ export class HoneyBatchesService {
|
||||
// The extraction first: batches of one extraction are counted against
|
||||
// its amount, so two of them must not be written side by side.
|
||||
await lockRowForUpdate(tx, 'Extraction', dto.extractionId);
|
||||
// The amount never goes to Prisma directly: the ledger sets it together
|
||||
// with the initial amount.
|
||||
const { amount, ...rest } = dto;
|
||||
const honeyBatch = await tx.honeyBatch.create({
|
||||
data: { ...dto, batchNumber },
|
||||
data: { ...rest, batchNumber },
|
||||
});
|
||||
if (typeof amount === 'number') {
|
||||
await this.ledger.setInitialIn(tx, honeyBatch.id, amount);
|
||||
}
|
||||
const created = await tx.honeyBatch.findUniqueOrThrow({
|
||||
where: { id: honeyBatch.id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
@@ -136,7 +148,7 @@ export class HoneyBatchesService {
|
||||
entityId: honeyBatch.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: honeyBatch,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -147,10 +159,6 @@ export class HoneyBatchesService {
|
||||
payload: { extractionId: honeyBatch.extractionId },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
const created = await tx.honeyBatch.findUniqueOrThrow({
|
||||
where: { id: honeyBatch.id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
return withWarnings(
|
||||
created,
|
||||
await this.warningsFor(tx, created.extractionId),
|
||||
@@ -198,10 +206,16 @@ export class HoneyBatchesService {
|
||||
? dto.storageContainerId
|
||||
: null;
|
||||
|
||||
const { amount, ...rest } = dto;
|
||||
if (amount !== undefined) {
|
||||
await this.changeAmount(tx, before, amount, actingUserId);
|
||||
}
|
||||
if (Object.keys(rest).length > 0) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
data: { ...rest },
|
||||
});
|
||||
}
|
||||
// Correcting the container has to leave the same trace a deliberate
|
||||
// relocation does - otherwise the batch's storage history would skip a
|
||||
// container it was recorded in.
|
||||
@@ -245,6 +259,114 @@ export class HoneyBatchesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A PATCH of the amount, as a booking (spec 7.7). `before` was read under
|
||||
* the batch lock, so the difference is taken against the real rest. From
|
||||
* "not recorded" to a value sets the initial amount; with a recorded amount
|
||||
* the difference is a CORRECTION; null takes the amount away, which the
|
||||
* ledger refuses once bookings exist.
|
||||
*/
|
||||
private async changeAmount(
|
||||
tx: Prisma.TransactionClient,
|
||||
before: { id: string; amount: number | null; initialAmount: number | null },
|
||||
amount: number | null,
|
||||
actingUserId: string,
|
||||
): Promise<void> {
|
||||
if (amount === null) {
|
||||
await this.ledger.clearIn(tx, before.id);
|
||||
return;
|
||||
}
|
||||
if (before.initialAmount === null) {
|
||||
await this.ledger.setInitialIn(tx, before.id, amount);
|
||||
return;
|
||||
}
|
||||
if (before.amount === null) {
|
||||
throw new ConflictException(
|
||||
'Die Menge der Honigcharge ist uneinheitlich gespeichert. Bitte die Charge neu laden oder den Support fragen.',
|
||||
);
|
||||
}
|
||||
const deltaKg = roundKg(roundToGram(amount) - before.amount);
|
||||
if (deltaKg === 0) {
|
||||
return;
|
||||
}
|
||||
await this.ledger.bookIn(tx, {
|
||||
honeyBatchId: before.id,
|
||||
deltaKg,
|
||||
type: 'CORRECTION',
|
||||
actingUserId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A manual booking in the movement book: a withdrawal (loose goods, sample,
|
||||
* breakage) or a correction after weighing again. Bottlings book
|
||||
* themselves. Booking, audit entry and event commit together.
|
||||
*/
|
||||
async createMovement(
|
||||
id: string,
|
||||
dto: CreateHoneyBatchMovementDto,
|
||||
actingUserId: string,
|
||||
): Promise<HoneyBatchMovementView> {
|
||||
if (dto.type === 'WITHDRAWAL') {
|
||||
if (dto.reason === undefined) {
|
||||
throw new BadRequestException('Bitte einen Grund angeben.');
|
||||
}
|
||||
if (dto.deltaKg > 0) {
|
||||
throw new BadRequestException(
|
||||
'Eine Entnahme braucht eine negative Menge.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockRowForUpdate(tx, 'HoneyBatch', id);
|
||||
const before = await tx.honeyBatch.findUnique({
|
||||
where: { id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const booked = await this.ledger.bookIn(tx, {
|
||||
honeyBatchId: id,
|
||||
deltaKg: dto.deltaKg,
|
||||
type: dto.type,
|
||||
reason: dto.reason ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
actingUserId,
|
||||
});
|
||||
if (booked.movementId === null) {
|
||||
throw new BadRequestException(
|
||||
booked.amountKg === null
|
||||
? 'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.'
|
||||
: 'Die Buchung ist kleiner als ein Gramm.',
|
||||
);
|
||||
}
|
||||
const updated = await tx.honeyBatch.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'HoneyBatchCorrected',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: id,
|
||||
payload: { extractionId: updated.extractionId },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
return this.ledger.movementView(tx, booked.movementId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the form may offer for a new batch of the extraction (spec 7.6): the
|
||||
* rest of its amount after the existing batches, and its honey type (the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
FAILED_RETENTION_DAYS,
|
||||
JOB_RUN_RETENTION_DAYS,
|
||||
JOB_RUN_STALE_HOURS,
|
||||
PENDING_DELIVERY_MAX_AGE_DAYS,
|
||||
WEBHOOK_DELIVERY_RETENTION_DAYS,
|
||||
} from './cleanup.job';
|
||||
|
||||
@@ -85,9 +87,13 @@ describe('CleanupJob', () => {
|
||||
'domainEvents.failed': 12,
|
||||
'webhookDeliveries.delivered': 13,
|
||||
'webhookDeliveries.failed': 14,
|
||||
'notificationDeliveries.done': 15,
|
||||
'notificationDeliveries.failed': 16,
|
||||
'notificationDeliveries.pending': 17,
|
||||
telegramLinkTokens: 18,
|
||||
'jobRuns.aborted': 1,
|
||||
'jobRuns.deleted': 15,
|
||||
weatherDays: 16,
|
||||
'jobRuns.deleted': 19,
|
||||
weatherDays: 20,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,11 +130,48 @@ describe('CleanupJob', () => {
|
||||
'"createdAt" < now() - (? * interval \'1 day\')',
|
||||
);
|
||||
// PENDING and PROCESSING entries are never touched, however old.
|
||||
for (const { sql } of statements()) {
|
||||
for (const { sql } of statements().filter(
|
||||
({ sql }) => !sql.includes('FROM "NotificationDelivery"'),
|
||||
)) {
|
||||
expect(sql).not.toMatch(/PENDING|PROCESSING/);
|
||||
}
|
||||
});
|
||||
|
||||
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', async () => {
|
||||
await job.run();
|
||||
|
||||
const failed = statementOn('NotificationDelivery', 'FAILED');
|
||||
expect(failed.sql).toContain(
|
||||
'"createdAt" < now() - (? * interval \'1 day\')',
|
||||
);
|
||||
expect(failed.values).toEqual([FAILED_RETENTION_DAYS]);
|
||||
const delivered = statements().find(
|
||||
({ sql }) =>
|
||||
sql.includes('DELETE FROM "NotificationDelivery"') &&
|
||||
sql.includes("IN ('DELIVERED', 'SKIPPED')"),
|
||||
);
|
||||
expect(delivered?.values).toEqual([WEBHOOK_DELIVERY_RETENTION_DAYS]);
|
||||
expect(delivered?.sql).toContain('"createdAt" < now()');
|
||||
const tokens = statementOn('TelegramLinkToken');
|
||||
expect(tokens.sql).toContain('"expiresAt" < now() - interval \'30 days\'');
|
||||
expect(tokens.values).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes PENDING NotificationDelivery rows older than 7 days and logs the count', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await job.run();
|
||||
|
||||
const { sql, values } = statementOn('NotificationDelivery', 'PENDING');
|
||||
expect(sql).toContain('"createdAt" < now() - (? * interval \'1 day\')');
|
||||
expect(values).toEqual([PENDING_DELIVERY_MAX_AGE_DAYS]);
|
||||
expect(PENDING_DELIVERY_MAX_AGE_DAYS).toBe(7);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('17 Telegram'));
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('deletes job runs after 90 days, only after stale runs were aborted', async () => {
|
||||
await job.run();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
@@ -14,6 +14,8 @@ export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
|
||||
/** FAILED events and deliveries stay longer, for diagnosis and retry. */
|
||||
export const FAILED_RETENTION_DAYS = 90;
|
||||
export const JOB_RUN_RETENTION_DAYS = 90;
|
||||
/** A Telegram delivery that waited this long is stale anyway (24 h at most). */
|
||||
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
|
||||
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
|
||||
export const JOB_RUN_STALE_HOURS = 6;
|
||||
|
||||
@@ -24,6 +26,7 @@ export const JOB_RUN_STALE_HOURS = 6;
|
||||
*/
|
||||
@Injectable()
|
||||
export class CleanupJob implements Job {
|
||||
private readonly logger = new Logger(CleanupJob.name);
|
||||
readonly name: JobName = 'cleanup';
|
||||
|
||||
constructor(
|
||||
@@ -60,6 +63,31 @@ export class CleanupJob implements Job {
|
||||
DELETE FROM "WebhookDelivery"
|
||||
WHERE "status" = 'FAILED'
|
||||
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
|
||||
// Template deliveries (a reply to a chat) carry no user, so the user
|
||||
// cascade never reaches them: they go by age like all the others.
|
||||
const notificationDeliveriesDone = await this.prisma.$executeRaw`
|
||||
DELETE FROM "NotificationDelivery"
|
||||
WHERE "status" IN ('DELIVERED', 'SKIPPED')
|
||||
AND "createdAt" < now() - (${WEBHOOK_DELIVERY_RETENTION_DAYS} * interval '1 day')`;
|
||||
const notificationDeliveriesFailed = await this.prisma.$executeRaw`
|
||||
DELETE FROM "NotificationDelivery"
|
||||
WHERE "status" = 'FAILED'
|
||||
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
|
||||
// Unsent for a week (bot token never fixed, loop off): the sender would
|
||||
// skip it as stale anyway. The only PENDING rows this job removes.
|
||||
const notificationDeliveriesPending = await this.prisma.$executeRaw`
|
||||
DELETE FROM "NotificationDelivery"
|
||||
WHERE "status" = 'PENDING'
|
||||
AND "createdAt" < now() - (${PENDING_DELIVERY_MAX_AGE_DAYS} * interval '1 day')`;
|
||||
if (notificationDeliveriesPending > 0) {
|
||||
this.logger.warn(
|
||||
`Removed ${notificationDeliveriesPending} Telegram deliveries that stayed pending for over ${PENDING_DELIVERY_MAX_AGE_DAYS} days`,
|
||||
);
|
||||
}
|
||||
// A link token lives 10 minutes; used or not, it is of no use after that.
|
||||
const telegramLinkTokens = await this.prisma.$executeRaw`
|
||||
DELETE FROM "TelegramLinkToken"
|
||||
WHERE "expiresAt" < now() - interval '30 days'`;
|
||||
// After abortStale: no run this old can still be RUNNING.
|
||||
const jobRunsDeleted = await this.prisma.$executeRaw`
|
||||
DELETE FROM "JobRun"
|
||||
@@ -78,6 +106,10 @@ export class CleanupJob implements Job {
|
||||
'domainEvents.failed': domainEventsFailed,
|
||||
'webhookDeliveries.delivered': webhookDeliveriesDelivered,
|
||||
'webhookDeliveries.failed': webhookDeliveriesFailed,
|
||||
'notificationDeliveries.done': notificationDeliveriesDone,
|
||||
'notificationDeliveries.failed': notificationDeliveriesFailed,
|
||||
'notificationDeliveries.pending': notificationDeliveriesPending,
|
||||
telegramLinkTokens,
|
||||
'jobRuns.aborted': jobRunsAborted,
|
||||
'jobRuns.deleted': jobRunsDeleted,
|
||||
weatherDays,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Notification } from '../../generated/prisma';
|
||||
import { berlinDateTime } from '../common/dates';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { NotificationDeliveryPlanner } from './delivery-planner';
|
||||
|
||||
const NOW = berlinDateTime('2026-10-13', '04:30');
|
||||
|
||||
function notification(
|
||||
id: string,
|
||||
userId: string,
|
||||
category: Notification['category'],
|
||||
): Notification {
|
||||
return { id, userId, category } as Notification;
|
||||
}
|
||||
|
||||
interface Preference {
|
||||
userId: string;
|
||||
telegramCategories: Notification['category'][];
|
||||
digestTime: string;
|
||||
quietFrom: string | null;
|
||||
quietUntil: string | null;
|
||||
}
|
||||
|
||||
const preference = (
|
||||
userId: string,
|
||||
overrides: Partial<Preference> = {},
|
||||
): Preference => ({
|
||||
userId,
|
||||
telegramCategories: ['TASK_DUE', 'DIGEST'],
|
||||
digestTime: '06:30',
|
||||
quietFrom: '21:00',
|
||||
quietUntil: '06:30',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('NotificationDeliveryPlanner', () => {
|
||||
let db: {
|
||||
notificationPreference: { findMany: jest.Mock };
|
||||
notificationDelivery: { createMany: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
$executeRaw: jest.Mock;
|
||||
};
|
||||
let planner: NotificationDeliveryPlanner;
|
||||
|
||||
const created = () =>
|
||||
(
|
||||
db.notificationDelivery.createMany.mock.calls[0] as [
|
||||
{
|
||||
data: {
|
||||
channel: string;
|
||||
userId: string;
|
||||
notificationId: string;
|
||||
availableAt: Date;
|
||||
}[];
|
||||
skipDuplicates: boolean;
|
||||
},
|
||||
]
|
||||
)[0];
|
||||
|
||||
beforeEach(() => {
|
||||
db = {
|
||||
notificationPreference: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
notificationDelivery: {
|
||||
createMany: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: unknown[] }) =>
|
||||
Promise.resolve({ count: data.length }),
|
||||
),
|
||||
},
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ now: NOW }]),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
planner = new NotificationDeliveryPlanner();
|
||||
});
|
||||
|
||||
const plan = (notifications: Notification[]) =>
|
||||
planner.planIn(db as unknown as DbClient, notifications);
|
||||
|
||||
it('plans nothing for an empty list and asks no question', async () => {
|
||||
expect(await plan([])).toBe(0);
|
||||
expect(db.notificationPreference.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks only for users with Telegram switched on and a link', async () => {
|
||||
await plan([notification('n1', 'u1', 'TASK_DUE')]);
|
||||
|
||||
expect(db.notificationPreference.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
userId: { in: ['u1'] },
|
||||
telegramEnabled: true,
|
||||
user: { telegramLink: { isNot: null } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('plans nothing without an eligible user (no row, nothing sent to the worker)', async () => {
|
||||
expect(await plan([notification('n1', 'u1', 'TASK_DUE')])).toBe(0);
|
||||
|
||||
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
|
||||
expect(db.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('plans a TELEGRAM row for a category the user chose, available after the quiet hours', async () => {
|
||||
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
|
||||
|
||||
const count = await plan([notification('n1', 'u1', 'TASK_DUE')]);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(created().skipDuplicates).toBe(true);
|
||||
expect(created().data).toEqual([
|
||||
{
|
||||
channel: 'TELEGRAM',
|
||||
userId: 'u1',
|
||||
notificationId: 'n1',
|
||||
availableAt: berlinDateTime('2026-10-13', '06:31'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a category the user did not switch on', async () => {
|
||||
db.notificationPreference.findMany.mockResolvedValue([
|
||||
preference('u1', { telegramCategories: ['DIGEST'] }),
|
||||
]);
|
||||
|
||||
const count = await plan([
|
||||
notification('n1', 'u1', 'TASK_DUE'),
|
||||
notification('n2', 'u1', 'SUGGESTION'),
|
||||
]);
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('plans each notification with the settings of its own user', async () => {
|
||||
db.notificationPreference.findMany.mockResolvedValue([
|
||||
preference('u1', { quietFrom: null, quietUntil: null }),
|
||||
preference('u2', { telegramCategories: ['SYSTEM'] }),
|
||||
]);
|
||||
|
||||
await plan([
|
||||
notification('n1', 'u1', 'DIGEST'),
|
||||
notification('n2', 'u2', 'SYSTEM'),
|
||||
notification('n3', 'u3', 'SYSTEM'),
|
||||
]);
|
||||
|
||||
expect(created().data).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: 'u1',
|
||||
availableAt: berlinDateTime('2026-10-13', '06:30'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
userId: 'u2',
|
||||
// 04:30 is inside the quiet hours of u2.
|
||||
availableAt: berlinDateTime('2026-10-13', '06:30'),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the clock of the database once for the whole list', async () => {
|
||||
db.notificationPreference.findMany.mockResolvedValue([
|
||||
preference('u1'),
|
||||
preference('u2'),
|
||||
]);
|
||||
|
||||
await plan([
|
||||
notification('n1', 'u1', 'DIGEST'),
|
||||
notification('n2', 'u2', 'DIGEST'),
|
||||
]);
|
||||
|
||||
expect(db.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wakes the worker once through notification_delivery after inserting', async () => {
|
||||
db.notificationPreference.findMany.mockResolvedValue([
|
||||
preference('u1'),
|
||||
preference('u2'),
|
||||
]);
|
||||
|
||||
await plan([
|
||||
notification('n1', 'u1', 'DIGEST'),
|
||||
notification('n2', 'u2', 'DIGEST'),
|
||||
]);
|
||||
|
||||
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
const [strings, channel] = db.$executeRaw.mock.calls[0] as [
|
||||
readonly string[],
|
||||
string,
|
||||
];
|
||||
expect(strings.join('?')).toContain('pg_notify');
|
||||
expect(channel).toBe('notification_delivery');
|
||||
});
|
||||
|
||||
it('does not wake the worker if every row existed already', async () => {
|
||||
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
|
||||
db.notificationDelivery.createMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
expect(await plan([notification('n1', 'u1', 'DIGEST')])).toBe(0);
|
||||
expect(db.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Notification } from '../../generated/prisma';
|
||||
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { deliveryAvailableAt } from './delivery-time';
|
||||
|
||||
/**
|
||||
* Queues the Telegram delivery of new notifications (Spec 9a.4). Runs in the
|
||||
* transaction that wrote the notifications, so a rollback takes the
|
||||
* deliveries with it. Only users who switched Telegram on, hold a link and
|
||||
* chose the category get a row; its text is rendered at send time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationDeliveryPlanner {
|
||||
/** Returns how many deliveries were queued. */
|
||||
async planIn(
|
||||
db: DbClient,
|
||||
notifications: readonly Notification[],
|
||||
): Promise<number> {
|
||||
if (notifications.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const userIds = [...new Set(notifications.map((row) => row.userId))];
|
||||
const preferences = await db.notificationPreference.findMany({
|
||||
where: {
|
||||
userId: { in: userIds },
|
||||
telegramEnabled: true,
|
||||
user: { telegramLink: { isNot: null } },
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
telegramCategories: true,
|
||||
digestTime: true,
|
||||
quietFrom: true,
|
||||
quietUntil: true,
|
||||
},
|
||||
});
|
||||
if (preferences.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const byUser = new Map(preferences.map((row) => [row.userId, row]));
|
||||
const now = await this.databaseNow(db);
|
||||
const data = notifications.flatMap((row) => {
|
||||
const preference = byUser.get(row.userId);
|
||||
if (!preference?.telegramCategories.includes(row.category)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
channel: 'TELEGRAM' as const,
|
||||
userId: row.userId,
|
||||
notificationId: row.id,
|
||||
availableAt: deliveryAvailableAt(now, row.category, preference),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (data.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
// A repeated call (retried handler) finds the row of its notification.
|
||||
const { count } = await db.notificationDelivery.createMany({
|
||||
data,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (count > 0) {
|
||||
await notifyWorker(db, WAKE_CHANNELS.notificationDeliveries);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** "now" once, from the database clock the claim compares against. */
|
||||
private async databaseNow(db: DbClient): Promise<Date> {
|
||||
const [row] = await db.$queryRaw<{ now: Date }[]>`SELECT now() AS "now"`;
|
||||
return row.now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { berlinDateTime } from '../common/dates';
|
||||
import {
|
||||
DeliveryPreferences,
|
||||
deliveryAvailableAt,
|
||||
isInQuietHours,
|
||||
} from './delivery-time';
|
||||
|
||||
const DEFAULTS: DeliveryPreferences = {
|
||||
digestTime: '06:30',
|
||||
quietFrom: '21:00',
|
||||
quietUntil: '06:30',
|
||||
};
|
||||
const NO_QUIET: DeliveryPreferences = {
|
||||
digestTime: '06:30',
|
||||
quietFrom: null,
|
||||
quietUntil: null,
|
||||
};
|
||||
|
||||
const at = (day: string, hhmm: string): Date => berlinDateTime(day, hhmm);
|
||||
|
||||
describe('isInQuietHours', () => {
|
||||
it('is false without quiet hours', () => {
|
||||
expect(isInQuietHours(at('2026-10-13', '23:30'), NO_QUIET)).toBe(false);
|
||||
});
|
||||
|
||||
it('spans midnight: 21:00 up to, not including, 06:30', () => {
|
||||
expect(isInQuietHours(at('2026-10-13', '20:59'), DEFAULTS)).toBe(false);
|
||||
expect(isInQuietHours(at('2026-10-13', '21:00'), DEFAULTS)).toBe(true);
|
||||
expect(isInQuietHours(at('2026-10-13', '23:59'), DEFAULTS)).toBe(true);
|
||||
expect(isInQuietHours(at('2026-10-14', '00:00'), DEFAULTS)).toBe(true);
|
||||
expect(isInQuietHours(at('2026-10-14', '06:29'), DEFAULTS)).toBe(true);
|
||||
expect(isInQuietHours(at('2026-10-14', '06:30'), DEFAULTS)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles a window inside one day', () => {
|
||||
const prefs = { ...NO_QUIET, quietFrom: '13:00', quietUntil: '15:00' };
|
||||
expect(isInQuietHours(at('2026-10-13', '12:59'), prefs)).toBe(false);
|
||||
expect(isInQuietHours(at('2026-10-13', '13:00'), prefs)).toBe(true);
|
||||
expect(isInQuietHours(at('2026-10-13', '15:00'), prefs)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the Berlin wall clock in summer and winter time', () => {
|
||||
expect(isInQuietHours(at('2026-07-01', '22:00'), DEFAULTS)).toBe(true);
|
||||
expect(isInQuietHours(at('2026-01-15', '22:00'), DEFAULTS)).toBe(true);
|
||||
expect(isInQuietHours(at('2026-01-15', '12:00'), DEFAULTS)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats equal bounds as no quiet hours', () => {
|
||||
const prefs = { ...NO_QUIET, quietFrom: '21:00', quietUntil: '21:00' };
|
||||
expect(isInQuietHours(at('2026-10-13', '22:00'), prefs)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliveryAvailableAt', () => {
|
||||
it('a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day', () => {
|
||||
const now = at('2026-10-13', '22:00');
|
||||
|
||||
expect(deliveryAvailableAt(now, 'SUGGESTION', DEFAULTS)).toEqual(
|
||||
at('2026-10-14', '06:30'),
|
||||
);
|
||||
});
|
||||
|
||||
it('a delivery created after midnight waits until 06:30 the same day', () => {
|
||||
const now = at('2026-10-14', '02:15');
|
||||
|
||||
expect(deliveryAvailableAt(now, 'SYSTEM', DEFAULTS)).toEqual(
|
||||
at('2026-10-14', '06:30'),
|
||||
);
|
||||
});
|
||||
|
||||
it('TASK_DUE waits until one minute after the digest time, DIGEST until the digest time', () => {
|
||||
const now = at('2026-10-13', '04:30');
|
||||
const prefs = { ...NO_QUIET, digestTime: '07:15' };
|
||||
|
||||
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
|
||||
at('2026-10-13', '07:16'),
|
||||
);
|
||||
expect(deliveryAvailableAt(now, 'DIGEST', prefs)).toEqual(
|
||||
at('2026-10-13', '07:15'),
|
||||
);
|
||||
});
|
||||
|
||||
it('with default quiet hours a task due at 04:30 comes at 06:31 and the digest at 06:30', () => {
|
||||
const now = at('2026-10-13', '04:30');
|
||||
|
||||
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(
|
||||
at('2026-10-13', '06:31'),
|
||||
);
|
||||
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(
|
||||
at('2026-10-13', '06:30'),
|
||||
);
|
||||
});
|
||||
|
||||
it('never goes back in time: after the digest time the category waits no longer', () => {
|
||||
const now = at('2026-10-13', '09:00');
|
||||
|
||||
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(now);
|
||||
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(now);
|
||||
});
|
||||
|
||||
it('a category time that falls into quiet hours moves to the end of them', () => {
|
||||
const now = at('2026-10-13', '04:30');
|
||||
const prefs = { ...DEFAULTS, quietUntil: '07:00' };
|
||||
|
||||
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
|
||||
at('2026-10-13', '07:00'),
|
||||
);
|
||||
});
|
||||
|
||||
it('without quiet hours a SUGGESTION is available now', () => {
|
||||
const now = at('2026-10-13', '23:00');
|
||||
|
||||
expect(deliveryAvailableAt(now, 'SUGGESTION', NO_QUIET)).toEqual(now);
|
||||
});
|
||||
|
||||
it('ends quiet hours at 06:30 Berlin time across the clock change', () => {
|
||||
const now = at('2026-10-24', '22:00');
|
||||
|
||||
const result = deliveryAvailableAt(now, 'SYSTEM', DEFAULTS);
|
||||
|
||||
expect(result).toEqual(at('2026-10-25', '06:30'));
|
||||
expect(result.toISOString()).toBe('2026-10-25T05:30:00.000Z');
|
||||
});
|
||||
|
||||
it('returns a new Date and leaves the input untouched', () => {
|
||||
const now = at('2026-10-13', '12:00');
|
||||
const result = deliveryAvailableAt(now, 'SYSTEM', NO_QUIET);
|
||||
|
||||
expect(result).not.toBe(now);
|
||||
expect(result).toEqual(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clock changes', () => {
|
||||
const quietUntil0230: DeliveryPreferences = {
|
||||
digestTime: '02:30',
|
||||
quietFrom: '21:00',
|
||||
quietUntil: '02:30',
|
||||
};
|
||||
const utc = (iso: string): Date => new Date(iso);
|
||||
|
||||
it('spring (2026-03-29, 02:00 jumps to 03:00): 01:59 is quiet, 03:00 is not', () => {
|
||||
expect(isInQuietHours(utc('2026-03-29T00:59:00Z'), quietUntil0230)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isInQuietHours(utc('2026-03-29T01:00:00Z'), quietUntil0230)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('spring: an end time inside the gap becomes the instant one hour later (03:30 CEST)', () => {
|
||||
const result = deliveryAvailableAt(
|
||||
utc('2026-03-28T21:00:00Z'),
|
||||
'SYSTEM',
|
||||
quietUntil0230,
|
||||
);
|
||||
|
||||
expect(result.toISOString()).toBe('2026-03-29T01:30:00.000Z');
|
||||
});
|
||||
|
||||
it('autumn (2026-10-25, 03:00 falls back to 02:00): both 02:30 are outside the quiet hours, 02:29 is inside', () => {
|
||||
expect(isInQuietHours(utc('2026-10-25T00:29:00Z'), quietUntil0230)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isInQuietHours(utc('2026-10-25T00:30:00Z'), quietUntil0230)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isInQuietHours(utc('2026-10-25T01:30:00Z'), quietUntil0230)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('autumn: the end of quiet hours is the FIRST 02:30 (no extra hour of waiting)', () => {
|
||||
const result = deliveryAvailableAt(
|
||||
utc('2026-10-24T20:00:00Z'),
|
||||
'SYSTEM',
|
||||
quietUntil0230,
|
||||
);
|
||||
|
||||
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
|
||||
});
|
||||
|
||||
it('autumn: the digest time 02:30 is the first occurrence as well', () => {
|
||||
const prefs = { ...quietUntil0230, quietFrom: null, quietUntil: null };
|
||||
|
||||
const result = deliveryAvailableAt(
|
||||
utc('2026-10-24T22:10:00Z'),
|
||||
'DIGEST',
|
||||
prefs,
|
||||
);
|
||||
|
||||
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { NotificationCategory } from '../../generated/prisma';
|
||||
import { addDays, berlinDateTime, berlinDayOf } from '../common/dates';
|
||||
|
||||
export interface DeliveryPreferences {
|
||||
/** HH:MM, Europe/Berlin. */
|
||||
digestTime: string;
|
||||
/** HH:MM; null together with quietUntil: no quiet hours. */
|
||||
quietFrom: string | null;
|
||||
quietUntil: string | null;
|
||||
}
|
||||
|
||||
const ONE_MINUTE_MS = 60_000;
|
||||
const ONE_HOUR_MS = 3_600_000;
|
||||
|
||||
const berlinClock = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
hourCycle: 'h23',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
/** The Berlin wall clock of an instant as HH:MM (compares as text). */
|
||||
function berlinClockOf(instant: Date): string {
|
||||
return berlinClock.format(instant);
|
||||
}
|
||||
|
||||
interface QuietWindow {
|
||||
from: string;
|
||||
until: string;
|
||||
}
|
||||
|
||||
/** Equal bounds or a missing bound mean: no quiet hours. */
|
||||
function quietWindow(prefs: DeliveryPreferences): QuietWindow | null {
|
||||
const { quietFrom: from, quietUntil: until } = prefs;
|
||||
if (from === null || until === null || from === until) {
|
||||
return null;
|
||||
}
|
||||
return { from, until };
|
||||
}
|
||||
|
||||
/** Quiet hours may span midnight (21:00-06:30); the end is exclusive. */
|
||||
export function isInQuietHours(now: Date, prefs: DeliveryPreferences): boolean {
|
||||
const window = quietWindow(prefs);
|
||||
if (window === null) {
|
||||
return false;
|
||||
}
|
||||
const time = berlinClockOf(now);
|
||||
return window.from < window.until
|
||||
? time >= window.from && time < window.until
|
||||
: time >= window.from || time < window.until;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant the Berlin clock shows `hhmm` on `day`. A wall time that
|
||||
* happens twice (autumn change) is taken at its FIRST occurrence; one inside
|
||||
* the spring gap becomes the instant one hour later (02:30 -> 03:30 CEST).
|
||||
*/
|
||||
function berlinWallTime(day: string, hhmm: string): Date {
|
||||
const instant = berlinDateTime(day, hhmm);
|
||||
const earlier = new Date(instant.getTime() - ONE_HOUR_MS);
|
||||
return berlinClockOf(earlier) === hhmm && berlinDayOf(earlier) === day
|
||||
? earlier
|
||||
: instant;
|
||||
}
|
||||
|
||||
/** The next instant after `instant` at which the Berlin clock shows `hhmm`. */
|
||||
function nextBerlinTime(instant: Date, hhmm: string): Date {
|
||||
const day = berlinDayOf(instant);
|
||||
const today = berlinWallTime(day, hhmm);
|
||||
return today.getTime() > instant.getTime()
|
||||
? today
|
||||
: berlinWallTime(addDays(day, 1), hhmm);
|
||||
}
|
||||
|
||||
/** TASK_DUE follows the morning overview by a minute; the digest is the overview. */
|
||||
function categoryTime(
|
||||
now: Date,
|
||||
category: NotificationCategory,
|
||||
prefs: DeliveryPreferences,
|
||||
): Date | null {
|
||||
if (category !== 'TASK_DUE' && category !== 'DIGEST') {
|
||||
return null;
|
||||
}
|
||||
const digest = berlinWallTime(berlinDayOf(now), prefs.digestTime);
|
||||
return category === 'TASK_DUE'
|
||||
? new Date(digest.getTime() + ONE_MINUTE_MS)
|
||||
: digest;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a Telegram delivery may go out (Spec 9a.4): not before `now`, not
|
||||
* before the category's time of day (TASK_DUE: one minute after the digest
|
||||
* time, DIGEST: the digest time), and not inside quiet hours - an instant
|
||||
* inside them moves to their end.
|
||||
*/
|
||||
export function deliveryAvailableAt(
|
||||
now: Date,
|
||||
category: NotificationCategory,
|
||||
prefs: DeliveryPreferences,
|
||||
): Date {
|
||||
const categoryAt = categoryTime(now, category, prefs);
|
||||
const earliest = new Date(
|
||||
Math.max(now.getTime(), categoryAt?.getTime() ?? now.getTime()),
|
||||
);
|
||||
const window = quietWindow(prefs);
|
||||
if (window === null || !isInQuietHours(earliest, prefs)) {
|
||||
return earliest;
|
||||
}
|
||||
return nextBerlinTime(earliest, window.until);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationDeliveryPlanner } from './delivery-planner';
|
||||
import { NotificationPreferencesService } from './notification-preferences.service';
|
||||
import { NotificationSettingsController } from './notification-settings.controller';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
@@ -6,7 +7,15 @@ import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Module({
|
||||
controllers: [NotificationsController, NotificationSettingsController],
|
||||
providers: [NotificationsService, NotificationPreferencesService],
|
||||
exports: [NotificationsService, NotificationPreferencesService],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
NotificationPreferencesService,
|
||||
NotificationDeliveryPlanner,
|
||||
],
|
||||
exports: [
|
||||
NotificationsService,
|
||||
NotificationPreferencesService,
|
||||
NotificationDeliveryPlanner,
|
||||
],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -3,6 +3,7 @@ import { NotificationsService } from './notifications.service';
|
||||
import { assertAppLink } from './notification-link';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { NotificationDeliveryPlanner } from './delivery-planner';
|
||||
|
||||
type CreateManyArg = {
|
||||
data: {
|
||||
@@ -19,6 +20,7 @@ type CreateManyArg = {
|
||||
describe('NotificationsService', () => {
|
||||
let service: NotificationsService;
|
||||
let held: Set<string>;
|
||||
let planner: { planIn: jest.Mock };
|
||||
let prisma: {
|
||||
notification: {
|
||||
createManyAndReturn: jest.Mock;
|
||||
@@ -28,9 +30,12 @@ describe('NotificationsService', () => {
|
||||
updateMany: jest.Mock;
|
||||
};
|
||||
user: { findMany: jest.Mock };
|
||||
$connect: jest.Mock;
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
planner = { planIn: jest.fn().mockResolvedValue(0) };
|
||||
prisma = {
|
||||
notification: {
|
||||
createManyAndReturn: jest
|
||||
@@ -44,7 +49,13 @@ describe('NotificationsService', () => {
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
user: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
// A transaction client lacks $connect; the root client has it.
|
||||
$connect: jest.fn(),
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
|
||||
Promise.resolve(work(prisma)),
|
||||
);
|
||||
held = new Set();
|
||||
service = new NotificationsService(
|
||||
prisma as unknown as PrismaService,
|
||||
@@ -53,6 +64,7 @@ describe('NotificationsService', () => {
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(held)),
|
||||
} as unknown as PermissionResolverService,
|
||||
planner as unknown as NotificationDeliveryPlanner,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,6 +167,64 @@ describe('NotificationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Telegram delivery planning', () => {
|
||||
const base = {
|
||||
priority: 'INFO' as const,
|
||||
category: 'SYSTEM' as const,
|
||||
message: 'm',
|
||||
};
|
||||
|
||||
it('plans the deliveries of the inserted rows right after the insert', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
|
||||
const rows = await service.recordForUsers(['u1', 'u2'], base);
|
||||
|
||||
expect(planner.planIn).toHaveBeenCalledTimes(1);
|
||||
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
|
||||
expect(
|
||||
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('plans with the caller transaction and opens none of its own', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
|
||||
},
|
||||
};
|
||||
|
||||
await service.record({ userId: 'u1', ...base }, tx as never);
|
||||
|
||||
expect(planner.planIn).toHaveBeenCalledWith(tx, [{ id: 'n' }]);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('without a caller transaction, rows and deliveries share one of their own', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
|
||||
await service.record({ userId: 'u1', ...base });
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(planner.planIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('plans only what was inserted: a repeated dedupe key plans nothing new', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
prisma.notification.createManyAndReturn.mockResolvedValue([]);
|
||||
|
||||
await service.record({ userId: 'u1', ...base, dedupeKey: 'k:u1' });
|
||||
|
||||
expect(planner.planIn).toHaveBeenCalledWith(expect.anything(), []);
|
||||
});
|
||||
|
||||
it('plans nothing when no recipient is eligible', async () => {
|
||||
await service.recordForUsers(['u1'], base);
|
||||
|
||||
expect(planner.planIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordForPermission', () => {
|
||||
const input = {
|
||||
priority: 'WARNUNG' as const,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Prisma,
|
||||
} from '../../generated/prisma';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { NotificationDeliveryPlanner } from './delivery-planner';
|
||||
import { assertAppLink, notificationDedupeKey } from './notification-link';
|
||||
|
||||
export interface CreateNotificationInput {
|
||||
@@ -87,13 +88,15 @@ function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] {
|
||||
* needs (the rule the PermissionsGuard applies). The stored
|
||||
* requiredPermissions are the recipient's and the content's permissions
|
||||
* together, so a later consumer (e.g. the Telegram sender) can check them
|
||||
* again.
|
||||
* again. Right after the insert, in the same transaction, the delivery planner
|
||||
* queues the Telegram delivery for users who asked for it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly deliveryPlanner: NotificationDeliveryPlanner,
|
||||
) {}
|
||||
|
||||
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
|
||||
@@ -274,7 +277,8 @@ export class NotificationsService {
|
||||
...recipientKeys,
|
||||
...(content.requiredPermissions ?? []),
|
||||
]);
|
||||
return db.notification.createManyAndReturn({
|
||||
const insertAndPlan = async (client: DbClient): Promise<Notification[]> => {
|
||||
const rows = await client.notification.createManyAndReturn({
|
||||
data: targets.map(({ userId, dedupeKey }) => ({
|
||||
userId,
|
||||
priority: content.priority,
|
||||
@@ -288,5 +292,15 @@ export class NotificationsService {
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
// Only the rows that were inserted: a repeated dedupe key plans nothing.
|
||||
await this.deliveryPlanner.planIn(client, rows);
|
||||
return rows;
|
||||
};
|
||||
// Rows and deliveries belong together: without the caller's transaction
|
||||
// they get their own. A caller WITH a transaction gets no nested one: the
|
||||
// insert and the planning run on its client and commit or roll back with it.
|
||||
return '$connect' in db
|
||||
? db.$transaction((tx) => insertAndPlan(tx))
|
||||
: insertAndPlan(db);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
|
||||
|
||||
const errorsFor = async (body: object): Promise<string[]> =>
|
||||
(await validate(plainToInstance(CreatePhenologyReportDto, body))).map(
|
||||
(error) => error.property,
|
||||
);
|
||||
|
||||
describe('CreatePhenologyReportDto', () => {
|
||||
const base = { phase: 'SALWEIDE', date: '2026-03-10' };
|
||||
|
||||
it.each([undefined, null, 'a1'])('accepts apiaryId %p', async (apiaryId) => {
|
||||
await expect(errorsFor({ ...base, apiaryId })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an empty apiaryId', async () => {
|
||||
await expect(errorsFor({ ...base, apiaryId: '' })).resolves.toEqual([
|
||||
'apiaryId',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { PhenologyPhase } from '../../../generated/prisma';
|
||||
|
||||
export const MAX_NOTES_LENGTH = 500;
|
||||
|
||||
export class CreatePhenologyReportDto {
|
||||
@ApiProperty({ enum: PhenologyPhase })
|
||||
@IsEnum(PhenologyPhase)
|
||||
phase!: PhenologyPhase;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Day of the observation (YYYY-MM-DD), not after today.',
|
||||
example: '2026-03-20',
|
||||
})
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: 'date must be a day in the format YYYY-MM-DD',
|
||||
})
|
||||
date!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description:
|
||||
'The apiary the report is for; null or omitted = all apiaries.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
apiaryId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, maxLength: MAX_NOTES_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTES_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListPhenologyReportsQuery {
|
||||
@ApiPropertyOptional({ description: 'Calendar year; default: this year.' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(2000)
|
||||
@Max(2100)
|
||||
year?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
|
||||
|
||||
/**
|
||||
* apiaryId null widens the report to all apiaries, notes null clears the
|
||||
* notes; phase and date cannot be null (400).
|
||||
*/
|
||||
export class UpdatePhenologyReportDto extends PartialType(
|
||||
CreatePhenologyReportDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} 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 type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
|
||||
import { ListPhenologyReportsQuery } from './dto/list-phenology-reports.query';
|
||||
import { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
|
||||
import { PhenologyService } from './phenology.service';
|
||||
|
||||
@ApiTags('phenology')
|
||||
@ApiBearerAuth()
|
||||
@Controller('phenology')
|
||||
export class PhenologyController {
|
||||
constructor(private readonly phenology: PhenologyService) {}
|
||||
|
||||
/** Season state of every active apiary; no coordinates. */
|
||||
@RequirePermission('apiaries:read')
|
||||
@Get('state')
|
||||
state() {
|
||||
return this.phenology.state();
|
||||
}
|
||||
|
||||
@RequirePermission('phenology:read')
|
||||
@Get('reports')
|
||||
list(@Query() query: ListPhenologyReportsQuery) {
|
||||
return this.phenology.list(query.year ?? Number(berlinToday().slice(0, 4)));
|
||||
}
|
||||
|
||||
@RequirePermission('phenology:create')
|
||||
@Idempotent()
|
||||
@Post('reports')
|
||||
create(
|
||||
@Body() dto: CreatePhenologyReportDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.phenology.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('phenology:update')
|
||||
@Patch('reports/:id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePhenologyReportDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.phenology.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('phenology:update')
|
||||
@Idempotent()
|
||||
@Post('reports/:id/revoke')
|
||||
revoke(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.phenology.revoke(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import type { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import { PhenologyEventHandlers } from './phenology.handlers';
|
||||
import type { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
// 2026-04-01 in Berlin, but still 2026-03-31 in UTC: the event day is the
|
||||
// Berlin day.
|
||||
const OCCURRED_AT = new Date('2026-03-31T22:30:00.000Z');
|
||||
const EVENT_DAY = '2026-04-01';
|
||||
// Delivery runs later (the fake clock): the later of the two days counts.
|
||||
const DELIVERY_DAY = '2026-04-09';
|
||||
|
||||
const envelope = <T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) =>
|
||||
({
|
||||
type,
|
||||
aggregateId,
|
||||
payload,
|
||||
occurredAt: OCCURRED_AT,
|
||||
}) as unknown as AnyDomainEventEnvelope<T>;
|
||||
|
||||
describe('PhenologyEventHandlers', () => {
|
||||
beforeAll(() => {
|
||||
// Delivery runs much later than the event.
|
||||
jest.useFakeTimers({ now: new Date('2026-04-09T10:00:00Z') });
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
let state: { recomputeIn: jest.Mock };
|
||||
let events: { recordAll: jest.Mock };
|
||||
let tx: { apiary: { findMany: jest.Mock } };
|
||||
let handlers: PhenologyEventHandlers;
|
||||
const asTx = () => tx as unknown as Prisma.TransactionClient;
|
||||
|
||||
beforeEach(() => {
|
||||
state = { recomputeIn: jest.fn().mockResolvedValue({ changed: false }) };
|
||||
events = { recordAll: jest.fn().mockResolvedValue(2) };
|
||||
tx = {
|
||||
apiary: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]),
|
||||
},
|
||||
};
|
||||
handlers = new PhenologyEventHandlers(
|
||||
state as unknown as PhenologyStateService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers the handlers under their ids and has no season notification', () => {
|
||||
const entries = (method: keyof PhenologyEventHandlers) =>
|
||||
domainEventHandlerMetadata(
|
||||
Object.getOwnPropertyDescriptor(
|
||||
PhenologyEventHandlers.prototype,
|
||||
method,
|
||||
)?.value as object,
|
||||
);
|
||||
|
||||
expect(entries('recompute')).toEqual([
|
||||
{
|
||||
types: ['PhenologyReported', 'PhenologyCorrected'],
|
||||
handlerId: 'phenology.recompute',
|
||||
},
|
||||
]);
|
||||
expect(entries('recomputeApiary')).toEqual([
|
||||
{
|
||||
types: ['PhenologyRecomputeRequested'],
|
||||
handlerId: 'phenology.recompute-apiary',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
Object.getOwnPropertyNames(PhenologyEventHandlers.prototype),
|
||||
).toEqual(['constructor', 'recompute', 'recomputeApiary']);
|
||||
});
|
||||
|
||||
describe('recompute', () => {
|
||||
it('a report for one apiary recomputes that apiary on the delivery day', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
|
||||
|
||||
await handlers.recompute(
|
||||
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(tx.apiary.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { status: 'ACTIVE', id: { in: ['a1'] } },
|
||||
}),
|
||||
);
|
||||
expect(state.recomputeIn).toHaveBeenCalledTimes(1);
|
||||
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
|
||||
allowBackward: false,
|
||||
});
|
||||
expect(events.recordAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a report for all apiaries records one follow-up event per active apiary', async () => {
|
||||
await handlers.recompute(
|
||||
envelope('PhenologyReported', 'r1', { apiaryId: null }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(state.recomputeIn).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
);
|
||||
expect(events.recordAll).toHaveBeenCalledWith(tx, [
|
||||
{
|
||||
type: 'PhenologyRecomputeRequested',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: 'a1',
|
||||
payload: { apiaryId: 'a1', allowBackward: false, day: DELIVERY_DAY },
|
||||
},
|
||||
{
|
||||
type: 'PhenologyRecomputeRequested',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: 'a2',
|
||||
payload: { apiaryId: 'a2', allowBackward: false, day: DELIVERY_DAY },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('a delayed delivery never writes an older day than the event', async () => {
|
||||
jest.setSystemTime(new Date('2026-03-30T10:00:00Z'));
|
||||
try {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
|
||||
|
||||
await handlers.recompute(
|
||||
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', EVENT_DAY, {
|
||||
allowBackward: false,
|
||||
});
|
||||
} finally {
|
||||
jest.setSystemTime(new Date('2026-04-09T10:00:00Z'));
|
||||
}
|
||||
});
|
||||
|
||||
it('a correction lets the phase go back', async () => {
|
||||
await handlers.recompute(
|
||||
envelope('PhenologyCorrected', 'r1', {
|
||||
apiaryId: 'a1',
|
||||
previousApiaryId: 'a1',
|
||||
}),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
|
||||
allowBackward: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('a scope move recomputes the old and the new apiary only', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
||||
|
||||
await handlers.recompute(
|
||||
envelope('PhenologyCorrected', 'r1', {
|
||||
apiaryId: 'a2',
|
||||
previousApiaryId: 'a1',
|
||||
}),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(tx.apiary.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { status: 'ACTIVE', id: { in: ['a1', 'a2'] } },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(state.recomputeIn.mock.calls as unknown[][]).map((call) => call[1]),
|
||||
).toEqual(['a1', 'a2']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['widened to all', { apiaryId: null, previousApiaryId: 'a1' }],
|
||||
['narrowed from all', { apiaryId: 'a1', previousApiaryId: null }],
|
||||
])('a correction %s fans out to all apiaries', async (_name, payload) => {
|
||||
await handlers.recompute(
|
||||
envelope('PhenologyCorrected', 'r1', payload),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(state.recomputeIn).not.toHaveBeenCalled();
|
||||
expect(events.recordAll).toHaveBeenCalledTimes(1);
|
||||
const [, recorded] = events.recordAll.mock.calls[0] as [
|
||||
unknown,
|
||||
{ payload: { allowBackward: boolean } }[],
|
||||
];
|
||||
expect(recorded).toHaveLength(2);
|
||||
expect(recorded[0].payload.allowBackward).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an error propagate so the delivery is retried', async () => {
|
||||
state.recomputeIn.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(
|
||||
handlers.recompute(
|
||||
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
|
||||
asTx(),
|
||||
),
|
||||
).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomputeApiary', () => {
|
||||
it('recomputes the one apiary on the requested day', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a2' }]);
|
||||
|
||||
await handlers.recomputeApiary(
|
||||
envelope('PhenologyRecomputeRequested', 'a2', {
|
||||
apiaryId: 'a2',
|
||||
allowBackward: true,
|
||||
day: '2026-04-01',
|
||||
}),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a2', '2026-04-01', {
|
||||
allowBackward: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips an apiary that is no longer active', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([]);
|
||||
|
||||
await handlers.recomputeApiary(
|
||||
envelope('PhenologyRecomputeRequested', 'a2', {
|
||||
apiaryId: 'a2',
|
||||
allowBackward: false,
|
||||
day: '2026-04-01',
|
||||
}),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(state.recomputeIn).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'a2', status: 'ACTIVE' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { berlinDayOf, berlinToday } from '../common/dates';
|
||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
type ReportEvent = AnyDomainEventEnvelope<
|
||||
'PhenologyReported' | 'PhenologyCorrected'
|
||||
>;
|
||||
|
||||
/** The apiaries a report event touches; null = all of them. */
|
||||
function scopeOf(event: ReportEvent): string[] | null {
|
||||
const scopes =
|
||||
event.type === 'PhenologyCorrected'
|
||||
? [event.payload.apiaryId, event.payload.previousApiaryId]
|
||||
: [event.payload.apiaryId];
|
||||
if (scopes.includes(null)) {
|
||||
return null;
|
||||
}
|
||||
return [...new Set(scopes as string[])].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The reactions to plant development reports. Pure DB writers through the
|
||||
* delivery transaction; errors propagate so a delivery is retried. A season
|
||||
* change is not announced one by one: the morning digest carries it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyEventHandlers {
|
||||
constructor(
|
||||
private readonly state: PhenologyStateService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A report (or its correction or withdrawal) recomputes the season state of
|
||||
* the apiaries it touched, on the later of the event's day and today (Berlin). After a correction the
|
||||
* phase may fall back. One or two apiaries are recomputed right here, in id
|
||||
* order; for all apiaries one PhenologyRecomputeRequested per active apiary
|
||||
* is recorded, so each apiary is recomputed (and retried) on its own.
|
||||
*/
|
||||
@OnDomainEvent(
|
||||
['PhenologyReported', 'PhenologyCorrected'],
|
||||
'phenology.recompute',
|
||||
)
|
||||
async recompute(
|
||||
event: ReportEvent,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
// A delayed delivery must not write an older day than the event's own.
|
||||
const day = [berlinDayOf(event.occurredAt), berlinToday()].sort()[1];
|
||||
const allowBackward = event.type === 'PhenologyCorrected';
|
||||
const scope = scopeOf(event);
|
||||
const apiaries = await tx.apiary.findMany({
|
||||
where:
|
||||
scope === null
|
||||
? { status: 'ACTIVE' }
|
||||
: { status: 'ACTIVE', id: { in: scope } },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (scope === null) {
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
apiaries.map(({ id }) => ({
|
||||
type: 'PhenologyRecomputeRequested' as const,
|
||||
aggregateType: 'Apiary' as const,
|
||||
aggregateId: id,
|
||||
payload: { apiaryId: id, allowBackward, day },
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const { id } of apiaries) {
|
||||
await this.state.recomputeIn(tx, id, day, { allowBackward });
|
||||
}
|
||||
}
|
||||
|
||||
/** One apiary of a fan-out; an apiary that is no longer active is skipped. */
|
||||
@OnDomainEvent('PhenologyRecomputeRequested', 'phenology.recompute-apiary')
|
||||
async recomputeApiary(
|
||||
event: AnyDomainEventEnvelope<'PhenologyRecomputeRequested'>,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const { apiaryId, allowBackward, day } = event.payload;
|
||||
const active = await tx.apiary.findMany({
|
||||
where: { id: apiaryId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (active.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.state.recomputeIn(tx, apiaryId, day, { allowBackward });
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
import { PhenologyController } from './phenology.controller';
|
||||
import { PhenologyEventHandlers } from './phenology.handlers';
|
||||
import { PhenologyService } from './phenology.service';
|
||||
import { PhenologyStep } from './phenology.step';
|
||||
|
||||
/**
|
||||
* The season state of apiaries (ApiaryPhenology): the service that computes
|
||||
* it and the first step of the daily evaluation. Imported by AppModule and
|
||||
* WorkerModule (the reports of Task 28 recompute through the service) and by
|
||||
* JobsModule (the step).
|
||||
* it, the first step of the daily evaluation, the plant development reports
|
||||
* (API) and the event handlers that recompute after a report. Imported by AppModule and WorkerModule (the handlers)
|
||||
* and by JobsModule (the step).
|
||||
*/
|
||||
@Module({
|
||||
imports: [DomainEventsModule],
|
||||
providers: [PhenologyStateService, PhenologyStep],
|
||||
imports: [DomainEventsModule, SeasonModule],
|
||||
controllers: [PhenologyController],
|
||||
providers: [
|
||||
PhenologyStateService,
|
||||
PhenologyStep,
|
||||
PhenologyService,
|
||||
PhenologyEventHandlers,
|
||||
],
|
||||
exports: [PhenologyStateService, PhenologyStep],
|
||||
})
|
||||
export class PhenologyModule {}
|
||||
@@ -0,0 +1,455 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuditLogService } from '../audit/audit-log.service';
|
||||
import type { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { PhenologyService } from './phenology.service';
|
||||
|
||||
const day = (value: string) => new Date(`${value}T00:00:00.000Z`);
|
||||
|
||||
/** Prisma leaves out the fields that are undefined. */
|
||||
const defined = (data: object): Record<string, unknown> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(data).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
const stored = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
phase: 'SALWEIDE',
|
||||
date: day('2026-03-10'),
|
||||
notes: null,
|
||||
userId: 'u0',
|
||||
revokedAt: null,
|
||||
createdAt: day('2026-03-10'),
|
||||
updatedAt: day('2026-03-10'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seasonState = (overrides: Record<string, unknown> = {}) => ({
|
||||
apiaryId: 'a1',
|
||||
year: 2026,
|
||||
phase: 'SALWEIDE',
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: null,
|
||||
segment: 'FRUEHJAHR',
|
||||
segmentSince: '2026-03-10',
|
||||
springSince: '2026-03-10',
|
||||
gts: 210.5,
|
||||
gtsThroughDay: '2026-03-31',
|
||||
note: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PhenologyService', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-04-01T10:00:00Z') });
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
let service: PhenologyService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
apiary: { findUnique: jest.Mock };
|
||||
phenologyReport: {
|
||||
create: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: {
|
||||
$transaction: jest.Mock;
|
||||
apiary: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
phenologyReport: { findMany: jest.Mock };
|
||||
};
|
||||
let audit: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let season: { statesFor: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'r1' }]),
|
||||
apiary: { findUnique: jest.fn().mockResolvedValue({ id: 'a1' }) },
|
||||
phenologyReport: {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve(stored(defined(data))),
|
||||
),
|
||||
findUnique: jest.fn().mockResolvedValue(stored()),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve(stored(defined(data))),
|
||||
),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((work: (client: typeof tx) => Promise<unknown>) =>
|
||||
work(tx),
|
||||
),
|
||||
apiary: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'a1' }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
audit = { record: jest.fn() };
|
||||
events = { record: jest.fn() };
|
||||
season = { statesFor: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new PhenologyService(
|
||||
prisma as unknown as PrismaService,
|
||||
audit as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
season,
|
||||
);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('writes report, audit entry and PhenologyReported in one transaction', async () => {
|
||||
await service.create(
|
||||
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'a1' },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
phase: 'SALWEIDE',
|
||||
date: day('2026-03-10'),
|
||||
apiaryId: 'a1',
|
||||
notes: null,
|
||||
userId: 'u1',
|
||||
},
|
||||
});
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'PhenologyReport',
|
||||
action: 'CREATE',
|
||||
userId: 'u1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'PhenologyReported',
|
||||
aggregateType: 'PhenologyReport',
|
||||
aggregateId: 'r1',
|
||||
actorUserId: 'u1',
|
||||
payload: {
|
||||
apiaryId: 'a1',
|
||||
phase: 'SALWEIDE',
|
||||
reportedOn: '2026-03-10',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('stores apiaryId null for all apiaries', async () => {
|
||||
await service.create({ phase: 'KIRSCHE', date: '2026-03-31' }, 'u1');
|
||||
|
||||
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ apiaryId: null }) as object,
|
||||
});
|
||||
expect(tx.apiary.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a day in the future with the German message', async () => {
|
||||
await expect(
|
||||
service.create({ phase: 'SALWEIDE', date: '2026-04-02' }, 'u1'),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException('Das Datum liegt in der Zukunft.'),
|
||||
);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts today (Berlin)', async () => {
|
||||
await expect(
|
||||
service.create({ phase: 'SALWEIDE', date: '2026-04-01' }, 'u1'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a day that does not exist', async () => {
|
||||
await expect(
|
||||
service.create({ phase: 'SALWEIDE', date: '2026-02-30' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown apiary', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'nope' },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('locks the report, reads before afterwards, audits with beforeData and records PhenologyCorrected', async () => {
|
||||
await service.update('r1', { phase: 'KIRSCHE' }, 'u1');
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.phenologyReport.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
entityId: 'r1',
|
||||
beforeData: expect.objectContaining({ phase: 'SALWEIDE' }) as object,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
type: 'PhenologyCorrected',
|
||||
aggregateId: 'r1',
|
||||
payload: {
|
||||
apiaryId: 'a1',
|
||||
previousApiaryId: 'a1',
|
||||
phase: 'KIRSCHE',
|
||||
reportedOn: '2026-03-10',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('apiaryId null widens the report to all apiaries; the event names both scopes', async () => {
|
||||
await service.update('r1', { apiaryId: null }, 'u1');
|
||||
|
||||
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: expect.objectContaining({ apiaryId: null }) as object,
|
||||
});
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
apiaryId: null,
|
||||
previousApiaryId: 'a1',
|
||||
}) as object,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('moving the report to another apiary: the event names the old and the new one', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue({ id: 'a2' });
|
||||
|
||||
await service.update('r1', { apiaryId: 'a2' }, 'u1');
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
apiaryId: 'a2',
|
||||
previousApiaryId: 'a1',
|
||||
}) as object,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('a patch that changes nothing writes no audit entry and no event', async () => {
|
||||
const result = await service.update(
|
||||
'r1',
|
||||
{
|
||||
phase: 'SALWEIDE',
|
||||
date: '2026-03-10',
|
||||
apiaryId: 'a1',
|
||||
notes: null,
|
||||
},
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(stored());
|
||||
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
|
||||
expect(audit.record).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an unknown new apiary answers 404 inside the transaction', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('r1', { apiaryId: 'nope' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notes null clears the notes', async () => {
|
||||
tx.phenologyReport.findUnique.mockResolvedValue(
|
||||
stored({ notes: 'früh' }),
|
||||
);
|
||||
await service.update('r1', { notes: null }, 'u1');
|
||||
|
||||
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: expect.objectContaining({ notes: null }) as object,
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the report is gone', async () => {
|
||||
tx.$queryRaw.mockResolvedValue([]);
|
||||
tx.phenologyReport.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('409 for a revoked report', async () => {
|
||||
tx.phenologyReport.findUnique.mockResolvedValue(
|
||||
stored({ revokedAt: day('2026-03-11') }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects a future date', async () => {
|
||||
await expect(
|
||||
service.update('r1', { date: '2026-05-01' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('sets revokedAt, audits with beforeData and records PhenologyCorrected', async () => {
|
||||
await service.revoke('r1', 'u1');
|
||||
|
||||
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: { revokedAt: expect.any(Date) as Date },
|
||||
});
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
beforeData: expect.objectContaining({ revokedAt: null }) as object,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
type: 'PhenologyCorrected',
|
||||
payload: expect.objectContaining({
|
||||
apiaryId: 'a1',
|
||||
previousApiaryId: 'a1',
|
||||
}) as object,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('409 when already revoked', async () => {
|
||||
tx.phenologyReport.findUnique.mockResolvedValue(
|
||||
stored({ revokedAt: day('2026-03-11') }),
|
||||
);
|
||||
|
||||
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404 when unknown', async () => {
|
||||
tx.$queryRaw.mockResolvedValue([]);
|
||||
tx.phenologyReport.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns the reports of the year with days, apiary name and revokedAt', async () => {
|
||||
prisma.phenologyReport.findMany.mockResolvedValue([
|
||||
{
|
||||
...stored({ revokedAt: day('2026-03-12') }),
|
||||
apiary: { name: 'Hang' },
|
||||
user: { name: 'Imker' },
|
||||
},
|
||||
{
|
||||
...stored({ id: 'r2', apiaryId: null }),
|
||||
apiary: null,
|
||||
user: { name: 'Imker' },
|
||||
},
|
||||
]);
|
||||
|
||||
const views = await service.list(2026);
|
||||
|
||||
expect(prisma.phenologyReport.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { date: { gte: day('2026-01-01'), lt: day('2027-01-01') } },
|
||||
}),
|
||||
);
|
||||
expect(views).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'r1',
|
||||
apiaryName: 'Hang',
|
||||
date: '2026-03-10',
|
||||
revokedAt: '2026-03-12T00:00:00.000Z',
|
||||
userName: 'Imker',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'r2',
|
||||
apiaryId: null,
|
||||
apiaryName: null,
|
||||
revokedAt: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state', () => {
|
||||
it('lists active apiaries with phase and segment, the report day, no coordinates', async () => {
|
||||
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
|
||||
season.statesFor.mockResolvedValue(new Map([['a1', seasonState()]]));
|
||||
prisma.phenologyReport.findMany.mockResolvedValue([
|
||||
stored({ date: day('2026-03-12') }),
|
||||
stored({ id: 'r2', date: day('2026-03-10'), apiaryId: null }),
|
||||
stored({ id: 'r3', phase: 'KIRSCHE', date: day('2026-03-01') }),
|
||||
]);
|
||||
|
||||
const states = await service.state();
|
||||
|
||||
expect(prisma.apiary.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { status: 'ACTIVE' } }),
|
||||
);
|
||||
expect(states).toEqual([
|
||||
{
|
||||
apiaryId: 'a1',
|
||||
apiaryName: 'Hang',
|
||||
phase: 'SALWEIDE',
|
||||
segment: 'FRUEHJAHR',
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: '2026-03-10',
|
||||
gts: 210.5,
|
||||
gtsThroughDay: '2026-03-31',
|
||||
note: null,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(states)).not.toMatch(/latitude|longitude/);
|
||||
});
|
||||
|
||||
it('reportedOn is null unless the phase comes from a report', async () => {
|
||||
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
|
||||
season.statesFor.mockResolvedValue(
|
||||
new Map([['a1', seasonState({ phaseSource: 'GTS' })]]),
|
||||
);
|
||||
|
||||
const [state] = await service.state();
|
||||
|
||||
expect(state.reportedOn).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
PhaseSource,
|
||||
PhenologyPhase,
|
||||
PhenologyReport,
|
||||
Prisma,
|
||||
SeasonSegment,
|
||||
} from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock';
|
||||
import type { SeasonNote } from '../season/season-types';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
|
||||
import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
|
||||
|
||||
const FUTURE_MESSAGE = 'Das Datum liegt in der Zukunft.';
|
||||
const NOT_FOUND_MESSAGE = 'Meldung nicht gefunden.';
|
||||
const REVOKED_MESSAGE = 'Die Meldung wurde schon zurückgezogen.';
|
||||
|
||||
export interface PhenologyReportView {
|
||||
id: string;
|
||||
apiaryId: string | null;
|
||||
/** null = the report counts for all apiaries. */
|
||||
apiaryName: string | null;
|
||||
phase: PhenologyPhase;
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
notes: string | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The season state of an apiary as the API shows it: never coordinates. */
|
||||
export interface PhenologyApiaryState {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
phase: PhenologyPhase | null;
|
||||
segment: SeasonSegment;
|
||||
phaseSource: PhaseSource | null;
|
||||
reportedOn: string | null;
|
||||
gts: number | null;
|
||||
gtsThroughDay: string | null;
|
||||
note: SeasonNote;
|
||||
}
|
||||
|
||||
/** A report day must exist in the calendar and must not lie after today. */
|
||||
function assertReportDay(day: string): Date {
|
||||
let date: Date;
|
||||
try {
|
||||
date = dayToDate(day);
|
||||
} catch {
|
||||
throw new BadRequestException('Das Datum ist ungültig.');
|
||||
}
|
||||
if (day > berlinToday()) {
|
||||
throw new BadRequestException(FUTURE_MESSAGE);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function yearRange(year: number): { gte: Date; lt: Date } {
|
||||
return {
|
||||
gte: dayToDate(`${year}-01-01`),
|
||||
lt: dayToDate(`${year + 1}-01-01`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The plant development reports of the beekeeper (first bloom of Salweide,
|
||||
* Kirsche, ...). A report changes the season state of its apiary (or of all);
|
||||
* the recomputation runs from the events (PhenologyEventHandlers).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly season: SeasonService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreatePhenologyReportDto,
|
||||
userId: string,
|
||||
): Promise<PhenologyReport> {
|
||||
const date = assertReportDay(dto.date);
|
||||
const apiaryId = dto.apiaryId ?? null;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.assertApiaryIn(tx, apiaryId);
|
||||
const created = await tx.phenologyReport.create({
|
||||
data: {
|
||||
phase: dto.phase,
|
||||
date,
|
||||
apiaryId,
|
||||
notes: dto.notes ?? null,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'PhenologyReport',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'PhenologyReported',
|
||||
aggregateType: 'PhenologyReport',
|
||||
aggregateId: created.id,
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
apiaryId: created.apiaryId,
|
||||
phase: created.phase,
|
||||
reportedOn: utcDay(created.date),
|
||||
},
|
||||
});
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdatePhenologyReportDto,
|
||||
userId: string,
|
||||
): Promise<PhenologyReport> {
|
||||
const date = dto.date === undefined ? undefined : assertReportDay(dto.date);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Lock order: Apiary before PhenologyReport (LOCKABLE_TABLES).
|
||||
await this.assertApiaryIn(tx, dto.apiaryId ?? null);
|
||||
const before = await this.lockActive(tx, id);
|
||||
const next = {
|
||||
phase: dto.phase ?? before.phase,
|
||||
date: date ?? before.date,
|
||||
apiaryId: dto.apiaryId === undefined ? before.apiaryId : dto.apiaryId,
|
||||
notes: dto.notes === undefined ? before.notes : dto.notes,
|
||||
};
|
||||
if (
|
||||
next.phase === before.phase &&
|
||||
next.date.getTime() === before.date.getTime() &&
|
||||
next.apiaryId === before.apiaryId &&
|
||||
next.notes === before.notes
|
||||
) {
|
||||
return before;
|
||||
}
|
||||
const updated = await tx.phenologyReport.update({
|
||||
where: { id },
|
||||
data: {
|
||||
phase: dto.phase,
|
||||
date,
|
||||
apiaryId: dto.apiaryId,
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'PhenologyReport',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'PhenologyCorrected',
|
||||
aggregateType: 'PhenologyReport',
|
||||
aggregateId: id,
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
apiaryId: updated.apiaryId,
|
||||
previousApiaryId: before.apiaryId,
|
||||
phase: updated.phase,
|
||||
reportedOn: utcDay(updated.date),
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/** Withdraws a report; the apiaries it counted for are recomputed. */
|
||||
async revoke(id: string, userId: string): Promise<PhenologyReport> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockActive(tx, id);
|
||||
const revoked = await tx.phenologyReport.update({
|
||||
where: { id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'PhenologyReport',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
beforeData: before,
|
||||
afterData: revoked,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'PhenologyCorrected',
|
||||
aggregateType: 'PhenologyReport',
|
||||
aggregateId: id,
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
apiaryId: revoked.apiaryId,
|
||||
previousApiaryId: revoked.apiaryId,
|
||||
phase: revoked.phase,
|
||||
reportedOn: utcDay(revoked.date),
|
||||
},
|
||||
});
|
||||
return revoked;
|
||||
});
|
||||
}
|
||||
|
||||
/** All reports of a year, withdrawn ones included (with revokedAt). */
|
||||
async list(year: number): Promise<PhenologyReportView[]> {
|
||||
const rows = await this.prisma.phenologyReport.findMany({
|
||||
where: { date: yearRange(year) },
|
||||
include: {
|
||||
apiary: { select: { name: true } },
|
||||
user: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
apiaryId: row.apiaryId,
|
||||
apiaryName: row.apiary?.name ?? null,
|
||||
phase: row.phase,
|
||||
date: utcDay(row.date),
|
||||
notes: row.notes,
|
||||
userId: row.userId,
|
||||
userName: row.user.name,
|
||||
revokedAt: row.revokedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The season state of every active apiary. Never coordinates. */
|
||||
async state(): Promise<PhenologyApiaryState[]> {
|
||||
const today = berlinToday();
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const states = await this.season.statesFor(
|
||||
this.prisma,
|
||||
apiaries.map((apiary) => apiary.id),
|
||||
today,
|
||||
);
|
||||
const reports = await this.prisma.phenologyReport.findMany({
|
||||
where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) },
|
||||
select: { apiaryId: true, phase: true, date: true },
|
||||
});
|
||||
return apiaries.flatMap((apiary) => {
|
||||
const state = states.get(apiary.id);
|
||||
if (state === undefined) {
|
||||
return [];
|
||||
}
|
||||
const reportedOn =
|
||||
state.phaseSource === 'REPORT'
|
||||
? (reports
|
||||
.filter(
|
||||
(report) =>
|
||||
report.phase === state.phase &&
|
||||
(report.apiaryId === null || report.apiaryId === apiary.id),
|
||||
)
|
||||
.map((report) => utcDay(report.date))
|
||||
.sort()[0] ?? null)
|
||||
: null;
|
||||
return [
|
||||
{
|
||||
apiaryId: apiary.id,
|
||||
apiaryName: apiary.name,
|
||||
phase: state.phase,
|
||||
segment: state.segment,
|
||||
phaseSource: state.phaseSource,
|
||||
reportedOn,
|
||||
gts: state.gts,
|
||||
gtsThroughDay: state.gtsThroughDay,
|
||||
note: state.note,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Locks the report and reads it afterwards: 404 if gone, 409 if withdrawn. */
|
||||
private async lockActive(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
): Promise<PhenologyReport> {
|
||||
const exists = await lockRowForUpdate(tx, 'PhenologyReport', id);
|
||||
const before = exists
|
||||
? await tx.phenologyReport.findUnique({ where: { id } })
|
||||
: null;
|
||||
if (before === null) {
|
||||
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
||||
}
|
||||
if (before.revokedAt !== null) {
|
||||
throw new ConflictException(REVOKED_MESSAGE);
|
||||
}
|
||||
return before;
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 for an unknown apiary; the share lock keeps it from being deleted
|
||||
* before the report that points at it is written.
|
||||
*/
|
||||
private async assertApiaryIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string | null,
|
||||
): Promise<void> {
|
||||
if (apiaryId === null) {
|
||||
return;
|
||||
}
|
||||
await lockRowsForShare(tx, 'Apiary', [apiaryId]);
|
||||
const apiary = await tx.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (apiary === null) {
|
||||
throw new NotFoundException('Stand nicht gefunden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import type { NextStepsService } from '../autopilot/next-steps.service';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { DigestService } from './digest.service';
|
||||
|
||||
describe('DigestService season changes since the last digest', () => {
|
||||
const NOW = new Date('2026-06-10T02:30:00.000Z');
|
||||
let domainEvents: jest.Mock;
|
||||
let jobRuns: jest.Mock;
|
||||
let recordForUsers: jest.Mock;
|
||||
let service: DigestService;
|
||||
|
||||
const change = (
|
||||
apiaryId: string,
|
||||
previousSegment: string | null,
|
||||
segment: string,
|
||||
occurredAt = '2026-06-09T12:00:00.000Z',
|
||||
) => ({
|
||||
occurredAt: new Date(occurredAt),
|
||||
payload: { apiaryId, previousSegment, segment },
|
||||
});
|
||||
|
||||
const lines = (): string[] =>
|
||||
(
|
||||
recordForUsers.mock.calls[0] as [string[], { message: string }]
|
||||
)[1].message.split('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
domainEvents = jest.fn().mockResolvedValue([]);
|
||||
jobRuns = jest.fn().mockResolvedValue([]);
|
||||
recordForUsers = jest.fn().mockResolvedValue([{ id: 'n' }]);
|
||||
const prisma = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
colony: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ apiaryId: 'a1' }, { apiaryId: 'a2' }]),
|
||||
},
|
||||
apiary: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'a1', name: 'Wald' },
|
||||
{ id: 'a2', name: 'Tal' },
|
||||
]),
|
||||
},
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
domainEvent: { findMany: domainEvents },
|
||||
jobRun: { findMany: jobRuns },
|
||||
$transaction: (work: (client: unknown) => unknown) => work({}),
|
||||
} as unknown as PrismaService;
|
||||
service = new DigestService(
|
||||
prisma,
|
||||
{ recordForUsers } as unknown as NotificationsService,
|
||||
{
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set(['apiaries:read'])),
|
||||
} as unknown as PermissionResolverService,
|
||||
{
|
||||
inspectionDueAll: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as NextStepsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the changes between the end of the previous window and now', async () => {
|
||||
jobRuns.mockResolvedValue([
|
||||
{
|
||||
result: {
|
||||
digestsWritten: 1,
|
||||
digestWindowEnd: '2026-06-09T05:00:00.000Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(domainEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
type: 'SeasonPhaseChanged',
|
||||
occurredAt: { gte: new Date('2026-06-09T05:00:00.000Z'), lt: NOW },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the last 24 hours without a previous digest run', async () => {
|
||||
jobRuns.mockResolvedValue([{ result: { TaskDue: 1 } }, { result: null }]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(domainEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
type: 'SeasonPhaseChanged',
|
||||
occurredAt: { gte: new Date('2026-06-09T02:30:00.000Z'), lt: NOW },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists a segment change since the last digest, with its interval', async () => {
|
||||
domainEvents.mockResolvedValue([change('a1', 'FRUEHJAHR', 'SCHWARMZEIT')]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(lines()).toContain(
|
||||
'Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores phase-only changes and a first state without previous segment', async () => {
|
||||
domainEvents.mockResolvedValue([
|
||||
change('a1', 'SOMMER', 'SOMMER'),
|
||||
change('a2', null, 'FRUEHJAHR'),
|
||||
]);
|
||||
|
||||
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a change back neutrally and nets out several changes of one apiary', async () => {
|
||||
domainEvents.mockResolvedValue([
|
||||
change('a1', 'FRUEHJAHR', 'SCHWARMZEIT', '2026-06-09T08:00:00.000Z'),
|
||||
change('a1', 'SCHWARMZEIT', 'FRUEHJAHR', '2026-06-09T09:00:00.000Z'),
|
||||
change('a2', 'FRUEHJAHR', 'WINTERRUHE', '2026-06-09T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(lines().slice(1)).toEqual(['Stand Tal: wieder Winterruhe.']);
|
||||
});
|
||||
|
||||
it('treats autumn to winter rest as a beginning, not a return', async () => {
|
||||
domainEvents.mockResolvedValue([change('a1', 'HERBST', 'WINTERRUHE')]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(lines()).toContain('Stand Wald: Winterruhe beginnt.');
|
||||
});
|
||||
|
||||
it('leaves out apiaries without living colonies', async () => {
|
||||
domainEvents.mockResolvedValue([change('a9', 'FRUEHJAHR', 'SCHWARMZEIT')]);
|
||||
|
||||
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,22 @@ describe('buildDigest', () => {
|
||||
expect(lines(digest?.message)[1]).toBe('Stand Wald: Winterruhe beginnt.');
|
||||
});
|
||||
|
||||
it('words a segment that went back neutrally', () => {
|
||||
const digest = buildDigest('u1', new Set(['apiaries:read']), {
|
||||
...EMPTY,
|
||||
seasonChanges: [
|
||||
{
|
||||
apiaryName: 'Wald',
|
||||
segment: 'WINTERRUHE',
|
||||
intervalDays: null,
|
||||
isReturn: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(lines(digest?.message)[1]).toBe('Stand Wald: wieder Winterruhe.');
|
||||
});
|
||||
|
||||
it('builds no digest without content', () => {
|
||||
expect(buildDigest('u1', ALL, EMPTY)).toBeNull();
|
||||
expect(buildDigest('u1', new Set(), DATA)).toBeNull();
|
||||
@@ -203,6 +219,8 @@ describe('DigestService', () => {
|
||||
colony: {
|
||||
findMany: jest.fn().mockResolvedValue([{ apiaryId: 'a1' }]),
|
||||
},
|
||||
domainEvent: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
jobRun: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
apiary: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'a1', name: 'Wald' }]),
|
||||
},
|
||||
@@ -215,9 +233,6 @@ describe('DigestService', () => {
|
||||
{
|
||||
inspectionDueAll: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as NextStepsService,
|
||||
{
|
||||
statesFor: jest.fn().mockResolvedValue(new Map()),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import { loggableError } from '../common/loggable-error';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonSegmentKey } from '../season/season-types';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
|
||||
/** One line of the digest and the permissions that entitled the reader to it. */
|
||||
@@ -36,6 +35,8 @@ export interface DigestData {
|
||||
apiaryName: string;
|
||||
segment: SeasonSegmentKey;
|
||||
intervalDays: number | null;
|
||||
/** The segment went back (a revoked report): worded neutrally. */
|
||||
isReturn?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -55,6 +56,17 @@ const SEGMENT_NAMES: Readonly<Record<SeasonSegmentKey, string>> = {
|
||||
HERBST: 'Herbst',
|
||||
};
|
||||
|
||||
/** The time span of season changes a digest run reports. */
|
||||
export interface DigestWindow {
|
||||
from?: Date;
|
||||
until?: Date;
|
||||
}
|
||||
|
||||
/** Key in the JobRun result: where the season window of that digest ended. */
|
||||
export const WINDOW_END_KEY = 'digestWindowEnd';
|
||||
const FALLBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const PREVIOUS_RUNS_LOOKED_AT = 10;
|
||||
|
||||
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
@@ -133,8 +145,9 @@ function seasonLines(
|
||||
return [];
|
||||
}
|
||||
return data.seasonChanges.map((change) => ({
|
||||
text:
|
||||
`Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
|
||||
text: change.isReturn
|
||||
? `Stand ${change.apiaryName}: wieder ${SEGMENT_NAMES[change.segment]}.`
|
||||
: `Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
|
||||
(change.intervalDays === null
|
||||
? ''
|
||||
: ` Durchsicht alle ${change.intervalDays} Tage.`),
|
||||
@@ -183,16 +196,23 @@ export class DigestService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly nextSteps: NextStepsService,
|
||||
private readonly seasons: SeasonService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The digests of the Berlin day `today` for every active user who did not
|
||||
* switch them off (`userIds` narrows that, for tests). Returns how many
|
||||
* were written; a rerun for the same day writes 0 (the keys exist). A failing user is logged and does not stop the others;
|
||||
* the run then fails with the count of the digests that went through.
|
||||
* were written; a rerun for the same day writes 0 (the keys exist). A failing
|
||||
* user is logged and does not stop the others; the run then fails with the
|
||||
* count of the digests that went through. `window` bounds the season changes
|
||||
* this digest reports: `until` (default now) is stored by DigestStep as the
|
||||
* start of the next window; `from` (default: the previous window end, else
|
||||
* 24 hours before `until`) is for tests.
|
||||
*/
|
||||
async runFor(today: string, userIds?: readonly string[]): Promise<number> {
|
||||
async runFor(
|
||||
today: string,
|
||||
userIds?: readonly string[],
|
||||
window: DigestWindow = {},
|
||||
): Promise<number> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
@@ -206,7 +226,7 @@ export class DigestService {
|
||||
if (users.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const data = await this.loadData(today);
|
||||
const data = await this.loadData(today, window);
|
||||
let written = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of users) {
|
||||
@@ -249,7 +269,10 @@ export class DigestService {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async loadData(today: string): Promise<DigestData> {
|
||||
private async loadData(
|
||||
today: string,
|
||||
window: DigestWindow,
|
||||
): Promise<DigestData> {
|
||||
const [tasks, suggestions, inspectionDue, seasonChanges] =
|
||||
await Promise.all([
|
||||
this.loadTasks(today),
|
||||
@@ -259,7 +282,7 @@ export class DigestService {
|
||||
}),
|
||||
// Every colony, whatever the reader may see: the lines are filtered per user.
|
||||
this.nextSteps.inspectionDueAll(today),
|
||||
this.loadSeasonChanges(today),
|
||||
this.loadSeasonChanges(window),
|
||||
]);
|
||||
return { today, tasks, inspectionDue, suggestions, seasonChanges };
|
||||
}
|
||||
@@ -279,40 +302,142 @@ export class DigestService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Apiaries with living colonies whose season segment begins today. */
|
||||
/**
|
||||
* The start of the window: the end of the window the previous digest run
|
||||
* reported (stored in its JobRun result), else 24 hours before `until`.
|
||||
* Only runs that reached the digest step carry a window end.
|
||||
*/
|
||||
private async windowStart(until: Date): Promise<Date> {
|
||||
const runs = await this.prisma.jobRun.findMany({
|
||||
where: {
|
||||
job: 'daily-evaluation',
|
||||
status: { in: ['SUCCEEDED', 'FAILED'] },
|
||||
},
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: PREVIOUS_RUNS_LOOKED_AT,
|
||||
select: { result: true },
|
||||
});
|
||||
for (const { result } of runs) {
|
||||
const end =
|
||||
result !== null && typeof result === 'object' && !Array.isArray(result)
|
||||
? result[WINDOW_END_KEY]
|
||||
: undefined;
|
||||
if (typeof end === 'string' && !Number.isNaN(Date.parse(end))) {
|
||||
return new Date(end);
|
||||
}
|
||||
}
|
||||
return new Date(until.getTime() - FALLBACK_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apiaries with living colonies whose season segment changed in the window
|
||||
* [previous window end, until) according to the SeasonPhaseChanged events.
|
||||
* Several changes of one apiary count as one (first previous segment to
|
||||
* last segment); no net change, no line. A change that happened after
|
||||
* `until` belongs to the next digest, so every change is reported once.
|
||||
*/
|
||||
private async loadSeasonChanges(
|
||||
today: string,
|
||||
window: DigestWindow,
|
||||
): Promise<DigestData['seasonChanges']> {
|
||||
const colonies = await this.prisma.colony.findMany({
|
||||
where: LIVING_COLONY_WHERE,
|
||||
const until = window.until ?? new Date();
|
||||
const from = window.from ?? (await this.windowStart(until));
|
||||
const events = await this.prisma.domainEvent.findMany({
|
||||
where: {
|
||||
type: 'SeasonPhaseChanged',
|
||||
occurredAt: { gte: from, lt: until },
|
||||
},
|
||||
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
|
||||
select: { payload: true },
|
||||
});
|
||||
const nets = netSegmentChanges(events.map((event) => event.payload));
|
||||
if (nets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const withColonies = await this.prisma.colony.findMany({
|
||||
where: {
|
||||
...LIVING_COLONY_WHERE,
|
||||
apiaryId: { in: nets.map((net) => net.apiaryId) },
|
||||
},
|
||||
select: { apiaryId: true },
|
||||
distinct: ['apiaryId'],
|
||||
});
|
||||
const apiaryIds = colonies.map((colony) => colony.apiaryId);
|
||||
const states = await this.seasons.statesFor(this.prisma, apiaryIds, today);
|
||||
const changed = [...states.values()].filter(
|
||||
(state) => state.segmentSince === today,
|
||||
);
|
||||
const living = new Set(withColonies.map((colony) => colony.apiaryId));
|
||||
const changed = nets.filter((net) => living.has(net.apiaryId));
|
||||
if (changed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const [apiaries, interval] = await Promise.all([
|
||||
this.prisma.apiary.findMany({
|
||||
where: { id: { in: changed.map((state) => state.apiaryId) } },
|
||||
where: { id: { in: changed.map((net) => net.apiaryId) } },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
readRule(this.prisma, 'INSPECTION_INTERVAL'),
|
||||
]);
|
||||
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
|
||||
return changed
|
||||
.map((state) => ({
|
||||
apiaryName: names.get(state.apiaryId) ?? '',
|
||||
segment: state.segment,
|
||||
.map((net) => ({
|
||||
apiaryName: names.get(net.apiaryId) ?? '',
|
||||
segment: net.segment,
|
||||
intervalDays:
|
||||
state.segment === 'WINTERRUHE' || !interval.active
|
||||
net.segment === 'WINTERRUHE' || !interval.active
|
||||
? null
|
||||
: interval.config.intervalDays[state.segment],
|
||||
: interval.config.intervalDays[net.segment],
|
||||
isReturn: net.isReturn,
|
||||
}))
|
||||
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
|
||||
}
|
||||
}
|
||||
|
||||
interface NetSegmentChange {
|
||||
apiaryId: string;
|
||||
segment: SeasonSegmentKey;
|
||||
isReturn: boolean;
|
||||
}
|
||||
|
||||
function isSegment(value: unknown): value is SeasonSegmentKey {
|
||||
return SEASON_SEGMENTS.some((segment) => segment === value);
|
||||
}
|
||||
|
||||
/** Autumn to winter rest is the normal course of the year, not a step back. */
|
||||
function wentBack(from: SeasonSegmentKey, to: SeasonSegmentKey): boolean {
|
||||
if (from === 'HERBST' && to === 'WINTERRUHE') {
|
||||
return false;
|
||||
}
|
||||
return SEASON_SEGMENTS.indexOf(to) < SEASON_SEGMENTS.indexOf(from);
|
||||
}
|
||||
|
||||
/**
|
||||
* First previous segment to last segment per apiary, in event order. Events
|
||||
* without a valid previous or new segment (a first state, phase-only
|
||||
* changes) say nothing about a segment change.
|
||||
*/
|
||||
function netSegmentChanges(payloads: readonly unknown[]): NetSegmentChange[] {
|
||||
const firstPrevious = new Map<string, SeasonSegmentKey>();
|
||||
const last = new Map<string, SeasonSegmentKey>();
|
||||
for (const payload of payloads) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
continue;
|
||||
}
|
||||
const { apiaryId, previousSegment, segment } = payload as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (
|
||||
typeof apiaryId !== 'string' ||
|
||||
!isSegment(previousSegment) ||
|
||||
!isSegment(segment)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!firstPrevious.has(apiaryId)) {
|
||||
firstPrevious.set(apiaryId, previousSegment);
|
||||
}
|
||||
last.set(apiaryId, segment);
|
||||
}
|
||||
return [...last].flatMap(([apiaryId, segment]) => {
|
||||
const previous = firstPrevious.get(apiaryId);
|
||||
return previous === undefined || previous === segment
|
||||
? []
|
||||
: [{ apiaryId, segment, isReturn: wentBack(previous, segment) }];
|
||||
});
|
||||
}
|
||||
@@ -14,8 +14,26 @@ describe('DigestStep', () => {
|
||||
runFor,
|
||||
} as unknown as DigestService).run('2026-06-10');
|
||||
|
||||
expect(runFor).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(result).toEqual({ digestsWritten: 7 });
|
||||
expect(runFor).toHaveBeenCalledWith('2026-06-10', undefined, {
|
||||
until: expect.any(Date) as unknown,
|
||||
});
|
||||
const until = (
|
||||
runFor.mock.calls[0] as [string, undefined, { until: Date }]
|
||||
)[2].until;
|
||||
expect(result).toEqual({
|
||||
digestsWritten: 7,
|
||||
digestWindowEnd: until.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('stores no window end when nothing was written, so the next run reads the same window', async () => {
|
||||
const runFor = jest.fn().mockResolvedValue(0);
|
||||
|
||||
const result = await new DigestStep({
|
||||
runFor,
|
||||
} as unknown as DigestService).run('2026-06-10');
|
||||
|
||||
expect(result).toEqual({ digestsWritten: 0 });
|
||||
});
|
||||
|
||||
it('lets a partial failure through with its counter', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
|
||||
import type { JobResult } from '../jobs/job';
|
||||
import { DigestService } from './digest.service';
|
||||
import { DigestService, WINDOW_END_KEY } from './digest.service';
|
||||
|
||||
/**
|
||||
* Step "digest", the last of the daily evaluation: the in-app morning
|
||||
@@ -16,6 +16,16 @@ export class DigestStep implements DailyEvaluationStep {
|
||||
constructor(private readonly digests: DigestService) {}
|
||||
|
||||
async run(today: string): Promise<JobResult> {
|
||||
return { digestsWritten: await this.digests.runFor(today) };
|
||||
// The window of season changes ends here; the next run starts where
|
||||
// this one stopped, so every change is reported once.
|
||||
const until = new Date();
|
||||
const digestsWritten = await this.digests.runFor(today, undefined, {
|
||||
until,
|
||||
});
|
||||
// A run that wrote nothing (a same-day rerun, or no content) reported
|
||||
// no change: the next run must still read the earlier window.
|
||||
return digestsWritten === 0
|
||||
? { digestsWritten }
|
||||
: { digestsWritten, [WINDOW_END_KEY]: until.toISOString() };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
import { DigestService } from './digest.service';
|
||||
import { DigestStep } from './digest.step';
|
||||
import { TaskDueHandlers } from './task-due.handlers';
|
||||
@@ -14,7 +13,7 @@ import { TaskDueHandlers } from './task-due.handlers';
|
||||
* PermissionsModule (API) or JobPermissionsModule (worker, jobs).
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationsModule, SeasonModule, AutopilotModule],
|
||||
imports: [NotificationsModule, AutopilotModule],
|
||||
providers: [TaskDueHandlers, DigestService, DigestStep],
|
||||
exports: [DigestService, DigestStep],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { AutomationTriggerType } from '../../generated/prisma';
|
||||
import {
|
||||
SUGGESTION_TEXT_REGISTERS,
|
||||
SUGGESTION_TEXTS,
|
||||
} from './suggestion-texts';
|
||||
|
||||
/**
|
||||
* Guard (spec 14.3): a trigger that proposes suggestions has a reason text,
|
||||
* and every reason code a producer passes on is registered. A suggestion
|
||||
* whose code has no template renders with the bare intent title and no
|
||||
* reasons, which is a silent loss of the explanation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Triggers that never are the `ruleKey` of a suggestion: number formats,
|
||||
* notification-only rules, warnings and the season state. A new trigger is
|
||||
* expected to suggest, so it fails here until it has a text template or is
|
||||
* named in this set on purpose.
|
||||
*/
|
||||
const NON_SUGGESTING_TRIGGERS: ReadonlySet<AutomationTriggerType> = new Set([
|
||||
'HONEY_BATCH_NUMBER_FORMAT',
|
||||
'BOTTLING_LOT_NUMBER_FORMAT',
|
||||
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
'PRODUCTION_BATCH_NUMBER_FORMAT',
|
||||
'WITHDRAWAL_ENDED',
|
||||
'HONEY_WATER_CONTENT',
|
||||
'PHENOLOGY',
|
||||
]);
|
||||
|
||||
const SUGGESTING_TRIGGERS: readonly AutomationTriggerType[] = Object.values(
|
||||
AutomationTriggerType,
|
||||
).filter((trigger) => !NON_SUGGESTING_TRIGGERS.has(trigger));
|
||||
|
||||
const SRC = join(__dirname, '..');
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return sourceFiles(path);
|
||||
}
|
||||
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/** `reasonCode: <expression>,` of a producer: the expression up to the comma that ends the line. */
|
||||
const REASON_CODE_PROPERTY = /\breasonCode:\s*([^;]*?),[ \t]*\r?\n/g;
|
||||
const STRING_LITERAL = /'([A-Z][A-Z0-9_]*)'/g;
|
||||
const TEMPLATE_PREFIX = /`([A-Z][A-Z0-9_]*)\$\{/g;
|
||||
|
||||
/** The codes of a ternary are its branches; its condition may compare other strings. */
|
||||
function valueOf(expression: string): string {
|
||||
const branch = /\?(?![.?])/.exec(expression);
|
||||
return branch === null ? expression : expression.slice(branch.index);
|
||||
}
|
||||
|
||||
interface ReasonCodeUse {
|
||||
file: string;
|
||||
literals: string[];
|
||||
prefixes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason codes written into the producers of suggestions: files that
|
||||
* name a `ruleKey` (the candidate and propose inputs). Band ratings and
|
||||
* view types have a `reasonCode` of their own and do not.
|
||||
*/
|
||||
function reasonCodeUses(): ReasonCodeUse[] {
|
||||
return sourceFiles(SRC).flatMap((path) => {
|
||||
const source = readFileSync(path, 'utf8');
|
||||
if (!/\bruleKey\b/.test(source)) {
|
||||
return [];
|
||||
}
|
||||
const file = relative(SRC, path).split(sep).join('/');
|
||||
return [...source.matchAll(REASON_CODE_PROPERTY)].map((match) => {
|
||||
const value = valueOf(match[1]);
|
||||
return {
|
||||
file,
|
||||
literals: [...value.matchAll(STRING_LITERAL)].map((m) => m[1]),
|
||||
prefixes: [...value.matchAll(TEMPLATE_PREFIX)].map((m) => m[1]),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('suggesting triggers', () => {
|
||||
it('every suggesting AutomationTriggerType has at least one registered reason text template', () => {
|
||||
const registeredKeys = new Set(
|
||||
SUGGESTION_TEXT_REGISTERS.flatMap((register) =>
|
||||
Object.values(register).map((template) => template.ruleKey),
|
||||
),
|
||||
);
|
||||
const missing = SUGGESTING_TRIGGERS.filter(
|
||||
(trigger) => !registeredKeys.has(trigger),
|
||||
);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('every template belongs to a suggesting trigger, and the deny set holds real triggers without templates', () => {
|
||||
const known = new Set<string>(Object.values(AutomationTriggerType));
|
||||
const listed = new Set<string>(SUGGESTING_TRIGGERS);
|
||||
|
||||
expect([...NON_SUGGESTING_TRIGGERS].filter((t) => !known.has(t))).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
Object.entries(SUGGESTION_TEXTS)
|
||||
.filter(([, template]) => !listed.has(template.ruleKey))
|
||||
.map(([reasonCode]) => reasonCode),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every reasonCode passed to proposeIn in src is registered', () => {
|
||||
const uses = reasonCodeUses();
|
||||
const registered = Object.keys(SUGGESTION_TEXTS);
|
||||
|
||||
// The scan itself must see the producers, or the guard guards nothing.
|
||||
expect(uses.length).toBeGreaterThanOrEqual(5);
|
||||
const unregistered = uses.flatMap(({ file, literals, prefixes }) => [
|
||||
...literals
|
||||
.filter((code) => !registered.includes(code))
|
||||
.map((code) => `${file}: ${code}`),
|
||||
...prefixes
|
||||
.filter((prefix) => !registered.some((code) => code.startsWith(prefix)))
|
||||
.map((prefix) => `${file}: ${prefix}\${...}`),
|
||||
]);
|
||||
|
||||
expect(unregistered).toEqual([]);
|
||||
});
|
||||
|
||||
it('the scan knows the autopilot producer', () => {
|
||||
const files = reasonCodeUses().map((use) => use.file);
|
||||
|
||||
expect(files).toEqual(
|
||||
expect.arrayContaining([
|
||||
'autopilot/autopilot-rules.ts',
|
||||
'automation/automation.service.ts',
|
||||
'suggestions/chains/chain-planner.ts',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { renderSuggestion } from '../suggestion-texts';
|
||||
|
||||
const lookup: EvidenceLookup = {
|
||||
colonyCode: () => 'B3',
|
||||
queenCode: () => null,
|
||||
queenCode: (id) => (id === 'q12' ? 'Q-12' : null),
|
||||
treatmentProduct: (id) => (id === 't1' ? 'Oxuvar' : null),
|
||||
apiaryName: () => null,
|
||||
stockItemName: () => null,
|
||||
@@ -95,4 +95,125 @@ describe('autopilot texts', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('SUMMER_TREATMENT_DUE quotes how the end of the honey flow is known', () => {
|
||||
expect(
|
||||
render('SUMMER_TREATMENT_DUE', {
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: '2026-07-25',
|
||||
since: '2026-07-25',
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Sommerbehandlung planen',
|
||||
reasons: [
|
||||
'Tracht vorbei (gemeldet am 25.07.).',
|
||||
'Noch keine Behandlung seit 25.07.',
|
||||
],
|
||||
});
|
||||
expect(
|
||||
render('SUMMER_TREATMENT_DUE', {
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: null,
|
||||
since: '2026-07-27',
|
||||
}).reasons[0],
|
||||
).toBe('Tracht vorbei (gemeldet).');
|
||||
expect(
|
||||
render('SUMMER_TREATMENT_DUE', {
|
||||
phaseSource: 'GTS',
|
||||
since: '2026-07-25',
|
||||
}).reasons[0],
|
||||
).toBe('Tracht vorbei (geschätzt).');
|
||||
expect(
|
||||
render('SUMMER_TREATMENT_DUE', {
|
||||
phaseSource: 'CALENDAR',
|
||||
since: '2026-08-01',
|
||||
}).reasons[0],
|
||||
).toBe('Tracht vorbei (Kalender).');
|
||||
});
|
||||
|
||||
it('AUTUMN_FEED_MISSING', () => {
|
||||
expect(
|
||||
render('AUTUMN_FEED_MISSING', {
|
||||
phaseSource: 'REPORT',
|
||||
since: '2026-07-25',
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Einfüttern',
|
||||
reasons: ['Tracht vorbei.', 'Noch keine Fütterung erfasst.'],
|
||||
});
|
||||
});
|
||||
|
||||
it('FEED_LOW names the inspection day or the colony card, in German', () => {
|
||||
expect(
|
||||
render('FEED_LOW', {
|
||||
inspectedOn: '2026-04-03',
|
||||
condition: 'WEAK',
|
||||
source: 'INSPECTION',
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Füttern',
|
||||
reasons: ['Futter bei der Durchsicht am 03.04.: niedrig.'],
|
||||
});
|
||||
expect(
|
||||
render('FEED_LOW', {
|
||||
inspectedOn: null,
|
||||
condition: 'CRITICAL',
|
||||
source: 'COLONY_CARD',
|
||||
}).reasons,
|
||||
).toEqual(['Futter laut Volkskarte: kritisch.']);
|
||||
});
|
||||
|
||||
it('HONEY_SUPER_CHECK names the phase, its source, the colony and the frames', () => {
|
||||
expect(
|
||||
render('HONEY_SUPER_CHECK', {
|
||||
colonyId: 'c1',
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
frames: 9,
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Honigraum prüfen',
|
||||
reasons: ['Kirsche blüht (geschätzt).', 'B3 besetzt 9 Waben.'],
|
||||
});
|
||||
expect(
|
||||
render('HONEY_SUPER_CHECK', {
|
||||
colonyId: 'c1',
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
frames: 1,
|
||||
}).reasons,
|
||||
).toEqual(['Apfel blüht (gemeldet).', 'B3 besetzt 1 Wabe.']);
|
||||
});
|
||||
|
||||
it('QUEEN_OLD names the queen and her hatch year', () => {
|
||||
expect(render('QUEEN_OLD', { queenId: 'q12', hatchYear: 2024 })).toEqual({
|
||||
title: 'Umweiseln planen',
|
||||
reasons: ['Königin Q-12 ist von 2024.'],
|
||||
});
|
||||
expect(
|
||||
render('QUEEN_OLD', { queenId: 'gone', hatchYear: 2024 }).reasons,
|
||||
).toEqual(['Die Königin ist von 2024.']);
|
||||
});
|
||||
|
||||
it('WINTER_CHECK_AUTUMN_MISSING and WINTER_CHECK_SPRING_MISSING name the winter', () => {
|
||||
expect(render('WINTER_CHECK_AUTUMN_MISSING', { winterYear: 2026 })).toEqual(
|
||||
{
|
||||
title: 'Einwinterung erfassen',
|
||||
reasons: ['Für den Winter 2026/27 fehlt die Einwinterung.'],
|
||||
},
|
||||
);
|
||||
expect(
|
||||
render('WINTER_CHECK_SPRING_MISSING', {
|
||||
winterYear: 2025,
|
||||
phase: 'SALWEIDE',
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Auswinterung erfassen',
|
||||
reasons: ['Salweide blüht.', 'Auswinterung 2025/26 fehlt.'],
|
||||
});
|
||||
expect(
|
||||
render('WINTER_CHECK_SPRING_MISSING', { winterYear: 2099, phase: null })
|
||||
.reasons,
|
||||
).toEqual(['Auswinterung 2099/00 fehlt.']);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { daysBetween } from '../../common/dates';
|
||||
import { FEED_CONDITION_LABELS_DE } from '../../common/feed-vocabulary';
|
||||
import type { SuggestionEvidence } from '../suggestion.types';
|
||||
import {
|
||||
formatDayDe,
|
||||
@@ -16,6 +17,20 @@ const SEGMENT_PHRASES: Readonly<Record<string, string>> = {
|
||||
HERBST: 'Im Herbst',
|
||||
};
|
||||
|
||||
const PHASE_PHRASES: Readonly<Record<string, string>> = {
|
||||
SALWEIDE: 'Salweide blüht',
|
||||
KIRSCHE: 'Kirsche blüht',
|
||||
APFEL: 'Apfel blüht',
|
||||
HOLUNDER: 'Holunder blüht',
|
||||
LINDE: 'Linde blüht',
|
||||
};
|
||||
|
||||
const PHASE_SOURCE_WORDS: Readonly<Record<string, string>> = {
|
||||
REPORT: 'gemeldet',
|
||||
GTS: 'geschätzt',
|
||||
CALENDAR: 'Kalender',
|
||||
};
|
||||
|
||||
function text(evidence: SuggestionEvidence, key: string): string | null {
|
||||
const value = evidence[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
@@ -165,6 +180,182 @@ const VARROA_RECHECK_DUE: SuggestionTextTemplate = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 'Tracht vorbei (gemeldet am 25.07.)' - the day only where the report day is known. */
|
||||
function honeyFlowOverReason(evidence: SuggestionEvidence): string {
|
||||
const source = text(evidence, 'phaseSource');
|
||||
const reportedOn = text(evidence, 'reportedOn');
|
||||
if (source === 'REPORT' && reportedOn !== null) {
|
||||
return `Tracht vorbei (gemeldet am ${formatDayDe(reportedOn)}).`;
|
||||
}
|
||||
const word = PHASE_SOURCE_WORDS[source ?? ''];
|
||||
return word === undefined ? 'Tracht vorbei.' : `Tracht vorbei (${word}).`;
|
||||
}
|
||||
|
||||
/** 'Kirsche blüht (geschätzt).' for a known phase, else nothing. */
|
||||
function phaseReason(
|
||||
evidence: SuggestionEvidence,
|
||||
withSource: boolean,
|
||||
): string[] {
|
||||
const phrase = PHASE_PHRASES[text(evidence, 'phase') ?? ''];
|
||||
if (phrase === undefined) {
|
||||
return [];
|
||||
}
|
||||
const word = PHASE_SOURCE_WORDS[text(evidence, 'phaseSource') ?? ''];
|
||||
return [
|
||||
withSource && word !== undefined ? `${phrase} (${word}).` : `${phrase}.`,
|
||||
];
|
||||
}
|
||||
|
||||
/** '2026/27' for the winter that begins in 2026. */
|
||||
function winterLabel(winterYear: number): string {
|
||||
return `${winterYear}/${String((winterYear + 1) % 100).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const SUMMER_TREATMENT_DUE: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
phase: 'TRACHTENDE',
|
||||
phaseSource: 'REPORT',
|
||||
since: '2026-07-25',
|
||||
},
|
||||
render: (evidence) => {
|
||||
const since = text(evidence, 'since');
|
||||
return {
|
||||
title: 'Sommerbehandlung planen',
|
||||
reasons: [
|
||||
honeyFlowOverReason(evidence),
|
||||
since === null
|
||||
? 'Noch keine Behandlung erfasst.'
|
||||
: `Noch keine Behandlung seit ${formatDayDe(since)}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const AUTUMN_FEED_MISSING: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
phase: 'TRACHTENDE',
|
||||
phaseSource: 'REPORT',
|
||||
since: '2026-07-25',
|
||||
},
|
||||
render: () => ({
|
||||
title: 'Einfüttern',
|
||||
reasons: ['Tracht vorbei.', 'Noch keine Fütterung erfasst.'],
|
||||
}),
|
||||
};
|
||||
|
||||
const FEED_LOW: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
inspectionId: 'i1',
|
||||
inspectedOn: '2026-04-03',
|
||||
condition: 'WEAK',
|
||||
source: 'INSPECTION',
|
||||
},
|
||||
render: (evidence) => {
|
||||
const condition = text(evidence, 'condition') ?? '';
|
||||
const label = Object.hasOwn(FEED_CONDITION_LABELS_DE, condition)
|
||||
? FEED_CONDITION_LABELS_DE[
|
||||
condition as keyof typeof FEED_CONDITION_LABELS_DE
|
||||
].toLowerCase()
|
||||
: null;
|
||||
const inspectedOn = text(evidence, 'inspectedOn');
|
||||
const where =
|
||||
text(evidence, 'source') === 'INSPECTION' && inspectedOn !== null
|
||||
? `bei der Durchsicht am ${formatDayDe(inspectedOn)}`
|
||||
: 'laut Volkskarte';
|
||||
return {
|
||||
title: 'Füttern',
|
||||
reasons: [
|
||||
label === null
|
||||
? `Futter ${where} niedrig.`
|
||||
: `Futter ${where}: ${label}.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const HONEY_SUPER_CHECK: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
inspectionId: 'i1',
|
||||
frames: 9,
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
},
|
||||
render: (evidence, lookup) => {
|
||||
const colonyId = text(evidence, 'colonyId');
|
||||
const code = colonyId === null ? null : lookup.colonyCode(colonyId);
|
||||
const frames = count(evidence, 'frames');
|
||||
return {
|
||||
title: 'Honigraum prüfen',
|
||||
reasons: [
|
||||
...phaseReason(evidence, true),
|
||||
...(frames === null
|
||||
? []
|
||||
: [
|
||||
`${code ?? 'Das Volk'} besetzt ${frames} ${frames === 1 ? 'Wabe' : 'Waben'}.`,
|
||||
]),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const QUEEN_OLD: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: { colonyId: 'c1', queenId: 'q1', hatchYear: 2024 },
|
||||
render: (evidence, lookup) => {
|
||||
const queenId = text(evidence, 'queenId');
|
||||
const code = queenId === null ? null : lookup.queenCode(queenId);
|
||||
const year = count(evidence, 'hatchYear');
|
||||
const subject = code === null ? 'Die Königin' : `Königin ${code}`;
|
||||
return {
|
||||
title: 'Umweiseln planen',
|
||||
reasons: [
|
||||
year === null ? `${subject} ist alt.` : `${subject} ist von ${year}.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const WINTER_CHECK_AUTUMN_MISSING: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: { colonyId: 'c1', winterYear: 2026 },
|
||||
render: (evidence) => {
|
||||
const year = count(evidence, 'winterYear');
|
||||
return {
|
||||
title: 'Einwinterung erfassen',
|
||||
reasons: [
|
||||
year === null
|
||||
? 'Die Einwinterung fehlt.'
|
||||
: `Für den Winter ${winterLabel(year)} fehlt die Einwinterung.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const WINTER_CHECK_SPRING_MISSING: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: { colonyId: 'c1', winterYear: 2025, phase: 'SALWEIDE' },
|
||||
render: (evidence) => {
|
||||
const year = count(evidence, 'winterYear');
|
||||
return {
|
||||
title: 'Auswinterung erfassen',
|
||||
reasons: [
|
||||
...phaseReason(evidence, false),
|
||||
year === null
|
||||
? 'Die Auswinterung fehlt.'
|
||||
: `Auswinterung ${winterLabel(year)} fehlt.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Texts of the season autopilot suggestions, by reason code. */
|
||||
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {
|
||||
INSPECTION_DUE,
|
||||
@@ -172,4 +363,11 @@ export const AUTOPILOT_TEXTS: SuggestionTextRegister = {
|
||||
VARROA_MEASURE_DUE,
|
||||
VARROA_WINTER_MEASURE,
|
||||
VARROA_RECHECK_DUE,
|
||||
SUMMER_TREATMENT_DUE,
|
||||
AUTUMN_FEED_MISSING,
|
||||
FEED_LOW,
|
||||
HONEY_SUPER_CHECK,
|
||||
QUEEN_OLD,
|
||||
WINTER_CHECK_AUTUMN_MISSING,
|
||||
WINTER_CHECK_SPRING_MISSING,
|
||||
};
|
||||
@@ -0,0 +1,892 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import * as claims from '../common/outbox/outbox-claims';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import * as rowLock from '../prisma/row-lock';
|
||||
import { TelegramApiError } from './telegram-api-error';
|
||||
import type { TelegramClient } from './telegram-client';
|
||||
import {
|
||||
TELEGRAM_BATCH_BUDGET_MS,
|
||||
TELEGRAM_CHAT_SPACING_MS,
|
||||
TELEGRAM_MAX_PER_SECOND,
|
||||
TELEGRAM_SEND_BATCH,
|
||||
TELEGRAM_SEND_POLL_MS,
|
||||
TELEGRAM_TOKEN_INVALID_PAUSE_MS,
|
||||
TelegramSender,
|
||||
} from './telegram-sender';
|
||||
import { TELEGRAM_TEMPLATES } from './telegram-texts';
|
||||
|
||||
jest.mock('../common/outbox/outbox-claims');
|
||||
jest.mock('../prisma/row-lock');
|
||||
|
||||
const claimBatch = jest.mocked(claims.claimBatch);
|
||||
const deferClaim = jest.mocked(claims.deferClaim);
|
||||
const settleSkipped = jest.mocked(claims.settleSkipped);
|
||||
const settleSuccess = jest.mocked(claims.settleSuccess);
|
||||
const settleFailure = jest.mocked(claims.settleFailure);
|
||||
const releaseStaleClaimRows = jest.mocked(claims.releaseStaleClaimRows);
|
||||
const lockRowForUpdate = jest.mocked(rowLock.lockRowForUpdate);
|
||||
|
||||
const CLAIMED_AT = new Date('2026-10-14T05:00:00.000Z');
|
||||
const CHAT = 4_711_000_001n;
|
||||
const ORIGIN = 'https://app.example.org';
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
chatId: bigint | null;
|
||||
notificationId: string | null;
|
||||
templateCode: string | null;
|
||||
attempts: number;
|
||||
claimedAt: Date;
|
||||
}
|
||||
|
||||
const delivery = (id: string, userId = `u-${id}`, attempts = 0): Row => ({
|
||||
id,
|
||||
userId,
|
||||
chatId: null,
|
||||
notificationId: `n-${id}`,
|
||||
templateCode: null,
|
||||
attempts,
|
||||
claimedAt: CLAIMED_AT,
|
||||
});
|
||||
|
||||
const template = (id: string, code: string, chatId = CHAT): Row => ({
|
||||
id,
|
||||
userId: null,
|
||||
chatId,
|
||||
notificationId: null,
|
||||
templateCode: code,
|
||||
attempts: 0,
|
||||
claimedAt: CLAIMED_AT,
|
||||
});
|
||||
|
||||
const context = (overrides: Record<string, unknown> = {}) => ({
|
||||
userActive: true,
|
||||
telegramEnabled: true,
|
||||
categories: ['TASK_DUE', 'DIGEST'],
|
||||
detail: true,
|
||||
chatId: CHAT,
|
||||
category: 'TASK_DUE',
|
||||
message: 'Heute fällig: Futter bestellen',
|
||||
link: '/tasks',
|
||||
requiredPermissions: ['tasks:read'],
|
||||
stale: false,
|
||||
taskOpen: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('TelegramSender', () => {
|
||||
let prisma: { $queryRaw: jest.Mock; $transaction: jest.Mock };
|
||||
let tx: {
|
||||
telegramLink: { deleteMany: jest.Mock };
|
||||
notificationPreference: { updateMany: jest.Mock };
|
||||
};
|
||||
let client: {
|
||||
sendMessage: jest.Mock<Promise<void>, [bigint, string, AbortSignal?]>;
|
||||
};
|
||||
let permissions: {
|
||||
getFreshPermissions: jest.Mock;
|
||||
getPermissions: jest.Mock;
|
||||
};
|
||||
let notifications: { record: jest.Mock; recordForPermission: jest.Mock };
|
||||
let sender: TelegramSender;
|
||||
let signal: AbortSignal;
|
||||
let logError: jest.SpyInstance;
|
||||
|
||||
/** Queue the claim and the answers of the send-time check, in order. */
|
||||
function claim(rows: Row[], ...contexts: (object | null)[]): void {
|
||||
claimBatch.mockResolvedValueOnce(rows);
|
||||
for (const answer of contexts) {
|
||||
prisma.$queryRaw.mockResolvedValueOnce(answer === null ? [] : [answer]);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
process.env.FRONTEND_ORIGIN = ORIGIN;
|
||||
prisma = {
|
||||
$queryRaw: jest.fn(),
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
tx = {
|
||||
telegramLink: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
notificationPreference: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
};
|
||||
prisma.$transaction.mockImplementation((work: (t: unknown) => unknown) =>
|
||||
Promise.resolve(work(tx)),
|
||||
);
|
||||
client = {
|
||||
sendMessage: jest
|
||||
.fn<Promise<void>, [bigint, string, AbortSignal?]>()
|
||||
.mockResolvedValue(undefined),
|
||||
};
|
||||
permissions = {
|
||||
getFreshPermissions: jest.fn().mockResolvedValue(new Set(['tasks:read'])),
|
||||
getPermissions: jest.fn(),
|
||||
};
|
||||
notifications = {
|
||||
record: jest.fn().mockResolvedValue({ id: 'n' }),
|
||||
recordForPermission: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
claimBatch.mockResolvedValue([]);
|
||||
deferClaim.mockResolvedValue(true);
|
||||
settleSkipped.mockResolvedValue(true);
|
||||
settleSuccess.mockResolvedValue(true);
|
||||
settleFailure.mockResolvedValue('retry');
|
||||
releaseStaleClaimRows.mockResolvedValue([]);
|
||||
lockRowForUpdate.mockResolvedValue(true);
|
||||
signal = new AbortController().signal;
|
||||
logError = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
sender = new TelegramSender(
|
||||
prisma as unknown as PrismaService,
|
||||
client as unknown as TelegramClient,
|
||||
permissions as unknown as PermissionResolverService,
|
||||
notifications as unknown as NotificationsService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete process.env.FRONTEND_ORIGIN;
|
||||
});
|
||||
|
||||
it('exposes the documented limits', () => {
|
||||
expect(TELEGRAM_SEND_POLL_MS).toBe(5_000);
|
||||
expect(TELEGRAM_SEND_BATCH).toBe(25);
|
||||
expect(TELEGRAM_MAX_PER_SECOND).toBe(25);
|
||||
expect(TELEGRAM_CHAT_SPACING_MS).toBe(1_100);
|
||||
expect(TELEGRAM_TOKEN_INVALID_PAUSE_MS).toBe(900_000);
|
||||
});
|
||||
|
||||
describe('claiming', () => {
|
||||
it('claims a batch of 25 oldest first and returns how many it got', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
|
||||
const claimed = await sender.runOnce(signal);
|
||||
|
||||
expect(claimed).toBe(1);
|
||||
expect(claimBatch).toHaveBeenCalledWith(prisma, 'NotificationDelivery', {
|
||||
limit: 25,
|
||||
orderBy: 'createdAt',
|
||||
filter: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends only the oldest delivery of a user and puts the rest back for 1.1 s', async () => {
|
||||
claim([delivery('d1', 'u1'), delivery('d2', 'u1')], context());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(settleSuccess).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'DELIVERED',
|
||||
);
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd2',
|
||||
CLAIMED_AT,
|
||||
1_100,
|
||||
);
|
||||
expect(settleFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats replies to one chat like one target', async () => {
|
||||
claim([
|
||||
template('t1', 'LINKED'),
|
||||
template('t2', 'HELP'),
|
||||
template('t3', 'STOPPED', 5n),
|
||||
]);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage.mock.calls.map((call) => call[0])).toEqual([
|
||||
CHAT,
|
||||
5n,
|
||||
]);
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
't2',
|
||||
CLAIMED_AT,
|
||||
1_100,
|
||||
);
|
||||
});
|
||||
|
||||
it('limits the claim to given users', async () => {
|
||||
claim([]);
|
||||
|
||||
await sender.runOnce(signal, { userIds: ['u1', 'u2'] });
|
||||
|
||||
const options = claimBatch.mock.calls[0][2];
|
||||
expect(options.filter).toBeDefined();
|
||||
});
|
||||
|
||||
it('claims nothing once the signal is aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
expect(await sender.runOnce(controller.signal)).toBe(0);
|
||||
expect(claimBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('puts claimed deliveries back without an attempt when the signal aborts mid-batch', async () => {
|
||||
const controller = new AbortController();
|
||||
claim([delivery('d1'), delivery('d2')], context());
|
||||
client.sendMessage.mockImplementation(() => {
|
||||
controller.abort();
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await sender.runOnce(controller.signal);
|
||||
|
||||
expect(settleSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd2',
|
||||
CLAIMED_AT,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checks at send time', () => {
|
||||
it.each([
|
||||
['user inactive', { userActive: false }],
|
||||
['telegram disabled', { telegramEnabled: false }],
|
||||
['not linked', { chatId: null }],
|
||||
['category off', { categories: ['DIGEST'] }],
|
||||
['stale', { stale: true }],
|
||||
['task already done', { taskOpen: false }],
|
||||
])('skips with "%s"', async (reason, overrides) => {
|
||||
claim([delivery('d1')], context(overrides));
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
reason,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks permissions and the link at send time and skips with a reason', async () => {
|
||||
permissions.getFreshPermissions.mockResolvedValue(
|
||||
new Set(['colonies:read']),
|
||||
);
|
||||
claim(
|
||||
[delivery('d1', 'u1')],
|
||||
context({ requiredPermissions: ['tasks:read', 'colonies:read'] }),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(permissions.getFreshPermissions).toHaveBeenCalledWith('u1');
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'permission revoked',
|
||||
);
|
||||
});
|
||||
|
||||
it('the permission check reads fresh, not the 30 s cache', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(permissions.getFreshPermissions).toHaveBeenCalledTimes(1);
|
||||
expect(permissions.getPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a stale DIGEST from yesterday is SKIPPED', async () => {
|
||||
claim(
|
||||
[delivery('d1')],
|
||||
context({ category: 'DIGEST', requiredPermissions: [], stale: true }),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'stale',
|
||||
);
|
||||
});
|
||||
|
||||
it('asks the database once for the staleness: Berlin day for TASK_DUE and DIGEST, 24 hours for the rest', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const [query] = prisma.$queryRaw.mock.calls[0] as [{ sql: string }];
|
||||
expect(query.sql).toContain("'TASK_DUE', 'DIGEST'");
|
||||
expect(query.sql).toContain("AT TIME ZONE 'Europe/Berlin'");
|
||||
expect(query.sql).toContain("interval '24 hours'");
|
||||
expect(query.sql).toContain('"Task"');
|
||||
});
|
||||
|
||||
it('skips when the notification is gone', async () => {
|
||||
claim([delivery('d1')], null);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'notification gone',
|
||||
);
|
||||
});
|
||||
|
||||
it('answers a bot reply without any of these checks', async () => {
|
||||
claim([template('t1', 'LINK_INVALID')]);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(permissions.getFreshPermissions).not.toHaveBeenCalled();
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
CHAT,
|
||||
TELEGRAM_TEMPLATES.LINK_INVALID,
|
||||
signal,
|
||||
);
|
||||
expect(settleSuccess).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
't1',
|
||||
CLAIMED_AT,
|
||||
'DELIVERED',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a reply with an unknown template code', async () => {
|
||||
claim([template('t1', 'NOPE')]);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
't1',
|
||||
CLAIMED_AT,
|
||||
'unknown template',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text', () => {
|
||||
it('sends plain text of at most 600 characters with the app link', async () => {
|
||||
claim([delivery('d1')], context({ message: 'x'.repeat(2_000) }));
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
const [chatId, text] = client.sendMessage.mock.calls[0] as [
|
||||
bigint,
|
||||
string,
|
||||
AbortSignal,
|
||||
];
|
||||
expect(chatId).toBe(CHAT);
|
||||
expect([...text].length).toBeLessThanOrEqual(600);
|
||||
expect(text.endsWith(`\n\n${ORIGIN}/tasks`)).toBe(true);
|
||||
});
|
||||
|
||||
it('sends only a hint and the link when the user turned details off', async () => {
|
||||
claim([delivery('d1')], context({ detail: false }));
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage.mock.calls[0][1]).toBe(
|
||||
`Neue Meldung in ApiCore.\n\n${ORIGIN}/tasks`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('answers of Telegram', () => {
|
||||
it('200 settles as DELIVERED', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(settleSuccess).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'DELIVERED',
|
||||
);
|
||||
});
|
||||
|
||||
it('429 defers by retry_after without counting an attempt', async () => {
|
||||
claim([delivery('d1', 'u1', 2)], context());
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError('sendMessage', 429, 'Too Many Requests', 17),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
17_000,
|
||||
);
|
||||
expect(settleFailure).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 removes the link, switches Telegram off and notifies the user in the app', async () => {
|
||||
claim([delivery('d1', 'u1')], context());
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError(
|
||||
'sendMessage',
|
||||
403,
|
||||
'Forbidden: bot was blocked',
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationPreference',
|
||||
'u1',
|
||||
);
|
||||
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1', chatId: CHAT },
|
||||
});
|
||||
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
expect(notifications.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'u1',
|
||||
category: 'SYSTEM',
|
||||
message:
|
||||
'Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'chat unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('400 "chat not found" ends the link like a 403', async () => {
|
||||
claim([delivery('d1', 'u1')], context());
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError(
|
||||
'sendMessage',
|
||||
400,
|
||||
'Bad Request: chat not found',
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(tx.telegramLink.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'chat unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('any other 400 is a failed attempt, not the end of the link', async () => {
|
||||
claim([delivery('d1', 'u1', 1)], context());
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError(
|
||||
'sendMessage',
|
||||
400,
|
||||
'Bad Request: message is too long',
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled();
|
||||
expect(settleFailure).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
1,
|
||||
'Telegram sendMessage failed: Bad Request: message is too long',
|
||||
);
|
||||
});
|
||||
|
||||
it('a 403 for an old chat does not remove a newer link of the same user', async () => {
|
||||
tx.telegramLink.deleteMany.mockResolvedValue({ count: 0 });
|
||||
claim([delivery('d1', 'u1')], context());
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError('sendMessage', 403, 'Forbidden', null),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
|
||||
expect(notifications.record).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
'chat unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('a 403 for a bot reply only skips it', async () => {
|
||||
claim([template('t1', 'LINKED')]);
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError('sendMessage', 403, 'Forbidden', null),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
't1',
|
||||
CLAIMED_AT,
|
||||
'chat unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['5xx', new TelegramApiError('sendMessage', 502, 'Bad Gateway', null)],
|
||||
['timeout', new TelegramApiError('sendMessage', null, 'timeout', null)],
|
||||
[
|
||||
'network error',
|
||||
new TelegramApiError('sendMessage', null, 'network error', null),
|
||||
],
|
||||
])('%s is a failed attempt under the retry rule', async (_name, error) => {
|
||||
claim([delivery('d1', 'u1', 3)], context());
|
||||
client.sendMessage.mockRejectedValue(error);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(settleFailure).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
3,
|
||||
error.message,
|
||||
);
|
||||
expect(settleSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('settles an unexpected error as a failed attempt and goes on with the batch', async () => {
|
||||
prisma.$queryRaw.mockRejectedValueOnce(new Error('connection reset'));
|
||||
claim([delivery('d1'), delivery('d2')], context());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(settleFailure).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
0,
|
||||
'connection reset',
|
||||
);
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs a failed settle instead of throwing', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError('sendMessage', 500, 'oops', null),
|
||||
);
|
||||
settleFailure.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(sender.runOnce(signal)).resolves.toBe(1);
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('d1 not settled: db down'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid token', () => {
|
||||
const unauthorized = () =>
|
||||
new TelegramApiError('sendMessage', 401, 'Unauthorized', null);
|
||||
|
||||
it('401 keeps the delivery pending, pauses 15 min and notifies system:manage once a day', async () => {
|
||||
claim([delivery('d1'), delivery('d2')], context());
|
||||
client.sendMessage.mockRejectedValue(unauthorized());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
900_000,
|
||||
);
|
||||
expect(settleFailure).not.toHaveBeenCalled();
|
||||
expect(settleSkipped).not.toHaveBeenCalled();
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||||
'system:manage',
|
||||
expect.objectContaining({
|
||||
category: 'SYSTEM',
|
||||
priority: 'WARNUNG',
|
||||
message: 'Telegram: Bot-Token ungültig.',
|
||||
link: '/settings/system',
|
||||
dedupeKeyPrefix: expect.stringMatching(
|
||||
/^telegram-token-invalid:\d{4}-\d{2}-\d{2}$/,
|
||||
) as string,
|
||||
}),
|
||||
);
|
||||
// The second delivery of the batch is not tried with the bad token.
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd2',
|
||||
CLAIMED_AT,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('claims nothing while paused', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
client.sendMessage.mockRejectedValue(unauthorized());
|
||||
await sender.runOnce(signal);
|
||||
claimBatch.mockClear();
|
||||
|
||||
expect(await sender.runOnce(signal)).toBe(0);
|
||||
expect(await sender.drain(signal)).toBe(0);
|
||||
expect(claimBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let a failing notice break the settle', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
client.sendMessage.mockRejectedValue(unauthorized());
|
||||
notifications.recordForPermission.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(sender.runOnce(signal)).resolves.toBe(1);
|
||||
|
||||
expect(deferClaim).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never puts the token or a chat id into the log', async () => {
|
||||
claim([delivery('d1')], context());
|
||||
client.sendMessage.mockRejectedValue(unauthorized());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
const logged = JSON.stringify(logError.mock.calls);
|
||||
expect(logged).not.toContain(String(CHAT));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rates', () => {
|
||||
it('keeps 1.1 s between two messages to the same chat', async () => {
|
||||
claim([delivery('d1', 'u1')], context());
|
||||
await sender.runOnce(signal);
|
||||
deferClaim.mockClear();
|
||||
claim([delivery('d2', 'u1')], context());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
const [, , id, , delay] = deferClaim.mock.calls[0];
|
||||
expect(id).toBe('d2');
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
expect(delay).toBeLessThanOrEqual(1_100);
|
||||
});
|
||||
|
||||
it('sends at most 25 messages per second over all chats', async () => {
|
||||
const rows = Array.from({ length: 4 }, (_, i) => delivery(`d${i}`));
|
||||
claim(rows, ...rows.map((_, i) => context({ chatId: BigInt(i + 1) })));
|
||||
const started = performance.now();
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
// Four messages need three gaps of 40 ms.
|
||||
expect(performance.now() - started).toBeGreaterThanOrEqual(100);
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('drain waits the chat spacing after a batch that held messages back, then sends the next', async () => {
|
||||
claim([delivery('d1', 'u1'), delivery('d2', 'u1')], context());
|
||||
claim([delivery('d2', 'u1')], context());
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const done = sender.drain(signal);
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
await expect(done).resolves.toBe(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('drain stops after maxRounds', async () => {
|
||||
claimBatch.mockResolvedValue([template('t', 'HELP', 1n)]);
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const done = sender.drain(signal, 3);
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await expect(done).resolves.toBe(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
expect(claimBatch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseStaleClaims', () => {
|
||||
it('returns how many claims were released and logs the FAILED ones', async () => {
|
||||
releaseStaleClaimRows.mockResolvedValue([
|
||||
{ id: 'a', status: 'PENDING' },
|
||||
{ id: 'b', status: 'FAILED' },
|
||||
]);
|
||||
|
||||
expect(await sender.releaseStaleClaims()).toBe(2);
|
||||
expect(releaseStaleClaimRows).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('b'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fix round 1', () => {
|
||||
it('429 pauses the whole pod for retry_after, not only the row', async () => {
|
||||
claim([delivery('d1'), delivery('d2')], context());
|
||||
client.sendMessage.mockRejectedValue(
|
||||
new TelegramApiError('sendMessage', 429, 'Too Many Requests', 30),
|
||||
);
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
// The second delivery of the batch is put back, not tried.
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
'd2',
|
||||
CLAIMED_AT,
|
||||
expect.any(Number),
|
||||
);
|
||||
claimBatch.mockClear();
|
||||
expect(await sender.runOnce(signal)).toBe(0);
|
||||
expect(claimBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a batch stops after about 120 s and puts the rest back without an attempt', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
claim(
|
||||
[delivery('d1'), delivery('d2'), delivery('d3')],
|
||||
context({ chatId: 1n }),
|
||||
context({ chatId: 2n }),
|
||||
context({ chatId: 3n }),
|
||||
);
|
||||
client.sendMessage.mockImplementationOnce(async () => {
|
||||
jest.advanceTimersByTime(TELEGRAM_BATCH_BUDGET_MS + 1);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const done = sender.runOnce(signal);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
await done;
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
for (const id of ['d2', 'd3']) {
|
||||
expect(deferClaim).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
id,
|
||||
CLAIMED_AT,
|
||||
0,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
describe('missing FRONTEND_ORIGIN', () => {
|
||||
const environment = process.env.NODE_ENV;
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.FRONTEND_ORIGIN;
|
||||
warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
(process.env as Record<string, string | undefined>).NODE_ENV =
|
||||
environment;
|
||||
});
|
||||
|
||||
it('outside development sends no link and warns once', async () => {
|
||||
(process.env as Record<string, string | undefined>).NODE_ENV =
|
||||
'production';
|
||||
claim([delivery('d1')], context());
|
||||
claim([delivery('d2')], context({ chatId: 77n }));
|
||||
|
||||
await sender.runOnce(signal);
|
||||
await sender.runOnce(signal);
|
||||
|
||||
const texts = client.sendMessage.mock.calls.map((call) => call[1]);
|
||||
expect(texts).toEqual([
|
||||
'Heute fällig: Futter bestellen',
|
||||
'Heute fällig: Futter bestellen',
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('development falls back to localhost', async () => {
|
||||
(process.env as Record<string, string | undefined>).NODE_ENV =
|
||||
'development';
|
||||
claim([delivery('d1')], context());
|
||||
|
||||
await sender.runOnce(signal);
|
||||
|
||||
expect(client.sendMessage.mock.calls[0][1]).toContain(
|
||||
'http://localhost:3000/tasks',
|
||||
);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import {
|
||||
claimBatch,
|
||||
deferClaim,
|
||||
releaseStaleClaimRows,
|
||||
settleFailure,
|
||||
settleSkipped,
|
||||
settleSuccess,
|
||||
} from '../common/outbox/outbox-claims';
|
||||
import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { TelegramApiError } from './telegram-api-error';
|
||||
import { TelegramClient } from './telegram-client';
|
||||
import {
|
||||
renderTelegramText,
|
||||
TELEGRAM_TEMPLATES,
|
||||
TelegramTemplateCode,
|
||||
} from './telegram-texts';
|
||||
|
||||
export const TELEGRAM_SEND_POLL_MS = 5_000;
|
||||
export const TELEGRAM_SEND_BATCH = 25;
|
||||
export const TELEGRAM_MAX_PER_SECOND = 25;
|
||||
export const TELEGRAM_CHAT_SPACING_MS = 1_100;
|
||||
export const TELEGRAM_TOKEN_INVALID_PAUSE_MS = 900_000;
|
||||
|
||||
const TABLE = 'NotificationDelivery';
|
||||
const MIN_SEND_GAP_MS = Math.ceil(1_000 / TELEGRAM_MAX_PER_SECOND);
|
||||
const DEFAULT_RETRY_AFTER_SEC = 5;
|
||||
/** How long the memory of "last message to this chat" is kept. */
|
||||
const CHAT_MEMORY_MS = 10_000;
|
||||
|
||||
const CHAT_GONE_MESSAGE =
|
||||
'Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.';
|
||||
const TOKEN_INVALID_MESSAGE = 'Telegram: Bot-Token ungültig.';
|
||||
|
||||
/** Reasons a delivery is skipped; they land in lastError, so no personal data. */
|
||||
const SKIP = {
|
||||
userInactive: 'user inactive',
|
||||
telegramDisabled: 'telegram disabled',
|
||||
notLinked: 'not linked',
|
||||
categoryOff: 'category off',
|
||||
permissionRevoked: 'permission revoked',
|
||||
stale: 'stale',
|
||||
taskDone: 'task already done',
|
||||
chatUnavailable: 'chat unavailable',
|
||||
unknownTemplate: 'unknown template',
|
||||
notificationGone: 'notification gone',
|
||||
} as const;
|
||||
|
||||
/** A claimed NotificationDelivery row, as RETURNING * yields it. */
|
||||
interface ClaimedDelivery {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
chatId: bigint | null;
|
||||
notificationId: string | null;
|
||||
templateCode: string | null;
|
||||
attempts: number;
|
||||
claimedAt: Date;
|
||||
}
|
||||
|
||||
/** What the send-time check reads about a delivery, in one query. */
|
||||
interface DeliveryContext {
|
||||
userActive: boolean;
|
||||
telegramEnabled: boolean;
|
||||
categories: string[];
|
||||
detail: boolean;
|
||||
chatId: bigint | null;
|
||||
category: string;
|
||||
message: string;
|
||||
link: string | null;
|
||||
requiredPermissions: string[];
|
||||
stale: boolean;
|
||||
taskOpen: boolean;
|
||||
}
|
||||
|
||||
type Target = { skip: string } | { chatId: bigint; text: string };
|
||||
|
||||
/** 'spaced': put back only because the chat got a message a moment ago. */
|
||||
type Outcome = 'settled' | 'spaced';
|
||||
|
||||
interface RoundResult {
|
||||
claimed: number;
|
||||
spaced: number;
|
||||
}
|
||||
|
||||
export interface TelegramSendFilter {
|
||||
/** Only deliveries of these users (e2e tests: no foreign rows). */
|
||||
userIds?: readonly string[];
|
||||
}
|
||||
|
||||
function claimFilter(filter?: TelegramSendFilter): Prisma.Sql | undefined {
|
||||
if (filter?.userIds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return filter.userIds.length === 0
|
||||
? Prisma.sql`FALSE`
|
||||
: Prisma.sql`"userId" IN (${Prisma.join([...filter.userIds])})`;
|
||||
}
|
||||
|
||||
function targetKey(delivery: ClaimedDelivery): string {
|
||||
return delivery.userId !== null
|
||||
? `user:${delivery.userId}`
|
||||
: `chat:${String(delivery.chatId)}`;
|
||||
}
|
||||
|
||||
/** The oldest delivery of each user or chat; the claim is ordered by age. */
|
||||
function splitOldestPerTarget(claimed: readonly ClaimedDelivery[]): {
|
||||
oldest: ClaimedDelivery[];
|
||||
rest: ClaimedDelivery[];
|
||||
} {
|
||||
const seen = new Set<string>();
|
||||
const oldest: ClaimedDelivery[] = [];
|
||||
const rest: ClaimedDelivery[] = [];
|
||||
for (const delivery of claimed) {
|
||||
const key = targetKey(delivery);
|
||||
if (seen.has(key)) {
|
||||
rest.push(delivery);
|
||||
} else {
|
||||
seen.add(key);
|
||||
oldest.push(delivery);
|
||||
}
|
||||
}
|
||||
return { oldest, rest };
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const done = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, ms);
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function isChatGone(error: TelegramApiError): boolean {
|
||||
return (
|
||||
error.statusCode === 403 ||
|
||||
(error.statusCode === 400 && /chat not found/i.test(error.description))
|
||||
);
|
||||
}
|
||||
|
||||
const DEVELOPMENT_ORIGIN = 'http://localhost:3000';
|
||||
/** A batch stops after this long; the claim is stale after 5 minutes. */
|
||||
export const TELEGRAM_BATCH_BUDGET_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Sends the queued Telegram deliveries (Spec 9a.6): claims up to 25 due
|
||||
* rows, takes the oldest of each user or chat, checks again at send time
|
||||
* that the message may still go out, and settles each row. Bot replies
|
||||
* (templateCode) skip the checks: they answer a person in a chat.
|
||||
* Run by the worker's telegram-send loop. ASSUMPTION: one worker replica
|
||||
* (the worker deployment has replicas: 1). The per-chat spacing, the global
|
||||
* rate limit and the pause after a 401 or 429 live in this pod's memory; with
|
||||
* several pods each would keep its own (the one-delivery-per-target rule in
|
||||
* SQL and Telegram's 429 answers would still protect the bot). Never logs the token, a chat id
|
||||
* or a message text.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramSender {
|
||||
private readonly logger = new Logger(TelegramSender.name);
|
||||
private pausedUntil = 0;
|
||||
private warnedMissingOrigin = false;
|
||||
private lastSendAt = 0;
|
||||
private readonly lastSendToChat = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly client: TelegramClient,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** One batch. Returns how many deliveries it claimed. */
|
||||
async runOnce(
|
||||
signal: AbortSignal,
|
||||
filter?: TelegramSendFilter,
|
||||
): Promise<number> {
|
||||
return (await this.round(signal, filter)).claimed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs batches until one claims nothing, at most `maxRounds`. After a
|
||||
* batch that put messages back for the chat spacing it waits that
|
||||
* spacing, so a burst for one person does not wait for the next poll.
|
||||
*/
|
||||
async drain(
|
||||
signal: AbortSignal,
|
||||
maxRounds = DRAIN_MAX_ROUNDS,
|
||||
filter?: TelegramSendFilter,
|
||||
): Promise<number> {
|
||||
let total = 0;
|
||||
for (let round = 0; round < maxRounds && !signal.aborted; round++) {
|
||||
const result = await this.round(signal, filter);
|
||||
if (result.claimed === 0) {
|
||||
break;
|
||||
}
|
||||
total += result.claimed;
|
||||
if (result.spaced > 0) {
|
||||
await sleep(TELEGRAM_CHAT_SPACING_MS, signal);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Puts deliveries whose worker died back in line (or to FAILED). */
|
||||
async releaseStaleClaims(): Promise<number> {
|
||||
const released = await releaseStaleClaimRows(this.prisma, TABLE);
|
||||
const failed = released
|
||||
.filter((claim) => claim.status === 'FAILED')
|
||||
.map((claim) => claim.id);
|
||||
if (failed.length > 0) {
|
||||
this.logger.error(
|
||||
`Telegram deliveries FAILED, an expired claim used up their attempts: ${failed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return released.length;
|
||||
}
|
||||
|
||||
private async round(
|
||||
signal: AbortSignal,
|
||||
filter?: TelegramSendFilter,
|
||||
): Promise<RoundResult> {
|
||||
if (signal.aborted || this.remainingPauseMs() > 0) {
|
||||
return { claimed: 0, spaced: 0 };
|
||||
}
|
||||
const claimed = await claimBatch<ClaimedDelivery>(this.prisma, TABLE, {
|
||||
limit: TELEGRAM_SEND_BATCH,
|
||||
orderBy: 'createdAt',
|
||||
filter: claimFilter(filter),
|
||||
});
|
||||
const { oldest, rest } = splitOldestPerTarget(claimed);
|
||||
let spaced = rest.length;
|
||||
for (const delivery of rest) {
|
||||
await deferClaim(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
TELEGRAM_CHAT_SPACING_MS,
|
||||
);
|
||||
}
|
||||
const started = performance.now();
|
||||
for (const delivery of oldest) {
|
||||
if (performance.now() - started > TELEGRAM_BATCH_BUDGET_MS) {
|
||||
// Out of time: the claim would go stale. Back without an attempt.
|
||||
await deferClaim(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
0,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const outcome = await this.handle(delivery, signal);
|
||||
if (outcome === 'spaced') {
|
||||
spaced++;
|
||||
}
|
||||
}
|
||||
return { claimed: claimed.length, spaced };
|
||||
}
|
||||
|
||||
/** Shutdown and a paused bot put the claim back without an attempt. */
|
||||
private async handle(
|
||||
delivery: ClaimedDelivery,
|
||||
signal: AbortSignal,
|
||||
): Promise<Outcome> {
|
||||
const pauseMs = this.remainingPauseMs();
|
||||
if (signal.aborted || pauseMs > 0) {
|
||||
await deferClaim(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
pauseMs,
|
||||
);
|
||||
return 'settled';
|
||||
}
|
||||
try {
|
||||
return await this.process(delivery, signal);
|
||||
} catch (error: unknown) {
|
||||
await this.failSafely(delivery, error);
|
||||
return 'settled';
|
||||
}
|
||||
}
|
||||
|
||||
private async process(
|
||||
delivery: ClaimedDelivery,
|
||||
signal: AbortSignal,
|
||||
): Promise<Outcome> {
|
||||
const target =
|
||||
delivery.templateCode !== null
|
||||
? this.templateTarget(delivery)
|
||||
: await this.notificationTarget(delivery);
|
||||
if ('skip' in target) {
|
||||
await settleSkipped(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
target.skip,
|
||||
);
|
||||
return 'settled';
|
||||
}
|
||||
const waitMs = this.chatWaitMs(target.chatId);
|
||||
if (waitMs > 0) {
|
||||
await deferClaim(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
waitMs,
|
||||
);
|
||||
return 'spaced';
|
||||
}
|
||||
await this.keepRateLimit(signal);
|
||||
await this.send(delivery, target, signal);
|
||||
return 'settled';
|
||||
}
|
||||
|
||||
private templateTarget(delivery: ClaimedDelivery): Target {
|
||||
const code = delivery.templateCode;
|
||||
if (
|
||||
code === null ||
|
||||
delivery.chatId === null ||
|
||||
!Object.hasOwn(TELEGRAM_TEMPLATES, code)
|
||||
) {
|
||||
return { skip: SKIP.unknownTemplate };
|
||||
}
|
||||
return {
|
||||
chatId: delivery.chatId,
|
||||
text: TELEGRAM_TEMPLATES[code as TelegramTemplateCode],
|
||||
};
|
||||
}
|
||||
|
||||
/** Spec 9a.6: everything that may have changed since the delivery was queued. */
|
||||
private async notificationTarget(delivery: ClaimedDelivery): Promise<Target> {
|
||||
const context = await this.loadContext(delivery);
|
||||
if (context === null) {
|
||||
return { skip: SKIP.notificationGone };
|
||||
}
|
||||
if (!context.userActive) {
|
||||
return { skip: SKIP.userInactive };
|
||||
}
|
||||
if (!context.telegramEnabled) {
|
||||
return { skip: SKIP.telegramDisabled };
|
||||
}
|
||||
if (context.chatId === null) {
|
||||
return { skip: SKIP.notLinked };
|
||||
}
|
||||
if (!context.categories.includes(context.category)) {
|
||||
return { skip: SKIP.categoryOff };
|
||||
}
|
||||
// Fresh, not the 30 s cache: this process never hears the invalidation
|
||||
// of the API pods, so a revoked right would keep sending for 30 s.
|
||||
const held = await this.permissions.getFreshPermissions(
|
||||
delivery.userId as string,
|
||||
);
|
||||
if (!context.requiredPermissions.every((key) => held.has(key))) {
|
||||
return { skip: SKIP.permissionRevoked };
|
||||
}
|
||||
if (context.stale) {
|
||||
return { skip: SKIP.stale };
|
||||
}
|
||||
if (!context.taskOpen) {
|
||||
return { skip: SKIP.taskDone };
|
||||
}
|
||||
return {
|
||||
chatId: context.chatId,
|
||||
text: renderTelegramText(context, context.detail, this.frontendOrigin()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One query for user, settings, link, notification, age and task. Stale:
|
||||
* TASK_DUE and DIGEST only on the Berlin day they were written, every
|
||||
* other category for 24 hours. "createdAt" is a UTC timestamp without
|
||||
* zone; AT TIME ZONE 'UTC' makes it an instant first.
|
||||
*/
|
||||
private async loadContext(
|
||||
delivery: ClaimedDelivery,
|
||||
): Promise<DeliveryContext | null> {
|
||||
const rows = await this.prisma.$queryRaw<DeliveryContext[]>(Prisma.sql`
|
||||
SELECT
|
||||
(u."isActive" AND r."status" = 'ACTIVE') AS "userActive",
|
||||
COALESCE(p."telegramEnabled", FALSE) AS "telegramEnabled",
|
||||
COALESCE(p."telegramCategories"::text[], ARRAY[]::text[]) AS "categories",
|
||||
COALESCE(p."telegramDetail", TRUE) AS "detail",
|
||||
l."chatId" AS "chatId",
|
||||
n."category"::text AS "category",
|
||||
n."message" AS "message",
|
||||
n."link" AS "link",
|
||||
n."requiredPermissions" AS "requiredPermissions",
|
||||
CASE
|
||||
WHEN n."category" IN ('TASK_DUE', 'DIGEST') THEN
|
||||
to_char((n."createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD')
|
||||
<> to_char(now() AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD')
|
||||
ELSE now() - (n."createdAt" AT TIME ZONE 'UTC') > interval '24 hours'
|
||||
END AS "stale",
|
||||
CASE
|
||||
WHEN n."category" = 'TASK_DUE' AND n."relatedType" = 'Task' AND n."relatedId" IS NOT NULL THEN
|
||||
COALESCE((SELECT t."status" IN ('OPEN', 'IN_PROGRESS') FROM "Task" t WHERE t."id" = n."relatedId"), FALSE)
|
||||
ELSE TRUE
|
||||
END AS "taskOpen"
|
||||
FROM "NotificationDelivery" d
|
||||
JOIN "Notification" n ON n."id" = d."notificationId"
|
||||
JOIN "User" u ON u."id" = d."userId"
|
||||
JOIN "Role" r ON r."id" = u."roleId"
|
||||
LEFT JOIN "NotificationPreference" p ON p."userId" = u."id"
|
||||
LEFT JOIN "TelegramLink" l ON l."userId" = u."id"
|
||||
WHERE d."id" = ${delivery.id}`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async send(
|
||||
delivery: ClaimedDelivery,
|
||||
target: { chatId: bigint; text: string },
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.client.sendMessage(target.chatId, target.text, signal);
|
||||
} catch (error: unknown) {
|
||||
await this.onSendError(delivery, target.chatId, error);
|
||||
return;
|
||||
}
|
||||
this.lastSendToChat.set(String(target.chatId), performance.now());
|
||||
await settleSuccess(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
'DELIVERED',
|
||||
);
|
||||
}
|
||||
|
||||
private async onSendError(
|
||||
delivery: ClaimedDelivery,
|
||||
chatId: bigint,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
if (!(error instanceof TelegramApiError)) {
|
||||
await this.failSafely(delivery, error);
|
||||
return;
|
||||
}
|
||||
if (error.statusCode === 429) {
|
||||
const seconds = error.retryAfterSec ?? DEFAULT_RETRY_AFTER_SEC;
|
||||
// Telegram limits the whole bot: the pod waits, not only this row.
|
||||
this.pausedUntil = Math.max(
|
||||
this.pausedUntil,
|
||||
performance.now() + seconds * 1_000,
|
||||
);
|
||||
await deferClaim(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
seconds * 1_000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isChatGone(error)) {
|
||||
await this.endDelivery(delivery, chatId);
|
||||
return;
|
||||
}
|
||||
if (error.statusCode === 401) {
|
||||
await this.pauseForInvalidToken(delivery);
|
||||
return;
|
||||
}
|
||||
await this.failSafely(delivery, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bot is blocked or the chat is gone: the link ends, but only if it
|
||||
* still points to this chat - a newer link of the same user to another
|
||||
* chat stays. Settings, link and message to the user change together.
|
||||
*/
|
||||
private async endDelivery(
|
||||
delivery: ClaimedDelivery,
|
||||
chatId: bigint,
|
||||
): Promise<void> {
|
||||
const userId = delivery.userId;
|
||||
if (userId === null) {
|
||||
await settleSkipped(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
SKIP.chatUnavailable,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await lockRowForUpdate(tx, 'NotificationPreference', userId);
|
||||
const { count } = await tx.telegramLink.deleteMany({
|
||||
where: { userId, chatId },
|
||||
});
|
||||
if (count === 1) {
|
||||
await tx.notificationPreference.updateMany({
|
||||
where: { userId },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
await this.notifications.record(
|
||||
{
|
||||
userId,
|
||||
priority: 'HINWEIS',
|
||||
category: 'SYSTEM',
|
||||
message: CHAT_GONE_MESSAGE,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await settleSkipped(
|
||||
tx,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
SKIP.chatUnavailable,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The token is rejected: nothing can go out, so the whole loop waits. */
|
||||
private async pauseForInvalidToken(delivery: ClaimedDelivery): Promise<void> {
|
||||
this.pausedUntil = performance.now() + TELEGRAM_TOKEN_INVALID_PAUSE_MS;
|
||||
await deferClaim(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
TELEGRAM_TOKEN_INVALID_PAUSE_MS,
|
||||
);
|
||||
try {
|
||||
// One per user and day: the dedupe key carries the Berlin day.
|
||||
await this.notifications.recordForPermission('system:manage', {
|
||||
category: 'SYSTEM',
|
||||
priority: 'WARNUNG',
|
||||
message: TOKEN_INVALID_MESSAGE,
|
||||
link: '/settings/system',
|
||||
dedupeKeyPrefix: `telegram-token-invalid:${berlinToday()}`,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`Telegram token notice not recorded: ${loggableError(error).message}`,
|
||||
);
|
||||
}
|
||||
this.logger.error(
|
||||
'Telegram rejected the bot token (401); sending paused for 15 minutes',
|
||||
);
|
||||
}
|
||||
|
||||
/** A failed attempt under the shared retry rule; the error text carries no secret. */
|
||||
private async failSafely(
|
||||
delivery: ClaimedDelivery,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
const { message } = loggableError(error);
|
||||
try {
|
||||
await settleFailure(
|
||||
this.prisma,
|
||||
TABLE,
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
delivery.attempts,
|
||||
message,
|
||||
);
|
||||
} catch (settleError: unknown) {
|
||||
this.logger.error(
|
||||
`Telegram delivery ${delivery.id} not settled: ${loggableError(settleError).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only development may fall back to localhost. Elsewhere a missing origin
|
||||
* (the startup check normally prevents it) sends the text without a link.
|
||||
*/
|
||||
private frontendOrigin(): string | null {
|
||||
const configured = process.env.FRONTEND_ORIGIN;
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const environment = process.env.NODE_ENV;
|
||||
if (environment === undefined || environment === 'development') {
|
||||
return DEVELOPMENT_ORIGIN;
|
||||
}
|
||||
if (!this.warnedMissingOrigin) {
|
||||
this.warnedMissingOrigin = true;
|
||||
this.logger.warn(
|
||||
'FRONTEND_ORIGIN is not set: Telegram messages go out without a link',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private remainingPauseMs(): number {
|
||||
return Math.max(0, Math.ceil(this.pausedUntil - performance.now()));
|
||||
}
|
||||
|
||||
/** Milliseconds until the chat may get its next message; 0: now. */
|
||||
private chatWaitMs(chatId: bigint): number {
|
||||
const now = performance.now();
|
||||
for (const [chat, sentAt] of this.lastSendToChat) {
|
||||
if (now - sentAt > CHAT_MEMORY_MS) {
|
||||
this.lastSendToChat.delete(chat);
|
||||
}
|
||||
}
|
||||
const sentAt = this.lastSendToChat.get(String(chatId));
|
||||
return sentAt === undefined
|
||||
? 0
|
||||
: Math.max(0, Math.ceil(TELEGRAM_CHAT_SPACING_MS - (now - sentAt)));
|
||||
}
|
||||
|
||||
/** At most TELEGRAM_MAX_PER_SECOND messages per second over all chats. */
|
||||
private async keepRateLimit(signal: AbortSignal): Promise<void> {
|
||||
const waitMs = MIN_SEND_GAP_MS - (performance.now() - this.lastSendAt);
|
||||
if (waitMs > 0) {
|
||||
await sleep(waitMs, signal);
|
||||
}
|
||||
this.lastSendAt = performance.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
renderTelegramText,
|
||||
TELEGRAM_TEMPLATES,
|
||||
TelegramTemplateCode,
|
||||
} from './telegram-texts';
|
||||
|
||||
const ORIGIN = 'https://app.example.org';
|
||||
|
||||
describe('TELEGRAM_TEMPLATES', () => {
|
||||
it('has the exact texts of the bot replies', () => {
|
||||
expect(TELEGRAM_TEMPLATES).toEqual({
|
||||
LINKED:
|
||||
'Verbunden mit ApiCore. Hier kommen deine Erinnerungen. /stop beendet das.',
|
||||
LINK_INVALID:
|
||||
'Der Link ist ungültig oder abgelaufen. Bitte in ApiCore neu verbinden.',
|
||||
STOPPED: 'Abgemeldet. Du bekommst keine Nachrichten mehr.',
|
||||
HELP: 'Dieser Bot schickt Erinnerungen aus ApiCore. Verbinden geht nur in ApiCore unter Profil > Benachrichtigungen. /stop beendet.',
|
||||
} satisfies Record<TelegramTemplateCode, string>);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTelegramText', () => {
|
||||
it('sends the message, a blank line and the app link', () => {
|
||||
const text = renderTelegramText(
|
||||
{ message: 'Heute fällig: Futter bestellen', link: '/tasks' },
|
||||
true,
|
||||
ORIGIN,
|
||||
);
|
||||
|
||||
expect(text).toBe(
|
||||
'Heute fällig: Futter bestellen\n\nhttps://app.example.org/tasks',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to /dashboard without a link', () => {
|
||||
const text = renderTelegramText({ message: 'm', link: null }, true, ORIGIN);
|
||||
|
||||
expect(text).toBe('m\n\nhttps://app.example.org/dashboard');
|
||||
});
|
||||
|
||||
it('without details only says there is a new message, plus the link', () => {
|
||||
const text = renderTelegramText(
|
||||
{ message: 'Heute fällig: Schwarmzelle Völker', link: '/tasks' },
|
||||
false,
|
||||
ORIGIN,
|
||||
);
|
||||
|
||||
expect(text).toBe(
|
||||
'Neue Meldung in ApiCore.\n\nhttps://app.example.org/tasks',
|
||||
);
|
||||
expect(text).not.toContain('Schwarm');
|
||||
});
|
||||
|
||||
it('tolerates a trailing slash of the origin', () => {
|
||||
const text = renderTelegramText(
|
||||
{ message: 'm', link: '/tasks' },
|
||||
true,
|
||||
`${ORIGIN}/`,
|
||||
);
|
||||
|
||||
expect(text.endsWith('https://app.example.org/tasks')).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts a long message to at most 600 characters and keeps the link whole', () => {
|
||||
const text = renderTelegramText(
|
||||
{ message: 'ä'.repeat(2000), link: '/tasks' },
|
||||
true,
|
||||
ORIGIN,
|
||||
);
|
||||
|
||||
expect([...text].length).toBeLessThanOrEqual(600);
|
||||
expect(text.endsWith('\n\nhttps://app.example.org/tasks')).toBe(true);
|
||||
expect(text).toContain('…\n\n');
|
||||
});
|
||||
|
||||
it('counts code points, not UTF-16 units', () => {
|
||||
const text = renderTelegramText(
|
||||
{ message: '🐝'.repeat(1000), link: '/tasks' },
|
||||
true,
|
||||
ORIGIN,
|
||||
);
|
||||
|
||||
expect([...text].length).toBeLessThanOrEqual(600);
|
||||
expect(text).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/);
|
||||
});
|
||||
|
||||
it('removes control characters and invisible characters from the message', () => {
|
||||
const text = renderTelegramText(
|
||||
{ message: 'a\u0007bcd\r\ne', link: '/tasks' },
|
||||
true,
|
||||
ORIGIN,
|
||||
);
|
||||
|
||||
expect(text).toBe('abcd\ne\n\nhttps://app.example.org/tasks');
|
||||
});
|
||||
|
||||
it('without an origin sends the text alone, no link', () => {
|
||||
expect(
|
||||
renderTelegramText({ message: 'Hallo', link: '/tasks' }, true, null),
|
||||
).toBe('Hallo');
|
||||
expect(
|
||||
renderTelegramText({ message: 'Hallo', link: '/tasks' }, false, null),
|
||||
).toBe('Neue Meldung in ApiCore.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
sanitizeTelegramText,
|
||||
TELEGRAM_MAX_MESSAGE_CHARS,
|
||||
} from './telegram-client';
|
||||
|
||||
export type TelegramTemplateCode =
|
||||
'LINKED' | 'LINK_INVALID' | 'STOPPED' | 'HELP';
|
||||
|
||||
/** The replies of the bot to what a person does in the chat (Spec 9a.5). */
|
||||
export const TELEGRAM_TEMPLATES: Record<TelegramTemplateCode, string> = {
|
||||
LINKED:
|
||||
'Verbunden mit ApiCore. Hier kommen deine Erinnerungen. /stop beendet das.',
|
||||
LINK_INVALID:
|
||||
'Der Link ist ungültig oder abgelaufen. Bitte in ApiCore neu verbinden.',
|
||||
STOPPED: 'Abgemeldet. Du bekommst keine Nachrichten mehr.',
|
||||
HELP: 'Dieser Bot schickt Erinnerungen aus ApiCore. Verbinden geht nur in ApiCore unter Profil > Benachrichtigungen. /stop beendet.',
|
||||
};
|
||||
|
||||
const DEFAULT_LINK = '/dashboard';
|
||||
const DETAIL_OFF_TEXT = 'Neue Meldung in ApiCore.';
|
||||
const ELLIPSIS = '…';
|
||||
|
||||
/**
|
||||
* The plain text of a notification: its message (or, without details, only
|
||||
* the hint that there is one), a blank line and the app link. At most 600
|
||||
* characters; a long message is cut, the link stays whole. No parse_mode on
|
||||
* the wire, so a title cannot inject formatting.
|
||||
*/
|
||||
export function renderTelegramText(
|
||||
notification: { message: string; link: string | null },
|
||||
detail: boolean,
|
||||
frontendOrigin: string | null,
|
||||
): string {
|
||||
// Without a known origin no link at all: never a localhost link.
|
||||
const tail =
|
||||
frontendOrigin === null
|
||||
? ''
|
||||
: `\n\n${sanitizeTelegramText(
|
||||
`${frontendOrigin.replace(/\/+$/, '')}${notification.link ?? DEFAULT_LINK}`,
|
||||
)}`;
|
||||
const head = detail
|
||||
? sanitizeTelegramText(notification.message)
|
||||
: DETAIL_OFF_TEXT;
|
||||
const budget = TELEGRAM_MAX_MESSAGE_CHARS - [...tail].length;
|
||||
const characters = [...head];
|
||||
if (characters.length <= budget) {
|
||||
return `${head}${tail}`;
|
||||
}
|
||||
const kept = characters.slice(0, Math.max(budget - 1, 0)).join('');
|
||||
return sanitizeTelegramText(`${kept}${ELLIPSIS}${tail}`);
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { telegramConfig } from './telegram-config';
|
||||
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
|
||||
import { TelegramSender } from './telegram-sender';
|
||||
|
||||
/**
|
||||
* The Bot API client for the worker's Telegram loops. Reads the token from
|
||||
* the environment once; without it the client reports isConfigured() false
|
||||
* and the loops do not run.
|
||||
* and the loops do not run. The sender needs the permission resolver, which
|
||||
* the global JobPermissionsModule (worker) or PermissionsModule (API) provides:
|
||||
* this module imports neither, so the permission catalog is never rewritten
|
||||
* by it.
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
providers: [
|
||||
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
|
||||
TelegramClient,
|
||||
TelegramSender,
|
||||
],
|
||||
exports: [TelegramClient],
|
||||
exports: [TelegramClient, TelegramSender],
|
||||
})
|
||||
export class TelegramModule {}
|
||||
@@ -33,7 +33,7 @@ describe('WinterChecksService', () => {
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { recordAll: jest.Mock };
|
||||
let events: { recordAll: jest.Mock; record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
@@ -63,7 +63,10 @@ describe('WinterChecksService', () => {
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { recordAll: jest.fn().mockResolvedValue(0) };
|
||||
events = {
|
||||
recordAll: jest.fn().mockResolvedValue(0),
|
||||
record: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new WinterChecksService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -71,6 +74,18 @@ describe('WinterChecksService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records WinterCheckRecorded with ids only in the same transaction', async () => {
|
||||
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'WinterCheckRecorded',
|
||||
aggregateType: 'WinterCheck',
|
||||
aggregateId: 'w1',
|
||||
actorUserId: 'u1',
|
||||
payload: { colonyId: 'c1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('audits a colony marked deceased through the audit service inside the transaction', async () => {
|
||||
await service.create(lostCheck, 'u1');
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ export class WinterChecksService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordWinterCheck(tx, created, actingUserId);
|
||||
return created;
|
||||
}),
|
||||
);
|
||||
@@ -196,12 +197,35 @@ export class WinterChecksService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordWinterCheck(tx, updated, actingUserId);
|
||||
if (updated.colonyId !== before.colonyId) {
|
||||
await this.recordWinterCheck(
|
||||
tx,
|
||||
{ ...updated, colonyId: before.colonyId },
|
||||
actingUserId,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
return check;
|
||||
}
|
||||
|
||||
/** Lets the autopilot see the entry: A9/A10 resolve right after it. */
|
||||
private recordWinterCheck(
|
||||
tx: Prisma.TransactionClient,
|
||||
check: WinterCheck,
|
||||
actingUserId: string,
|
||||
): Promise<void> {
|
||||
return this.events.record(tx, {
|
||||
type: 'WinterCheckRecorded',
|
||||
aggregateType: 'WinterCheck',
|
||||
aggregateId: check.id,
|
||||
actorUserId: actingUserId,
|
||||
payload: { colonyId: check.colonyId },
|
||||
});
|
||||
}
|
||||
|
||||
private assertConsistent(check: WinterCheckShape): void {
|
||||
const problem = validateWinterCheck(check);
|
||||
if (problem) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TelegramModule } from '../telegram/telegram.module';
|
||||
import { WeatherHistoryModule } from '../weather/history/weather-history.module';
|
||||
import { WorkerRuntime } from './worker-runtime.service';
|
||||
|
||||
@@ -7,10 +8,11 @@ import { WorkerRuntime } from './worker-runtime.service';
|
||||
* AllInOneModule ("all"), never by AppModule: API pods run no background
|
||||
* loops. Its dependencies come from the global Prisma, DomainEvents,
|
||||
* CalendarSync and Webhooks modules of the importing root module; the weather
|
||||
* source comes with WeatherHistoryModule.
|
||||
* source comes with WeatherHistoryModule and the Telegram client and sender
|
||||
* with TelegramModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [WeatherHistoryModule],
|
||||
imports: [WeatherHistoryModule, TelegramModule],
|
||||
providers: [WorkerRuntime],
|
||||
exports: [WorkerRuntime],
|
||||
})
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
DRAIN_MAX_ROUNDS,
|
||||
} from '../domain-events/domain-event-dispatcher';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import type { TelegramClient } from '../telegram/telegram-client';
|
||||
import {
|
||||
TELEGRAM_SEND_POLL_MS,
|
||||
type TelegramSender,
|
||||
} from '../telegram/telegram-sender';
|
||||
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
@@ -59,6 +64,11 @@ class FakeWebhookDeliveries {
|
||||
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
|
||||
}
|
||||
|
||||
class FakeTelegramSender {
|
||||
releaseStaleClaims = jest.fn<Promise<number>, []>().mockResolvedValue(0);
|
||||
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
|
||||
}
|
||||
|
||||
describe('WorkerRuntime', () => {
|
||||
const ListenerMock = PgListener as unknown as jest.Mock;
|
||||
const originalDatabaseUrl = process.env.DATABASE_URL;
|
||||
@@ -70,6 +80,8 @@ describe('WorkerRuntime', () => {
|
||||
};
|
||||
let webhookDeliveries: FakeWebhookDeliveries;
|
||||
let weatherHistory: { syncOnce: jest.Mock };
|
||||
let telegramConfigured: boolean;
|
||||
let telegramSender: FakeTelegramSender;
|
||||
let runtime: WorkerRuntime;
|
||||
let logError: jest.SpyInstance;
|
||||
|
||||
@@ -108,14 +120,22 @@ describe('WorkerRuntime', () => {
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
runtime = new WorkerRuntime(
|
||||
telegramConfigured = false;
|
||||
telegramSender = new FakeTelegramSender();
|
||||
runtime = createRuntime();
|
||||
});
|
||||
|
||||
function createRuntime(): WorkerRuntime {
|
||||
return new WorkerRuntime(
|
||||
prisma as unknown as PrismaService,
|
||||
dispatcher as unknown as DomainEventDispatcher,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
webhookDeliveries as unknown as WebhookDeliveryWorker,
|
||||
weatherHistory as unknown as WeatherHistoryService,
|
||||
{ isConfigured: () => telegramConfigured } as unknown as TelegramClient,
|
||||
telegramSender as unknown as TelegramSender,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await runtime.onModuleDestroy();
|
||||
@@ -183,6 +203,72 @@ describe('WorkerRuntime', () => {
|
||||
expect(webhookReleaseOrder).toBeLessThan(webhookDrainOrder);
|
||||
});
|
||||
|
||||
describe('telegram-send loop', () => {
|
||||
it('is not registered without a bot token', async () => {
|
||||
await bootstrap();
|
||||
await jest.advanceTimersByTimeAsync(TELEGRAM_SEND_POLL_MS * 2);
|
||||
|
||||
expect(telegramSender.drain).not.toHaveBeenCalled();
|
||||
expect(listenerOptions().channels).not.toContain('notification_delivery');
|
||||
});
|
||||
|
||||
describe('with a bot token', () => {
|
||||
beforeEach(async () => {
|
||||
await runtime.onModuleDestroy();
|
||||
telegramConfigured = true;
|
||||
runtime = createRuntime();
|
||||
});
|
||||
|
||||
it('listens on notification_delivery', async () => {
|
||||
await runtime.onApplicationBootstrap();
|
||||
|
||||
expect(listenerOptions().channels).toContain('notification_delivery');
|
||||
});
|
||||
|
||||
it('releases stale claims, then drains every due delivery, stoppable by shutdown', async () => {
|
||||
await bootstrap();
|
||||
|
||||
expect(telegramSender.releaseStaleClaims).toHaveBeenCalledTimes(1);
|
||||
expect(telegramSender.drain).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const [releaseOrder] =
|
||||
telegramSender.releaseStaleClaims.mock.invocationCallOrder;
|
||||
const [drainOrder] = telegramSender.drain.mock.invocationCallOrder;
|
||||
expect(releaseOrder).toBeLessThan(drainOrder);
|
||||
});
|
||||
|
||||
it('polls every 5 s', async () => {
|
||||
await bootstrap();
|
||||
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(TELEGRAM_SEND_POLL_MS - 1);
|
||||
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(telegramSender.drain).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('wakes on a notification_delivery notification', async () => {
|
||||
await bootstrap();
|
||||
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
|
||||
|
||||
notify('notification_delivery');
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(telegramSender.drain).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('aborts the signal at shutdown', async () => {
|
||||
await bootstrap();
|
||||
const [signal] = telegramSender.drain.mock.calls[0] as [AbortSignal];
|
||||
|
||||
await runtime.onModuleDestroy();
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('polls webhook deliveries every 5 s', async () => {
|
||||
await bootstrap();
|
||||
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
DRAIN_MAX_ROUNDS,
|
||||
} from '../domain-events/domain-event-dispatcher';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TelegramClient } from '../telegram/telegram-client';
|
||||
import {
|
||||
TELEGRAM_SEND_POLL_MS,
|
||||
TelegramSender,
|
||||
} from '../telegram/telegram-sender';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
@@ -77,6 +82,8 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly webhookDeliveries: WebhookDeliveryWorker,
|
||||
private readonly weatherHistory: WeatherHistoryService,
|
||||
private readonly telegramClient: TelegramClient,
|
||||
private readonly telegramSender: TelegramSender,
|
||||
) {
|
||||
const loops = this.workerLoops().map((definition) => ({
|
||||
definition,
|
||||
@@ -119,6 +126,17 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
channel: WAKE_CHANNELS.webhookDeliveries,
|
||||
tick: () => this.deliverWebhooks(),
|
||||
},
|
||||
// Without a bot token the loop is not registered at all.
|
||||
...(this.telegramClient.isConfigured()
|
||||
? [
|
||||
{
|
||||
name: 'telegram-send',
|
||||
intervalMs: TELEGRAM_SEND_POLL_MS,
|
||||
channel: WAKE_CHANNELS.notificationDeliveries,
|
||||
tick: () => this.sendTelegram(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
// Daily mean temperatures for the phenology; hourly, no outbox.
|
||||
name: 'phenology-weather',
|
||||
@@ -216,6 +234,11 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendTelegram(): Promise<void> {
|
||||
await this.telegramSender.releaseStaleClaims();
|
||||
await this.telegramSender.drain(this.stopping.signal);
|
||||
}
|
||||
|
||||
private async deliverWebhooks(): Promise<void> {
|
||||
await this.webhookDeliveries.releaseStaleClaims();
|
||||
// No filter: every due delivery of the database.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PhenologyModule } from '../phenology/phenology.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { TelegramModule } from '../telegram/telegram.module';
|
||||
import { WebhooksModule } from '../webhooks/webhooks.module';
|
||||
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
|
||||
import { WorkerHealthServer } from './worker-health-server.service';
|
||||
@@ -41,6 +42,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
||||
PhenologyModule,
|
||||
WithdrawalModule,
|
||||
RemindersModule,
|
||||
TelegramModule,
|
||||
WorkerRuntimeModule,
|
||||
],
|
||||
providers: [WorkerHealthServer],
|
||||
|
||||
@@ -329,10 +329,13 @@ describe('Automation rules (e2e)', () => {
|
||||
it('proposes feeding when feed status is kritisch, without a notification', async () => {
|
||||
await record('inspections', { feedStatus: 'CRITICAL' });
|
||||
|
||||
const suggestions = await suggestionsOf('FEED_STATUS_CRITICAL');
|
||||
// One open thing per intent: the season autopilot (A6) may have proposed
|
||||
// the feeding first, then the rule's proposal is dropped by design.
|
||||
const suggestions = await prisma.suggestion.findMany({
|
||||
where: { colonyId, intent: 'FEED', status: 'OPEN' },
|
||||
});
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0]).toMatchObject({ intent: 'FEED', status: 'OPEN' });
|
||||
// The rule is off for notifications by default (notify: false).
|
||||
// Neither source notifies by default (rule notify: false, autopilot never).
|
||||
expect(
|
||||
await prisma.notification.count({
|
||||
where: { relatedType: 'Suggestion', relatedId: suggestions[0].id },
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||
import { NextStepsService } from '../src/autopilot/next-steps.service';
|
||||
import { berlinToday } from '../src/common/dates';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { login } from './utils/api';
|
||||
@@ -28,6 +30,8 @@ describe('Autopilot (e2e)', () => {
|
||||
const colonyIds: string[] = [];
|
||||
const roleIds: string[] = [];
|
||||
const userIds: string[] = [];
|
||||
const seasonApiaryIds: string[] = [];
|
||||
const winterCheckIds: string[] = [];
|
||||
|
||||
/** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */
|
||||
async function colonyWithLateInspection(name: string): Promise<string> {
|
||||
@@ -88,11 +92,25 @@ describe('Autopilot (e2e)', () => {
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: inspections.map((i) => i.id) } },
|
||||
where: {
|
||||
aggregateId: {
|
||||
in: [
|
||||
...inspections.map((i) => i.id),
|
||||
...seasonApiaryIds,
|
||||
...winterCheckIds,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.winterCheck.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.feeding.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.inspection.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
@@ -103,7 +121,9 @@ describe('Autopilot (e2e)', () => {
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||
await prisma.apiary.deleteMany({
|
||||
where: { id: { in: [apiaryId, ...seasonApiaryIds] } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
@@ -406,4 +426,329 @@ describe('Autopilot (e2e)', () => {
|
||||
expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1);
|
||||
expect(hidden.body).toEqual([]);
|
||||
});
|
||||
|
||||
/** The stored season of the e2e apiary in 2001: KIRSCHE by report. */
|
||||
async function reportKirsche(forApiaryId = apiaryId): Promise<void> {
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId: forApiaryId,
|
||||
year: 2001,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
reportedPhase: 'KIRSCHE',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: d('2001-04-20'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dropSeason = () =>
|
||||
prisma.apiaryPhenology.deleteMany({ where: { apiaryId, year: 2001 } });
|
||||
|
||||
it('swarm season by report, last inspection 9 days ago -> step 1 INSPECT overdue', async () => {
|
||||
await reportKirsche();
|
||||
try {
|
||||
const colonyId = await colonyWithLateInspection('report');
|
||||
|
||||
await sync(colonyId);
|
||||
|
||||
const view = await app
|
||||
.get(NextStepsService)
|
||||
.forColony(colonyId, userId, TODAY);
|
||||
expect(view).toMatchObject({
|
||||
segment: 'SCHWARMZEIT',
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'REPORT',
|
||||
});
|
||||
expect(view.steps[0]).toMatchObject({
|
||||
intent: 'INSPECT',
|
||||
urgency: 'OVERDUE',
|
||||
dueOn: '2001-05-10',
|
||||
});
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('A7: KIRSCHE and 9 occupied frames -> a HONEY_SUPER suggestion; 7 frames -> none', async () => {
|
||||
await reportKirsche();
|
||||
try {
|
||||
const strong = await colonyWithLateInspection('strong');
|
||||
const weak = await colonyWithLateInspection('weakframes');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId: strong },
|
||||
data: { colonyStrength: 9 },
|
||||
});
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId: weak },
|
||||
data: { colonyStrength: 7 },
|
||||
});
|
||||
|
||||
await sync(strong);
|
||||
await sync(weak);
|
||||
|
||||
const [superSuggestion] = await suggestionsOf(strong, 'HONEY_SUPER');
|
||||
expect(superSuggestion).toMatchObject({
|
||||
status: 'OPEN',
|
||||
mode: 'DO',
|
||||
reasonCode: 'HONEY_SUPER_CHECK',
|
||||
requiredPermission: 'inspections:read',
|
||||
dedupeKey: `SEASON_AUTOPILOT:${strong}:2001`,
|
||||
});
|
||||
expect(await suggestionsOf(weak, 'HONEY_SUPER')).toHaveLength(0);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('never more than 3 steps per colony', async () => {
|
||||
await reportKirsche();
|
||||
try {
|
||||
const colonyId = await colonyWithLateInspection('fourway');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId },
|
||||
data: { colonyStrength: 9, feedStatus: 'WEAK' },
|
||||
});
|
||||
await prisma.varroaMeasurement.updateMany({
|
||||
where: { colonyId },
|
||||
data: { date: d('2001-04-01') },
|
||||
});
|
||||
|
||||
await sync(colonyId);
|
||||
|
||||
const open = await prisma.suggestion.findMany({
|
||||
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
|
||||
select: { intent: true },
|
||||
});
|
||||
expect(open.map((s) => s.intent).sort()).toEqual([
|
||||
'FEED',
|
||||
'HONEY_SUPER',
|
||||
'INSPECT',
|
||||
'VARROA_MEASURE',
|
||||
]);
|
||||
const view = await app
|
||||
.get(NextStepsService)
|
||||
.forColony(colonyId, userId, TODAY);
|
||||
expect(view.steps).toHaveLength(3);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('a SeasonPhaseChanged syncs every living colony of the apiary', async () => {
|
||||
const year = Number(berlinToday().slice(0, 4));
|
||||
const own = await prisma.apiary.create({
|
||||
data: { name: `Autopilot e2e season ${suffix}` },
|
||||
});
|
||||
const other = await prisma.apiary.create({
|
||||
data: { name: `Autopilot e2e other ${suffix}` },
|
||||
});
|
||||
seasonApiaryIds.push(own.id, other.id);
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId: own.id,
|
||||
year,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d(`${year}-01-01`),
|
||||
},
|
||||
});
|
||||
const colonyIn = async (name: string, apiary: string, status = 'ACTIVE') =>
|
||||
(
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `AP-E2E-${suffix}-${name}`,
|
||||
apiaryId: apiary,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
status: status as 'ACTIVE',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
const first = await colonyIn('season-1', own.id);
|
||||
const second = await colonyIn('season-2', own.id);
|
||||
const ended = await colonyIn('season-ended', own.id, 'DECEASED');
|
||||
const elsewhere = await colonyIn('season-other', other.id);
|
||||
colonyIds.push(first, second, ended, elsewhere);
|
||||
await prisma.$transaction((tx) =>
|
||||
app.get(DomainEventsService).record(tx, {
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: own.id,
|
||||
payload: {
|
||||
apiaryId: own.id,
|
||||
phase: 'HERBST',
|
||||
segment: 'HERBST',
|
||||
previousPhase: 'TRACHTENDE',
|
||||
previousSegment: 'SPAETSOMMER',
|
||||
source: 'REPORT',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await drainDomainEvents(app, [own.id]);
|
||||
|
||||
for (const colonyId of [first, second]) {
|
||||
const [winter] = await suggestionsOf(colonyId, 'WINTER_CHECK');
|
||||
expect(winter).toMatchObject({
|
||||
status: 'OPEN',
|
||||
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}-AUTUMN`,
|
||||
});
|
||||
}
|
||||
expect(await suggestionsOf(ended, 'WINTER_CHECK')).toHaveLength(0);
|
||||
expect(await suggestionsOf(elsewhere, 'WINTER_CHECK')).toHaveLength(0);
|
||||
for (const colonyId of [first, second]) {
|
||||
const [feed] = await suggestionsOf(colonyId, 'FEED');
|
||||
expect(feed).toMatchObject({
|
||||
status: 'OPEN',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}`,
|
||||
});
|
||||
}
|
||||
|
||||
// The wintering-in entry resolves A9 right after it is recorded.
|
||||
const winterCheck = await prisma.winterCheck.create({
|
||||
data: {
|
||||
colonyId: first,
|
||||
userId,
|
||||
winterYear: year,
|
||||
season: 'AUTUMN',
|
||||
date: new Date(),
|
||||
},
|
||||
});
|
||||
winterCheckIds.push(winterCheck.id);
|
||||
await prisma.$transaction((tx) =>
|
||||
app.get(DomainEventsService).record(tx, {
|
||||
type: 'WinterCheckRecorded',
|
||||
aggregateType: 'WinterCheck',
|
||||
aggregateId: winterCheck.id,
|
||||
payload: { colonyId: first },
|
||||
}),
|
||||
);
|
||||
await drainDomainEvents(app, [winterCheck.id]);
|
||||
|
||||
const [resolved] = await suggestionsOf(first, 'WINTER_CHECK');
|
||||
expect(resolved.status).toBe('RESOLVED');
|
||||
const [stillOpen] = await suggestionsOf(second, 'WINTER_CHECK');
|
||||
expect(stillOpen.status).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('an apiary-wide sync and single-colony syncs running together finish without a deadlock', async () => {
|
||||
const year = Number(berlinToday().slice(0, 4));
|
||||
const crowded = await prisma.apiary.create({
|
||||
data: { name: `Autopilot e2e crowd ${suffix}` },
|
||||
});
|
||||
seasonApiaryIds.push(crowded.id);
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId: crowded.id,
|
||||
year,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d(`${year}-01-01`),
|
||||
},
|
||||
});
|
||||
const ids: string[] = [];
|
||||
for (let n = 0; n < 5; n += 1) {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `AP-E2E-${suffix}-crowd-${n}`,
|
||||
apiaryId: crowded.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
ids.push(colony.id);
|
||||
colonyIds.push(colony.id);
|
||||
}
|
||||
const today = berlinToday();
|
||||
|
||||
await Promise.all([
|
||||
prisma.$transaction((tx) =>
|
||||
autopilot.syncApiaryIn(tx, crowded.id, today),
|
||||
),
|
||||
...[...ids]
|
||||
.reverse()
|
||||
.map((id) =>
|
||||
prisma.$transaction((tx) => autopilot.syncColonyIn(tx, id, today)),
|
||||
),
|
||||
prisma.$transaction((tx) =>
|
||||
autopilot.syncApiaryIn(tx, crowded.id, today),
|
||||
),
|
||||
]);
|
||||
|
||||
for (const id of ids) {
|
||||
expect(await suggestionsOf(id, 'WINTER_CHECK')).toHaveLength(1);
|
||||
expect(await suggestionsOf(id, 'FEED')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('HERBST approximation: feedings count from the calendar day of TRACHTENDE (08-01), not from the real start of SPAETSOMMER', async () => {
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
year: 2001,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d('2001-09-15'),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const feedAt = async (name: string, day: string) => {
|
||||
const colonyId = await colonyWithLateInspection(name);
|
||||
await prisma.feeding.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId,
|
||||
date: d(day),
|
||||
feedType: 'SUGAR_WATER',
|
||||
amount: 5,
|
||||
unit: 'L',
|
||||
},
|
||||
});
|
||||
await sync(colonyId, '2001-09-20');
|
||||
return colonyId;
|
||||
};
|
||||
const before = await feedAt('fed-before', '2001-07-30');
|
||||
const after = await feedAt('fed-after', '2001-08-05');
|
||||
|
||||
expect(await suggestionsOf(before, 'FEED')).toHaveLength(1);
|
||||
expect(await suggestionsOf(after, 'FEED')).toHaveLength(0);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('A5 open and an A6 candidate present -> still one FEED suggestion', async () => {
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
year: 2001,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d('2001-09-15'),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const colonyId = await colonyWithLateInspection('feedboth');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId },
|
||||
data: { feedStatus: 'WEAK' },
|
||||
});
|
||||
|
||||
await sync(colonyId, '2001-09-20');
|
||||
await sync(colonyId, '2001-09-21');
|
||||
|
||||
const open = await prisma.suggestion.findMany({
|
||||
where: { colonyId, intent: 'FEED', status: 'OPEN' },
|
||||
});
|
||||
expect(open).toHaveLength(1);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,13 @@ describe('Bottling (e2e)', () => {
|
||||
data: { extractionId, harvestId },
|
||||
});
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: { batchNumber: `HC-${Date.now()}`, extractionId, amount: 25 },
|
||||
data: {
|
||||
batchNumber: `HC-${Date.now()}`,
|
||||
extractionId,
|
||||
amount: 25,
|
||||
// The movement book keeps this batch: bookings need a start amount.
|
||||
initialAmount: 25,
|
||||
},
|
||||
});
|
||||
honeyBatchId = honeyBatch.id;
|
||||
});
|
||||
@@ -141,6 +147,7 @@ describe('Bottling (e2e)', () => {
|
||||
});
|
||||
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingBatchId } });
|
||||
}
|
||||
await prisma.batchMovement.deleteMany({ where: { honeyBatchId } });
|
||||
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
|
||||
await prisma.extractionHarvest.deleteMany({ where: { extractionId } });
|
||||
await prisma.extraction.deleteMany({ where: { id: extractionId } });
|
||||
@@ -198,6 +205,18 @@ describe('Bottling (e2e)', () => {
|
||||
const honeyBatch = honeyBatchResponse.body as HoneyBatchBody;
|
||||
expect(honeyBatch.amount).toBe(0);
|
||||
expect(honeyBatch.status).toBe('BOTTLED');
|
||||
// The booking in the movement book, in grams, with its bottling.
|
||||
const movements = await prisma.batchMovement.findMany({
|
||||
where: { honeyBatchId },
|
||||
});
|
||||
expect(movements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'BOTTLING',
|
||||
quantity: -25000,
|
||||
sourceBottlingBatchId: bottlingBatchId,
|
||||
userId: beekeeperId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('bottling create records BatchBottled and StockChanged together', async () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('Data integrity (e2e)', () => {
|
||||
batchNumber: `HB-${suffix}-${Math.round(Math.random() * 1e9)}`,
|
||||
extractionId: extraction.id,
|
||||
amount,
|
||||
initialAmount: amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -219,6 +220,10 @@ describe('Data integrity (e2e)', () => {
|
||||
where: { id: honeyBatch.id },
|
||||
});
|
||||
expect(after.amount).toBeCloseTo(4, 3);
|
||||
const bookings = await prisma.batchMovement.findMany({
|
||||
where: { honeyBatchId: honeyBatch.id },
|
||||
});
|
||||
expect(bookings.map((booking) => booking.quantity)).toEqual([-6000]);
|
||||
});
|
||||
|
||||
it('gives the amount back when a bottling is removed', async () => {
|
||||
|
||||
@@ -147,8 +147,11 @@ describe('Follow-up controls (e2e)', () => {
|
||||
|
||||
const colony = await newColony('OFF');
|
||||
await treat(colony.id);
|
||||
// Only the follow-up rules are meant; the autopilot proposes on its own.
|
||||
expect(
|
||||
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
|
||||
await prisma.suggestion.count({
|
||||
where: { colonyId: colony.id, source: 'RULE' },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
import { INVARIANT_SQL } from './utils/honey-ledger';
|
||||
|
||||
interface HoneyBatchBody {
|
||||
id: string;
|
||||
amount: number | null;
|
||||
initialAmount: number | null;
|
||||
status: string;
|
||||
warnings: { code: string }[];
|
||||
}
|
||||
|
||||
interface BottlingBody {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface MovementBody {
|
||||
id: string;
|
||||
type: string;
|
||||
quantityGrams: number;
|
||||
reason: string | null;
|
||||
notes: string | null;
|
||||
sourceBottlingBatchId: string | null;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The movement book of a honey batch (spec 4.6, 7.7): every change of the
|
||||
* rest is a booking, and amount = initialAmount + sum(quantity) / 1000.
|
||||
* Asserts only on this file's own batches - other files share the database.
|
||||
*/
|
||||
describe('Honey batch movement book (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
const adminEmail = `honey-ledger-admin-${suffix}@example.com`;
|
||||
const staffEmail = `honey-ledger-staff-${suffix}@example.com`;
|
||||
let adminId: string;
|
||||
let staffId: string;
|
||||
let adminToken: string;
|
||||
let staffToken: string;
|
||||
let extractionId: string;
|
||||
const honeyBatchIds: string[] = [];
|
||||
const bottlingIds: string[] = [];
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const admin = () => ({ Authorization: `Bearer ${adminToken}` });
|
||||
|
||||
async function createBatch(amount?: number): Promise<HoneyBatchBody> {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/honey-batches')
|
||||
.set(admin())
|
||||
.send({
|
||||
batchNumber: `HL-${suffix}-${honeyBatchIds.length}`,
|
||||
extractionId,
|
||||
...(amount === undefined ? {} : { amount }),
|
||||
})
|
||||
.expect(201);
|
||||
const body = response.body as HoneyBatchBody;
|
||||
honeyBatchIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
function bottle(honeyBatchId: string, jarCount: number) {
|
||||
return request(server())
|
||||
.post('/api/v1/bottling-batches')
|
||||
.set(admin())
|
||||
.send({
|
||||
honeyBatchId,
|
||||
date: '2026-08-01T00:00:00.000Z',
|
||||
bestBeforeDate: '2028-08-01T00:00:00.000Z',
|
||||
jarSize: 500,
|
||||
jarCount,
|
||||
lotNumber: `HL-L-${suffix}-${Math.round(Math.random() * 1e9)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function bottleOk(honeyBatchId: string, jarCount: number) {
|
||||
const response = await bottle(honeyBatchId, jarCount).expect(201);
|
||||
const body = response.body as BottlingBody;
|
||||
bottlingIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function stored(id: string) {
|
||||
return prisma.honeyBatch.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
||||
async function brokenBatchIds(): Promise<string[]> {
|
||||
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(INVARIANT_SQL);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async function movementsOf(id: string): Promise<MovementBody[]> {
|
||||
const response = await request(server())
|
||||
.get(`/api/v1/honey-batches/${id}/movements`)
|
||||
.set(admin())
|
||||
.expect(200);
|
||||
return (response.body as { items: MovementBody[]; hasMore: boolean }).items;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
adminId = (
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: adminEmail,
|
||||
passwordHash,
|
||||
name: 'Ledger Admin',
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
})
|
||||
).id;
|
||||
staffId = (
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: staffEmail,
|
||||
passwordHash,
|
||||
name: 'Ledger Staff',
|
||||
role: { connect: { key: 'STAFF' } },
|
||||
},
|
||||
})
|
||||
).id;
|
||||
adminToken = (await login(app, adminEmail, password)).accessToken;
|
||||
staffToken = (await login(app, staffEmail, password)).accessToken;
|
||||
extractionId = (
|
||||
await prisma.extraction.create({
|
||||
data: { date: new Date(), userId: adminId },
|
||||
})
|
||||
).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.batchMovement.deleteMany({
|
||||
where: { honeyBatchId: { in: honeyBatchIds } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
aggregateId: { in: [...honeyBatchIds, ...bottlingIds] },
|
||||
},
|
||||
});
|
||||
await prisma.bottlingBatch.deleteMany({
|
||||
where: { id: { in: bottlingIds } },
|
||||
});
|
||||
await prisma.honeyBatch.deleteMany({
|
||||
where: { id: { in: honeyBatchIds } },
|
||||
});
|
||||
await prisma.extraction.deleteMany({ where: { id: extractionId } });
|
||||
const userIds = [adminId, staffId].filter(Boolean);
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('keeps the invariant after create, bottle, correct and remove', async () => {
|
||||
const batch = await createBatch(10);
|
||||
expect(batch.amount).toBe(10);
|
||||
const row = await stored(batch.id);
|
||||
expect(row.initialAmount).toBe(10);
|
||||
// Creating with an amount books nothing.
|
||||
expect(await movementsOf(batch.id)).toEqual([]);
|
||||
|
||||
const bottling = await bottleOk(batch.id, 8);
|
||||
expect((await stored(batch.id)).amount).toBe(6);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/honey-batches/${batch.id}`)
|
||||
.set(admin())
|
||||
.send({ amount: 5 })
|
||||
.expect(200);
|
||||
expect((await stored(batch.id)).amount).toBe(5);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/bottling-batches/${bottling.id}`)
|
||||
.set(admin())
|
||||
.send({ jarCount: 4 })
|
||||
.expect(200);
|
||||
expect((await stored(batch.id)).amount).toBe(7);
|
||||
|
||||
await request(server())
|
||||
.delete(`/api/v1/bottling-batches/${bottling.id}`)
|
||||
.set(admin())
|
||||
.expect(200);
|
||||
const after = await stored(batch.id);
|
||||
expect(after.amount).toBe(9);
|
||||
expect(after.initialAmount).toBe(10);
|
||||
expect(await brokenBatchIds()).not.toContain(batch.id);
|
||||
|
||||
const book = await movementsOf(batch.id);
|
||||
expect(
|
||||
book.map((movement) => [movement.type, movement.quantityGrams]).reverse(),
|
||||
).toEqual([
|
||||
['BOTTLING', -4000],
|
||||
['CORRECTION', -1000],
|
||||
['BOTTLING', 2000],
|
||||
['BOTTLING', 2000],
|
||||
]);
|
||||
expect(book[0]).toMatchObject({ notes: 'Abfüllung entfernt' });
|
||||
});
|
||||
|
||||
it('two parallel bottlings over the rest: exactly one 400', async () => {
|
||||
const batch = await createBatch(10);
|
||||
|
||||
// 12 jars of 500 g = 6 kg each, 10 kg available.
|
||||
const results = await Promise.all([
|
||||
bottle(batch.id, 12),
|
||||
bottle(batch.id, 12),
|
||||
]);
|
||||
|
||||
const statuses = results.map((result) => result.status).sort();
|
||||
expect(statuses).toEqual([201, 400]);
|
||||
for (const result of results) {
|
||||
if (result.status === 201) {
|
||||
bottlingIds.push((result.body as BottlingBody).id);
|
||||
}
|
||||
}
|
||||
expect((await stored(batch.id)).amount).toBe(4);
|
||||
expect(await movementsOf(batch.id)).toHaveLength(1);
|
||||
expect(await brokenBatchIds()).not.toContain(batch.id);
|
||||
});
|
||||
|
||||
it('PATCH amount null with movements answers 400', async () => {
|
||||
const batch = await createBatch(10);
|
||||
await bottleOk(batch.id, 2);
|
||||
|
||||
const response = await request(server())
|
||||
.patch(`/api/v1/honey-batches/${batch.id}`)
|
||||
.set(admin())
|
||||
.send({ amount: null })
|
||||
.expect(400);
|
||||
|
||||
expect((response.body as ErrorBody).message).toBe(
|
||||
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
|
||||
);
|
||||
expect((await stored(batch.id)).amount).toBe(9);
|
||||
});
|
||||
|
||||
it('PATCH amount null without movements takes the amount away again', async () => {
|
||||
const batch = await createBatch(10);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/honey-batches/${batch.id}`)
|
||||
.set(admin())
|
||||
.send({ amount: null })
|
||||
.expect(200);
|
||||
|
||||
const row = await stored(batch.id);
|
||||
expect(row.amount).toBeNull();
|
||||
expect(row.initialAmount).toBeNull();
|
||||
});
|
||||
|
||||
it('a batch without an amount gets one by PATCH without a booking', async () => {
|
||||
const batch = await createBatch();
|
||||
expect(batch.amount).toBeNull();
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/honey-batches/${batch.id}`)
|
||||
.set(admin())
|
||||
.send({ amount: 7 })
|
||||
.expect(200);
|
||||
|
||||
const row = await stored(batch.id);
|
||||
expect(row.amount).toBe(7);
|
||||
expect(row.initialAmount).toBe(7);
|
||||
expect(await movementsOf(batch.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a batch without an amount is bottled without a booking', async () => {
|
||||
const batch = await createBatch();
|
||||
|
||||
await bottleOk(batch.id, 2);
|
||||
|
||||
const row = await stored(batch.id);
|
||||
expect(row.amount).toBeNull();
|
||||
expect(row.status).toBe('BOTTLED');
|
||||
expect(await movementsOf(batch.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a bottling made before the amount was recorded never changes the ledger', async () => {
|
||||
const batch = await createBatch();
|
||||
const untracked = await bottleOk(batch.id, 4);
|
||||
await request(server())
|
||||
.patch(`/api/v1/honey-batches/${batch.id}`)
|
||||
.set(admin())
|
||||
.send({ amount: 10 })
|
||||
.expect(200);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/bottling-batches/${untracked.id}`)
|
||||
.set(admin())
|
||||
.send({ jarCount: 2 })
|
||||
.expect(200);
|
||||
expect((await stored(batch.id)).amount).toBe(10);
|
||||
|
||||
await request(server())
|
||||
.delete(`/api/v1/bottling-batches/${untracked.id}`)
|
||||
.set(admin())
|
||||
.expect(200);
|
||||
expect((await stored(batch.id)).amount).toBe(10);
|
||||
expect(await movementsOf(batch.id)).toEqual([]);
|
||||
expect(await brokenBatchIds()).not.toContain(batch.id);
|
||||
});
|
||||
|
||||
it('rounds a PATCH amount to the gram and a correction may carry a reason', async () => {
|
||||
const batch = await createBatch(10);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/honey-batches/${batch.id}`)
|
||||
.set(admin())
|
||||
.send({ amount: 12.3456 })
|
||||
.expect(200);
|
||||
expect((await stored(batch.id)).amount).toBe(12.346);
|
||||
|
||||
const response = await request(server())
|
||||
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
||||
.set(admin())
|
||||
.send({ type: 'CORRECTION', deltaKg: -0.346, reason: 'SONSTIGES' })
|
||||
.expect(201);
|
||||
expect(response.body).toMatchObject({ reason: 'SONSTIGES' });
|
||||
expect((await stored(batch.id)).amount).toBe(12);
|
||||
expect(await brokenBatchIds()).not.toContain(batch.id);
|
||||
});
|
||||
|
||||
it('GET movements lists BOTTLING with its source bottling', async () => {
|
||||
const batch = await createBatch(10);
|
||||
const bottling = await bottleOk(batch.id, 2);
|
||||
|
||||
const book = await movementsOf(batch.id);
|
||||
|
||||
expect(book).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'BOTTLING',
|
||||
quantityGrams: -1000,
|
||||
sourceBottlingBatchId: bottling.id,
|
||||
userId: adminId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('GET movements needs the read permission and a known batch', async () => {
|
||||
await request(server())
|
||||
.get('/api/v1/honey-batches/unknown/movements')
|
||||
.set(admin())
|
||||
.expect(404);
|
||||
await request(server())
|
||||
.get('/api/v1/honey-batches/unknown/movements')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
describe('POST movements', () => {
|
||||
it('books a withdrawal with its reason and a correction', async () => {
|
||||
const batch = await createBatch(10);
|
||||
|
||||
const withdrawal = await request(server())
|
||||
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
||||
.set(admin())
|
||||
.send({
|
||||
type: 'WITHDRAWAL',
|
||||
deltaKg: -1.5,
|
||||
reason: 'EIGENBEDARF',
|
||||
notes: 'Für die Familie',
|
||||
})
|
||||
.expect(201);
|
||||
expect(withdrawal.body).toMatchObject({
|
||||
type: 'WITHDRAWAL',
|
||||
quantityGrams: -1500,
|
||||
reason: 'EIGENBEDARF',
|
||||
notes: 'Für die Familie',
|
||||
userId: adminId,
|
||||
});
|
||||
expect((await stored(batch.id)).amount).toBe(8.5);
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
||||
.set(admin())
|
||||
.send({ type: 'CORRECTION', deltaKg: 0.25, notes: 'Nachgewogen' })
|
||||
.expect(201);
|
||||
expect((await stored(batch.id)).amount).toBe(8.75);
|
||||
|
||||
expect(await brokenBatchIds()).not.toContain(batch.id);
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: batch.id,
|
||||
userId: adminId,
|
||||
},
|
||||
});
|
||||
expect(audit.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('refuses what would take the rest below 0, with the German message', async () => {
|
||||
const batch = await createBatch(1);
|
||||
|
||||
const response = await request(server())
|
||||
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
||||
.set(admin())
|
||||
.send({ type: 'WITHDRAWAL', deltaKg: -2, reason: 'BRUCH' })
|
||||
.expect(400);
|
||||
|
||||
expect((response.body as ErrorBody).message).toBe(
|
||||
'Die Menge kann nicht unter 0 sinken.',
|
||||
);
|
||||
expect((await stored(batch.id)).amount).toBe(1);
|
||||
expect(await movementsOf(batch.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('validates the booking', async () => {
|
||||
const batch = await createBatch(5);
|
||||
const post = (body: object) =>
|
||||
request(server())
|
||||
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
||||
.set(admin())
|
||||
.send(body);
|
||||
|
||||
await post({ type: 'WITHDRAWAL', deltaKg: -1 }).expect(400);
|
||||
await post({ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' }).expect(
|
||||
400,
|
||||
);
|
||||
await post({ type: 'CORRECTION', deltaKg: 0 }).expect(400);
|
||||
await post({ type: 'CORRECTION', deltaKg: 100001 }).expect(400);
|
||||
await post({ type: 'BOTTLING', deltaKg: -1 }).expect(400);
|
||||
await post({
|
||||
type: 'CORRECTION',
|
||||
deltaKg: 1,
|
||||
notes: 'x'.repeat(501),
|
||||
}).expect(400);
|
||||
expect(await movementsOf(batch.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a batch whose amount is not recorded', async () => {
|
||||
const batch = await createBatch();
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
||||
.set(admin())
|
||||
.send({ type: 'CORRECTION', deltaKg: 1 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('needs the update permission', async () => {
|
||||
const batch = await createBatch(5);
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
||||
.set({ Authorization: `Bearer ${staffToken}` })
|
||||
.send({ type: 'CORRECTION', deltaKg: 1 })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PhenologyStateService } from '../src/phenology/phenology-state.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { login } from './utils/api';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
|
||||
interface ReportBody {
|
||||
id: string;
|
||||
apiaryId: string | null;
|
||||
phase: string;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
phase: string | null;
|
||||
segment: string;
|
||||
phaseSource: string | null;
|
||||
reportedOn: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clock stands on a fixed day of 2002 (only Date is faked): before the
|
||||
* calendar SALWEIDE of that year, so a report decides the phase and the
|
||||
* segment changes. No other file works in 2002.
|
||||
*/
|
||||
const NOW = new Date('2002-03-10T10:00:00.000Z');
|
||||
const TODAY = '2002-03-10';
|
||||
|
||||
describe('Phenology reports (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
const apiaryIds: string[] = [];
|
||||
const userIds: string[] = [];
|
||||
const roleIds: string[] = [];
|
||||
const reportIds: string[] = [];
|
||||
let beekeeperToken: string;
|
||||
let noRightsToken: string;
|
||||
let keyCounter = 0;
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const auth = (token = beekeeperToken) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
});
|
||||
const nextKey = () => `phenology-${suffix}-${(keyCounter += 1)}-key-0001`;
|
||||
|
||||
async function createUser(
|
||||
label: string,
|
||||
role: { key: string } | { id: string },
|
||||
): Promise<{ id: string; email: string }> {
|
||||
const email = `phenology-e2e-${label}-${suffix}@example.com`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 4),
|
||||
name: `Phenology ${label}`,
|
||||
role: { connect: role },
|
||||
},
|
||||
});
|
||||
userIds.push(user.id);
|
||||
return { id: user.id, email };
|
||||
}
|
||||
|
||||
async function createApiary(label: string): Promise<string> {
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: {
|
||||
name: `Phenology ${label} ${suffix}`,
|
||||
latitude: 48.1,
|
||||
longitude: 9.2,
|
||||
},
|
||||
});
|
||||
apiaryIds.push(apiary.id);
|
||||
return apiary.id;
|
||||
}
|
||||
|
||||
async function report(body: Record<string, unknown>): Promise<ReportBody> {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/phenology/reports')
|
||||
.set(auth())
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send(body)
|
||||
.expect(201);
|
||||
const created = response.body as ReportBody;
|
||||
reportIds.push(created.id);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function drain(reportId: string): Promise<void> {
|
||||
await drainDomainEvents(app, [reportId]);
|
||||
}
|
||||
|
||||
const correlationsOf = async (aggregateIds: string[]): Promise<string[]> =>
|
||||
(
|
||||
await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: { in: aggregateIds } },
|
||||
select: { correlationId: true },
|
||||
distinct: ['correlationId'],
|
||||
})
|
||||
).map((event) => event.correlationId);
|
||||
|
||||
/**
|
||||
* A report for all apiaries fans out to every active apiary of the shared
|
||||
* test database. Delivers the report event (which records the follow-ups),
|
||||
* removes the follow-ups of foreign apiaries (they belong to this
|
||||
* correlation, i.e. to this test) and delivers the rest.
|
||||
*/
|
||||
async function deliverKeepingOnly(
|
||||
reportId: string,
|
||||
keep: string[],
|
||||
): Promise<number> {
|
||||
const correlationIds = await correlationsOf([reportId]);
|
||||
await app.get(DomainEventDispatcher).runOnce({ correlationIds });
|
||||
const requests = await prisma.domainEvent.count({
|
||||
where: {
|
||||
type: 'PhenologyRecomputeRequested',
|
||||
correlationId: { in: correlationIds },
|
||||
},
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
type: 'PhenologyRecomputeRequested',
|
||||
correlationId: { in: correlationIds },
|
||||
aggregateId: { notIn: keep },
|
||||
},
|
||||
});
|
||||
await drain(reportId);
|
||||
return requests;
|
||||
}
|
||||
|
||||
const rowOf = (apiaryId: string) =>
|
||||
prisma.apiaryPhenology.findUniqueOrThrow({
|
||||
where: { apiaryId_year: { apiaryId, year: 2002 } },
|
||||
});
|
||||
|
||||
const changesOf = (apiaryId: string) =>
|
||||
prisma.domainEvent.findMany({
|
||||
where: { type: 'SeasonPhaseChanged', aggregateId: apiaryId },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const beekeeper = await createUser('beekeeper', { key: 'BEEKEEPER' });
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `PHENOLOGY_NONE_${suffix}`,
|
||||
name: 'Ohne Pflanzenentwicklung',
|
||||
permissions: {
|
||||
create: [{ permission: { connect: { key: 'apiaries:read' } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
roleIds.push(role.id);
|
||||
const noRights = await createUser('norights', { id: role.id });
|
||||
beekeeperToken = (await login(app, beekeeper.email, password)).accessToken;
|
||||
noRightsToken = (await login(app, noRights.email, password)).accessToken;
|
||||
jest.useFakeTimers({
|
||||
now: NOW,
|
||||
doNotFake: [
|
||||
'hrtime',
|
||||
'nextTick',
|
||||
'performance',
|
||||
'queueMicrotask',
|
||||
'requestAnimationFrame',
|
||||
'cancelAnimationFrame',
|
||||
'requestIdleCallback',
|
||||
'cancelIdleCallback',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
'setInterval',
|
||||
'clearInterval',
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
// Only the events of this file's own reports and apiaries (and their
|
||||
// follow-ups, same correlation).
|
||||
const correlationIds = await correlationsOf([...reportIds, ...apiaryIds]);
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ correlationId: { in: correlationIds } },
|
||||
{ aggregateId: { in: [...reportIds, ...apiaryIds] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.notification.deleteMany({
|
||||
where: { relatedType: 'Apiary', relatedId: { in: apiaryIds } },
|
||||
});
|
||||
await prisma.phenologyReport.deleteMany({
|
||||
where: { id: { in: reportIds } },
|
||||
});
|
||||
// The state rows of our apiaries go with them (ON DELETE CASCADE).
|
||||
await prisma.apiary.deleteMany({ where: { id: { in: apiaryIds } } });
|
||||
await prisma.idempotencyRecord.deleteMany({
|
||||
where: { key: { startsWith: `phenology-${suffix}` } },
|
||||
});
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('a report for all apiaries sets SALWEIDE on every apiary and records SeasonPhaseChanged once per apiary', async () => {
|
||||
const first = await createApiary('all-1');
|
||||
const second = await createApiary('all-2');
|
||||
|
||||
const created = await report({ phase: 'SALWEIDE', date: TODAY });
|
||||
try {
|
||||
const requests = await deliverKeepingOnly(created.id, [first, second]);
|
||||
// One follow-up per active apiary of the database, at least ours.
|
||||
expect(requests).toBeGreaterThanOrEqual(2);
|
||||
await assertAllApiaries([first, second]);
|
||||
} finally {
|
||||
// The report would otherwise count for the apiaries of the other tests.
|
||||
await prisma.phenologyReport.delete({ where: { id: created.id } });
|
||||
}
|
||||
});
|
||||
|
||||
async function assertAllApiaries(apiaryIds: string[]): Promise<void> {
|
||||
const [first] = apiaryIds;
|
||||
for (const apiaryId of apiaryIds) {
|
||||
const row = await rowOf(apiaryId);
|
||||
expect(row).toMatchObject({
|
||||
reportedPhase: 'SALWEIDE',
|
||||
effectivePhase: 'SALWEIDE',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'FRUEHJAHR',
|
||||
});
|
||||
expect(await changesOf(apiaryId)).toHaveLength(1);
|
||||
// The same state again changes nothing and records nothing.
|
||||
await prisma.$transaction((tx) =>
|
||||
app.get(PhenologyStateService).recomputeIn(tx, apiaryId, TODAY),
|
||||
);
|
||||
expect(await changesOf(apiaryId)).toHaveLength(1);
|
||||
}
|
||||
const states = (
|
||||
await request(server())
|
||||
.get('/api/v1/phenology/state')
|
||||
.set(auth())
|
||||
.expect(200)
|
||||
).body as StateBody[];
|
||||
expect(states.find((state) => state.apiaryId === first)).toMatchObject({
|
||||
phase: 'SALWEIDE',
|
||||
segment: 'FRUEHJAHR',
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: TODAY,
|
||||
});
|
||||
}
|
||||
|
||||
it('revoking recomputes and the phase can go back', async () => {
|
||||
const apiaryId = await createApiary('revoke');
|
||||
const created = await report({
|
||||
phase: 'SALWEIDE',
|
||||
date: TODAY,
|
||||
apiaryId,
|
||||
});
|
||||
await drain(created.id);
|
||||
expect((await rowOf(apiaryId)).effectivePhase).toBe('SALWEIDE');
|
||||
|
||||
const revoked = await request(server())
|
||||
.post(`/api/v1/phenology/reports/${created.id}/revoke`)
|
||||
.set(auth())
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.expect(201);
|
||||
expect((revoked.body as ReportBody).revokedAt).not.toBeNull();
|
||||
await drain(created.id);
|
||||
|
||||
const row = await rowOf(apiaryId);
|
||||
expect(row.effectivePhase).toBeNull();
|
||||
expect(row.reportedPhase).toBeNull();
|
||||
expect(row.segment).toBe('WINTERRUHE');
|
||||
await request(server())
|
||||
.post(`/api/v1/phenology/reports/${created.id}/revoke`)
|
||||
.set(auth())
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.expect(409);
|
||||
const listed = (
|
||||
await request(server())
|
||||
.get('/api/v1/phenology/reports?year=2002')
|
||||
.set(auth())
|
||||
.expect(200)
|
||||
).body as ReportBody[];
|
||||
expect(
|
||||
listed.find((entry) => entry.id === created.id)?.revokedAt,
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('a correction recomputes: the phase of the report changes', async () => {
|
||||
const apiaryId = await createApiary('correct');
|
||||
const created = await report({ phase: 'SALWEIDE', date: TODAY, apiaryId });
|
||||
await drain(created.id);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/phenology/reports/${created.id}`)
|
||||
.set(auth())
|
||||
.send({ phase: 'KIRSCHE', notes: 'früh' })
|
||||
.expect(200);
|
||||
await drain(created.id);
|
||||
|
||||
expect(await rowOf(apiaryId)).toMatchObject({
|
||||
reportedPhase: 'KIRSCHE',
|
||||
effectivePhase: 'KIRSCHE',
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { entityType: 'PhenologyReport', entityId: created.id },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
});
|
||||
// Both entries carry the faked clock, so their order is not defined.
|
||||
expect(audit.map((entry) => entry.action).sort()).toEqual([
|
||||
'CREATE',
|
||||
'UPDATE',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a retry with the same key yields one report', async () => {
|
||||
const apiaryId = await createApiary('retry');
|
||||
const key = nextKey();
|
||||
const body = { phase: 'SALWEIDE', date: TODAY, apiaryId };
|
||||
|
||||
const firstResponse = await request(server())
|
||||
.post('/api/v1/phenology/reports')
|
||||
.set(auth())
|
||||
.set('Idempotency-Key', key)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
const secondResponse = await request(server())
|
||||
.post('/api/v1/phenology/reports')
|
||||
.set(auth())
|
||||
.set('Idempotency-Key', key)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const id = (firstResponse.body as ReportBody).id;
|
||||
reportIds.push(id);
|
||||
expect((secondResponse.body as ReportBody).id).toBe(id);
|
||||
expect(await prisma.phenologyReport.count({ where: { apiaryId } })).toBe(1);
|
||||
expect(
|
||||
await prisma.domainEvent.count({
|
||||
where: { type: 'PhenologyReported', aggregateId: id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('a date in the future answers 400 "Das Datum liegt in der Zukunft."', async () => {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/phenology/reports')
|
||||
.set(auth())
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ phase: 'SALWEIDE', date: '2002-03-11' })
|
||||
.expect(400);
|
||||
|
||||
expect((response.body as { message: string }).message).toBe(
|
||||
'Das Datum liegt in der Zukunft.',
|
||||
);
|
||||
});
|
||||
|
||||
it('403 without phenology:create; /phenology/state never contains coordinates', async () => {
|
||||
await request(server())
|
||||
.post('/api/v1/phenology/reports')
|
||||
.set(auth(noRightsToken))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ phase: 'SALWEIDE', date: TODAY })
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.get('/api/v1/phenology/reports')
|
||||
.set(auth(noRightsToken))
|
||||
.expect(403);
|
||||
await createApiary('state');
|
||||
|
||||
const response = await request(server())
|
||||
.get('/api/v1/phenology/state')
|
||||
.set(auth(noRightsToken))
|
||||
.expect(200);
|
||||
|
||||
expect(JSON.stringify(response.body)).not.toMatch(/latitude|longitude/i);
|
||||
expect((response.body as StateBody[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a segment change sends no single notification (the morning digest carries it)', async () => {
|
||||
const apiaryId = await createApiary('quiet');
|
||||
const created = await report({ phase: 'KIRSCHE', date: TODAY, apiaryId });
|
||||
await drain(created.id);
|
||||
|
||||
expect((await rowOf(apiaryId)).segment).toBe('SCHWARMZEIT');
|
||||
expect(await changesOf(apiaryId)).toHaveLength(1);
|
||||
expect(
|
||||
await prisma.notification.count({
|
||||
where: { relatedType: 'Apiary', relatedId: apiaryId },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('a report never moves the phase back', async () => {
|
||||
const apiaryId = await createApiary('forward');
|
||||
const high = await report({ phase: 'KIRSCHE', date: TODAY, apiaryId });
|
||||
await drain(high.id);
|
||||
|
||||
const low = await report({ phase: 'SALWEIDE', date: TODAY, apiaryId });
|
||||
await drain(low.id);
|
||||
|
||||
expect(await rowOf(apiaryId)).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
});
|
||||
|
||||
it('moving a report to another apiary recomputes the old one and the new one', async () => {
|
||||
const oldApiary = await createApiary('move-old');
|
||||
const newApiary = await createApiary('move-new');
|
||||
const created = await report({
|
||||
phase: 'SALWEIDE',
|
||||
date: TODAY,
|
||||
apiaryId: oldApiary,
|
||||
});
|
||||
await drain(created.id);
|
||||
expect((await rowOf(oldApiary)).effectivePhase).toBe('SALWEIDE');
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/phenology/reports/${created.id}`)
|
||||
.set(auth())
|
||||
.send({ apiaryId: newApiary })
|
||||
.expect(200);
|
||||
await drain(created.id);
|
||||
|
||||
expect((await rowOf(oldApiary)).effectivePhase).toBeNull();
|
||||
expect((await rowOf(newApiary)).effectivePhase).toBe('SALWEIDE');
|
||||
});
|
||||
|
||||
it('a patch without a change records neither an audit entry nor an event', async () => {
|
||||
const apiaryId = await createApiary('nochange');
|
||||
const created = await report({ phase: 'SALWEIDE', date: TODAY, apiaryId });
|
||||
|
||||
const response = await request(server())
|
||||
.patch(`/api/v1/phenology/reports/${created.id}`)
|
||||
.set(auth())
|
||||
.send({ phase: 'SALWEIDE' })
|
||||
.expect(200);
|
||||
|
||||
expect((response.body as ReportBody).id).toBe(created.id);
|
||||
expect(
|
||||
await prisma.domainEvent.count({
|
||||
where: { type: 'PhenologyCorrected', aggregateId: created.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { entityType: 'PhenologyReport', entityId: created.id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { App } from 'supertest/types';
|
||||
import { addDays } from '../src/common/dates';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import type { AnyDomainEventEnvelope } from '../src/domain-events/domain-event-types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
@@ -18,6 +19,13 @@ const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
* file's own users, apiary and tasks.
|
||||
*/
|
||||
const TODAY = '2001-04-15';
|
||||
const YESTERDAY = '2001-04-14';
|
||||
|
||||
/** The digest window of a run at 04:30 (UTC, for the test) on `day`. */
|
||||
const windowOf = (day: string) => ({
|
||||
from: new Date(`${addDays(day, -1)}T02:30:00.000Z`),
|
||||
until: new Date(`${day}T02:30:00.000Z`),
|
||||
});
|
||||
|
||||
describe('Reminders (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
@@ -138,6 +146,27 @@ describe('Reminders (e2e)', () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Two segment changes of the apiary: one before yesterday's digest
|
||||
// (reported then), one after it (reported today). Status DONE, so no
|
||||
// dispatcher touches them.
|
||||
const seasonEvent = (
|
||||
previousSegment: string,
|
||||
segment: string,
|
||||
occurredAt: string,
|
||||
) =>
|
||||
prisma.domainEvent.create({
|
||||
data: {
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: apiaryId,
|
||||
payload: { apiaryId, previousSegment, segment },
|
||||
correlationId: `reminders-e2e-${suffix}`,
|
||||
occurredAt: new Date(occurredAt),
|
||||
status: 'DONE',
|
||||
},
|
||||
});
|
||||
await seasonEvent('FRUEHJAHR', 'SCHWARMZEIT', '2001-04-13T12:00:00.000Z');
|
||||
await seasonEvent('SCHWARMZEIT', 'SOMMER', '2001-04-14T12:00:00.000Z');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -147,7 +176,7 @@ describe('Reminders (e2e)', () => {
|
||||
},
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: taskIds } },
|
||||
where: { aggregateId: { in: [...taskIds, apiaryId] } },
|
||||
});
|
||||
await prisma.inspection.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
@@ -243,8 +272,8 @@ describe('Reminders (e2e)', () => {
|
||||
users.nothing,
|
||||
];
|
||||
|
||||
const first = await digests.runFor(TODAY, own);
|
||||
const second = await digests.runFor(TODAY, own);
|
||||
const first = await digests.runFor(TODAY, own, windowOf(TODAY));
|
||||
const second = await digests.runFor(TODAY, own, windowOf(TODAY));
|
||||
|
||||
expect(first).toBeGreaterThanOrEqual(2);
|
||||
expect(second).toBe(0);
|
||||
@@ -264,8 +293,10 @@ describe('Reminders (e2e)', () => {
|
||||
`Durchsicht fällig: 2 Völker am Stand ${apiaryName}.`,
|
||||
);
|
||||
expect(lines).toContain(
|
||||
`Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`,
|
||||
`Stand ${apiaryName}: Sommer beginnt. Durchsicht alle 14 Tage.`,
|
||||
);
|
||||
// The change before yesterday's digest is not repeated.
|
||||
expect(full.message).not.toContain('Schwarmzeit beginnt');
|
||||
expect(full.requiredPermissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
'tasks:read',
|
||||
@@ -276,18 +307,33 @@ describe('Reminders (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('the digest of the day before reports the change of its own window', async () => {
|
||||
await digests.runFor(YESTERDAY, [users.full], windowOf(YESTERDAY));
|
||||
|
||||
const [yesterday] = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId: users.full,
|
||||
dedupeKey: `digest:${YESTERDAY}:${users.full}`,
|
||||
},
|
||||
});
|
||||
expect(yesterday.message).toContain(
|
||||
`Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`,
|
||||
);
|
||||
expect(yesterday.message).not.toContain('Sommer beginnt');
|
||||
});
|
||||
|
||||
it('the digest of a user without varroa or inspection rights has no such line', async () => {
|
||||
await digests.runFor(TODAY, [users.noInspection]);
|
||||
await digests.runFor(TODAY, [users.noInspection], windowOf(TODAY));
|
||||
|
||||
const [digest] = await digestOf(users.noInspection);
|
||||
expect(digest.message).toContain(`Stand ${apiaryName}: Schwarmzeit`);
|
||||
expect(digest.message).toContain(`Stand ${apiaryName}: Sommer`);
|
||||
expect(digest.message).not.toContain('Durchsicht fällig');
|
||||
expect(digest.requiredPermissions).not.toContain('inspections:read');
|
||||
expect(digest.requiredPermissions).not.toContain('colonies:read');
|
||||
});
|
||||
|
||||
it('builds no digest for a user who can see nothing in it, and none for a user who switched it off', async () => {
|
||||
await digests.runFor(TODAY, [users.nothing, users.off]);
|
||||
await digests.runFor(TODAY, [users.nothing, users.off], windowOf(TODAY));
|
||||
|
||||
expect(await digestOf(users.nothing)).toHaveLength(0);
|
||||
expect(await digestOf(users.off)).toHaveLength(0);
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { App } from 'supertest/types';
|
||||
import type { NotificationCategory } from '../generated/prisma';
|
||||
import { berlinDateTime, berlinToday, dayToDate } from '../src/common/dates';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { PermissionResolverService } from '../src/auth/permissions/permission-resolver.service';
|
||||
import {
|
||||
DeliveryPreferences,
|
||||
isInQuietHours,
|
||||
} from '../src/notifications/delivery-time';
|
||||
import { NotificationsService } from '../src/notifications/notifications.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { TelegramSender } from '../src/telegram/telegram-sender';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
import { createTelegramTestApp, FakeTelegramClient } from './utils/telegram';
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
const CLOCK_TOLERANCE_MS = 60_000;
|
||||
const ALL_CATEGORIES: NotificationCategory[] = [
|
||||
'TASK_DUE',
|
||||
'DIGEST',
|
||||
'SUGGESTION',
|
||||
'SYSTEM',
|
||||
];
|
||||
const NEVER_ABORTED = new AbortController().signal;
|
||||
|
||||
describe('Telegram delivery (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
let telegram: FakeTelegramClient;
|
||||
let sender: TelegramSender;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const chatBase = 8_000_000_000n + BigInt(Math.round(Math.random() * 1e6));
|
||||
let chatCounter = 0n;
|
||||
const roleIds: string[] = [];
|
||||
const userIds: string[] = [];
|
||||
const taskIds: string[] = [];
|
||||
|
||||
interface TestUser {
|
||||
id: string;
|
||||
chatId: bigint;
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
label: string,
|
||||
permissions: string[],
|
||||
preference: {
|
||||
telegramCategories?: NotificationCategory[];
|
||||
quietFrom?: string | null;
|
||||
quietUntil?: string | null;
|
||||
digestTime?: string;
|
||||
telegramDetail?: boolean;
|
||||
} = {},
|
||||
linked = true,
|
||||
): Promise<TestUser & { roleId: string }> {
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `TELEGRAM_${label.toUpperCase()}_${suffix}`,
|
||||
name: `Telegram ${label}`,
|
||||
permissions: {
|
||||
create: permissions.map((key) => ({
|
||||
permission: { connect: { key } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleIds.push(role.id);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `telegram-e2e-${label}-${suffix}@example.com`,
|
||||
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
|
||||
name: `Telegram ${label}`,
|
||||
role: { connect: { id: role.id } },
|
||||
},
|
||||
});
|
||||
userIds.push(user.id);
|
||||
const chatId = chatBase + ++chatCounter;
|
||||
await prisma.notificationPreference.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
telegramEnabled: true,
|
||||
telegramCategories: ALL_CATEGORIES,
|
||||
...preference,
|
||||
},
|
||||
});
|
||||
if (linked) {
|
||||
await prisma.telegramLink.create({
|
||||
data: { userId: user.id, chatId, telegramFirstName: label },
|
||||
});
|
||||
}
|
||||
return { id: user.id, chatId, roleId: role.id };
|
||||
}
|
||||
|
||||
/** A notification with its pending delivery, as the planner leaves them. */
|
||||
async function queue(
|
||||
userId: string,
|
||||
options: {
|
||||
category?: NotificationCategory;
|
||||
message?: string;
|
||||
requiredPermissions?: string[];
|
||||
createdAgoMs?: number;
|
||||
relatedType?: string;
|
||||
relatedId?: string;
|
||||
link?: string;
|
||||
} = {},
|
||||
): Promise<{ notificationId: string; deliveryId: string }> {
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
priority: 'INFO',
|
||||
category: options.category ?? 'SYSTEM',
|
||||
message: options.message ?? 'Hallo vom Test',
|
||||
requiredPermissions: options.requiredPermissions ?? [],
|
||||
relatedType: options.relatedType ?? null,
|
||||
relatedId: options.relatedId ?? null,
|
||||
link: options.link ?? '/dashboard',
|
||||
createdAt: new Date(Date.now() - (options.createdAgoMs ?? 0)),
|
||||
},
|
||||
});
|
||||
const delivery = await prisma.notificationDelivery.create({
|
||||
data: {
|
||||
channel: 'TELEGRAM',
|
||||
userId,
|
||||
notificationId: notification.id,
|
||||
availableAt: new Date(Date.now() - 1_000),
|
||||
},
|
||||
});
|
||||
return { notificationId: notification.id, deliveryId: delivery.id };
|
||||
}
|
||||
|
||||
const drainFor = (...ids: string[]) =>
|
||||
sender.drain(NEVER_ABORTED, 5, { userIds: ids });
|
||||
const deliveryOf = (id: string) =>
|
||||
prisma.notificationDelivery.findUniqueOrThrow({ where: { id } });
|
||||
|
||||
beforeAll(async () => {
|
||||
({ app, telegram } = await createTelegramTestApp());
|
||||
prisma = app.get(PrismaService);
|
||||
sender = app.get(TelegramSender);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.notificationDelivery.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
|
||||
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('planning', () => {
|
||||
it('a due task yields one delivery after the morning time and none inside quiet hours', async () => {
|
||||
const today = berlinToday();
|
||||
const morning: DeliveryPreferences = {
|
||||
digestTime: '12:00',
|
||||
quietFrom: null,
|
||||
quietUntil: null,
|
||||
};
|
||||
const night: DeliveryPreferences = {
|
||||
digestTime: '06:30',
|
||||
quietFrom: '00:00',
|
||||
quietUntil: '23:59',
|
||||
};
|
||||
const early = await createUser('plan-early', ['tasks:read'], morning);
|
||||
const quiet = await createUser('plan-quiet', ['tasks:read'], night);
|
||||
const off = await createUser('plan-off', ['tasks:read'], {
|
||||
telegramCategories: ['DIGEST'],
|
||||
});
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title: `Telegram e2e ${suffix}`,
|
||||
dueDate: dayToDate(today),
|
||||
},
|
||||
});
|
||||
taskIds.push(task.id);
|
||||
// Without an assignee every active user with tasks:complete gets it;
|
||||
// assign it to each user in turn to keep the test to its own users.
|
||||
const before = new Date();
|
||||
for (const user of [early, quiet, off]) {
|
||||
await prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: { assignedUserId: user.id },
|
||||
});
|
||||
await prisma.$transaction((tx) =>
|
||||
app.get(DomainEventsService).record(tx, {
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
aggregateId: task.id,
|
||||
payload: { dueOn: today },
|
||||
dedupeKey: `TaskDue:${task.id}:${today}:${user.id}`,
|
||||
}),
|
||||
);
|
||||
await drainDomainEvents(app, [task.id]);
|
||||
}
|
||||
|
||||
const forUser = (userId: string) =>
|
||||
prisma.notificationDelivery.findMany({
|
||||
where: { userId },
|
||||
include: { notification: true },
|
||||
});
|
||||
const [earlyRow, ...earlyRest] = await forUser(early.id);
|
||||
const [quietRow, ...quietRest] = await forUser(quiet.id);
|
||||
|
||||
expect(earlyRow.notification?.category).toBe('TASK_DUE');
|
||||
expect(earlyRest).toHaveLength(0);
|
||||
expect(earlyRow.channel).toBe('TELEGRAM');
|
||||
expect(earlyRow.status).toBe('PENDING');
|
||||
// Not before one minute after the morning time of that day; when that
|
||||
// time has passed already, right away.
|
||||
const morningAt = berlinDateTime(today, '12:01');
|
||||
if (before < morningAt) {
|
||||
expect(earlyRow.availableAt).toEqual(morningAt);
|
||||
} else {
|
||||
expect(earlyRow.availableAt.getTime()).toBeGreaterThanOrEqual(
|
||||
before.getTime() - CLOCK_TOLERANCE_MS,
|
||||
);
|
||||
}
|
||||
expect(isInQuietHours(earlyRow.availableAt, morning)).toBe(false);
|
||||
// Quiet hours all day: the delivery waits for their end.
|
||||
expect(quietRest).toHaveLength(0);
|
||||
expect(isInQuietHours(quietRow.availableAt, night)).toBe(false);
|
||||
expect(quietRow.availableAt.getTime()).toBeGreaterThanOrEqual(
|
||||
before.getTime() - CLOCK_TOLERANCE_MS,
|
||||
);
|
||||
// The user who did not choose TASK_DUE has the notification, no delivery.
|
||||
expect(await forUser(off.id)).toHaveLength(0);
|
||||
expect(
|
||||
await prisma.notification.count({
|
||||
where: { userId: off.id, category: 'TASK_DUE' },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('a user without a link gets no delivery', async () => {
|
||||
const user = await createUser('plan-unlinked', [], {}, false);
|
||||
|
||||
await app.get(NotificationsService).record({
|
||||
userId: user.id,
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
});
|
||||
|
||||
expect(
|
||||
await prisma.notificationDelivery.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('queues the SYSTEM message of a user who chose that category, once, even if the key repeats', async () => {
|
||||
const user = await createUser('plan-system', [], {
|
||||
telegramCategories: ['SYSTEM'],
|
||||
quietFrom: null,
|
||||
quietUntil: null,
|
||||
});
|
||||
const input = {
|
||||
userId: user.id,
|
||||
priority: 'INFO' as const,
|
||||
category: 'SYSTEM' as const,
|
||||
message: 'm',
|
||||
dedupeKey: `telegram-e2e-system:${suffix}`,
|
||||
};
|
||||
|
||||
await app.get(NotificationsService).record(input);
|
||||
await app.get(NotificationsService).record(input);
|
||||
|
||||
expect(
|
||||
await prisma.notificationDelivery.count({ where: { userId: user.id } }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sending', () => {
|
||||
it('two send loops in parallel deliver each message exactly once', async () => {
|
||||
const users = await Promise.all(
|
||||
['par-a', 'par-b', 'par-c', 'par-d', 'par-e'].map((label) =>
|
||||
createUser(label, []),
|
||||
),
|
||||
);
|
||||
const queued = await Promise.all(
|
||||
users.map((user) =>
|
||||
queue(user.id, { message: `Nachricht ${user.id}` }),
|
||||
),
|
||||
);
|
||||
const second = new TelegramSender(
|
||||
prisma,
|
||||
telegram as never,
|
||||
app.get(PermissionResolverService),
|
||||
app.get(NotificationsService),
|
||||
);
|
||||
const ids = users.map((user) => user.id);
|
||||
|
||||
const claimed = await Promise.all([
|
||||
sender.drain(NEVER_ABORTED, 5, { userIds: ids }),
|
||||
second.drain(NEVER_ABORTED, 5, { userIds: ids }),
|
||||
]);
|
||||
|
||||
expect(claimed[0] + claimed[1]).toBe(users.length);
|
||||
for (const user of users) {
|
||||
expect(telegram.sentTo(user.chatId)).toHaveLength(1);
|
||||
}
|
||||
for (const { deliveryId } of queued) {
|
||||
const row = await deliveryOf(deliveryId);
|
||||
expect(row.status).toBe('DELIVERED');
|
||||
expect(row.deliveredAt).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends plain text with the app link', async () => {
|
||||
const user = await createUser('text', [], {});
|
||||
const { deliveryId } = await queue(user.id, {
|
||||
message: 'Heute fällig: Futter',
|
||||
link: '/tasks',
|
||||
});
|
||||
|
||||
await drainFor(user.id);
|
||||
|
||||
const [message] = telegram.sentTo(user.chatId);
|
||||
expect(message.text).toMatch(
|
||||
/^Heute fällig: Futter\n\nhttps?:\/\/.+\/tasks$/,
|
||||
);
|
||||
expect((await deliveryOf(deliveryId)).status).toBe('DELIVERED');
|
||||
});
|
||||
|
||||
it('a revoked permission skips the delivery at send time', async () => {
|
||||
const user = await createUser('revoked', ['tasks:read']);
|
||||
const { deliveryId } = await queue(user.id, {
|
||||
requiredPermissions: ['tasks:read'],
|
||||
});
|
||||
// The resolver in this process has the right cached ...
|
||||
await app.get(PermissionResolverService).getPermissions(user.id);
|
||||
// ... and loses it without any invalidateUser call.
|
||||
await prisma.rolePermission.deleteMany({
|
||||
where: { roleId: user.roleId },
|
||||
});
|
||||
|
||||
await drainFor(user.id);
|
||||
|
||||
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
|
||||
const row = await deliveryOf(deliveryId);
|
||||
expect(row.status).toBe('SKIPPED');
|
||||
expect(row.lastError).toBe('permission revoked');
|
||||
});
|
||||
|
||||
it('skips what went stale: a DIGEST of yesterday, a SUGGESTION older than 24 h', async () => {
|
||||
const user = await createUser('stale', [], {
|
||||
telegramCategories: ['DIGEST', 'SUGGESTION', 'SYSTEM'],
|
||||
});
|
||||
const digest = await queue(user.id, {
|
||||
category: 'DIGEST',
|
||||
createdAgoMs: 36 * HOUR_MS,
|
||||
});
|
||||
const old = await queue(user.id, {
|
||||
category: 'SUGGESTION',
|
||||
createdAgoMs: 25 * HOUR_MS,
|
||||
});
|
||||
await drainFor(user.id);
|
||||
const fresh = await queue(user.id, {
|
||||
category: 'SUGGESTION',
|
||||
createdAgoMs: 23 * HOUR_MS,
|
||||
});
|
||||
|
||||
await drainFor(user.id);
|
||||
|
||||
expect((await deliveryOf(digest.deliveryId)).lastError).toBe('stale');
|
||||
expect((await deliveryOf(old.deliveryId)).status).toBe('SKIPPED');
|
||||
expect((await deliveryOf(fresh.deliveryId)).status).toBe('DELIVERED');
|
||||
expect(telegram.sentTo(user.chatId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips a due-task reminder once the task is done', async () => {
|
||||
const user = await createUser('taskdone', ['tasks:read'], {
|
||||
telegramCategories: ['TASK_DUE'],
|
||||
});
|
||||
const task = await prisma.task.create({
|
||||
data: { title: `Done ${suffix}`, status: 'DONE' },
|
||||
});
|
||||
taskIds.push(task.id);
|
||||
const { deliveryId } = await queue(user.id, {
|
||||
category: 'TASK_DUE',
|
||||
relatedType: 'Task',
|
||||
relatedId: task.id,
|
||||
requiredPermissions: ['tasks:read'],
|
||||
});
|
||||
|
||||
await drainFor(user.id);
|
||||
|
||||
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
|
||||
expect((await deliveryOf(deliveryId)).lastError).toBe(
|
||||
'task already done',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips when Telegram was switched off, the link is gone or the category is off', async () => {
|
||||
const off = await createUser('sw-off', [], {});
|
||||
await prisma.notificationPreference.update({
|
||||
where: { userId: off.id },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
const unlinked = await createUser('sw-unlinked', [], {}, false);
|
||||
const category = await createUser('sw-category', [], {
|
||||
telegramCategories: ['DIGEST'],
|
||||
});
|
||||
const rows = await Promise.all([
|
||||
queue(off.id),
|
||||
queue(unlinked.id),
|
||||
queue(category.id),
|
||||
]);
|
||||
|
||||
await drainFor(off.id, unlinked.id, category.id);
|
||||
|
||||
const reasons = await Promise.all(
|
||||
rows.map(async (row) => (await deliveryOf(row.deliveryId)).lastError),
|
||||
);
|
||||
expect(reasons).toEqual([
|
||||
'telegram disabled',
|
||||
'not linked',
|
||||
'category off',
|
||||
]);
|
||||
});
|
||||
|
||||
it('403 removes the link, switches Telegram off and notifies the user in the app', async () => {
|
||||
const user = await createUser('blocked', []);
|
||||
const { deliveryId } = await queue(user.id);
|
||||
telegram.failWith(
|
||||
403,
|
||||
'Forbidden: bot was blocked by the user',
|
||||
user.chatId,
|
||||
);
|
||||
|
||||
await drainFor(user.id);
|
||||
|
||||
expect(
|
||||
await prisma.telegramLink.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
const preference = await prisma.notificationPreference.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
expect(preference.telegramEnabled).toBe(false);
|
||||
const row = await deliveryOf(deliveryId);
|
||||
expect(row.status).toBe('SKIPPED');
|
||||
expect(row.lastError).toBe('chat unavailable');
|
||||
const notices = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
category: 'SYSTEM',
|
||||
message: { contains: 'Zustellung beendet' },
|
||||
},
|
||||
});
|
||||
expect(notices).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('5xx keeps the delivery for a retry with the attempt counted', async () => {
|
||||
const user = await createUser('server-error', []);
|
||||
const { deliveryId } = await queue(user.id);
|
||||
telegram.failWith(502, 'Bad Gateway', user.chatId);
|
||||
|
||||
await drainFor(user.id);
|
||||
|
||||
const row = await deliveryOf(deliveryId);
|
||||
expect(row.status).toBe('PENDING');
|
||||
expect(row.attempts).toBe(1);
|
||||
expect(row.availableAt.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps 1.1 s between two messages to the same chat, in order', async () => {
|
||||
const user = await createUser('spacing', []);
|
||||
const first = await queue(user.id, { message: 'erste' });
|
||||
// Another createdAt, so the order is not a tie.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const second = await queue(user.id, { message: 'zweite' });
|
||||
|
||||
await drainFor(user.id);
|
||||
|
||||
const sent = telegram
|
||||
.sentTo(user.chatId)
|
||||
.map((m) => m.text.split('\n')[0]);
|
||||
expect(sent).toEqual(['erste', 'zweite']);
|
||||
const [a, b] = await Promise.all([
|
||||
deliveryOf(first.deliveryId),
|
||||
deliveryOf(second.deliveryId),
|
||||
]);
|
||||
expect(a.status).toBe('DELIVERED');
|
||||
expect(b.status).toBe('DELIVERED');
|
||||
expect(
|
||||
(b.deliveredAt as Date).getTime() - (a.deliveredAt as Date).getTime(),
|
||||
).toBeGreaterThanOrEqual(1_000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { configureApp } from '../../src/app.config';
|
||||
import { THROTTLER_NAMESPACE } from '../../src/common/throttler/prisma-throttler.storage';
|
||||
import { TelegramApiError } from '../../src/telegram/telegram-api-error';
|
||||
import { TelegramClient } from '../../src/telegram/telegram-client';
|
||||
import { TelegramModule } from '../../src/telegram/telegram.module';
|
||||
|
||||
export interface SentMessage {
|
||||
chatId: bigint;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the Bot API: no network. Every sendMessage is recorded;
|
||||
* `failWith` scripts the next answers (an error is thrown once per entry,
|
||||
* in order, for the given chat or any chat).
|
||||
*/
|
||||
export class FakeTelegramClient {
|
||||
readonly sent: SentMessage[] = [];
|
||||
private readonly failures: { chatId: bigint | null; error: Error }[] = [];
|
||||
|
||||
isConfigured(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
failWith(statusCode: number, description: string, chatId?: bigint): void {
|
||||
this.failures.push({
|
||||
chatId: chatId ?? null,
|
||||
error: new TelegramApiError('sendMessage', statusCode, description, null),
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage(chatId: bigint, text: string): Promise<void> {
|
||||
const index = this.failures.findIndex(
|
||||
(failure) => failure.chatId === null || failure.chatId === chatId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
const [failure] = this.failures.splice(index, 1);
|
||||
return Promise.reject(failure.error);
|
||||
}
|
||||
this.sent.push({ chatId, text });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
sentTo(chatId: bigint): SentMessage[] {
|
||||
return this.sent.filter((message) => message.chatId === chatId);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TelegramTestApp {
|
||||
app: INestApplication<App>;
|
||||
telegram: FakeTelegramClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole API plus TelegramModule, with the Bot API client replaced by a
|
||||
* FakeTelegramClient. The TelegramSender is taken from the app and driven
|
||||
* by the test (no worker loop runs).
|
||||
*/
|
||||
export async function createTelegramTestApp(): Promise<TelegramTestApp> {
|
||||
const telegram = new FakeTelegramClient();
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule, TelegramModule],
|
||||
})
|
||||
.overrideProvider(THROTTLER_NAMESPACE)
|
||||
.useValue(randomUUID())
|
||||
.overrideProvider(TelegramClient)
|
||||
.useValue(telegram)
|
||||
.compile();
|
||||
const app = moduleFixture.createNestApplication<NestExpressApplication>();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
return { app, telegram };
|
||||
}
|
||||
Reference in new issue
Block a user