feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop phenology-weather with backoff, Retry-After, User-Agent, chunked writes and forecast-to-archive correction - Phenology: growing degree sum per DWD, phase from reports, GTS and calendar, calibration from previous years, daily job step, SeasonService reads the stored state - Honey chain: prefill for extractions and batches (proposals only), plausibility and withdrawal warnings, Extraction lock, chain events and batch.created webhook from HoneyBatchRecorded - Telegram base: schema, NotificationDelivery outbox, hardened bot client, startup check without crash, no-network-outside-loops guard - ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
+109
-14
@@ -30,6 +30,9 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 | – |
|
||||
| `WithdrawalModule` | Wartezeit-Prüfung bei Ernte, Abfüllung und Behandlung, Meldungen | Notifications |
|
||||
| `RemindersModule` | Erinnerung je Aufgabe, Morgenübersicht | Notifications, Season, Autopilot |
|
||||
|
||||
@@ -117,9 +120,9 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Auch die Königin-Zuordnung (`QueenAssigned` → `calendar-sync.queen-assignment`) und die Wartezeit (`calendar-sync.withdrawal`, Abschnitt 11.8) laufen so; `CalendarSyncService.enqueue` und das Einreihen nach dem Commit gibt es nicht mehr, nur `enqueueIn`. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung/Wartezeit) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender und `getWithdrawalStatus` rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8.
|
||||
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender und `getWithdrawalStatus` rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. Auch bei der Schleuderung warnt die App (`WITHDRAWAL_ACTIVE`, Abschnitt 11.12). Diese Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`: Lebensmittelsicherheit geht vor der Lese-Trennung.
|
||||
|
||||
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker, Königin-Zuordnung sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`) und den Webhook `colony.created`. Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`; `queen.replaced` kommt seit T4 aus `QueenAssigned`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`.
|
||||
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker, Königin-Zuordnung sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`) und den Webhook `colony.created`. Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`; `queen.replaced` kommt seit T4 aus `QueenAssigned`) und Lager (`product.stock_changed`); `batch.created` kommt seit T4b aus `HoneyBatchRecorded` (Abschnitt 10, Webhook-Zustellung), Schleuderungen und Honigchargen haben keine Post-Commit-Seiteneffekte mehr. Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`.
|
||||
12. **Automatische Hinweise sind Vorschläge, keine Aufgaben**: Automatisierungsregeln legen keine Aufgaben mehr an, sondern schreiben Vorschläge (`Suggestion`, Abschnitt 11.1). Erst „Übernehmen“ erzeugt die Aufgabe (Quelle „Aus Vorschlag“: `Task.sourceSuggestionId`, `sourceAutomationTrigger` = Regel); bestehende Automatik-Aufgaben bleiben Aufgaben. Je Regel und Objekt existiert höchstens eine offene Sache (Vorschlag oder Aufgabe). Geprüft wird unter der Volkssperre und der Zeilensperre der Regel (`SELECT … FOR UPDATE`, Reihenfolge Volk, dann Regel), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel, gerechnet ab dem Berliner Tag. Ohne sie ist die Aufgabe beim Übernehmen heute fällig. Anker je Regel (ein verworfener Anker kommt nie wieder): `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` die Durchsicht-ID; `VARROA_THRESHOLD` die Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4); `SWARM_MOOD_HIGH` die Kontroll-ID; `COLONY_HEALTH_CRITICAL` die Ereignis-ID; `STOCK_ITEM_LOW` die jüngste IN-Bewegung (sonst `none`); `RAW_MATERIAL_LOW` die jüngste Charge der Sorte (sonst `none`); `TREATMENT_FOLLOW_UP` und `FEEDING_FOLLOW_UP` die Behandlungs- bzw. Fütterungs-ID. Die Tagesrate des natürlichen Milbenfalls wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Vorschlag, Meldung und der Audit-Eintrag der Regel (`AutomationRule CREATE`, mit `suggestionId`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (Kategorie `SUGGESTION`, WARNUNG, bei `URGENT` KRITISCH) schreibt ein Vorschlag nur, wenn die Regel `config.notify` hat. Empfänger sind Inhaber von `tasks:read` und des Leserechts des Bereichs; der Text lautet „<Volkscode>: <Titel>“ (bewusste Abweichung vom Spec-Wortlaut, bei dem das Präfix der Regelcode war; ohne Volk nur der Titel). Der Kalendereintrag (`calendar-sync.task`) und der Webhook `task.created` (`webhooks.forward`) folgen erst aus `TaskCreated` beim Übernehmen; die Regel ruft `WebhooksService` nicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge von `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` nach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der Vorschlag `RESOLVED` („Bedingung entfällt“); besteht der Befund, bleibt es bei einem Vorschlag mit aktuellem Beleg (der Anker bleibt der alte); eine rückdatierte Durchsicht ändert nichts, eine Korrektur schlägt nie neu vor, führt aber Offenes nach. Dasselbe gilt für `VARROA_THRESHOLD` bei `VarroaCorrected`. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`, überspringen beendete Völker und prüfen unter der Volkssperre erneut. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie.
|
||||
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30.
|
||||
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
||||
@@ -128,7 +131,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
|
||||
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
|
||||
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
|
||||
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
||||
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
||||
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`.
|
||||
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
||||
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt.
|
||||
@@ -148,12 +151,17 @@ 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). T4a kennt nur den 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. Vor `SALWEIDE` gilt noch `WINTER` des Vorjahres. Gemeldete Phasen und Wetter (Gradtagszahl) folgen später über `SeasonService.statesFor`; die Aufrufer bleiben gleich.
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
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.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
@@ -229,7 +237,7 @@ Scheitert die Migration, scheitert der initContainer und der neue Pod startet ni
|
||||
|
||||
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
|
||||
|
||||
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
|
||||
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Seit T4b zusätzlich `PhenologyReport` (eigene Beobachtungen; Import nach `winterCheck`, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). `ApiaryPhenology` (abgeleiteter Saisonzustand, der Job `daily-evaluation` berechnet ihn neu) und `ApiaryWeatherDay` (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren `HoneyBatch`-Zeilen kein `initialAmount` tragen, werden beim Import wie in Migration `t4_honey_ledger` zurückgerechnet (Annahme 44, `src/backup/legacy-honey-ledger.ts`): `initialAmount` wird gesetzt; die `BOTTLING`-Buchungen mit der stabilen ID `legacy-bottling:<Abfüllung>` entstehen nur aus versiegelten Dateien, nach dem Abschnitt `batchMovement`, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur `initialAmount` bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (`honeyLedgerViolations`) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und `imported.honeyLedgerViolations = <Anzahl>` (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits). Die Telegram-Tabellen werden nie exportiert: `TelegramLink` (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), `TelegramLinkToken` (Einmal-Tokens, nur Minuten gültig), `NotificationDelivery` (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und `TelegramPollState` (Update-Offset und Abfrage-Lease dieses Deployments). Die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
|
||||
- **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“).
|
||||
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
|
||||
|
||||
@@ -248,10 +256,24 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
|
||||
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
|
||||
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; `erp-inventory:read` zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
|
||||
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` |
|
||||
| Wetter und Telegram | Wetter (Open-Meteo) nur aus der Worker-Schleife `phenology-weather` und der Wetter-Anzeige, über `src/weather/`: nur https-URLs aus der Konfiguration, keine Weiterleitungen (`redirect: 'error'`), 8 s Frist, Antwort höchstens 1 000 000 Byte, Fehlertexte fest (kein URL, keine Ursache, kein Schlüssel). Telegram (`api.telegram.org`) nur aus Worker-Schleifen über `TelegramClient` (`src/telegram/`): Klartext ohne `parse_mode`, Linkvorschau aus, Text höchstens 600 Zeichen ohne Steuer- und Richtungszeichen, keine Weiterleitungen, Antwort höchstens 256 kB; der Token steht nie in Logs oder Fehlern (`TelegramApiError` trägt Methode, Status, bereinigte Beschreibung, `retry_after` 1–3600 s, keine URL). Wächter `src/common/no-network-outside-loops.spec.ts`: Wetter-Hosts nur in `src/weather/`, `api.telegram.org` nur in `src/telegram/`, `fetch(` und Netzwerkpakete nur in den erlaubten Dateien (Graph-Client, Passwort-Prüfung, Webhook-Zustellung, Worker-Health, Wetter, Telegram), nie in Handlern oder Jobs; `handler-contract.spec.ts` verbietet den Import von `telegram-client` in Handlern |
|
||||
| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
|
||||
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
|
||||
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
|
||||
|
||||
**Umgebungsvariablen seit T4b/T4c** (Vorlagen in `.env.example`, `configmap.example.yaml`, `secret.example.yaml`; alle optional):
|
||||
|
||||
| Variable | Wo | Bedeutung |
|
||||
|---|---|---|
|
||||
| `WEATHER_HISTORY_SOURCE` | ConfigMap, API und Worker | Wetterquelle, Standard `open-meteo`; eine unbekannte Quelle stoppt den Start (Meldung nennt nur den Namen der Variable) |
|
||||
| `OPEN_METEO_FORECAST_URL`, `OPEN_METEO_ARCHIVE_URL` | ConfigMap | Endpunkte, Standard die öffentlichen Open-Meteo-URLs; nur `https:` |
|
||||
| `OPEN_METEO_API_KEY` | Secret | Bleibt leer (Annahme 42); falls gesetzt, wird er als `apikey` gesendet |
|
||||
| `WEATHER_USER_AGENT_CONTACT` | ConfigMap | Kontakt für den Header `User-Agent: ApiCore/<Version> (+<Kontakt>)`; ohne Wert `(self-hosted beekeeping app)`; kein persönlicher Standard, auf druckbares ASCII reduziert |
|
||||
| `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).
|
||||
|
||||
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
||||
|
||||
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
|
||||
@@ -302,6 +324,13 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
|
||||
**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:
|
||||
- `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.
|
||||
`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).
|
||||
|
||||
| Handler-ID | Ereignis | Wirkung |
|
||||
@@ -318,17 +347,20 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
| `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. |
|
||||
| `withdrawal.extraction-check` | `ExtractionRecorded`, `ExtractionCorrected` | Prüft die Ernten der Schleuderung gegen die Wartezeitfenster (`conflictsForExtraction`) und meldet mit demselben Schlüssel wie Ernte und Abfüllung (`withdrawal-conflict:<Ernte>:<Volk>`): Ein Konflikt wird einmal gemeldet, egal welche Stufe ihn zuerst sieht (Abschnitt 11.8, 11.12). |
|
||||
| `notifications.withdrawal-ended` | `WithdrawalEnded` | INFO „<Volk> ist wieder erntefähig“ (Abschnitt 11.8). |
|
||||
| `notifications.task-due` | `TaskDue` | Erinnerung je fälliger Aufgabe (Abschnitt 11.9). |
|
||||
| `calendar-sync.queen-assignment` | `QueenAssigned` | `enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId)`. |
|
||||
| `calendar-sync.withdrawal` | `TreatmentRecorded`, `TreatmentCorrected` | `enqueueIn(tx, 'WITHDRAWAL', treatmentId)` (Abschnitt 11.8). |
|
||||
| `webhooks.forward` | erweitert um `QueenAssigned` | `queen.replaced` (siehe „Webhook-Zustellung“). |
|
||||
| `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.
|
||||
|
||||
### Webhook-Zustellung
|
||||
|
||||
**Einreihen.** `WebhooksService.enqueueDeliveries(db, event, payload)` legt je aktivem Abonnement des Webhook-Ereignisses eine `WebhookDelivery` an und weckt den Worker mit `pg_notify('webhook_delivery', '')`. Gesendet wird dabei nichts. Der Body `JSON.stringify({ event, data })` wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin:
|
||||
- Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`, `QueenAssigned` → `queen.replaced`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.updated` die Honigcharge, bei `queen.replaced` die Königin, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht.
|
||||
- Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `batch.created`, `queen.created`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie.
|
||||
- Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`, `QueenAssigned` → `queen.replaced`, `HoneyBatchRecorded` → `batch.created`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.created` und `batch.updated` die Honigcharge (bei `batch.created` mit `honeyBatchInclude`, `src/honey-batches/honey-batch-include.ts`, sodass die Form von `GET /honey-batches/:id` gleich bleibt; `HoneyBatchCorrected` sendet nichts), bei `queen.replaced` die Königin, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht.
|
||||
- Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `queen.created`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie.
|
||||
|
||||
**Zustellung.** Die Worker-Schleife `webhook-deliveries` (alle 5 s und auf `webhook_delivery`) ruft `WebhookDeliveryWorker.releaseStaleClaims()` und dann `drain()` auf. Claim, Abschluss und Freigabe laufen über `outbox-claims.ts`: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (`createdAt`), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (`X-ApiCore-Signature: sha256=HMAC(secret, "<t>.<id>.<body>")`) und über `postWebhook` gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. `X-ApiCore-Delivery` ist die `WebhookDelivery.id` und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID.
|
||||
|
||||
@@ -355,7 +387,7 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
|
||||
|
||||
**`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`.
|
||||
|
||||
**`daily-evaluation`** (täglich 04:30) führt feste Schritte in dieser Reihenfolge aus (`DAILY_EVALUATION_STEP_ORDER`, `runDailyEvaluationSteps` in `src/jobs/daily-evaluation-steps.ts`): `phenology`, `time-events`, `autopilot`, `suggestions-expiry`, `digest`. „Heute“ ist der Berliner Tag (`berlinToday()`, Annahme 41). Der Schritt `phenology` (Wetter, gemeldete Phasen) gehört zur Wetter-Stufe; sein Platz in der Reihenfolge steht schon fest. Ein scheiternder Schritt hält die übrigen nicht auf: Der Lauf endet `FAILED` mit allen Zählern (`JobPartialFailure`, `JobRun.result` bleibt erhalten), `error` nennt Schritt und Ursache (`Daily evaluation failed for: time-events: TaskDue`, bei Völkern `autopilot: 2 colonies`).
|
||||
**`daily-evaluation`** (täglich 04:30) führt feste Schritte in dieser Reihenfolge aus (`DAILY_EVALUATION_STEP_ORDER`, `runDailyEvaluationSteps` in `src/jobs/daily-evaluation-steps.ts`): `phenology`, `time-events`, `autopilot`, `suggestions-expiry`, `digest`. „Heute“ ist der Berliner Tag (`berlinToday()`, Annahme 41). Der Schritt `phenology` (`PhenologyStep`, Abschnitt 11.11) berechnet den Saisonzustand `ApiaryPhenology` für jeden aktiven Stand, je Stand eine Transaktion. Zähler `phenologyApiaries`, `seasonChanges`; ein gescheiterter Stand hält die anderen nicht auf (`JobPartialFailure`, Lauf `FAILED` mit Zählern). Er steht vor `time-events`, damit alle späteren Schritte den frischen Zustand sehen. Ein scheiternder Schritt hält die übrigen nicht auf: Der Lauf endet `FAILED` mit allen Zählern (`JobPartialFailure`, `JobRun.result` bleibt erhalten), `error` nennt Schritt und Ursache (`Daily evaluation failed for: time-events: TaskDue`, bei Völkern `autopilot: 2 colonies`).
|
||||
- **`time-events`** (`src/jobs/steps/time-events.step.ts`) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den Tag des fachlichen Ereignisses. Je Typ eine Transaktion; scheitert eine Regel, laufen die übrigen weiter, und der Schritt wirft `JobPartialFailure` mit den Zählern der anderen. Das Ergebnis zählt die neuen Ereignisse je Typ.
|
||||
- `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): Ende des Wartezeitfensters über den Fenster-Helfer (Berliner Tag, Abschnitt 11.8), nur mit Wartezeit. Die SQL-Kandidatenabfrage nimmt einen Tag Rand auf jeder Seite, der Helfer entscheidet.
|
||||
- `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`).
|
||||
@@ -379,9 +411,9 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
|
||||
|
||||
Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist).
|
||||
|
||||
## 11. Logik-Ketten und Saison-Autopilot (T4a)
|
||||
## 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`. Nicht Teil dieser Lieferung sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`, T4b), Wetter und gemeldete Pflanzenphasen, die Autopilot-Regeln A4–A10 und die Telegram-Zustellung (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).
|
||||
|
||||
### 11.1 Vorschläge
|
||||
|
||||
@@ -425,7 +457,7 @@ Verwaltung > Automatisierung hat für die zehn Regeln mit eigenen Werten je ein
|
||||
|
||||
### 11.3 Saison und Pflanzenentwicklung
|
||||
|
||||
`SeasonService.statesFor` liefert je Stand den Zustand aus der Regel `PHENOLOGY` (`SeasonModule`). Heute entsteht der Abschnitt allein aus dem Kalender-Rückfall (Annahme 36; ein nicht aufsteigender `calendarFallback` fällt auf den Standard). `springSince` ist der Tag, an dem `SALWEIDE` dieses Jahres erreicht ist, sonst `null`.
|
||||
`SeasonService.statesFor` liefert je Stand den Zustand aus der Regel `PHENOLOGY` (`SeasonModule`). Der Abschnitt entsteht aus der gespeicherten Zeile `ApiaryPhenology` des laufenden Jahres (Phase aus Meldung, GTS und Kalender, Annahme 36; ein nicht aufsteigender `calendarFallback` fällt auf den Standard). Ohne Zeile oder bei einer Zeile, die älter als 2 Tage ist, gilt der reine Kalenderzustand. `springSince` ist der Tag, an dem `SALWEIDE` dieses Jahres erreicht ist (genähert, siehe Annahme 36), sonst `null`.
|
||||
|
||||
- **Durchsicht fällig** (`inspectionDueOn`): letzte Durchsicht (ohne Eintrag die Anlage des Volkes) plus das Intervall des heutigen Abschnitts aus `INSPECTION_INTERVAL` (Schwarmzeit 7 Tage; die Oberfläche prüft 7–9 Tage für die Schwarmzeit, sonst 7–35, der Server entscheidet). Wer vor Beginn von `FRUEHJAHR` zuletzt durchgesehen wurde, ist ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“). In `WINTERRUHE` ist nie etwas fällig. Fällt der Termin in den Winter, rückt er auf den Frühjahrsbeginn.
|
||||
- **Varroa-Messung fällig** (`varroaMeasurementDue`): letzte Messung (sonst Anlage) plus `varroaMeasureIntervalDays` (28); in `WINTERRUHE` einmal im Fenster 15.11.–20.12., wenn seit 01.11. nicht gemessen wurde (Winterfenster = Annahme nach LWG-Konzept, Restentmilbung im brutfreien Volk). Ein Fälligkeitstag, der in den Winter gefallen ist, rückt auf `springSince`.
|
||||
@@ -487,9 +519,9 @@ Oberfläche: Das Aufräum-Sheet (von Vorschlagsliste, Schritten und „Heute“)
|
||||
|
||||
### 11.8 Wartezeit-Kette
|
||||
|
||||
Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10) bestimmt die Wartezeit. Die Prüfung läuft bei Ernte, Abfüllung und nachträglich erfasster oder geänderter Behandlung (Schleuderung folgt mit T4b).
|
||||
Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10) bestimmt die Wartezeit. Die Prüfung läuft bei Ernte, Abfüllung und nachträglich erfasster oder geänderter Behandlung sowie seit T4b bei der Schleuderung (Handler `withdrawal.extraction-check`, Abschnitt 11.12).
|
||||
- **Warnungen.** `POST/PATCH /harvests` und `/bottling-batches` antworten mit `warnings` (Format `PlausibilityWarning`: Code `WITHDRAWAL_ACTIVE`, Stufe `WARNUNG`; Stufen `HINWEIS`, `WARNUNG`, `BESTAETIGEN`) und nie mit 4xx wegen der Wartezeit. Text: „B3: Wartezeit nach Oxuvar endet am 20.08.2026.“. Überdecken mehrere Fenster den Erntetag, nennt die Warnung das längste. Die Prüfung läuft in der Transaktion nach dem Schreiben.
|
||||
- **Meldung.** Pro Ernte und Volk höchstens eine Meldung (WARNUNG, Kategorie `WITHDRAWAL`, an Inhaber von `harvests:read` und `treatments:read`, Link `/harvests/<id>`, Schlüssel `withdrawal-conflict:<Ernte>:<Volk>`), auch wenn Abfüllung oder Behandlung erneut warnen; die erste Meldung gewinnt. Wurde die Ernte schon abgefüllt, hängt sie „Bereits abgefüllt: Los L-1, L-2.“ an (Rückruf-Relevanz). Korrekturen, die aus dem Fenster herausführen, nehmen eine frühere Meldung nicht zurück.
|
||||
- **Meldung.** Pro Ernte und Volk höchstens eine Meldung (WARNUNG, Kategorie `WITHDRAWAL`, an Inhaber von `harvests:read` und `treatments:read`, Link `/harvests/<id>`, Schlüssel `withdrawal-conflict:<Ernte>:<Volk>`), auch wenn Schleuderung, Abfüllung oder Behandlung erneut warnen; die erste Meldung gewinnt. Wurde die Ernte schon abgefüllt, hängt sie „Bereits abgefüllt: Los L-1, L-2.“ an (Rückruf-Relevanz). Korrekturen, die aus dem Fenster herausführen, nehmen eine frühere Meldung nicht zurück.
|
||||
- **Ende.** `WithdrawalEnded` (Job `time-events`) löst `notifications.withdrawal-ended` aus: INFO „<Volk> ist wieder erntefähig“ (Kategorie `WITHDRAWAL`, `harvests:create` + `treatments:read`, Link `/colonies/<id>`, Schlüssel `withdrawal-ended:<Behandlung>:<endedOn>`). Der Handler schweigt, wenn `WITHDRAWAL_ENDED.notify` aus ist, das Volk nicht lebt, die Behandlung fehlt, ihr Ende nicht mehr `endedOn` ist (veraltetes Ereignis) oder ein weiteres Fenster des Volkes an diesem Tag läuft.
|
||||
- **Kalender.** Die Ansicht bekommt den Typ `WITHDRAWAL` als Spanne (`date` = erster Tag, `endDate` = letzter Tag einschließlich, id `withdrawal:<Behandlung>`), und zwar wie die Behandlungstermine nur mit `treatments:read`; eine ungültige `from`/`to`-Angabe antwortet 400. Der M365-Kalender bekommt einen ganztägigen Mehrtagestermin „Wartezeit <Volk> · <Mittel>“ (`date` = erster Tag, `endDate` exklusiv = erster erntefähiger Tag); ohne Wartezeittage oder bei einem Fenster von null Tagen (`withdrawalSpanOf`) entfällt der Termin (Eintrag `SKIPPED`, ein vorhandenes Graph-Event wird gelöscht). Abschaltbar über `syncWithdrawal` (Standard an).
|
||||
- **Oberfläche.** Die Formulare zeigen Warnungen nach dem Speichern als Toast „Ernte gespeichert · n Warnung(en)“ bzw. „Abfüllung angelegt · n Warnung(en)“ mit einer Zeile je Warnung; Warnungen bleiben stehen, bis sie bestätigt werden („Verstanden“), Hinweise laufen aus. Offline gespeicherte Ernten haben keine Warnungen; die Abfüllung arbeitet nur online. Die Bestätigungsliste (`WarningList`) ist für die Wassergehalt-Bestätigung vorbereitet. Der Kalender zeigt „Wartezeit“ an jedem Tag der Spanne.
|
||||
@@ -511,3 +543,66 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
|
||||
**Übernahme.** `POST /inspections` nimmt `applyToColony` (höchstens 3 verschiedene Werte aus `occupiedFrames`, `broodStatus`, `feedStatus`). Durchsicht und Übernahme laufen in einer Transaktion: Der Server sperrt zuerst das Volk, speichert die Durchsicht, prüft dann, ob es eine neuere gibt (Datum, `createdAt`, `id`), und plant die Übernahme (`planColonyTakeover`) allein aus der gespeicherten Zeile, nie aus der Anfrage; `ColoniesService.updateIn(tx, …)` schreibt (inklusive Historie, Ereignissen und Audit-Eintrag mit Vorher/Nachher). Die Antwort trägt `colonyTakeover { applied, skipped[{ field, reason }] }` (leer, wenn nichts verlangt wurde). Gründe in der Prüfreihenfolge: `NO_PERMISSION` (braucht `colonies:update`, die Anfrage antwortet trotzdem 201), `COLONY_ENDED` (die Durchsicht bleibt gespeichert), `NEWER_INSPECTION` (blockiert jedes Feld), dann je Feld `NO_VALUE`, `OUT_OF_RANGE`. Eine Korrektur (PATCH) übernimmt nie etwas (`applyToColony` dort: 400).
|
||||
|
||||
**Oberfläche.** Der Block „Volkskarte“ steht über „Durchsicht speichern“, nur mit `colonies:update` und nur, wenn Felder gefüllt sind: je Feld ein Kontrollkästchen mit „Futter: Ausreichend → Niedrig“, „Besetzte Waben: 6 → 9“, „Brut: Mittel → Gut“. Angehakt wird nur, was in dieser Durchsicht selbst eingegeben wurde (ein Wert aus „Letztes Mal“ hakt nie von allein an; Tippen auf das Feld hakt an, Leeren nimmt zurück, ein manuelles Abhaken bleibt). Gesendet wird `applyToColony` nur mit Recht und nur für gefüllte Felder. Online meldet ein Toast die Gründe („Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.“, „…: Das Volk ist beendet.“); gelungene Felder laden die Volks-Abfragen neu. Offline reiht die Warteschlange `applyToColony` mit ein und der Server entscheidet beim Sync still (NEWER_INSPECTION, COLONY_ENDED, NO_PERMISSION); „bisher“-Werte kommen aus der zwischengespeicherten Volksliste, ohne sie steht „bisher unbekannt“.
|
||||
|
||||
### 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).
|
||||
|
||||
**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.
|
||||
|
||||
**Wetterquelle.** Open-Meteo, Annahme 42. Die Schnittstelle `WeatherHistorySource` (`src/weather/history/`) kapselt den Adapter `OpenMeteoHistorySource`. Das Tagesmittel ist `temperature_2m_mean` mit `timezone=Europe/Berlin`, also der Mittelwert der Stundenwerte des Berliner Kalendertags, auch an den Tagen der Zeitumstellung mit 23 und 25 Stunden. Die letzten 90 Tage kommen vom Forecast-Endpunkt (`past_days=92`, `forecast_days=1`), ältere Lücken vom Archiv (`start_date`, `end_date`). Das Fenster bestimmt der Dienst, nicht der Adapter. Werte außerhalb −50…50 °C und Nicht-Zahlen werden `null`. Jeder Fehler ist ein `WeatherSourceError` mit festem Text; er enthält weder URL noch Schlüssel noch Ursache. Konfiguration: Abschnitt 9. Jede Anfrage trägt `User-Agent: ApiCore/<Version> (+<WEATHER_USER_AGENT_CONTACT>)`, auch die Wetter-Anzeige der API (`WeatherService`, die dieselbe Konfiguration und Rundung nutzt).
|
||||
|
||||
**Schleife `phenology-weather`** (Abschnitt 10; `WeatherHistoryService.syncOnce`):
|
||||
- Takt stündlich, erster Durchlauf 10 s nach dem Start des Workers.
|
||||
- Aktive Stände mit Koordinaten, gruppiert nach dem auf 0,01° gerundeten Punkt: Stände am selben Punkt teilen eine Anfrage.
|
||||
- Fehlend heißt: keine Zeile vom 1. Januar bis gestern (Berliner Tag), oder `meanTempC` ist in den letzten 90 Tagen `null` (ältere `null`-Werte werden nicht erneut geholt). Ein Stand, der nach dem 1. Januar angelegt wird, bekommt die Tage ab 1. Januar unabhängig vom Anlagedatum. Frühere Jahre für die Kalibrierung werden nicht nachgeholt; die Aufbewahrung beträgt 4 Jahre.
|
||||
- Beide Bereiche eines Punkts werden zuerst geholt, dann in Blöcken von höchstens 200 Upserts je Transaktion geschrieben (`WEATHER_WRITE_CHUNK_SIZE`). Ein Fehler schreibt für den Punkt nichts, wird ohne URL geloggt und im nächsten Takt wiederholt.
|
||||
- Abstand 300 ms zwischen den Punkten. Bei HTTP 429 endet der Durchlauf, und bis Retry-After (Sekunden oder HTTP-Datum, höchstens 24 h) wird auch über Takte hinweg nichts angefragt. Ein 503 mit Retry-After wirkt ebenso, ein 503 ohne ist ein gewöhnlicher Fehler. Drei fehlgeschlagene Punkte hintereinander beenden den Durchlauf (`endedEarly`); ein Erfolg setzt die Zählung zurück.
|
||||
- Korrektur: Eine `forecast`-Zeile, die älter als das 90-Tage-Fenster ist, gilt als fehlend und wird durch den Archivwert ersetzt, höchstens einmal in 7 Tagen (`FORECAST_RECHECK_MS`, nach `fetchedAt`). Hat das Archiv keinen Wert, bleibt der gespeicherte stehen und nur `fetchedAt` wird aktualisiert; ein `null` aus dem Archiv überschreibt nie einen gespeicherten Wert.
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
||||
**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.
|
||||
|
||||
**Sperren.** `ExtractionsService.update` und `HoneyBatchesService.update` sperren die Zeile zuerst und lesen „vorher“ danach. Reihenfolge Schleuderung vor Honigcharge: `create` einer Charge sperrt die Schleuderung vor dem Schreiben (`lockRowForUpdate`). `update` liest die Schleuderungs-ID vorab, sperrt die Schleuderungen (bisherige und neue, `lockRowsForUpdate`), dann die Charge und liest sie neu; ist die Charge inzwischen zu einer anderen Schleuderung gewandert, antwortet die Anfrage 409. Die Mengenprüfung der Chargen einer Schleuderung läuft so unter der Schleuderungs-Sperre.
|
||||
|
||||
**Vorbelegung** (nur Vorschläge, Annahme 45; die Antwort-Klassen tragen in Swagger die Beschreibung „Vorschlag, keine Messung – nie stillschweigend übernehmen“):
|
||||
- `GET /extractions/prefill?harvestIds=a,b` (Recht `extractions:create`, 1–50 verschiedene IDs, Komma-Liste oder wiederholter Parameter; vor `:id` deklariert): `amountKg` = Summe der Nettogewichte, nur wenn alle bekannt sind; `honeyType` nur, wenn alle Ernten dieselbe nicht leere Sorte haben; `sources` mit Berliner Tag; Hinweis `HARVEST_NET_MISSING`; Warnung `WITHDRAWAL_ACTIVE`. Unbekannte IDs: 400 mit derselben Meldung wie beim Anlegen.
|
||||
- `GET /honey-batches/prefill?extractionId=` (Recht `honey-batches:create`, vor `:id`): `remainingKg` = Menge der Schleuderung minus Summe der vorhandenen Chargen (nie unter 0, `null` ohne Menge der Schleuderung), `honeyType` der Ernten, `waterContent` der Schleuderung, Warnungen. Unbekannte Schleuderung: 404.
|
||||
- Beide Routen hängen an den Anlege-Rechten, weil sie dieselben Daten liefern wie der Anlege-Ablauf.
|
||||
|
||||
**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.
|
||||
- `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`).
|
||||
|
||||
**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).
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**`TelegramClient`** (`src/telegram/`; Konfiguration per Token `TELEGRAM_CONFIG`, bereitgestellt von `TelegramModule`). Aufrufe `getUpdates` (`allowed_updates ["message"]`; Updates ohne `message.chat` werden übersprungen) und `sendMessage`. Sicherheit (Annahme 46, Abschnitt 9):
|
||||
- Der Token steht nie in Logs, Fehlern oder `lastError`. `TelegramApiError` trägt Methode, Status, bereinigte Beschreibung und `retry_after`; `describeTelegramFailure` folgt `cause` höchstens 5 Ebenen tief und gibt nie die Meldung zurück.
|
||||
- Nur HTTPS zu `api.telegram.org`; `redirect: 'error'`, eine Weiterleitung ergibt „network error“ ohne URL und Token; Antwortkörper werden gestreamt und bei 256 kB abgebrochen.
|
||||
- `retry_after` wird auf 1–3600 s begrenzt (Nicht-Ganzzahlen und Texte werden `null`).
|
||||
- Der Text wird bereinigt (`sanitizeTelegramText`): Steuerzeichen und die Richtungs- und Nullbreitenzeichen U+200B–200F, U+202A–202E, U+2066–2069 entfernt, höchstens 600 Zeichen nach Codepunkten (kein halbes Surrogatpaar) mit Auslassungspunkten; Klartext, kein `parse_mode`.
|
||||
- Ein fehlerhaft geformter Token macht den Client unbrauchbar („bot token not configured“), ohne dass er ruft.
|
||||
- `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).
|
||||
@@ -108,3 +108,28 @@ ENABLE_SWAGGER=false
|
||||
# APICORE_ROLE=api
|
||||
# Port des Health-Endpunkts des Worker-Prozesses.
|
||||
# WORKER_HEALTH_PORT=3002
|
||||
|
||||
# Wetterquelle fuer die Pflanzenentwicklung (Gradtagszahl) und die
|
||||
# Wetteranzeige. Einzige Quelle ist die freie Open-Meteo-API (bewusst
|
||||
# uebernommenes Lizenzrisiko, nicht-kommerzielle Bedingungen); der Adapter
|
||||
# (src/weather/history) erlaubt einen Wechsel. Nur https:-URLs; ein
|
||||
# unbekannter Quellenname oder eine http:-URL stoppt den Start. Der Schluessel
|
||||
# ist optional, gehoert in ein Secret und steht nie in Logs oder Fehlermeldungen.
|
||||
# WEATHER_HISTORY_SOURCE=open-meteo
|
||||
# OPEN_METEO_FORECAST_URL=https://api.open-meteo.com/v1/forecast
|
||||
# OPEN_METEO_ARCHIVE_URL=https://archive-api.open-meteo.com/v1/archive
|
||||
# OPEN_METEO_API_KEY=
|
||||
# Optional: Kontakt (E-Mail oder URL) fuer den User-Agent-Header der
|
||||
# Wetteranfragen ("ApiCore/<Version> (+<Kontakt>)"); ohne Wert
|
||||
# "ApiCore/<Version> (self-hosted beekeeping app)".
|
||||
# WEATHER_USER_AGENT_CONTACT=
|
||||
|
||||
# Telegram-Erinnerungen (optional, T4c). TELEGRAM_BOT_USERNAME (ohne @, 5-32
|
||||
# Zeichen: Buchstaben, Ziffern, Unterstrich) steht in API und Worker und
|
||||
# erzeugt den Deep Link. TELEGRAM_BOT_TOKEN (BotFather) gehoert NUR dem Worker,
|
||||
# im Cluster in das eigene Secret apicore-telegram; der API-Pod darf es nicht
|
||||
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
|
||||
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
|
||||
# (/setjoingroups) und keinen Webhook setzen.
|
||||
# TELEGRAM_BOT_USERNAME=
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
@@ -0,0 +1,111 @@
|
||||
-- T4 migration 11/n (part Telegram and delivery): chat binding, single-use
|
||||
-- link tokens, the delivery outbox and the receiver's poll state. All four
|
||||
-- tables are excluded from the backup (MODELS_EXCLUDED_FROM_BACKUP).
|
||||
-- NotificationDelivery carries either a notification for a user or a bot
|
||||
-- system reply for a chat, never both (CHECK below), and at most one
|
||||
-- delivery per notification and channel. TelegramPollState holds exactly one
|
||||
-- row (CHECK below), created here.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationChannel" AS ENUM ('TELEGRAM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'SKIPPED', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramLink" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"chatId" BIGINT NOT NULL,
|
||||
"telegramFirstName" TEXT,
|
||||
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TelegramLink_pkey" PRIMARY KEY ("userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramLinkToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TelegramLinkToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NotificationDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channel" "NotificationChannel" NOT NULL,
|
||||
"userId" TEXT,
|
||||
"chatId" BIGINT,
|
||||
"notificationId" TEXT,
|
||||
"templateCode" TEXT,
|
||||
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"lastStatusCode" INTEGER,
|
||||
"lastError" TEXT,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramPollState" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"updateOffset" BIGINT NOT NULL DEFAULT 0,
|
||||
"leaseOwner" TEXT,
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"lastPollAt" TIMESTAMP(3),
|
||||
"lastError" TEXT,
|
||||
|
||||
CONSTRAINT "TelegramPollState_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TelegramLink_chatId_key" ON "TelegramLink"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TelegramLinkToken_tokenHash_key" ON "TelegramLinkToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TelegramLinkToken_userId_idx" ON "TelegramLinkToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationDelivery_status_availableAt_idx" ON "NotificationDelivery"("status", "availableAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationDelivery_userId_createdAt_idx" ON "NotificationDelivery"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationDelivery_notificationId_idx" ON "NotificationDelivery"("notificationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NotificationDelivery_notificationId_channel_key" ON "NotificationDelivery"("notificationId", "channel");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TelegramLink" ADD CONSTRAINT "TelegramLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TelegramLinkToken" ADD CONSTRAINT "TelegramLinkToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- A delivery is either a notification for a user (rendered at send time) or a
|
||||
-- system reply for a chat (rendered from templateCode).
|
||||
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_target_check" CHECK (
|
||||
("notificationId" IS NOT NULL AND "userId" IS NOT NULL AND "templateCode" IS NULL AND "chatId" IS NULL)
|
||||
OR ("notificationId" IS NULL AND "templateCode" IS NOT NULL AND "chatId" IS NOT NULL)
|
||||
);
|
||||
|
||||
-- The receiver's single state row.
|
||||
ALTER TABLE "TelegramPollState" ADD CONSTRAINT "TelegramPollState_singleton_check" CHECK ("id" = 'singleton');
|
||||
INSERT INTO "TelegramPollState" ("id") VALUES ('singleton');
|
||||
@@ -127,6 +127,9 @@ model User {
|
||||
rawMaterialLots RawMaterialLot[]
|
||||
notifications Notification[]
|
||||
notificationPreference NotificationPreference?
|
||||
telegramLink TelegramLink?
|
||||
telegramLinkTokens TelegramLinkToken[]
|
||||
notificationDeliveries NotificationDelivery[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
webhookSubscriptions WebhookSubscription[]
|
||||
documents Document[]
|
||||
@@ -1408,19 +1411,20 @@ enum NotificationCategory {
|
||||
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
|
||||
// des Inhalts; dedupeKey = "<praefix>:<userId>".
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
priority NotificationPriority
|
||||
category NotificationCategory
|
||||
message String
|
||||
relatedType String?
|
||||
relatedId String?
|
||||
link String?
|
||||
requiredPermissions String[] @default([])
|
||||
dedupeKey String? @unique
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
requiredPermissions String[] @default([])
|
||||
dedupeKey String? @unique
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
deliveries NotificationDelivery[]
|
||||
|
||||
@@index([userId, read])
|
||||
@@index([priority])
|
||||
@@ -1441,6 +1445,78 @@ model NotificationPreference {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum NotificationChannel {
|
||||
TELEGRAM
|
||||
}
|
||||
|
||||
enum NotificationDeliveryStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
DELIVERED
|
||||
SKIPPED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// Chat binding of a user to this deployment's Telegram bot (T4c). One private
|
||||
// chat belongs to exactly one user.
|
||||
model TelegramLink {
|
||||
userId String @id
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
chatId BigInt @unique
|
||||
telegramFirstName String?
|
||||
linkedAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Single-use link token, valid for 10 minutes; only the SHA-256 is stored.
|
||||
model TelegramLinkToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Outbox like WebhookDelivery. The text is not stored: the sender renders it
|
||||
// from the Notification or the templateCode. CHECK (migration): exactly one of
|
||||
// (notificationId with userId) or (templateCode with chatId).
|
||||
model NotificationDelivery {
|
||||
id String @id @default(cuid())
|
||||
channel NotificationChannel
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
chatId BigInt?
|
||||
notificationId String?
|
||||
notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
templateCode String?
|
||||
status NotificationDeliveryStatus @default(PENDING)
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now())
|
||||
claimedAt DateTime?
|
||||
lastStatusCode Int?
|
||||
lastError String?
|
||||
deliveredAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([notificationId, channel])
|
||||
@@index([status, availableAt])
|
||||
@@index([userId, createdAt])
|
||||
@@index([notificationId])
|
||||
}
|
||||
|
||||
// One row: update offset and poll lease of the Telegram receiver.
|
||||
model TelegramPollState {
|
||||
id String @id @default("singleton")
|
||||
updateOffset BigInt @default(0)
|
||||
leaseOwner String?
|
||||
leaseUntil DateTime?
|
||||
lastPollAt DateTime?
|
||||
lastError String?
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhooks (Abschnitt 27) – v1.x Modul 28
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { QrModule } from './qr/qr.module';
|
||||
import { PhenologyModule } from './phenology/phenology.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { BottlingModule } from './bottling/bottling.module';
|
||||
@@ -114,6 +115,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
SuggestionsModule,
|
||||
CleanupModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
WithdrawalModule,
|
||||
RemindersModule,
|
||||
LabelTemplatesModule,
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('NextStepsService', () => {
|
||||
treatment: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
apiary: { findMany: jest.Mock };
|
||||
apiaryPhenology: { findMany: jest.Mock };
|
||||
};
|
||||
let permissionResolver: { getPermissions: jest.Mock };
|
||||
|
||||
@@ -70,6 +71,7 @@ describe('NextStepsService', () => {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
apiary: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
permissionResolver = { getPermissions: jest.fn() };
|
||||
service = new NextStepsService(
|
||||
|
||||
@@ -1486,6 +1486,26 @@ describe('BackupService', () => {
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport');
|
||||
});
|
||||
|
||||
it('excludes the Telegram models and the delivery queue from the backup, each with a reason', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
for (const [model, key] of [
|
||||
['TelegramLink', 'telegramLink'],
|
||||
['TelegramLinkToken', 'telegramLinkToken'],
|
||||
['NotificationDelivery', 'notificationDelivery'],
|
||||
['TelegramPollState', 'telegramPollState'],
|
||||
] as const) {
|
||||
expect(bundle.data).not.toHaveProperty(key);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toEqual(
|
||||
expect.stringMatching(/.{30,}/),
|
||||
);
|
||||
}
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.TelegramLink).toMatch(/re-link/);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.NotificationDelivery).toMatch(
|
||||
/resend old messages/,
|
||||
);
|
||||
});
|
||||
|
||||
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
||||
await importData({
|
||||
apiary: [{ id: 'apiary-1' }],
|
||||
|
||||
@@ -146,6 +146,12 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
||||
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
|
||||
ApiaryPhenology: 'Derived season state, recomputed by daily-evaluation.',
|
||||
ApiaryWeatherDay: 'Weather cache, refetched by the worker.',
|
||||
TelegramLink:
|
||||
"Chat binding to this deployment's bot - users re-link after a restore (like ApiKey).",
|
||||
TelegramLinkToken: 'Single-use link tokens, valid for minutes.',
|
||||
NotificationDelivery:
|
||||
'Delivery queue - restoring it would resend old messages.',
|
||||
TelegramPollState: 'Update offset and poll lease of this deployment.',
|
||||
};
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
|
||||
@@ -49,7 +49,6 @@ describe('legacy after-commit paths', () => {
|
||||
it('only the modules without an event still dispatch webhooks directly', () => {
|
||||
expect(filesCalling('webhooks.dispatch(')).toEqual([
|
||||
'src/colonies/colonies.service.ts',
|
||||
'src/honey-batches/honey-batches.service.ts',
|
||||
'src/queens/queens.service.ts',
|
||||
'src/storage/storage.service.ts',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* Guard for outbound calls: the network is only reached from the worker
|
||||
* loops' own directories. A static source scan (like handler-contract.spec):
|
||||
* - weather hosts appear only in src/weather/, api.telegram.org only in
|
||||
* src/telegram/,
|
||||
* - `fetch` (call, reference or globalThis.fetch) and network packages
|
||||
* (http, https, undici, axios, got, node-fetch, with or without `node:`)
|
||||
* appear only in src/weather/, src/telegram/ and the older files that
|
||||
* predate this guard,
|
||||
* - a *.handlers.ts file or a file under src/jobs/ imports no network
|
||||
* package and no module that itself reaches the network - checked on its
|
||||
* own, whatever the fetch rule says.
|
||||
*/
|
||||
const SRC = join(__dirname, '..');
|
||||
|
||||
interface SourceFile {
|
||||
path: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const WEATHER_HOSTS = /open-meteo\.com/i;
|
||||
const TELEGRAM_HOST = /api\.telegram\.org/i;
|
||||
const FETCH_USE = /(?<![\w$.])fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/;
|
||||
const NETWORK_PACKAGES = new Set([
|
||||
'http',
|
||||
'https',
|
||||
'undici',
|
||||
'axios',
|
||||
'got',
|
||||
'node-fetch',
|
||||
]);
|
||||
const IMPORT_SPECIFIER =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
|
||||
|
||||
const ALLOWED_DIRECTORIES = ['weather/', 'telegram/'];
|
||||
// Files that reach the network and predate the guard.
|
||||
const ALLOWED_FILES = [
|
||||
'calendar-sync/graph-client.service.ts',
|
||||
'common/password/password-policy.ts',
|
||||
'webhooks/webhook-delivery.ts',
|
||||
'worker/worker-health.ts',
|
||||
'worker/worker-health-server.service.ts',
|
||||
];
|
||||
|
||||
function relativePath(path: string): string {
|
||||
return path
|
||||
.slice(SRC.length + 1)
|
||||
.split(sep)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/** Source without comments, so prose like "fetch the data" does not count. */
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
}
|
||||
|
||||
function sourceFiles(): SourceFile[] {
|
||||
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
|
||||
.filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts'))
|
||||
.map((file) => {
|
||||
const path = join(SRC, file);
|
||||
return {
|
||||
path: relativePath(path),
|
||||
source: readFileSync(path, 'utf8'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isNetworkPackage(specifier: string): boolean {
|
||||
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
|
||||
}
|
||||
|
||||
function importsOf(source: string): string[] {
|
||||
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function inAllowedPlace(path: string): boolean {
|
||||
return (
|
||||
ALLOWED_DIRECTORIES.some((directory) => path.startsWith(directory)) ||
|
||||
ALLOWED_FILES.includes(path)
|
||||
);
|
||||
}
|
||||
|
||||
function reachesNetwork(file: SourceFile): boolean {
|
||||
return (
|
||||
FETCH_USE.test(file.source) || importsOf(file.source).some(isNetworkPackage)
|
||||
);
|
||||
}
|
||||
|
||||
function isHandlerOrJob(path: string): boolean {
|
||||
return path.endsWith('.handlers.ts') || path.startsWith('jobs/');
|
||||
}
|
||||
|
||||
/** The src-relative path a relative import points to, if it is one of `known`. */
|
||||
function resolveImport(
|
||||
from: string,
|
||||
specifier: string,
|
||||
known: ReadonlySet<string>,
|
||||
): string | undefined {
|
||||
if (!specifier.startsWith('.')) {
|
||||
return undefined;
|
||||
}
|
||||
const base = resolve(dirname(join(SRC, from)), specifier);
|
||||
return [`${base}.ts`, join(base, 'index.ts')]
|
||||
.map(relativePath)
|
||||
.find((candidate) => known.has(candidate));
|
||||
}
|
||||
|
||||
/** Where the code breaks the rule; empty when it keeps it. */
|
||||
function violations(rawFiles: readonly SourceFile[]): string[] {
|
||||
const files = rawFiles.map(({ path, source }) => ({
|
||||
path,
|
||||
source: withoutComments(source),
|
||||
}));
|
||||
const networkFiles = new Set(
|
||||
files.filter(reachesNetwork).map((file) => file.path),
|
||||
);
|
||||
return files.flatMap(({ path, source }) => {
|
||||
const found: string[] = [];
|
||||
if (WEATHER_HOSTS.test(source) && !path.startsWith('weather/')) {
|
||||
found.push(`${path}: weather host outside src/weather/`);
|
||||
}
|
||||
if (TELEGRAM_HOST.test(source) && !path.startsWith('telegram/')) {
|
||||
found.push(`${path}: api.telegram.org outside src/telegram/`);
|
||||
}
|
||||
if (FETCH_USE.test(source) && !inAllowedPlace(path)) {
|
||||
found.push(`${path}: fetch outside the allowed places`);
|
||||
}
|
||||
const specifiers = importsOf(source);
|
||||
if (specifiers.some(isNetworkPackage) && !inAllowedPlace(path)) {
|
||||
found.push(`${path}: network package import outside the allowed places`);
|
||||
}
|
||||
if (isHandlerOrJob(path)) {
|
||||
if (FETCH_USE.test(source)) {
|
||||
found.push(`${path}: fetch in a handler or job`);
|
||||
}
|
||||
if (specifiers.some(isNetworkPackage)) {
|
||||
found.push(`${path}: network package imported by a handler or job`);
|
||||
}
|
||||
if (
|
||||
specifiers.some(
|
||||
(specifier) =>
|
||||
resolveImport(path, specifier, networkFiles) !== undefined,
|
||||
)
|
||||
) {
|
||||
found.push(
|
||||
`${path}: handler or job imports a module that reaches the network`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
describe('network calls stay inside their loop directories', () => {
|
||||
it('network calls stay inside their loop directories', () => {
|
||||
expect(violations(sourceFiles())).toEqual([]);
|
||||
});
|
||||
|
||||
it('knows the real network files (the scan is not vacuous)', () => {
|
||||
const found = sourceFiles()
|
||||
.map(({ path, source }) => ({ path, source: withoutComments(source) }))
|
||||
.filter(reachesNetwork)
|
||||
.map((file) => file.path);
|
||||
|
||||
expect(found).toEqual(
|
||||
expect.arrayContaining([
|
||||
...ALLOWED_FILES,
|
||||
'weather/weather.service.ts',
|
||||
'telegram/telegram-client.ts',
|
||||
]),
|
||||
);
|
||||
expect(found.every(inAllowedPlace)).toBe(true);
|
||||
expect(existsSync(join(SRC, 'jobs'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('with planted violations', () => {
|
||||
it.each([
|
||||
['colonies/colonies.service.ts', 'await fetch(url);'],
|
||||
['colonies/colonies.service.ts', 'const f = fetch;'],
|
||||
['colonies/colonies.service.ts', 'await globalThis.fetch(url);'],
|
||||
['colonies/colonies.service.ts', "import https from 'node:https';"],
|
||||
['colonies/colonies.service.ts', "import http from 'http';"],
|
||||
['colonies/colonies.service.ts', "import { fetch } from 'undici';"],
|
||||
['colonies/colonies.service.ts', "import axios from 'axios';"],
|
||||
['colonies/colonies.service.ts', "import got from 'got';"],
|
||||
['colonies/colonies.service.ts', "import fetch from 'node-fetch';"],
|
||||
['colonies/colonies.service.ts', "const h = require('node:http');"],
|
||||
['colonies/colonies.handlers.ts', 'await fetch(url);'],
|
||||
['jobs/daily-evaluation.job.ts', 'await fetch(url);'],
|
||||
['jobs/steps/phenology.step.ts', "import { get } from 'node:https';"],
|
||||
['colonies/colonies.handlers.ts', "import axios from 'axios';"],
|
||||
[
|
||||
'notifications/notifications.service.ts',
|
||||
"const u = 'https://api.telegram.org/bot';",
|
||||
],
|
||||
[
|
||||
'notifications/notifications.service.ts',
|
||||
"const u = 'https://api.open-meteo.com/v1';",
|
||||
],
|
||||
[
|
||||
'telegram/telegram-client.ts',
|
||||
"const u = 'https://api.open-meteo.com/v1';",
|
||||
],
|
||||
[
|
||||
'weather/weather.service.ts',
|
||||
"const u = 'https://api.telegram.org/bot';",
|
||||
],
|
||||
])('flags %s: %s', (path, source) => {
|
||||
expect(violations([{ path, source }])).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a handler or job importing a module that reaches the network, even without fetch of its own', () => {
|
||||
const client = {
|
||||
path: 'weather/weather.service.ts',
|
||||
source: 'await fetch(url);',
|
||||
};
|
||||
for (const path of [
|
||||
'colonies/colonies.handlers.ts',
|
||||
'jobs/daily-evaluation-steps.ts',
|
||||
]) {
|
||||
const importer = {
|
||||
path,
|
||||
source:
|
||||
"import { WeatherService } from '../weather/weather.service';",
|
||||
};
|
||||
expect(violations([client, importer])).toEqual([
|
||||
`${path}: handler or job imports a module that reaches the network`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'weather/weather.service.ts',
|
||||
"await fetch('https://api.open-meteo.com/v1');",
|
||||
],
|
||||
[
|
||||
'telegram/telegram-client.ts',
|
||||
"await fetch('https://api.telegram.org/bot');",
|
||||
],
|
||||
['calendar-sync/graph-client.service.ts', 'await fetch(url);'],
|
||||
['webhooks/webhook-delivery.ts', "import * as https from 'node:https';"],
|
||||
['colonies/colonies.service.ts', 'const prefetched = refetch + fetched;'],
|
||||
['colonies/colonies.service.ts', '// fetch the data later\nconst a = 1;'],
|
||||
['colonies/colonies.service.ts', "const scheme = 'https';"],
|
||||
['colonies/colonies.service.ts', 'const o = { fetch: 1 };'],
|
||||
])('lets %s pass: %s', (path, source) => {
|
||||
expect(violations([{ path, source }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { deferClaim, settleSkipped, settleSuccess } from './outbox-claims';
|
||||
|
||||
function fakeDb(count = 1) {
|
||||
return { $executeRaw: jest.fn().mockResolvedValue(count) };
|
||||
}
|
||||
|
||||
function sqlOf(db: ReturnType<typeof fakeDb>): Prisma.Sql {
|
||||
return (db.$executeRaw.mock.calls as [Prisma.Sql][])[0][0];
|
||||
}
|
||||
|
||||
describe('NotificationDelivery outbox helpers', () => {
|
||||
const claimedAt = new Date('2026-10-15T10:00:00.000Z');
|
||||
|
||||
it('settleSuccess settles NotificationDelivery as DELIVERED into deliveredAt', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await expect(
|
||||
settleSuccess(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'DELIVERED',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const query = sqlOf(db);
|
||||
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
||||
expect(query.sql).toContain('"deliveredAt" = now()');
|
||||
expect(query.sql).toContain('"NotificationDeliveryStatus"');
|
||||
});
|
||||
|
||||
it('settleSuccess refuses DONE for NotificationDelivery', async () => {
|
||||
await expect(
|
||||
settleSuccess(
|
||||
fakeDb() as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'DONE',
|
||||
),
|
||||
).rejects.toThrow('NotificationDelivery settles as DELIVERED, not DONE');
|
||||
});
|
||||
|
||||
it('settleSkipped is compare-and-set on id and claimedAt and keeps attempts', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await expect(
|
||||
settleSkipped(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'chat gone',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const query = sqlOf(db);
|
||||
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
||||
expect(query.sql).toContain(`CAST(? AS "NotificationDeliveryStatus")`);
|
||||
expect(query.sql).toContain(
|
||||
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
||||
);
|
||||
expect(query.sql).not.toContain('"attempts"');
|
||||
expect(query.values).toEqual(['SKIPPED', 'chat gone', 'd1', claimedAt]);
|
||||
});
|
||||
|
||||
it('settleSkipped reports a lost claim', async () => {
|
||||
await expect(
|
||||
settleSkipped(
|
||||
fakeDb(0) as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'x',
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('deferClaim puts the entry back to PENDING after the delay by the database clock, attempts unchanged, compare-and-set', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await expect(
|
||||
deferClaim(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
15 * 60_000,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const query = sqlOf(db);
|
||||
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
||||
expect(query.sql).toContain(`"status" = 'PENDING'`);
|
||||
expect(query.sql).toContain(
|
||||
`"availableAt" = now() + (? * interval '1 millisecond')`,
|
||||
);
|
||||
expect(query.sql).toContain('"claimedAt" = NULL');
|
||||
expect(query.sql).not.toContain('"attempts"');
|
||||
expect(query.sql).toContain(
|
||||
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
||||
);
|
||||
expect(query.values).toEqual([900_000, 'd1', claimedAt]);
|
||||
});
|
||||
|
||||
it('deferClaim reports a lost claim and rejects a negative or non-integer delay', async () => {
|
||||
await expect(
|
||||
deferClaim(
|
||||
fakeDb(0) as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
1,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
deferClaim(
|
||||
fakeDb() as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
-1,
|
||||
),
|
||||
).rejects.toThrow(RangeError);
|
||||
await expect(
|
||||
deferClaim(
|
||||
fakeDb() as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
1.5,
|
||||
),
|
||||
).rejects.toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('deferClaim caps the delay at 24 hours', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await deferClaim(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
30 * 24 * 3_600_000,
|
||||
);
|
||||
|
||||
expect(sqlOf(db).values[0]).toBe(24 * 3_600_000);
|
||||
});
|
||||
|
||||
it('settleSkipped truncates the reason to 200 characters', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await settleSkipped(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'r'.repeat(500),
|
||||
);
|
||||
|
||||
expect(sqlOf(db).values[1]).toBe('r'.repeat(200));
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,8 @@ import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
|
||||
* comparison and calculation runs in SQL against now() - the database
|
||||
* clock - never against a JS Date.
|
||||
*/
|
||||
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
|
||||
export type OutboxTable =
|
||||
'DomainEvent' | 'WebhookDelivery' | 'NotificationDelivery';
|
||||
|
||||
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
|
||||
|
||||
@@ -29,6 +30,9 @@ interface OutboxTableSql {
|
||||
successStatus: 'DONE' | 'DELIVERED';
|
||||
}
|
||||
|
||||
/** Outbox tables whose entries can be put back or skipped by the sender. */
|
||||
export type DeliveryOutboxTable = 'NotificationDelivery';
|
||||
|
||||
/**
|
||||
* Table, enum and column names cannot be bound as parameters. They come
|
||||
* only from these closed maps - never from caller input - which is what
|
||||
@@ -47,6 +51,12 @@ const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
|
||||
settledAtColumn: Prisma.raw('"deliveredAt"'),
|
||||
successStatus: 'DELIVERED',
|
||||
},
|
||||
NotificationDelivery: {
|
||||
table: Prisma.raw('"NotificationDelivery"'),
|
||||
statusType: Prisma.raw('"NotificationDeliveryStatus"'),
|
||||
settledAtColumn: Prisma.raw('"deliveredAt"'),
|
||||
successStatus: 'DELIVERED',
|
||||
},
|
||||
};
|
||||
|
||||
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
|
||||
@@ -54,6 +64,10 @@ const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
|
||||
createdAt: Prisma.raw('"createdAt"'),
|
||||
};
|
||||
|
||||
/** Longest wait deferClaim allows: a day. */
|
||||
const MAX_DEFER_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_SKIP_REASON_CHARS = 200;
|
||||
|
||||
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
|
||||
|
||||
function outboxTable(table: OutboxTable): OutboxTableSql {
|
||||
@@ -206,3 +220,56 @@ export async function settleFailure(
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a claimed entry back to PENDING, due after `delayMs` by the database
|
||||
* clock, without counting an attempt: the entry did not fail, it has to wait
|
||||
* (rate limit from the receiver, quiet period, paused bot); the delay is
|
||||
* capped at 24 hours. Compare-and-set
|
||||
* on id + claimedAt; false if the claim was lost.
|
||||
*/
|
||||
export async function deferClaim(
|
||||
db: DbClient,
|
||||
table: DeliveryOutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
delayMs: number,
|
||||
): Promise<boolean> {
|
||||
if (!Number.isInteger(delayMs) || delayMs < 0) {
|
||||
throw new RangeError(
|
||||
`Defer delay must be a non-negative integer, got ${delayMs}`,
|
||||
);
|
||||
}
|
||||
const { table: name } = outboxTable(table);
|
||||
const cappedDelayMs = Math.min(delayMs, MAX_DEFER_MS);
|
||||
const count = await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = 'PENDING',
|
||||
"claimedAt" = NULL,
|
||||
"availableAt" = now() + (${cappedDelayMs} * interval '1 millisecond')
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return count === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a claimed entry as SKIPPED: it will never be sent (the recipient
|
||||
* unlinked, the message went stale). `reason` lands in lastError and must
|
||||
* not contain personal data or secrets; it is cut to 200 characters. Compare-and-set on id + claimedAt;
|
||||
* false if the claim was lost.
|
||||
*/
|
||||
export async function settleSkipped(
|
||||
db: DbClient,
|
||||
table: DeliveryOutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
reason: string,
|
||||
): Promise<boolean> {
|
||||
const { table: name, statusType } = outboxTable(table);
|
||||
const count = await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = CAST(${'SKIPPED'} AS ${statusType}),
|
||||
"claimedAt" = NULL,
|
||||
"lastError" = ${reason.slice(0, MAX_SKIP_REASON_CHARS)}
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return count === 1;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const WAKE_CHANNELS = {
|
||||
domainEvents: 'domain_event',
|
||||
calendarSync: 'calendar_sync',
|
||||
webhookDeliveries: 'webhook_delivery',
|
||||
notificationDeliveries: 'notification_delivery',
|
||||
} as const;
|
||||
|
||||
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
|
||||
|
||||
@@ -39,7 +39,7 @@ const NETWORK_PACKAGES = new Set([
|
||||
'ky',
|
||||
'superagent',
|
||||
]);
|
||||
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/;
|
||||
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery|telegram-client/;
|
||||
const FETCH_CALL = /\bfetch\s*\(/;
|
||||
const IMPORT_SPECIFIER =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
|
||||
@@ -147,6 +147,7 @@ describe('domain event handler contract', () => {
|
||||
'calendar-sync/graph-client.service.ts',
|
||||
'webhooks/webhook-delivery.ts',
|
||||
'weather/weather.service.ts',
|
||||
'telegram/telegram-client.ts',
|
||||
'common/password/password-policy.ts',
|
||||
]),
|
||||
);
|
||||
@@ -187,6 +188,7 @@ describe('domain event handler contract', () => {
|
||||
"import { postWebhook } from '../webhooks/webhook-delivery';",
|
||||
"import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';",
|
||||
"import { WeatherService } from '../weather/weather.service';",
|
||||
"import { TelegramClient } from '../telegram/telegram-client';",
|
||||
"const { checkPassword } = await import('../common/password/password-policy');",
|
||||
])('flags %s', (line) => {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import {
|
||||
ExtractionPrefillQuery,
|
||||
MAX_PREFILL_HARVESTS,
|
||||
} from './extraction-prefill.query';
|
||||
|
||||
async function check(query: Record<string, unknown>) {
|
||||
const dto = plainToInstance(ExtractionPrefillQuery, query);
|
||||
const errors = await validate(dto, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return {
|
||||
dto,
|
||||
messages: errors.flatMap((error) => Object.values(error.constraints ?? {})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ExtractionPrefillQuery', () => {
|
||||
it('splits the comma-separated ids and drops empty segments', async () => {
|
||||
const { dto, messages } = await check({ harvestIds: 'ha,hb,' });
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(dto.harvestIds).toEqual(['ha', 'hb']);
|
||||
});
|
||||
|
||||
it('accepts a repeated query parameter', async () => {
|
||||
const { dto, messages } = await check({ harvestIds: ['ha', 'hb'] });
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(dto.harvestIds).toEqual(['ha', 'hb']);
|
||||
});
|
||||
|
||||
it('requires at least one id', async () => {
|
||||
expect((await check({})).messages).not.toEqual([]);
|
||||
expect((await check({ harvestIds: ',' })).messages).toContain(
|
||||
'harvestIds darf nicht leer sein',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows at most 50 ids', async () => {
|
||||
const ids = Array.from(
|
||||
{ length: MAX_PREFILL_HARVESTS + 1 },
|
||||
(_, i) => `h${i}`,
|
||||
);
|
||||
|
||||
expect((await check({ harvestIds: ids.join(',') })).messages).toContain(
|
||||
`harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
|
||||
);
|
||||
expect(
|
||||
(await check({ harvestIds: ids.slice(0, 50).join(',') })).messages,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects duplicates and ids with odd characters', async () => {
|
||||
expect((await check({ harvestIds: 'ha,ha' })).messages).toContain(
|
||||
'harvestIds enthält eine Ernte doppelt',
|
||||
);
|
||||
expect((await check({ harvestIds: 'ha,h b' })).messages).toContain(
|
||||
'harvestIds enthält eine ungültige Ernte-ID',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export const MAX_PREFILL_HARVESTS = 50;
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const IDS_MESSAGE = 'harvestIds enthält eine ungültige Ernte-ID';
|
||||
|
||||
// "a,b" and the repeated form harvestIds=a&harvestIds=b both become a list;
|
||||
// empty segments carry no id and are dropped.
|
||||
function toIdList({ value }: TransformFnParams): unknown {
|
||||
const parts: unknown =
|
||||
typeof value === 'string' ? value.split(',') : (value as unknown);
|
||||
return Array.isArray(parts) ? parts.filter((part) => part !== '') : parts;
|
||||
}
|
||||
|
||||
export class ExtractionPrefillQuery {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: `Kommagetrennte Ernte-IDs, 1 bis ${MAX_PREFILL_HARVESTS}`,
|
||||
example: 'harvest-a,harvest-b',
|
||||
})
|
||||
@Transform(toIdList)
|
||||
@IsArray({ message: IDS_MESSAGE })
|
||||
@ArrayMinSize(1, { message: 'harvestIds darf nicht leer sein' })
|
||||
@ArrayMaxSize(MAX_PREFILL_HARVESTS, {
|
||||
message: `harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
|
||||
})
|
||||
@ArrayUnique({ message: 'harvestIds enthält eine Ernte doppelt' })
|
||||
@IsString({ each: true, message: IDS_MESSAGE })
|
||||
@MaxLength(MAX_ID_LENGTH, { each: true, message: IDS_MESSAGE })
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, { each: true, message: IDS_MESSAGE })
|
||||
harvestIds!: string[];
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateExtractionDto } from './dto/create-extraction.dto';
|
||||
import { ExtractionPrefillQuery } from './dto/extraction-prefill.query';
|
||||
import { UpdateExtractionDto } from './dto/update-extraction.dto';
|
||||
import { ExtractionsService } from './extractions.service';
|
||||
|
||||
@@ -19,6 +28,15 @@ export class ExtractionsController {
|
||||
return this.extractionsService.findAll();
|
||||
}
|
||||
|
||||
// Before ':id', so 'prefill' is not read as an id. Needs the create
|
||||
// permission, not read: it offers the same data the create flow needs, and
|
||||
// POST answers it anyway (ruling, Task 28 review).
|
||||
@RequirePermission('extractions:create')
|
||||
@Get('prefill')
|
||||
prefill(@Query() query: ExtractionPrefillQuery) {
|
||||
return this.extractionsService.prefill(query.harvestIds);
|
||||
}
|
||||
|
||||
@RequirePermission('extractions:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
|
||||
import { ExtractionsController } from './extractions.controller';
|
||||
import { ExtractionsService } from './extractions.service';
|
||||
|
||||
@Module({
|
||||
imports: [WithdrawalModule],
|
||||
controllers: [ExtractionsController],
|
||||
providers: [ExtractionsService],
|
||||
exports: [ExtractionsService],
|
||||
|
||||
@@ -2,32 +2,59 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ExtractionsService } from './extractions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
|
||||
|
||||
const entry = (harvestId: string, netWeight: number | null) => ({
|
||||
harvestId,
|
||||
harvest: { id: harvestId, netWeight },
|
||||
});
|
||||
|
||||
describe('ExtractionsService', () => {
|
||||
let service: ExtractionsService;
|
||||
let prisma: {
|
||||
extraction: { findUnique: jest.Mock; update: jest.Mock };
|
||||
extraction: { findUnique: jest.Mock };
|
||||
harvest: { findMany: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let withdrawalCheck: {
|
||||
conflictsForExtraction: jest.Mock;
|
||||
conflictsForHarvests: jest.Mock;
|
||||
warningsFor: jest.Mock;
|
||||
};
|
||||
let tx: {
|
||||
extraction: { create: jest.Mock; update: jest.Mock };
|
||||
extraction: {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findUniqueOrThrow: jest.Mock;
|
||||
};
|
||||
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
|
||||
const extraction = { id: 'extraction-1', date: new Date() };
|
||||
const extraction = {
|
||||
id: 'extraction-1',
|
||||
date: new Date(),
|
||||
amount: null as number | null,
|
||||
harvests: [entry('harvest-1', 20), entry('harvest-2', 22.5)],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
extraction: {
|
||||
create: jest.fn().mockResolvedValue(extraction),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue(extraction),
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue(extraction),
|
||||
},
|
||||
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
prisma = {
|
||||
extraction: { findUnique: jest.fn(), update: jest.fn() },
|
||||
extraction: { findUnique: jest.fn() },
|
||||
harvest: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
@@ -38,9 +65,17 @@ describe('ExtractionsService', () => {
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { record: jest.fn() };
|
||||
withdrawalCheck = {
|
||||
conflictsForExtraction: jest.fn().mockResolvedValue([]),
|
||||
conflictsForHarvests: jest.fn().mockResolvedValue([]),
|
||||
warningsFor: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
service = new ExtractionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
withdrawalCheck as unknown as WithdrawalCheckService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -52,8 +87,6 @@ describe('ExtractionsService', () => {
|
||||
});
|
||||
|
||||
it('create links every harvest to the extraction inside a transaction', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
|
||||
await service.create(
|
||||
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
'user-1',
|
||||
@@ -68,6 +101,61 @@ describe('ExtractionsService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('create writes the audit entry and ExtractionRecorded in the same transaction', async () => {
|
||||
await service.create(
|
||||
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Extraction',
|
||||
entityId: 'extraction-1',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ExtractionRecorded',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: 'extraction-1',
|
||||
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('create answers the extraction with withdrawal and plausibility warnings, never an error', async () => {
|
||||
const conflict = { harvestId: 'harvest-1' };
|
||||
withdrawalCheck.conflictsForExtraction.mockResolvedValue([conflict]);
|
||||
withdrawalCheck.warningsFor.mockResolvedValue([
|
||||
{ code: 'WITHDRAWAL_ACTIVE' },
|
||||
]);
|
||||
tx.extraction.findUniqueOrThrow.mockResolvedValue({
|
||||
...extraction,
|
||||
amount: 50,
|
||||
});
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
date: '2026-07-05',
|
||||
harvestIds: ['harvest-1', 'harvest-2'],
|
||||
amount: 50,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(withdrawalCheck.conflictsForExtraction).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'extraction-1',
|
||||
);
|
||||
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(tx, [conflict]);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'WITHDRAWAL_ACTIVE',
|
||||
'EXTRACTION_EXCEEDS_HARVEST',
|
||||
]);
|
||||
});
|
||||
|
||||
it('create rejects an unknown harvest id instead of failing on the DB foreign-key constraint', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
|
||||
|
||||
@@ -82,9 +170,8 @@ describe('ExtractionsService', () => {
|
||||
|
||||
it('update writes an audit log entry with before and after data', async () => {
|
||||
const updated = { ...extraction, notes: 'geändert' };
|
||||
prisma.extraction.findUnique
|
||||
.mockResolvedValueOnce(extraction)
|
||||
.mockResolvedValueOnce(updated);
|
||||
tx.extraction.findUnique.mockResolvedValueOnce(extraction);
|
||||
tx.extraction.findUniqueOrThrow.mockResolvedValueOnce(updated);
|
||||
|
||||
const result = await service.update(
|
||||
'extraction-1',
|
||||
@@ -92,7 +179,7 @@ describe('ExtractionsService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
expect(result).toEqual({ ...updated, warnings: [] });
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Extraction',
|
||||
@@ -101,13 +188,49 @@ describe('ExtractionsService', () => {
|
||||
beforeData: extraction,
|
||||
afterData: updated,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update replaces the whole harvest set when harvestIds is given', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
it('update locks the row first and takes "before" from the locked row', async () => {
|
||||
const locked = { ...extraction, notes: 'gesperrt gelesen' };
|
||||
tx.extraction.findUnique.mockResolvedValue(locked);
|
||||
|
||||
await service.update('extraction-1', { notes: 'neu' }, 'user-1');
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.extraction.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ beforeData: locked }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('update throws NotFoundException for an unknown extraction', async () => {
|
||||
tx.extraction.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update records ExtractionCorrected with the harvests after the change', async () => {
|
||||
await service.update('extraction-1', { notes: 'x' }, 'user-1');
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ExtractionCorrected',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: 'extraction-1',
|
||||
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('update replaces the whole harvest set when harvestIds is given', async () => {
|
||||
await service.update(
|
||||
'extraction-1',
|
||||
{ harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
@@ -127,7 +250,6 @@ describe('ExtractionsService', () => {
|
||||
});
|
||||
|
||||
it('update rejects an unknown harvest id before touching the database', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
|
||||
|
||||
await expect(
|
||||
@@ -139,4 +261,100 @@ describe('ExtractionsService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('prefill', () => {
|
||||
const harvestRow = (
|
||||
id: string,
|
||||
netWeight: number | null,
|
||||
honeyType: string | null,
|
||||
) => ({
|
||||
id,
|
||||
date: new Date('2026-07-05T00:00:00.000Z'),
|
||||
netWeight,
|
||||
honeyType,
|
||||
});
|
||||
|
||||
it('sums the net weights and keeps a shared honey type', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, 'Blütenhonig'),
|
||||
harvestRow('harvest-2', 22.5, 'Blütenhonig'),
|
||||
]);
|
||||
|
||||
const result = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
|
||||
expect(result).toEqual({
|
||||
amountKg: 42.5,
|
||||
honeyType: 'Blütenhonig',
|
||||
sources: [
|
||||
{ harvestId: 'harvest-1', date: '2026-07-05', netWeight: 20 },
|
||||
{ harvestId: 'harvest-2', date: '2026-07-05', netWeight: 22.5 },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('warns before saving when a chosen harvest lies in a withdrawal window', async () => {
|
||||
const conflict = { harvestId: 'harvest-1' };
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, null),
|
||||
]);
|
||||
withdrawalCheck.conflictsForHarvests.mockResolvedValue([conflict]);
|
||||
withdrawalCheck.warningsFor.mockResolvedValue([
|
||||
{ code: 'WITHDRAWAL_ACTIVE' },
|
||||
]);
|
||||
|
||||
const result = await service.prefill(['harvest-1']);
|
||||
|
||||
expect(withdrawalCheck.conflictsForHarvests).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
['harvest-1'],
|
||||
);
|
||||
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(prisma, [
|
||||
conflict,
|
||||
]);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'WITHDRAWAL_ACTIVE',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers no honey type when the harvests differ or one has none', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, 'Blütenhonig'),
|
||||
harvestRow('harvest-2', 22.5, 'Waldhonig'),
|
||||
]);
|
||||
const differing = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
expect(differing.honeyType).toBeNull();
|
||||
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, 'Blütenhonig'),
|
||||
harvestRow('harvest-2', 22.5, null),
|
||||
]);
|
||||
const partly = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
expect(partly.honeyType).toBeNull();
|
||||
});
|
||||
|
||||
it('offers no amount when a net weight is missing and hints instead', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, null),
|
||||
harvestRow('harvest-2', null, null),
|
||||
]);
|
||||
|
||||
const result = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
|
||||
expect(result.amountKg).toBeNull();
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'HARVEST_NET_MISSING',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an unknown harvest id', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 1, null),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.prefill(['harvest-1', 'missing-harvest']),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,23 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import {
|
||||
withWarnings,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import {
|
||||
extractionWarnings,
|
||||
roundKg,
|
||||
sharedHoneyType,
|
||||
} from '../honey-batches/honey-plausibility';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
|
||||
import { CreateExtractionDto } from './dto/create-extraction.dto';
|
||||
import { UpdateExtractionDto } from './dto/update-extraction.dto';
|
||||
|
||||
@@ -16,11 +31,37 @@ const extractionInclude = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROPOSAL =
|
||||
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
|
||||
|
||||
export class ExtractionPrefillSource {
|
||||
@ApiProperty() harvestId!: string;
|
||||
@ApiProperty({ description: 'Berliner Tag, JJJJ-MM-TT' }) date!: string;
|
||||
@ApiProperty({ type: Number, nullable: true }) netWeight!: number | null;
|
||||
}
|
||||
|
||||
/** What the extraction form may offer from the chosen harvests (spec 7.6). */
|
||||
export class ExtractionPrefill {
|
||||
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
|
||||
amountKg!: number | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
|
||||
honeyType!: string | null;
|
||||
|
||||
@ApiProperty({ type: [ExtractionPrefillSource] })
|
||||
sources!: ExtractionPrefillSource[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
warnings!: PlausibilityWarning[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExtractionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly withdrawalCheck: WithdrawalCheckService,
|
||||
) {}
|
||||
|
||||
async findAll() {
|
||||
@@ -55,9 +96,42 @@ export class ExtractionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The extraction as the response body plus its warnings: a harvest inside
|
||||
* a withdrawal window and the amount against the net weight of the
|
||||
* harvests. Warnings inform, they never block (Annahme 10). The
|
||||
* withdrawal warning goes to everyone who records the extraction, whether
|
||||
* or not they may read treatments (ruling: food safety beats the read
|
||||
* split); the text names only colony, product and end day.
|
||||
*/
|
||||
private async withAllWarnings<
|
||||
T extends {
|
||||
id: string;
|
||||
amount: number | null;
|
||||
harvests: { harvestId: string; harvest: { netWeight: number | null } }[];
|
||||
},
|
||||
>(db: DbClient, extraction: T) {
|
||||
const conflicts = await this.withdrawalCheck.conflictsForExtraction(
|
||||
db,
|
||||
extraction.id,
|
||||
);
|
||||
return withWarnings(extraction, [
|
||||
...(await this.withdrawalCheck.warningsFor(db, conflicts)),
|
||||
...extractionWarnings(
|
||||
extraction.amount,
|
||||
extraction.harvests.map((entry) => ({
|
||||
id: entry.harvestId,
|
||||
netWeight: entry.harvest.netWeight,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async create(dto: CreateExtractionDto, actingUserId: string) {
|
||||
await this.assertHarvestsExist(dto.harvestIds);
|
||||
const extraction = await this.prisma.$transaction(async (tx) => {
|
||||
// Extraction, harvest links, audit entry and ExtractionRecorded commit
|
||||
// together; the withdrawal notification follows from the event.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.extraction.create({
|
||||
data: {
|
||||
date: new Date(dto.date),
|
||||
@@ -75,26 +149,48 @@ export class ExtractionsService {
|
||||
harvestId,
|
||||
})),
|
||||
});
|
||||
return created;
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Extraction',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'ExtractionRecorded',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: created.id,
|
||||
payload: { harvestIds: dto.harvestIds },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
const extraction = await tx.extraction.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: extractionInclude,
|
||||
});
|
||||
return this.withAllWarnings(tx, extraction);
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Extraction',
|
||||
entityId: extraction.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: extraction,
|
||||
});
|
||||
return this.findById(extraction.id);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
const { harvestIds, ...scalarFields } = dto;
|
||||
if (harvestIds) {
|
||||
await this.assertHarvestsExist(harvestIds);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// "Before" is read under the row lock, not before the transaction: a
|
||||
// concurrent edit cannot slip in between.
|
||||
await lockRowForUpdate(tx, 'Extraction', id);
|
||||
const before = await tx.extraction.findUnique({
|
||||
where: { id },
|
||||
include: extractionInclude,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Extraction not found');
|
||||
}
|
||||
await tx.extraction.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -113,17 +209,85 @@ export class ExtractionsService {
|
||||
})),
|
||||
});
|
||||
}
|
||||
const extraction = await tx.extraction.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: extractionInclude,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Extraction',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: extraction,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'ExtractionCorrected',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: id,
|
||||
payload: {
|
||||
harvestIds: extraction.harvests.map((entry) => entry.harvestId),
|
||||
},
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
return this.withAllWarnings(tx, extraction);
|
||||
});
|
||||
}
|
||||
|
||||
const extraction = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'Extraction',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: extraction,
|
||||
/**
|
||||
* What the form may offer for the chosen harvests (spec 7.6). The amount is
|
||||
* a measurement and so only a proposal: the sum of the net weights, and
|
||||
* only when every one is known. Read only, no lock.
|
||||
*/
|
||||
async prefill(harvestIds: readonly string[]): Promise<ExtractionPrefill> {
|
||||
const ids = [...new Set(harvestIds)];
|
||||
const harvests = await this.prisma.harvest.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, date: true, netWeight: true, honeyType: true },
|
||||
orderBy: [{ date: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return extraction;
|
||||
const found = new Set(harvests.map((harvest) => harvest.id));
|
||||
const missing = ids.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Unknown harvest id(s): ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const conflicts = await this.withdrawalCheck.conflictsForHarvests(
|
||||
this.prisma,
|
||||
ids,
|
||||
);
|
||||
const allNetKnown = harvests.every((harvest) => harvest.netWeight !== null);
|
||||
return {
|
||||
amountKg:
|
||||
allNetKnown && harvests.length > 0
|
||||
? roundKg(
|
||||
harvests.reduce(
|
||||
(sum, harvest) => sum + (harvest.netWeight ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
honeyType: sharedHoneyType(harvests),
|
||||
sources: harvests.map((harvest) => ({
|
||||
harvestId: harvest.id,
|
||||
date: berlinDayOf(harvest.date),
|
||||
netWeight: harvest.netWeight,
|
||||
})),
|
||||
// The same warnings the save will answer, so the form can warn first.
|
||||
warnings: [
|
||||
...(await this.withdrawalCheck.warningsFor(this.prisma, conflicts)),
|
||||
...extractionWarnings(
|
||||
null,
|
||||
harvests.map((harvest) => ({
|
||||
id: harvest.id,
|
||||
netWeight: harvest.netWeight,
|
||||
})),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
const MAX_ID_LENGTH = 64;
|
||||
|
||||
export class HoneyBatchPrefillQuery {
|
||||
@ApiProperty({ description: 'Die Schleuderung, aus der die Charge entsteht' })
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
@Matches(/^[A-Za-z0-9_-]+$/)
|
||||
extractionId!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The honey batch as the API and the webhook `batch.created` carry it. One
|
||||
* definition: the service answers with it and the webhook handler loads the
|
||||
* batch with it, so both payloads keep the same shape.
|
||||
*/
|
||||
export const honeyBatchInclude = {
|
||||
measurements: true,
|
||||
storageContainer: true,
|
||||
extraction: {
|
||||
include: {
|
||||
harvests: {
|
||||
include: {
|
||||
harvest: { include: { colonies: { include: { colony: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -20,6 +20,7 @@ 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 { HoneyBatchesService } from './honey-batches.service';
|
||||
|
||||
@ApiTags('honey-batches')
|
||||
@@ -34,6 +35,15 @@ export class HoneyBatchesController {
|
||||
return this.honeyBatchesService.findAll({ status });
|
||||
}
|
||||
|
||||
// Before ':id', so 'prefill' is not read as an id. Needs the create
|
||||
// permission, not read: it offers the same data the create flow needs, and
|
||||
// POST answers it anyway (ruling, Task 28 review).
|
||||
@RequirePermission('honey-batches:create')
|
||||
@Get('prefill')
|
||||
prefill(@Query() query: HoneyBatchPrefillQuery) {
|
||||
return this.honeyBatchesService.prefill(query.extractionId);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
|
||||
describe('HoneyBatchesService', () => {
|
||||
@@ -11,6 +11,7 @@ describe('HoneyBatchesService', () => {
|
||||
honeyBatch: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findUniqueOrThrow: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -19,6 +20,7 @@ describe('HoneyBatchesService', () => {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
extraction: { findUnique: jest.Mock };
|
||||
storageContainer: { findUnique: jest.Mock };
|
||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -28,8 +30,11 @@ describe('HoneyBatchesService', () => {
|
||||
delete: jest.Mock;
|
||||
};
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let automation: { generateNumber: jest.Mock };
|
||||
|
||||
const extraction = {
|
||||
id: 'extraction-1',
|
||||
@@ -49,6 +54,7 @@ describe('HoneyBatchesService', () => {
|
||||
const honeyBatch = {
|
||||
id: 'batch-1',
|
||||
batchNumber: 'HC-2026-001',
|
||||
extractionId: 'extraction-1',
|
||||
honeyType: 'Blütenhonig',
|
||||
amount: 20,
|
||||
status: 'RAW',
|
||||
@@ -61,6 +67,7 @@ describe('HoneyBatchesService', () => {
|
||||
honeyBatch: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
@@ -69,6 +76,7 @@ describe('HoneyBatchesService', () => {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
extraction: { findUnique: jest.fn() },
|
||||
storageContainer: { findUnique: jest.fn() },
|
||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -77,16 +85,21 @@ describe('HoneyBatchesService', () => {
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
$transaction: jest.fn(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
),
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
events = { record: jest.fn() };
|
||||
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
service = new HoneyBatchesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
automation as unknown as AutomationService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -97,20 +110,77 @@ describe('HoneyBatchesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('create stores the batch and returns it with relations', async () => {
|
||||
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
describe('create', () => {
|
||||
const dto = { batchNumber: 'HC-2026-001', extractionId: 'extraction-1' };
|
||||
|
||||
await service.create(
|
||||
{ batchNumber: 'HC-2026-001', extractionId: 'extraction-1' },
|
||||
'user-1',
|
||||
);
|
||||
beforeEach(() => {
|
||||
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
});
|
||||
});
|
||||
|
||||
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
|
||||
[{ data: { batchNumber: string; extractionId: string } }],
|
||||
];
|
||||
expect(createArg.data.batchNumber).toBe('HC-2026-001');
|
||||
expect(createArg.data.extractionId).toBe('extraction-1');
|
||||
it('stores the batch and returns it with relations and warnings', async () => {
|
||||
const result = await service.create(dto, 'user-1');
|
||||
|
||||
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
|
||||
[{ data: { batchNumber: string; extractionId: string } }],
|
||||
];
|
||||
expect(createArg.data.batchNumber).toBe('HC-2026-001');
|
||||
expect(createArg.data.extractionId).toBe('extraction-1');
|
||||
expect(result).toEqual({ ...honeyBatch, warnings: [] });
|
||||
});
|
||||
|
||||
it('writes the audit entry and HoneyBatchRecorded in the same transaction', async () => {
|
||||
await service.create(dto, 'user-1');
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HoneyBatch',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(prisma, {
|
||||
type: 'HoneyBatchRecorded',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: 'batch-1',
|
||||
payload: { extractionId: 'extraction-1' },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the extraction row before it writes and counts the batches', async () => {
|
||||
await service.create(dto, 'user-1');
|
||||
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
prisma.honeyBatch.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when the batches exceed the extraction, but still answers', 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.map((warning) => warning.code)).toEqual([
|
||||
'BATCHES_EXCEED_EXTRACTION',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('traceability flattens the extraction, harvest, and colony chain', async () => {
|
||||
@@ -365,11 +435,68 @@ describe('HoneyBatchesService', () => {
|
||||
|
||||
describe('update', () => {
|
||||
beforeEach(() => {
|
||||
prisma.$transaction.mockImplementation(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the row first, reads "before" from the locked row and records HoneyBatchCorrected', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
|
||||
const result = await service.update(
|
||||
'batch-1',
|
||||
{ honeyType: 'Waldhonig' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// "Before" is the read after the last lock.
|
||||
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
reads[reads.length - 1],
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
beforeData: honeyBatch,
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(prisma, {
|
||||
type: 'HoneyBatchCorrected',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: 'batch-1',
|
||||
payload: { extractionId: 'extraction-1' },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('locks the extraction before the batch and before counting the batches', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
|
||||
await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1');
|
||||
|
||||
// Extraction lock, then batch lock: two statements, both before the
|
||||
// update and the count of the batches.
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
prisma.honeyBatch.update.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown batch', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { honeyType: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a relocation when an edit corrects the storage container', async () => {
|
||||
@@ -418,6 +545,7 @@ describe('HoneyBatchesService', () => {
|
||||
entityId: 'batch-1',
|
||||
action: 'UPDATE',
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -433,6 +561,72 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefill', () => {
|
||||
it('proposes the rest of the extraction with its honey type and water content', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: 42.5,
|
||||
waterContent: 17.2,
|
||||
harvests: [
|
||||
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
|
||||
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
|
||||
],
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 20, amount: 20 },
|
||||
{ id: 'batch-9', initialAmount: null, amount: null },
|
||||
]);
|
||||
|
||||
const result = await service.prefill('extraction-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
remainingKg: 22.5,
|
||||
honeyType: 'Bl\u00fctenhonig',
|
||||
waterContent: 17.2,
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('proposes no amount when the extraction has none and never a negative one', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
waterContent: null,
|
||||
harvests: [{ harvest: { honeyType: null } }],
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
const none = await service.prefill('extraction-1');
|
||||
expect(none).toMatchObject({
|
||||
remainingKg: null,
|
||||
honeyType: null,
|
||||
waterContent: null,
|
||||
});
|
||||
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: 10,
|
||||
waterContent: null,
|
||||
harvests: [],
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 12, amount: 12 },
|
||||
]);
|
||||
const over = await service.prefill('extraction-1');
|
||||
expect(over.remainingKg).toBe(0);
|
||||
expect(over.warnings.map((warning) => warning.code)).toEqual([
|
||||
'BATCHES_EXCEED_EXTRACTION',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown extraction', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.prefill('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMeasurement corrects a reading and logs before/after', async () => {
|
||||
const before = {
|
||||
id: 'measurement-1',
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
withWarnings,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import { HoneyBatchStatus } from '../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
@@ -14,32 +22,43 @@ 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';
|
||||
|
||||
const honeyBatchInclude = {
|
||||
measurements: true,
|
||||
storageContainer: true,
|
||||
extraction: {
|
||||
include: {
|
||||
harvests: {
|
||||
include: {
|
||||
harvest: { include: { colonies: { include: { colony: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
import { honeyBatchInclude } from './honey-batch-include';
|
||||
import {
|
||||
batchWarnings,
|
||||
roundKg,
|
||||
sharedHoneyType,
|
||||
sumInitialAmounts,
|
||||
} from './honey-plausibility';
|
||||
|
||||
export interface HoneyBatchFilter {
|
||||
status?: HoneyBatchStatus;
|
||||
}
|
||||
|
||||
const PROPOSAL =
|
||||
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
|
||||
|
||||
/** What the honey batch form may offer for an extraction (spec 7.6). */
|
||||
export class HoneyBatchPrefill {
|
||||
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
|
||||
remainingKg!: number | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
|
||||
honeyType!: string | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
|
||||
waterContent!: number | null;
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
warnings!: PlausibilityWarning[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HoneyBatchesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: HoneyBatchFilter = {}) {
|
||||
@@ -61,6 +80,38 @@ export class HoneyBatchesService {
|
||||
return honeyBatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private async batchesOf(db: DbClient, extractionId: string) {
|
||||
const batches = await db.honeyBatch.findMany({
|
||||
where: { extractionId },
|
||||
select: { id: true, initialAmount: true, amount: 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. */
|
||||
private async warningsFor(
|
||||
db: DbClient,
|
||||
extractionId: string,
|
||||
): Promise<PlausibilityWarning[]> {
|
||||
const extraction = await db.extraction.findUnique({
|
||||
where: { id: extractionId },
|
||||
select: { id: true, amount: true },
|
||||
});
|
||||
if (extraction === null) {
|
||||
return [];
|
||||
}
|
||||
return batchWarnings(extraction, await this.batchesOf(db, extractionId));
|
||||
}
|
||||
|
||||
async create(dto: CreateHoneyBatchDto, actingUserId: string) {
|
||||
const batchNumber =
|
||||
dto.batchNumber ??
|
||||
@@ -70,34 +121,83 @@ export class HoneyBatchesService {
|
||||
'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format" aktivieren)',
|
||||
);
|
||||
}
|
||||
const honeyBatch = await this.prisma.honeyBatch.create({
|
||||
data: { ...dto, batchNumber },
|
||||
// Batch, audit entry and HoneyBatchRecorded commit together; the webhook
|
||||
// batch.created follows from the event.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 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);
|
||||
const honeyBatch = await tx.honeyBatch.create({
|
||||
data: { ...dto, batchNumber },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: honeyBatch.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: honeyBatch,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'HoneyBatchRecorded',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: honeyBatch.id,
|
||||
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),
|
||||
);
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: honeyBatch.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: honeyBatch,
|
||||
});
|
||||
const created = await this.findById(honeyBatch.id);
|
||||
await this.webhooks.dispatch('batch.created', created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
// A relocation is a move into a container. null only removes the
|
||||
// assignment - a batch may stand in no container, as it may be created
|
||||
// without one - so it is recorded by the audit entry below, not as a
|
||||
// relocation (which always needs a target container).
|
||||
const movedToContainerId =
|
||||
typeof dto.storageContainerId === 'string' &&
|
||||
dto.storageContainerId !== before.storageContainerId
|
||||
? dto.storageContainerId
|
||||
: null;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Lock order Extraction -> HoneyBatch. The extraction ids (current and
|
||||
// a new one) come from an unlocked peek; the locked re-read below
|
||||
// proves the peek still holds.
|
||||
const peek = await tx.honeyBatch.findUnique({
|
||||
where: { id },
|
||||
select: { extractionId: true },
|
||||
});
|
||||
if (!peek) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
await lockRowsForUpdate(tx, 'Extraction', [
|
||||
...new Set([peek.extractionId, dto.extractionId ?? peek.extractionId]),
|
||||
]);
|
||||
// "Before" is read under the row lock, not before the transaction: a
|
||||
// concurrent edit cannot slip in between.
|
||||
await lockRowForUpdate(tx, 'HoneyBatch', id);
|
||||
const before = await tx.honeyBatch.findUnique({
|
||||
where: { id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (before.extractionId !== peek.extractionId) {
|
||||
throw new ConflictException(
|
||||
'Die Honigcharge wurde gerade ge\u00e4ndert. Bitte erneut versuchen.',
|
||||
);
|
||||
}
|
||||
// A relocation is a move into a container. null only removes the
|
||||
// assignment - a batch may stand in no container, as it may be created
|
||||
// without one - so it is recorded by the audit entry below, not as a
|
||||
// relocation (which always needs a target container).
|
||||
const movedToContainerId =
|
||||
typeof dto.storageContainerId === 'string' &&
|
||||
dto.storageContainerId !== before.storageContainerId
|
||||
? dto.storageContainerId
|
||||
: null;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
@@ -116,17 +216,69 @@ export class HoneyBatchesService {
|
||||
},
|
||||
});
|
||||
}
|
||||
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 withWarnings(
|
||||
updated,
|
||||
await this.warningsFor(tx, updated.extractionId),
|
||||
);
|
||||
});
|
||||
const updated = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* one its harvests share) and water content. Proposals, never filled in
|
||||
* silently. Read only, no lock.
|
||||
*/
|
||||
async prefill(extractionId: string): Promise<HoneyBatchPrefill> {
|
||||
const extraction = await this.prisma.extraction.findUnique({
|
||||
where: { id: extractionId },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
waterContent: true,
|
||||
harvests: { select: { harvest: { select: { honeyType: true } } } },
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
if (!extraction) {
|
||||
throw new NotFoundException('Extraction not found');
|
||||
}
|
||||
const batches = await this.batchesOf(this.prisma, extractionId);
|
||||
return {
|
||||
remainingKg:
|
||||
extraction.amount === null
|
||||
? null
|
||||
: Math.max(
|
||||
0,
|
||||
roundKg(extraction.amount - sumInitialAmounts(batches)),
|
||||
),
|
||||
honeyType: sharedHoneyType(
|
||||
extraction.harvests.map((entry) => entry.harvest),
|
||||
),
|
||||
waterContent: extraction.waterContent,
|
||||
warnings: batchWarnings(extraction, batches),
|
||||
};
|
||||
}
|
||||
|
||||
async updateMeasurement(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
batchWarnings,
|
||||
extractionWarnings,
|
||||
sharedHoneyType,
|
||||
} from './honey-plausibility';
|
||||
|
||||
describe('extractionWarnings', () => {
|
||||
it('warns when the extraction exceeds the known net harvest', () => {
|
||||
const warnings = extractionWarnings(50, [
|
||||
{ id: 'h1', netWeight: 20 },
|
||||
{ id: 'h2', netWeight: 22.5 },
|
||||
]);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
code: 'EXTRACTION_EXCEEDS_HARVEST',
|
||||
severity: 'WARNUNG',
|
||||
message:
|
||||
'Die Schleudermenge (50 kg) liegt über dem Netto der Ernten (42,5 kg).',
|
||||
refs: [
|
||||
{ type: 'Harvest', id: 'h1' },
|
||||
{ type: 'Harvest', id: 'h2' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('stays silent when the extraction does not exceed the net harvest', () => {
|
||||
expect(extractionWarnings(42.5, [{ id: 'h1', netWeight: 42.5 }])).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(extractionWarnings(10, [{ id: 'h1', netWeight: 42.5 }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('hints when a net weight is missing and does not compare then', () => {
|
||||
const warnings = extractionWarnings(500, [
|
||||
{ id: 'h1', netWeight: 20 },
|
||||
{ id: 'h2', netWeight: null },
|
||||
]);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
code: 'HARVEST_NET_MISSING',
|
||||
severity: 'HINWEIS',
|
||||
message:
|
||||
'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
|
||||
refs: [{ type: 'Harvest', id: 'h2' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('says how many harvests lack the net weight', () => {
|
||||
const [warning] = extractionWarnings(null, [
|
||||
{ id: 'h1', netWeight: null },
|
||||
{ id: 'h2', netWeight: null },
|
||||
]);
|
||||
|
||||
expect(warning.message).toBe(
|
||||
'Bei 2 Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
|
||||
);
|
||||
});
|
||||
|
||||
it('has nothing to compare without an amount or without harvests', () => {
|
||||
expect(extractionWarnings(null, [{ id: 'h1', netWeight: 5 }])).toEqual([]);
|
||||
expect(extractionWarnings(5, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchWarnings', () => {
|
||||
it('warns when the initial amounts of the batches exceed the extraction', () => {
|
||||
const warnings = batchWarnings({ id: 'e1', amount: 40 }, [
|
||||
{ id: 'b1', initialAmount: 25 },
|
||||
{ id: 'b2', initialAmount: 20.5 },
|
||||
]);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
code: 'BATCHES_EXCEED_EXTRACTION',
|
||||
severity: 'WARNUNG',
|
||||
message:
|
||||
'Die Chargen (45,5 kg) übersteigen die Schleudermenge (40 kg).',
|
||||
refs: [
|
||||
{ type: 'Extraction', id: 'e1' },
|
||||
{ type: 'HoneyBatch', id: 'b1' },
|
||||
{ type: 'HoneyBatch', id: 'b2' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not count batches without an initial amount and ignores an unknown extraction amount', () => {
|
||||
expect(
|
||||
batchWarnings({ id: 'e1', amount: 40 }, [
|
||||
{ id: 'b1', initialAmount: 40 },
|
||||
{ id: 'b2', initialAmount: null },
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
batchWarnings({ id: 'e1', amount: null }, [
|
||||
{ id: 'b1', initialAmount: 400 },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates float noise at the limit', () => {
|
||||
expect(
|
||||
batchWarnings({ id: 'e1', amount: 0.3 }, [
|
||||
{ id: 'b1', initialAmount: 0.1 },
|
||||
{ id: 'b2', initialAmount: 0.2 },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedHoneyType', () => {
|
||||
it('keeps the type all harvests share and nothing otherwise', () => {
|
||||
expect(
|
||||
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Linde' }]),
|
||||
).toBe('Linde');
|
||||
expect(
|
||||
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Raps' }]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: null }]),
|
||||
).toBeNull();
|
||||
expect(sharedHoneyType([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { formatNumberDe } from '../suggestions/texts/format-number';
|
||||
import {
|
||||
WARNING_SEVERITY,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
|
||||
/** Half a gram in kg: the tolerance of the honey ledger (spec 4.6). */
|
||||
const TOLERANCE_KG = 0.0005;
|
||||
const MAX_FRACTION_DIGITS = 3;
|
||||
|
||||
const kgText = (value: number): string =>
|
||||
`${formatNumberDe(value, MAX_FRACTION_DIGITS)} kg`;
|
||||
|
||||
/** The honey type all harvests share; null when they differ or one has none. */
|
||||
export function sharedHoneyType(
|
||||
harvests: readonly { honeyType: string | null }[],
|
||||
): string | null {
|
||||
const [first] = harvests;
|
||||
if (first === undefined || first.honeyType === null) {
|
||||
return null;
|
||||
}
|
||||
return harvests.every((harvest) => harvest.honeyType === first.honeyType)
|
||||
? first.honeyType
|
||||
: null;
|
||||
}
|
||||
|
||||
/** kg on a gram grid, so sums of floats compare and print cleanly. */
|
||||
export function roundKg(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The extraction (Schleuderung) against the net weight of its harvests
|
||||
* (spec 7.6). The amount is compared only when every net weight is known;
|
||||
* a missing one is a hint instead. Never blocks.
|
||||
*/
|
||||
export function extractionWarnings(
|
||||
amountKg: number | null,
|
||||
harvests: readonly { id: string; netWeight: number | null }[],
|
||||
): PlausibilityWarning[] {
|
||||
const missing = harvests.filter((harvest) => harvest.netWeight === null);
|
||||
if (missing.length > 0) {
|
||||
return [
|
||||
{
|
||||
code: 'HARVEST_NET_MISSING',
|
||||
severity: WARNING_SEVERITY.HARVEST_NET_MISSING,
|
||||
message:
|
||||
missing.length === 1
|
||||
? 'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.'
|
||||
: `Bei ${missing.length} Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.`,
|
||||
refs: missing.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (amountKg === null || harvests.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const netKg = roundKg(
|
||||
harvests.reduce((sum, harvest) => sum + (harvest.netWeight ?? 0), 0),
|
||||
);
|
||||
if (amountKg <= netKg + TOLERANCE_KG) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: 'EXTRACTION_EXCEEDS_HARVEST',
|
||||
severity: WARNING_SEVERITY.EXTRACTION_EXCEEDS_HARVEST,
|
||||
message: `Die Schleudermenge (${kgText(amountKg)}) liegt über dem Netto der Ernten (${kgText(netKg)}).`,
|
||||
refs: harvests.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Σ initial amount of the batches; a batch without one does not count. */
|
||||
export function sumInitialAmounts(
|
||||
batches: readonly { initialAmount: number | null }[],
|
||||
): number {
|
||||
return roundKg(
|
||||
batches.reduce((sum, batch) => sum + (batch.initialAmount ?? 0), 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The honey batches of one extraction against its amount (spec 7.6): their
|
||||
* initial amounts together must not exceed what was extracted. Nothing to
|
||||
* compare without an extraction amount. Never blocks.
|
||||
*/
|
||||
export function batchWarnings(
|
||||
extraction: { id: string; amount: number | null },
|
||||
batches: readonly { id: string; initialAmount: number | null }[],
|
||||
): PlausibilityWarning[] {
|
||||
if (extraction.amount === null) {
|
||||
return [];
|
||||
}
|
||||
const total = sumInitialAmounts(batches);
|
||||
if (total <= extraction.amount + TOLERANCE_KG) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: 'BATCHES_EXCEED_EXTRACTION',
|
||||
severity: WARNING_SEVERITY.BATCHES_EXCEED_EXTRACTION,
|
||||
message: `Die Chargen (${kgText(total)}) übersteigen die Schleudermenge (${kgText(extraction.amount)}).`,
|
||||
refs: [
|
||||
{ type: 'Extraction', id: extraction.id },
|
||||
...batches
|
||||
.filter((batch) => batch.initialAmount !== null)
|
||||
.map((batch) => ({ type: 'HoneyBatch', id: batch.id })),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
|
||||
import {
|
||||
CleanupJob,
|
||||
DOMAIN_EVENT_RETENTION_DAYS,
|
||||
@@ -59,6 +60,16 @@ describe('CleanupJob', () => {
|
||||
expect(JOB_RUN_STALE_HOURS).toBe(6);
|
||||
});
|
||||
|
||||
it('cleanup deletes weather days before January 1st four years back', async () => {
|
||||
await job.run();
|
||||
|
||||
const { sql, values } = statementOn('ApiaryWeatherDay');
|
||||
expect(sql).toContain('"day" <');
|
||||
expect(sql).toContain('Europe/Berlin');
|
||||
expect(sql).toContain('make_date');
|
||||
expect(values).toEqual([WEATHER_RETENTION_YEARS]);
|
||||
});
|
||||
|
||||
it('cleanup removes only expired rows and reports counts', async () => {
|
||||
const result = await job.run();
|
||||
|
||||
@@ -76,6 +87,7 @@ describe('CleanupJob', () => {
|
||||
'webhookDeliveries.failed': 14,
|
||||
'jobRuns.aborted': 1,
|
||||
'jobRuns.deleted': 15,
|
||||
weatherDays: 16,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
|
||||
@@ -63,6 +64,12 @@ export class CleanupJob implements Job {
|
||||
const jobRunsDeleted = await this.prisma.$executeRaw`
|
||||
DELETE FROM "JobRun"
|
||||
WHERE "startedAt" < now() - (${JOB_RUN_RETENTION_DAYS} * interval '1 day')`;
|
||||
// Daily means before January 1st of (year - 4): the season needs the
|
||||
// current year only, the rest is a short history.
|
||||
const weatherDays = await this.prisma.$executeRaw`
|
||||
DELETE FROM "ApiaryWeatherDay"
|
||||
WHERE "day" < make_date(
|
||||
extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`;
|
||||
return {
|
||||
idempotencyRecords,
|
||||
consumedTokens,
|
||||
@@ -73,6 +80,7 @@ export class CleanupJob implements Job {
|
||||
'webhookDeliveries.failed': webhookDeliveriesFailed,
|
||||
'jobRuns.aborted': jobRunsAborted,
|
||||
'jobRuns.deleted': jobRunsDeleted,
|
||||
weatherDays,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
import type { AutopilotStep } from '../autopilot/autopilot.step';
|
||||
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
|
||||
import type { PhenologyStep } from '../phenology/phenology.step';
|
||||
import type { DigestStep } from '../reminders/digest.step';
|
||||
import type { TimeEventsStep } from './steps/time-events.step';
|
||||
|
||||
@@ -14,11 +15,13 @@ describe('DailyEvaluationJob', () => {
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
};
|
||||
const PHENOLOGY_COUNTS = { phenologyApiaries: 2, seasonChanges: 1 };
|
||||
const AUTOPILOT_COUNTS = {
|
||||
autopilotColonies: 4,
|
||||
suggestionsOpened: 2,
|
||||
suggestionsResolved: 1,
|
||||
};
|
||||
let phenology: { name: 'phenology'; run: jest.Mock };
|
||||
let timeEvents: { name: 'time-events'; run: jest.Mock };
|
||||
let autopilot: { name: 'autopilot'; run: jest.Mock };
|
||||
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
|
||||
@@ -27,6 +30,8 @@ describe('DailyEvaluationJob', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
|
||||
phenology = { name: 'phenology', run: jest.fn() };
|
||||
phenology.run.mockResolvedValue(PHENOLOGY_COUNTS);
|
||||
timeEvents = { name: 'time-events', run: jest.fn() };
|
||||
timeEvents.run.mockResolvedValue(COUNTS);
|
||||
autopilot = { name: 'autopilot', run: jest.fn() };
|
||||
@@ -36,6 +41,7 @@ describe('DailyEvaluationJob', () => {
|
||||
digest = { name: 'digest', run: jest.fn() };
|
||||
digest.run.mockResolvedValue({ digestsWritten: 5 });
|
||||
job = new DailyEvaluationJob(
|
||||
phenology as unknown as PhenologyStep,
|
||||
timeEvents as unknown as TimeEventsStep,
|
||||
autopilot as unknown as AutopilotStep,
|
||||
expiry as unknown as SuggestionsExpiryStep,
|
||||
@@ -54,20 +60,25 @@ describe('DailyEvaluationJob', () => {
|
||||
|
||||
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
|
||||
expect(await job.run()).toEqual({
|
||||
...PHENOLOGY_COUNTS,
|
||||
...COUNTS,
|
||||
...AUTOPILOT_COUNTS,
|
||||
suggestionsExpired: 3,
|
||||
digestsWritten: 5,
|
||||
});
|
||||
expect(phenology.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(digest.run).toHaveBeenCalledWith('2026-06-10');
|
||||
});
|
||||
|
||||
it('runs time-events, autopilot, suggestions-expiry and digest in that order', async () => {
|
||||
it('runs phenology, time-events, autopilot, suggestions-expiry and digest in that order', async () => {
|
||||
await job.run();
|
||||
|
||||
expect(phenology.run.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
timeEvents.run.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
autopilot.run.mock.invocationCallOrder[0],
|
||||
);
|
||||
@@ -95,6 +106,24 @@ describe('DailyEvaluationJob', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('a failing phenology step does not stop the others', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
phenology.run.mockRejectedValue(
|
||||
new JobPartialFailure(PHENOLOGY_COUNTS, ['1 apiary']),
|
||||
);
|
||||
|
||||
const failure = await job.run().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(timeEvents.run).toHaveBeenCalled();
|
||||
expect(failure).toMatchObject({
|
||||
failedSteps: ['phenology'],
|
||||
result: { phenologyApiaries: 2, seasonChanges: 1, digestsWritten: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a failing autopilot step does not stop the expiry step', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
autopilot.run.mockRejectedValue(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AutopilotStep } from '../autopilot/autopilot.step';
|
||||
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { PhenologyStep } from '../phenology/phenology.step';
|
||||
import { DigestStep } from '../reminders/digest.step';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ export class DailyEvaluationJob implements Job {
|
||||
private readonly logger = new Logger(DailyEvaluationJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly phenology: PhenologyStep,
|
||||
private readonly timeEvents: TimeEventsStep,
|
||||
private readonly autopilot: AutopilotStep,
|
||||
private readonly suggestionsExpiry: SuggestionsExpiryStep,
|
||||
@@ -34,6 +36,7 @@ export class DailyEvaluationJob implements Job {
|
||||
/** Evaluates the current Berlin day. */
|
||||
async run(): Promise<JobResult> {
|
||||
const steps: DailyEvaluationStep[] = [
|
||||
this.phenology,
|
||||
this.timeEvents,
|
||||
this.autopilot,
|
||||
this.suggestionsExpiry,
|
||||
|
||||
@@ -87,6 +87,8 @@ function fakeDb(rows: FakeRows): FakeDb {
|
||||
})),
|
||||
),
|
||||
},
|
||||
// No stored phenology: every apiary gets the calendar state.
|
||||
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: {
|
||||
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
|
||||
const config = rows.rules?.[where.triggerType];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BackupModule } from '../backup/backup.module';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PhenologyModule } from '../phenology/phenology.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
@@ -50,6 +51,7 @@ const JOB_CLASSES = [
|
||||
SystemModule,
|
||||
SeasonModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
RemindersModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { grasslandTemperatureSum } from './gts';
|
||||
import type { DailyMean } from '../season/season-types';
|
||||
|
||||
function daysOf(
|
||||
year: number,
|
||||
from: string,
|
||||
count: number,
|
||||
meanTempC: number | null,
|
||||
): DailyMean[] {
|
||||
const start = Date.parse(`${year}-${from}T00:00:00.000Z`);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
day: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
meanTempC,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('grasslandTemperatureSum', () => {
|
||||
it('weights January 0.5, February 0.75 and March 1', () => {
|
||||
const days = [
|
||||
...daysOf(2026, '01-01', 1, 10),
|
||||
...daysOf(2026, '02-01', 1, 10),
|
||||
...daysOf(2026, '03-01', 1, 10),
|
||||
];
|
||||
|
||||
const january = grasslandTemperatureSum(days, 2026, '2026-01-01', 1);
|
||||
const february = grasslandTemperatureSum(days, 2026, '2026-02-01', 1);
|
||||
const march = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
|
||||
|
||||
expect(january.gts).toBe(5);
|
||||
// The days in between are missing, not zero: only the three count.
|
||||
expect(february.gts).toBe(12.5);
|
||||
expect(march.gts).toBe(22.5);
|
||||
});
|
||||
|
||||
it('negative daily means count 0', () => {
|
||||
const result = grasslandTemperatureSum(
|
||||
[...daysOf(2026, '03-01', 2, -4), ...daysOf(2026, '03-03', 1, 3)],
|
||||
2026,
|
||||
'2026-03-03',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.gts).toBe(3);
|
||||
});
|
||||
|
||||
it('is unusable with more than 10 % missing days', () => {
|
||||
const full = daysOf(2026, '01-01', 90, 5);
|
||||
const gappy = full.map((d, i) => (i < 10 ? { ...d, meanTempC: null } : d));
|
||||
const edge = full.map((d, i) => (i < 9 ? { ...d, meanTempC: null } : d));
|
||||
|
||||
const ok = grasslandTemperatureSum(full, 2026, '2026-03-31', 0.1);
|
||||
const bad = grasslandTemperatureSum(gappy, 2026, '2026-03-31', 0.1);
|
||||
|
||||
expect(ok).toMatchObject({
|
||||
expectedDays: 90,
|
||||
missingDays: 0,
|
||||
usable: true,
|
||||
});
|
||||
expect(bad).toMatchObject({
|
||||
expectedDays: 90,
|
||||
missingDays: 10,
|
||||
usable: false,
|
||||
});
|
||||
expect(grasslandTemperatureSum(edge, 2026, '2026-03-31', 0.1).usable).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('reaches 200 °C on the expected day', () => {
|
||||
// January 31 x 10 x 0.5 = 155; each February day adds 7.5: 200 on Feb 6.
|
||||
const days = [
|
||||
...daysOf(2026, '01-01', 31, 10),
|
||||
...daysOf(2026, '02-01', 10, 10),
|
||||
];
|
||||
|
||||
expect(grasslandTemperatureSum(days, 2026, '2026-02-05', 0.1).gts).toBe(
|
||||
192.5,
|
||||
);
|
||||
expect(grasslandTemperatureSum(days, 2026, '2026-02-06', 0.1).gts).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the last day with data and nothing before the year starts', () => {
|
||||
const days = daysOf(2026, '01-01', 10, 4);
|
||||
|
||||
expect(
|
||||
grasslandTemperatureSum(days, 2026, '2026-01-20', 1).throughDay,
|
||||
).toBe('2026-01-10');
|
||||
expect(grasslandTemperatureSum([], 2026, '2025-12-31', 1)).toEqual({
|
||||
gts: 0,
|
||||
throughDay: null,
|
||||
expectedDays: 0,
|
||||
missingDays: 0,
|
||||
usable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts February 28th and March 1st with their own weights', () => {
|
||||
const days = [
|
||||
{ day: '2026-02-28', meanTempC: 10 },
|
||||
{ day: '2026-03-01', meanTempC: 10 },
|
||||
];
|
||||
|
||||
const result = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
|
||||
|
||||
// 28 + 31 + 1 = 60 days from January 1st; 10 x 0.75 + 10 x 1.
|
||||
expect(result).toMatchObject({ gts: 17.5, expectedDays: 60 });
|
||||
});
|
||||
|
||||
it('knows February 29th in a leap year', () => {
|
||||
const days = [
|
||||
{ day: '2028-02-28', meanTempC: 10 },
|
||||
{ day: '2028-02-29', meanTempC: 10 },
|
||||
{ day: '2028-03-01', meanTempC: 10 },
|
||||
];
|
||||
|
||||
const result = grasslandTemperatureSum(days, 2028, '2028-03-01', 1);
|
||||
|
||||
expect(result).toMatchObject({ gts: 25, expectedDays: 61 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { addDays } from '../common/dates';
|
||||
import type { DailyMean } from '../season/season-types';
|
||||
|
||||
/** Month weights of the grassland temperature sum (DWD): January 0.5, February 0.75. */
|
||||
export const GTS_MONTH_FACTORS: Readonly<Record<number, number>> = {
|
||||
1: 0.5,
|
||||
2: 0.75,
|
||||
};
|
||||
|
||||
export interface GtsResult {
|
||||
/** Sum of the positive daily means, weighted by month, from January 1st. */
|
||||
gts: number;
|
||||
/** The last day with a mean at or before the requested end; null if none. */
|
||||
throughDay: string | null;
|
||||
/** Days from January 1st to the requested end. */
|
||||
expectedDays: number;
|
||||
/** Of those, the days without a mean. */
|
||||
missingDays: number;
|
||||
/** False when more than `maxGapShare` of the days are missing. */
|
||||
usable: boolean;
|
||||
}
|
||||
|
||||
const monthFactor = (day: string): number =>
|
||||
GTS_MONTH_FACTORS[Number(day.slice(5, 7))] ?? 1;
|
||||
|
||||
/**
|
||||
* The grassland temperature sum (Grünlandtemperatursumme, DWD) of `year` up
|
||||
* to and including `throughDay`: every day's positive mean counts, January
|
||||
* with 0.5, February with 0.75, from March with 1; negative means count 0.
|
||||
* Days without a mean count as missing; with more than `maxGapShare` of them
|
||||
* the sum is not usable.
|
||||
*/
|
||||
export function grasslandTemperatureSum(
|
||||
days: readonly DailyMean[],
|
||||
year: number,
|
||||
throughDay: string,
|
||||
maxGapShare: number,
|
||||
): GtsResult {
|
||||
const first = `${year}-01-01`;
|
||||
const last = throughDay > `${year}-12-31` ? `${year}-12-31` : throughDay;
|
||||
if (last < first) {
|
||||
return {
|
||||
gts: 0,
|
||||
throughDay: null,
|
||||
expectedDays: 0,
|
||||
missingDays: 0,
|
||||
usable: false,
|
||||
};
|
||||
}
|
||||
const means = new Map<string, number>();
|
||||
for (const { day, meanTempC } of days) {
|
||||
if (meanTempC !== null && Number.isFinite(meanTempC)) {
|
||||
means.set(day, meanTempC);
|
||||
}
|
||||
}
|
||||
let gts = 0;
|
||||
let expectedDays = 0;
|
||||
let missingDays = 0;
|
||||
let lastDataDay: string | null = null;
|
||||
for (let day = first; day <= last; day = addDays(day, 1)) {
|
||||
expectedDays += 1;
|
||||
const mean = means.get(day);
|
||||
if (mean === undefined) {
|
||||
missingDays += 1;
|
||||
continue;
|
||||
}
|
||||
lastDataDay = day;
|
||||
gts += Math.max(0, mean) * monthFactor(day);
|
||||
}
|
||||
return {
|
||||
gts,
|
||||
throughDay: lastDataDay,
|
||||
expectedDays,
|
||||
missingDays,
|
||||
usable: missingDays / expectedDays <= maxGapShare,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { calibrateThresholds } from './phenology-calibration';
|
||||
|
||||
const year = (
|
||||
firstReportDay: Record<string, string>,
|
||||
gtsByDay: Record<string, number>,
|
||||
) => ({
|
||||
firstReportDay,
|
||||
gtsOn: (day: string): number | null => gtsByDay[day] ?? null,
|
||||
});
|
||||
|
||||
describe('calibrateThresholds', () => {
|
||||
it('uses the median GTS on the first report day of up to three previous years', () => {
|
||||
// Most recent year first; the fourth year is ignored.
|
||||
const years = [
|
||||
year({ KIRSCHE: '2025-04-10' }, { '2025-04-10': 300 }),
|
||||
year({ KIRSCHE: '2024-04-20' }, { '2024-04-20': 340 }),
|
||||
year({ KIRSCHE: '2023-04-05' }, { '2023-04-05': 280 }),
|
||||
year({ KIRSCHE: '2022-04-05' }, { '2022-04-05': 9999 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({
|
||||
KIRSCHE: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('averages the two middle values for an even count', () => {
|
||||
const years = [
|
||||
year({ APFEL: '2025-04-25' }, { '2025-04-25': 400 }),
|
||||
year({ APFEL: '2024-04-25' }, { '2024-04-25': 440 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['APFEL'])).toEqual({ APFEL: 420 });
|
||||
});
|
||||
|
||||
it('skips years without a report or without GTS and never invents a threshold', () => {
|
||||
const years = [
|
||||
year({}, {}),
|
||||
year({ KIRSCHE: '2024-04-20' }, {}),
|
||||
year({ HOLUNDER: '2023-05-30' }, { '2023-05-30': 700 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({});
|
||||
expect(calibrateThresholds([], ['KIRSCHE'])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PhenologyPhaseKey } from '../season/season-types';
|
||||
|
||||
/** At most this many previous years take part in the calibration. */
|
||||
export const CALIBRATION_YEARS = 3;
|
||||
|
||||
export interface CalibrationYear {
|
||||
/** First report day (YYYY-MM-DD) per phase in that year. */
|
||||
firstReportDay: Partial<Record<PhenologyPhaseKey, string>>;
|
||||
/** GTS of that year on a day; null if the weather data does not allow it. */
|
||||
gtsOn(day: string): number | null;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* GTS thresholds from the reports of the previous years: per phase the median
|
||||
* of the GTS on the first report day, over up to CALIBRATION_YEARS years
|
||||
* (`years` most recent first). A phase without any usable year gets no
|
||||
* threshold - nothing is invented.
|
||||
*/
|
||||
export function calibrateThresholds(
|
||||
years: readonly CalibrationYear[],
|
||||
phases: readonly PhenologyPhaseKey[],
|
||||
): Partial<Record<PhenologyPhaseKey, number>> {
|
||||
const thresholds: Partial<Record<PhenologyPhaseKey, number>> = {};
|
||||
const recent = years.slice(0, CALIBRATION_YEARS);
|
||||
for (const phase of phases) {
|
||||
const values: number[] = [];
|
||||
for (const year of recent) {
|
||||
const day = year.firstReportDay[phase];
|
||||
const gts = day === undefined ? null : year.gtsOn(day);
|
||||
if (gts !== null) {
|
||||
values.push(gts);
|
||||
}
|
||||
}
|
||||
if (values.length > 0) {
|
||||
thresholds[phase] = median(values);
|
||||
}
|
||||
}
|
||||
return thresholds;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import type { GtsResult } from './gts';
|
||||
import {
|
||||
effectivePhaseOf,
|
||||
reportsApplyingTo,
|
||||
seasonNoteFor,
|
||||
type PhaseInputs,
|
||||
} from './phenology-phase';
|
||||
|
||||
const fresh = (gts: number, throughDay = '2026-04-19'): GtsResult => ({
|
||||
gts,
|
||||
throughDay,
|
||||
expectedDays: 109,
|
||||
missingDays: 0,
|
||||
usable: true,
|
||||
});
|
||||
|
||||
function inputs(overrides: Partial<PhaseInputs> = {}): PhaseInputs {
|
||||
return {
|
||||
today: '2026-04-20',
|
||||
reports: [],
|
||||
gts: fresh(250),
|
||||
hasCoordinates: true,
|
||||
thresholds: { SALWEIDE: 200 },
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
previous: null,
|
||||
allowBackward: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('effectivePhaseOf', () => {
|
||||
it('takes the maximum of report, GTS and calendar', () => {
|
||||
// GTS: SALWEIDE; calendar (04-15): KIRSCHE; report: APFEL.
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: false }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reportedPhase: 'APFEL',
|
||||
reportedOn: '2026-04-18',
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses GTS when it is ahead of the calendar', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ today: '2026-03-25', gts: fresh(250, '2026-03-24') }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'SALWEIDE',
|
||||
phaseSource: 'GTS',
|
||||
reportedPhase: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores revoked reports', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: true }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.reportedPhase).toBeNull();
|
||||
expect(result.effectivePhase).toBe('KIRSCHE');
|
||||
expect(result.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('ignores reports dated in the future', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'LINDE', date: '2026-04-21', revoked: false }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.reportedPhase).toBeNull();
|
||||
});
|
||||
|
||||
it('without a previous-year report there is no KIRSCHE threshold, the calendar applies', () => {
|
||||
// GTS is usable but only SALWEIDE has a threshold: KIRSCHE and later
|
||||
// phases come from the calendar.
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(10, '2026-04-15'), today: '2026-04-16' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let the calendar decide a phase that has a GTS threshold', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
gts: fresh(10, '2026-04-15'),
|
||||
today: '2026-04-16',
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 400 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.effectivePhase).toBeNull();
|
||||
});
|
||||
|
||||
it('a calibrated threshold lets GTS reach KIRSCHE', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
gts: fresh(320, '2026-04-09'),
|
||||
today: '2026-04-10',
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 300 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the calendar for every phase without usable weather', () => {
|
||||
const gappy: GtsResult = { ...fresh(250), usable: false, missingDays: 30 };
|
||||
const result = effectivePhaseOf(inputs({ gts: gappy }));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
note: 'WEATHER_GAPS',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not use GTS older than 3 days', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(250, '2026-04-16'), today: '2026-04-20' }),
|
||||
);
|
||||
|
||||
expect(result.note).toBe('WEATHER_STALE');
|
||||
expect(result.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('flags missing coordinates and uses the calendar', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: null, hasCoordinates: false }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
note: 'NO_COORDINATES',
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('has no phase before spring, with the calendar as source', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(5, '2026-01-30'), today: '2026-01-31' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: null,
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('never moves backwards unless a report was revoked', () => {
|
||||
const base = inputs({ previous: { phase: 'APFEL', source: 'REPORT' } });
|
||||
|
||||
const held = effectivePhaseOf(base);
|
||||
const released = effectivePhaseOf({ ...base, allowBackward: true });
|
||||
|
||||
expect(held).toMatchObject({
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
});
|
||||
expect(released.effectivePhase).toBe('KIRSCHE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectivePhaseOf edge cases', () => {
|
||||
it('still uses GTS whose last day is exactly 3 days old', () => {
|
||||
const base = {
|
||||
today: '2026-04-20',
|
||||
reports: [],
|
||||
hasCoordinates: true,
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 240 },
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
previous: null,
|
||||
allowBackward: false,
|
||||
} satisfies Omit<PhaseInputs, 'gts'>;
|
||||
|
||||
const used = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-17') });
|
||||
const stale = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-16') });
|
||||
|
||||
expect(used).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
note: null,
|
||||
});
|
||||
expect(stale.note).toBe('WEATHER_STALE');
|
||||
expect(stale.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('reports the earliest of two reports of the same phase', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [
|
||||
{ phase: 'APFEL', date: '2026-04-18', revoked: false },
|
||||
{ phase: 'APFEL', date: '2026-04-12', revoked: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reportedPhase: 'APFEL',
|
||||
reportedOn: '2026-04-12',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportsApplyingTo', () => {
|
||||
it('a report for all apiaries counts for each apiary', () => {
|
||||
const reports = [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'APFEL' as const,
|
||||
date: '2026-04-18',
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
apiaryId: 'a',
|
||||
phase: 'KIRSCHE' as const,
|
||||
date: '2026-04-10',
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
apiaryId: 'b',
|
||||
phase: 'LINDE' as const,
|
||||
date: '2026-04-11',
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
|
||||
expect(reportsApplyingTo('a', reports).map((r) => r.phase)).toEqual([
|
||||
'APFEL',
|
||||
'KIRSCHE',
|
||||
]);
|
||||
expect(reportsApplyingTo('c', reports).map((r) => r.phase)).toEqual([
|
||||
'APFEL',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seasonNoteFor', () => {
|
||||
const base = {
|
||||
useGts: true,
|
||||
hasCoordinates: true,
|
||||
today: '2026-04-20',
|
||||
lastDataDay: '2026-04-19' as string | null,
|
||||
gtsUsable: true,
|
||||
};
|
||||
|
||||
it.each([
|
||||
[{}, null],
|
||||
[{ useGts: false, hasCoordinates: false }, null],
|
||||
[{ hasCoordinates: false }, 'NO_COORDINATES'],
|
||||
[{ lastDataDay: null }, 'WEATHER_STALE'],
|
||||
[{ lastDataDay: '2026-04-16' }, 'WEATHER_STALE'],
|
||||
[{ lastDataDay: '2026-04-17' }, null],
|
||||
[{ gtsUsable: false }, 'WEATHER_GAPS'],
|
||||
[{ today: '2026-01-01', lastDataDay: null, gtsUsable: false }, null],
|
||||
])('%j -> %s', (override, note) => {
|
||||
expect(seasonNoteFor({ ...base, ...override })).toBe(note);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { daysBetween } from '../common/dates';
|
||||
import {
|
||||
PHENOLOGY_PHASES,
|
||||
type PhaseSourceKey,
|
||||
type PhenologyPhaseKey,
|
||||
type SeasonNote,
|
||||
} from '../season/season-types';
|
||||
import type { GtsResult } from './gts';
|
||||
|
||||
/** GTS whose last day with data is older than this many days is not used. */
|
||||
export const GTS_MAX_AGE_DAYS = 3;
|
||||
|
||||
export interface PhaseReport {
|
||||
phase: PhenologyPhaseKey;
|
||||
date: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
export interface PhaseInputs {
|
||||
today: string;
|
||||
reports: readonly PhaseReport[];
|
||||
gts: GtsResult | null;
|
||||
hasCoordinates: boolean;
|
||||
/** GTS thresholds in force: configured plus calibrated. */
|
||||
thresholds: Partial<Record<PhenologyPhaseKey, number>>;
|
||||
config: PhenologyConfig;
|
||||
previous: {
|
||||
phase: PhenologyPhaseKey | null;
|
||||
source?: PhaseSourceKey | null;
|
||||
} | null;
|
||||
/** True after a report was revoked: the phase may fall back. */
|
||||
allowBackward: boolean;
|
||||
}
|
||||
|
||||
export interface EffectivePhase {
|
||||
reportedPhase: PhenologyPhaseKey | null;
|
||||
reportedOn: string | null;
|
||||
estimatedPhase: PhenologyPhaseKey | null;
|
||||
effectivePhase: PhenologyPhaseKey | null;
|
||||
phaseSource: PhaseSourceKey | null;
|
||||
note: SeasonNote;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
phase: PhenologyPhaseKey;
|
||||
source: PhaseSourceKey;
|
||||
}
|
||||
|
||||
const rankOf = (phase: PhenologyPhaseKey): number =>
|
||||
PHENOLOGY_PHASES.indexOf(phase);
|
||||
|
||||
/** The higher phase; the first wins a tie. */
|
||||
function higher(a: Candidate | null, b: Candidate | null): Candidate | null {
|
||||
if (a === null || b === null) {
|
||||
return a ?? b;
|
||||
}
|
||||
return rankOf(b.phase) > rankOf(a.phase) ? b : a;
|
||||
}
|
||||
|
||||
/** Reports that count for an apiary: its own and those for all apiaries. */
|
||||
export function reportsApplyingTo<T extends { apiaryId: string | null }>(
|
||||
apiaryId: string,
|
||||
reports: readonly T[],
|
||||
): T[] {
|
||||
return reports.filter(
|
||||
(report) => report.apiaryId === null || report.apiaryId === apiaryId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the weather cannot decide the phase, if it cannot: no coordinates, the
|
||||
* last day with data too old, or too many gaps. Null if GTS is off or fine.
|
||||
*/
|
||||
export function seasonNoteFor(input: {
|
||||
useGts: boolean;
|
||||
hasCoordinates: boolean;
|
||||
today: string;
|
||||
lastDataDay: string | null;
|
||||
gtsUsable: boolean;
|
||||
}): SeasonNote {
|
||||
if (!input.useGts) {
|
||||
return null;
|
||||
}
|
||||
if (!input.hasCoordinates) {
|
||||
return 'NO_COORDINATES';
|
||||
}
|
||||
// January 1st: no day of the year has passed, there is nothing to judge.
|
||||
if (input.today.endsWith('-01-01')) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
input.lastDataDay === null ||
|
||||
daysBetween(input.lastDataDay, input.today) > GTS_MAX_AGE_DAYS
|
||||
) {
|
||||
return 'WEATHER_STALE';
|
||||
}
|
||||
return input.gtsUsable ? null : 'WEATHER_GAPS';
|
||||
}
|
||||
|
||||
/** The GTS that may decide today's phase; null if it is off, stale or gappy. */
|
||||
export function usableGts(
|
||||
inputs: Pick<PhaseInputs, 'today' | 'gts' | 'hasCoordinates' | 'config'>,
|
||||
): GtsResult | null {
|
||||
const { gts } = inputs;
|
||||
if (gts === null || !gts.usable || gts.throughDay === null) {
|
||||
return null;
|
||||
}
|
||||
const note = seasonNoteFor({
|
||||
useGts: inputs.config.useGts,
|
||||
hasCoordinates: inputs.hasCoordinates,
|
||||
today: inputs.today,
|
||||
lastDataDay: gts.throughDay,
|
||||
gtsUsable: gts.usable,
|
||||
});
|
||||
return inputs.config.useGts && note === null ? gts : null;
|
||||
}
|
||||
|
||||
function reported(inputs: PhaseInputs): {
|
||||
phase: PhenologyPhaseKey | null;
|
||||
on: string | null;
|
||||
} {
|
||||
const yearStart = `${inputs.today.slice(0, 4)}-01-01`;
|
||||
const valid = inputs.reports.filter(
|
||||
(report) =>
|
||||
!report.revoked &&
|
||||
report.date >= yearStart &&
|
||||
report.date <= inputs.today,
|
||||
);
|
||||
let phase: PhenologyPhaseKey | null = null;
|
||||
for (const report of valid) {
|
||||
if (phase === null || rankOf(report.phase) > rankOf(phase)) {
|
||||
phase = report.phase;
|
||||
}
|
||||
}
|
||||
const dates = valid.filter((r) => r.phase === phase).map((r) => r.date);
|
||||
return { phase, on: dates.length > 0 ? dates.sort()[0] : null };
|
||||
}
|
||||
|
||||
function gtsCandidate(
|
||||
inputs: PhaseInputs,
|
||||
gts: GtsResult | null,
|
||||
): Candidate | null {
|
||||
if (gts === null) {
|
||||
return null;
|
||||
}
|
||||
let best: Candidate | null = null;
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
const threshold = inputs.thresholds[phase];
|
||||
if (threshold !== undefined && gts.gts >= threshold) {
|
||||
best = { phase, source: 'GTS' };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The latest calendar phase reached; phases with a GTS threshold are skipped while GTS decides. */
|
||||
function calendarCandidate(
|
||||
inputs: PhaseInputs,
|
||||
gtsDecides: boolean,
|
||||
): Candidate | null {
|
||||
const year = inputs.today.slice(0, 4);
|
||||
let best: Candidate | null = null;
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
if (gtsDecides && inputs.thresholds[phase] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
if (`${year}-${inputs.config.calendarFallback[phase]}` <= inputs.today) {
|
||||
best = { phase, source: 'CALENDAR' };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase of an apiary today: the maximum of the user's report, the GTS
|
||||
* estimate and the calendar (the calendar only for phases without a GTS
|
||||
* threshold, or everywhere while the weather cannot be used). Within a year it
|
||||
* only moves forward, unless `allowBackward` (a report was revoked). Before
|
||||
* the first phase of the year there is no phase, and the calendar is the source.
|
||||
*/
|
||||
export function effectivePhaseOf(inputs: PhaseInputs): EffectivePhase {
|
||||
const gts = usableGts(inputs);
|
||||
const note = seasonNoteFor({
|
||||
useGts: inputs.config.useGts,
|
||||
hasCoordinates: inputs.hasCoordinates,
|
||||
today: inputs.today,
|
||||
lastDataDay: inputs.gts?.throughDay ?? null,
|
||||
gtsUsable: inputs.gts?.usable ?? false,
|
||||
});
|
||||
const report = reported(inputs);
|
||||
const reportCandidate: Candidate | null =
|
||||
report.phase === null ? null : { phase: report.phase, source: 'REPORT' };
|
||||
const estimate = higher(
|
||||
gtsCandidate(inputs, gts),
|
||||
calendarCandidate(inputs, gts !== null),
|
||||
);
|
||||
let chosen = higher(reportCandidate, estimate);
|
||||
|
||||
const previous = inputs.previous?.phase ?? null;
|
||||
if (
|
||||
previous !== null &&
|
||||
!inputs.allowBackward &&
|
||||
(chosen === null || rankOf(previous) > rankOf(chosen.phase))
|
||||
) {
|
||||
chosen = {
|
||||
phase: previous,
|
||||
source: inputs.previous?.source ?? chosen?.source ?? 'CALENDAR',
|
||||
};
|
||||
}
|
||||
return {
|
||||
reportedPhase: report.phase,
|
||||
reportedOn: report.on,
|
||||
estimatedPhase: estimate?.phase ?? null,
|
||||
effectivePhase: chosen?.phase ?? null,
|
||||
phaseSource: chosen?.source ?? 'CALENDAR',
|
||||
note,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ApiaryPhenology } from '../../generated/prisma';
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { seasonNoteFor } from './phenology-phase';
|
||||
|
||||
/**
|
||||
* The season state a stored ApiaryPhenology row stands for.
|
||||
*
|
||||
* `reportedOn` is not stored and stays null. `springSince` (the day FRUEHJAHR
|
||||
* began) is not stored either: in FRUEHJAHR it is `segmentSince`; later in the
|
||||
* year it is the calendar day of SALWEIDE, but not later than `segmentSince`;
|
||||
* before the spring it is null.
|
||||
*/
|
||||
export function seasonStateFromRow(
|
||||
row: Omit<ApiaryPhenology, 'updatedAt'>,
|
||||
config: PhenologyConfig,
|
||||
hasCoordinates: boolean,
|
||||
today: string,
|
||||
): SeasonState {
|
||||
const segmentSince = utcDay(row.segmentSince);
|
||||
const calendarSpring = `${row.year}-${config.calendarFallback.SALWEIDE}`;
|
||||
let springSince: string | null;
|
||||
if (row.segment === 'FRUEHJAHR') {
|
||||
springSince = segmentSince;
|
||||
} else if (row.segment === 'WINTERRUHE' && row.effectivePhase !== 'WINTER') {
|
||||
springSince = null;
|
||||
} else {
|
||||
springSince = calendarSpring < segmentSince ? calendarSpring : segmentSince;
|
||||
}
|
||||
const gtsThroughDay =
|
||||
row.gtsThroughDay === null ? null : utcDay(row.gtsThroughDay);
|
||||
return {
|
||||
apiaryId: row.apiaryId,
|
||||
year: row.year,
|
||||
phase: row.effectivePhase,
|
||||
phaseSource: row.phaseSource,
|
||||
reportedOn: null,
|
||||
segment: row.segment,
|
||||
segmentSince,
|
||||
springSince,
|
||||
gts: row.gts,
|
||||
gtsThroughDay,
|
||||
note: seasonNoteFor({
|
||||
useGts: config.useGts,
|
||||
hasCoordinates,
|
||||
today,
|
||||
lastDataDay: gtsThroughDay,
|
||||
gtsUsable: row.gts !== null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
interface Fixture {
|
||||
apiary: { latitude: number | null; longitude: number | null } | null;
|
||||
row: Record<string, unknown> | null;
|
||||
reports: Record<string, unknown>[];
|
||||
weather: { day: Date; meanTempC: number | null }[];
|
||||
}
|
||||
|
||||
function setup(fixture: Partial<Fixture> = {}) {
|
||||
const data: Fixture = {
|
||||
apiary: { latitude: 48.1, longitude: 9.9 },
|
||||
row: null,
|
||||
reports: [],
|
||||
weather: [],
|
||||
...fixture,
|
||||
};
|
||||
const upsert = jest.fn().mockResolvedValue(undefined);
|
||||
const record = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRaw = jest.fn().mockResolvedValue([{ id: 'apiary-1' }]);
|
||||
const tx = {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: jest.fn(),
|
||||
$transaction: undefined,
|
||||
apiary: { findUnique: jest.fn().mockResolvedValue(data.apiary) },
|
||||
automationRule: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'rule-1',
|
||||
active: true,
|
||||
actionTemplate: null,
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
}),
|
||||
},
|
||||
apiaryPhenology: {
|
||||
findUnique: jest.fn().mockResolvedValue(data.row),
|
||||
upsert,
|
||||
},
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue(data.reports) },
|
||||
apiaryWeatherDay: { findMany: jest.fn().mockResolvedValue(data.weather) },
|
||||
};
|
||||
const events = { record } as unknown as DomainEventsService;
|
||||
const service = new PhenologyStateService({} as PrismaService, events);
|
||||
return { service, tx, upsert, record, queryRaw };
|
||||
}
|
||||
|
||||
/** Every day of 2026 from January 1 to `through` at `mean` degrees. */
|
||||
function weather(through: string, mean: number) {
|
||||
const days: { day: Date; meanTempC: number | null }[] = [];
|
||||
for (
|
||||
let t = d('2026-01-01').getTime();
|
||||
t <= d(through).getTime();
|
||||
t += 86_400_000
|
||||
) {
|
||||
days.push({ day: new Date(t), meanTempC: mean });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
const asTx = (tx: unknown) => tx as Prisma.TransactionClient;
|
||||
|
||||
describe('PhenologyStateService.recomputeIn', () => {
|
||||
it('records SeasonPhaseChanged once per apiary and day on a change', async () => {
|
||||
// Stored: FRUEHJAHR since March 20th; today the report puts it on APFEL.
|
||||
const { service, tx, record } = setup({
|
||||
row: {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'SALWEIDE',
|
||||
reportedPhase: null,
|
||||
effectivePhase: 'SALWEIDE',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'FRUEHJAHR',
|
||||
segmentSince: d('2026-03-20'),
|
||||
},
|
||||
reports: [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'APFEL',
|
||||
date: d('2026-04-18'),
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
weather: weather('2026-04-19', 10),
|
||||
});
|
||||
|
||||
const first = await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
const second = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(first.changed).toBe(true);
|
||||
expect(first.state).toMatchObject({
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-20',
|
||||
});
|
||||
// Both runs report the same change (the mocked row stays unchanged); the
|
||||
// event carries the same dedupe key, so the outbox keeps one of them.
|
||||
expect(second.changed).toBe(true);
|
||||
const keys = record.mock.calls.map(
|
||||
([, event]: [unknown, { dedupeKey: string }]) => event.dedupeKey,
|
||||
);
|
||||
expect(keys).toEqual([
|
||||
'SeasonPhaseChanged:apiary-1:2026-04-20',
|
||||
'SeasonPhaseChanged:apiary-1:2026-04-20',
|
||||
]);
|
||||
expect(record).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: 'apiary-1',
|
||||
payload: {
|
||||
apiaryId: 'apiary-1',
|
||||
phase: 'APFEL',
|
||||
segment: 'SCHWARMZEIT',
|
||||
previousPhase: 'SALWEIDE',
|
||||
previousSegment: 'FRUEHJAHR',
|
||||
source: 'REPORT',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records nothing when phase and segment stay', async () => {
|
||||
const { service, tx, record, upsert } = setup({
|
||||
row: {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: 800,
|
||||
gtsThroughDay: d('2026-04-19'),
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: null,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: d('2026-04-15'),
|
||||
},
|
||||
weather: weather('2026-04-19', 10),
|
||||
});
|
||||
|
||||
const result = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.state.segmentSince).toBe('2026-04-15');
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('locks the apiary row before it reads the stored state', async () => {
|
||||
const { service, tx, queryRaw } = setup();
|
||||
const order: string[] = [];
|
||||
queryRaw.mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||
});
|
||||
tx.apiaryPhenology.findUnique.mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
|
||||
expect(order.slice(0, 2)).toEqual(['lock', 'read']);
|
||||
});
|
||||
|
||||
it('first row of the year equals the calendar state: no event', async () => {
|
||||
const { service, tx, record, upsert } = setup({
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
|
||||
const result = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
expect(result.state).toMatchObject({
|
||||
note: 'NO_COORDINATES',
|
||||
phase: 'KIRSCHE',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-15',
|
||||
});
|
||||
const call = upsert.mock.calls[0] as [{ create: Record<string, unknown> }];
|
||||
expect(call[0].create).toMatchObject({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows going backwards only with allowBackward', async () => {
|
||||
const stored = {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: 'LINDE',
|
||||
effectivePhase: 'LINDE',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SOMMER',
|
||||
segmentSince: d('2026-04-18'),
|
||||
};
|
||||
const held = setup({
|
||||
row: stored,
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
const released = setup({
|
||||
row: stored,
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
|
||||
const keep = await held.service.recomputeIn(
|
||||
asTx(held.tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
const back = await released.service.recomputeIn(
|
||||
asTx(released.tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
{ allowBackward: true },
|
||||
);
|
||||
|
||||
expect(keep.state.phase).toBe('LINDE');
|
||||
expect(keep.changed).toBe(false);
|
||||
expect(back.state.phase).toBe('KIRSCHE');
|
||||
expect(back.changed).toBe(true);
|
||||
expect(released.record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws for an apiary that does not exist', async () => {
|
||||
const { service, tx } = setup({ apiary: null });
|
||||
|
||||
await expect(
|
||||
service.recomputeIn(asTx(tx), 'gone', '2026-04-20'),
|
||||
).rejects.toThrow('Apiary gone not found');
|
||||
});
|
||||
});
|
||||
|
||||
function weatherRange(from: string, through: string, mean: number) {
|
||||
const days: { day: Date; meanTempC: number | null }[] = [];
|
||||
for (let t = d(from).getTime(); t <= d(through).getTime(); t += 86_400_000) {
|
||||
days.push({ day: new Date(t), meanTempC: mean });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
describe('PhenologyStateService fix round 1', () => {
|
||||
const noCoordinates = { latitude: null, longitude: null };
|
||||
|
||||
it('filters the reports in SQL: own and those for all apiaries', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
|
||||
const call = tx.phenologyReport.findMany.mock.calls[0] as [
|
||||
{ where: { revokedAt: null; OR: unknown } },
|
||||
];
|
||||
expect(call[0].where.revokedAt).toBeNull();
|
||||
expect(call[0].where.OR).toEqual([
|
||||
{ apiaryId: 'apiary-1' },
|
||||
{ apiaryId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recomputeAll reads the rule and the reports once, not per apiary', async () => {
|
||||
const { tx, record } = setup({ apiary: noCoordinates });
|
||||
const prisma = {
|
||||
apiary: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
|
||||
},
|
||||
automationRule: tx.automationRule,
|
||||
phenologyReport: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
apiaryId: 'apiary-2',
|
||||
phase: 'LINDE',
|
||||
date: d('2026-04-18'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
$transaction: (fn: (t: unknown) => Promise<unknown>) => fn(tx),
|
||||
};
|
||||
const all = new PhenologyStateService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record } as unknown as DomainEventsService,
|
||||
);
|
||||
|
||||
const result = await all.recomputeAll('2026-04-20');
|
||||
|
||||
expect(result).toEqual({ apiaries: 2, changes: 1, failed: 0 });
|
||||
expect(prisma.automationRule.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.phenologyReport.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(tx.phenologyReport.findMany).not.toHaveBeenCalled();
|
||||
// The LINDE report of apiary-2 moved only apiary-2 (SOMMER).
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recomputeAll counts a failing apiary and goes on', async () => {
|
||||
const { tx, record } = setup({ apiary: noCoordinates });
|
||||
const prisma = {
|
||||
apiary: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
|
||||
},
|
||||
automationRule: tx.automationRule,
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockImplementation((fn: (t: unknown) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const all = new PhenologyStateService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record } as unknown as DomainEventsService,
|
||||
);
|
||||
jest
|
||||
.spyOn(
|
||||
(all as unknown as { logger: { error: () => void } }).logger,
|
||||
'error',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(await all.recomputeAll('2026-04-20')).toMatchObject({
|
||||
apiaries: 1,
|
||||
failed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('pins the pre-spring state: phase null here, WINTER in the calendar, WINTERRUHE in both', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-02-10',
|
||||
);
|
||||
const calendar = calendarSeasonState(
|
||||
'apiary-1',
|
||||
'2026-02-10',
|
||||
DEFAULT_PHENOLOGY_CONFIG,
|
||||
);
|
||||
|
||||
expect(state.phase).toBeNull();
|
||||
expect(calendar.phase).toBe('WINTER');
|
||||
expect(state.segment).toBe('WINTERRUHE');
|
||||
expect(calendar.segment).toBe('WINTERRUHE');
|
||||
});
|
||||
|
||||
it('calibrates KIRSCHE from last year report and weather end to end', async () => {
|
||||
// 2025: 10 degrees all year; KIRSCHE reported 2025-04-10 -> GTS through
|
||||
// April 9th = 155 + 210 + 310 + 90 = 765. 2026: the same weather, so the
|
||||
// threshold is reached on 2026-04-10, five days before the calendar.
|
||||
const { service, tx } = setup({
|
||||
reports: [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'KIRSCHE',
|
||||
date: d('2025-04-10'),
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
weather: [
|
||||
...weatherRange('2025-01-01', '2025-12-31', 10),
|
||||
...weatherRange('2026-01-01', '2026-04-09', 10),
|
||||
],
|
||||
});
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-10',
|
||||
);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
gts: 765,
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
});
|
||||
|
||||
it('looks up the row of the new year at rollover, not last year', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2027-01-02',
|
||||
);
|
||||
|
||||
expect(tx.apiaryPhenology.findUnique).toHaveBeenCalledWith({
|
||||
where: { apiaryId_year: { apiaryId: 'apiary-1', year: 2027 } },
|
||||
});
|
||||
expect(state).toMatchObject({ year: 2027, phase: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { addDays, dayToDate } from '../common/dates';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { dailyDedupeKey, utcDay } from '../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { segmentOf } from '../season/season-segment';
|
||||
import type {
|
||||
DailyMean,
|
||||
PhenologyPhaseKey,
|
||||
SeasonState,
|
||||
} from '../season/season-types';
|
||||
import { grasslandTemperatureSum } from './gts';
|
||||
import {
|
||||
CALIBRATION_YEARS,
|
||||
calibrateThresholds,
|
||||
type CalibrationYear,
|
||||
} from './phenology-calibration';
|
||||
import {
|
||||
effectivePhaseOf,
|
||||
reportsApplyingTo,
|
||||
usableGts,
|
||||
} from './phenology-phase';
|
||||
import { seasonStateFromRow } from './phenology-row';
|
||||
|
||||
export interface DatedReport {
|
||||
apiaryId: string | null;
|
||||
phase: PhenologyPhaseKey;
|
||||
date: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
/** What recomputeAll reads once per run instead of once per apiary. */
|
||||
export interface PreloadedPhenology {
|
||||
config: PhenologyConfig;
|
||||
/** Reports of all apiaries (apiaryId null = all), revoked ones excluded. */
|
||||
reports: readonly DatedReport[];
|
||||
}
|
||||
|
||||
export interface RecomputeOptions {
|
||||
/** After a revoked report: the phase may fall back. */
|
||||
allowBackward?: boolean;
|
||||
preloaded?: PreloadedPhenology;
|
||||
}
|
||||
|
||||
export interface RecomputeResult {
|
||||
changed: boolean;
|
||||
state: SeasonState;
|
||||
}
|
||||
|
||||
export interface RecomputeAllResult {
|
||||
apiaries: number;
|
||||
changes: number;
|
||||
/** Apiaries whose recomputation failed; the next run tries again. */
|
||||
failed: number;
|
||||
}
|
||||
|
||||
function groupByYear(days: readonly DailyMean[]): Map<number, DailyMean[]> {
|
||||
const byYear = new Map<number, DailyMean[]>();
|
||||
for (const day of days) {
|
||||
const year = Number(day.day.slice(0, 4));
|
||||
byYear.set(year, [...(byYear.get(year) ?? []), day]);
|
||||
}
|
||||
return byYear;
|
||||
}
|
||||
|
||||
/** The calibration input of one previous year. */
|
||||
function calibrationYear(
|
||||
year: number,
|
||||
reports: readonly DatedReport[],
|
||||
days: readonly DailyMean[],
|
||||
config: PhenologyConfig,
|
||||
): CalibrationYear {
|
||||
const firstReportDay: CalibrationYear['firstReportDay'] = {};
|
||||
for (const report of reports) {
|
||||
if (
|
||||
report.date.startsWith(`${year}-`) &&
|
||||
config.calibratePhases.includes(report.phase) &&
|
||||
(firstReportDay[report.phase] ?? '9999') > report.date
|
||||
) {
|
||||
firstReportDay[report.phase] = report.date;
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstReportDay,
|
||||
// The GTS the morning of that day would have shown: through the day before.
|
||||
gtsOn: (day) => {
|
||||
const result = grasslandTemperatureSum(
|
||||
days,
|
||||
year,
|
||||
addDays(day, -1),
|
||||
config.maxGapShare,
|
||||
);
|
||||
return result.usable ? result.gts : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and stores the season state of apiaries (ApiaryPhenology): the
|
||||
* phase from the user's reports, the GTS and the calendar (see
|
||||
* effectivePhaseOf), and the season segment. A change of phase or segment is
|
||||
* recorded as SeasonPhaseChanged (once per apiary and day).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyStateService {
|
||||
private readonly logger = new Logger(PhenologyStateService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Recomputes one apiary for `today` (YYYY-MM-DD, Berlin) inside the
|
||||
* caller's transaction: locks the apiary row, then reads the stored state,
|
||||
* so two recomputations of one apiary run one after the other.
|
||||
*/
|
||||
async recomputeIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
today: string,
|
||||
options: RecomputeOptions = {},
|
||||
): Promise<RecomputeResult> {
|
||||
const exists = await lockRowForUpdate(tx, 'Apiary', apiaryId);
|
||||
const apiary = exists
|
||||
? await tx.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
select: { latitude: true, longitude: true },
|
||||
})
|
||||
: null;
|
||||
if (apiary === null) {
|
||||
throw new Error(`Apiary ${apiaryId} not found`);
|
||||
}
|
||||
const year = Number(today.slice(0, 4));
|
||||
const config =
|
||||
options.preloaded?.config ?? (await readRule(tx, 'PHENOLOGY')).config;
|
||||
const stored = await tx.apiaryPhenology.findUnique({
|
||||
where: { apiaryId_year: { apiaryId, year } },
|
||||
});
|
||||
const hasCoordinates =
|
||||
apiary.latitude !== null && apiary.longitude !== null;
|
||||
const useWeather = config.useGts && hasCoordinates;
|
||||
const reports = options.preloaded
|
||||
? reportsApplyingTo(apiaryId, options.preloaded.reports)
|
||||
: await this.loadReports(tx, apiaryId, year, today);
|
||||
const days = useWeather
|
||||
? await this.loadWeather(tx, apiaryId, year, today, config, reports)
|
||||
: [];
|
||||
const byYear = groupByYear(days);
|
||||
|
||||
const gts = useWeather
|
||||
? grasslandTemperatureSum(
|
||||
byYear.get(year) ?? [],
|
||||
year,
|
||||
addDays(today, -1),
|
||||
config.maxGapShare,
|
||||
)
|
||||
: null;
|
||||
const calibrated =
|
||||
useWeather && config.calibrateFromReports
|
||||
? calibrateThresholds(
|
||||
Array.from(
|
||||
{ length: CALIBRATION_YEARS },
|
||||
(_, i) => year - 1 - i,
|
||||
).map((previousYear) =>
|
||||
calibrationYear(
|
||||
previousYear,
|
||||
reports,
|
||||
byYear.get(previousYear) ?? [],
|
||||
config,
|
||||
),
|
||||
),
|
||||
config.calibratePhases,
|
||||
)
|
||||
: {};
|
||||
const phaseInputs = {
|
||||
today,
|
||||
reports: reports.filter((report) => report.date.startsWith(`${year}-`)),
|
||||
gts,
|
||||
hasCoordinates,
|
||||
thresholds: config.useGts
|
||||
? { ...config.gtsThresholds, ...calibrated }
|
||||
: {},
|
||||
config,
|
||||
previous: stored
|
||||
? { phase: stored.effectivePhase, source: stored.phaseSource }
|
||||
: null,
|
||||
allowBackward: options.allowBackward ?? false,
|
||||
};
|
||||
const effective = effectivePhaseOf(phaseInputs);
|
||||
const segment = segmentOf(effective.effectivePhase, config.swarmSeason);
|
||||
|
||||
// Without a stored row the calendar state is what the users saw so far.
|
||||
const baseline = calendarSeasonState(apiaryId, today, config);
|
||||
const previousPhase = stored
|
||||
? stored.effectivePhase
|
||||
: baseline.springSince === null
|
||||
? null
|
||||
: baseline.phase;
|
||||
const previousSegment = stored ? stored.segment : baseline.segment;
|
||||
const segmentSince =
|
||||
previousSegment === segment
|
||||
? stored
|
||||
? utcDay(stored.segmentSince)
|
||||
: baseline.segmentSince
|
||||
: today;
|
||||
// Pre-spring rows hold phase null; calendarSeasonState says WINTER there
|
||||
// (segment WINTERRUHE in both). A stored WINTER would block every later
|
||||
// phase under the never-backwards rule.
|
||||
const changed =
|
||||
previousPhase !== effective.effectivePhase || previousSegment !== segment;
|
||||
|
||||
const used = usableGts(phaseInputs);
|
||||
const data = {
|
||||
gts: used === null ? null : used.gts,
|
||||
gtsThroughDay: gts?.throughDay ? dayToDate(gts.throughDay) : null,
|
||||
missingDays: gts?.missingDays ?? 0,
|
||||
estimatedPhase: effective.estimatedPhase,
|
||||
reportedPhase: effective.reportedPhase,
|
||||
effectivePhase: effective.effectivePhase,
|
||||
phaseSource: effective.phaseSource,
|
||||
segment,
|
||||
segmentSince: dayToDate(segmentSince),
|
||||
};
|
||||
await tx.apiaryPhenology.upsert({
|
||||
where: { apiaryId_year: { apiaryId, year } },
|
||||
create: { apiaryId, year, ...data },
|
||||
update: data,
|
||||
});
|
||||
if (changed) {
|
||||
await this.events.record(tx, {
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: apiaryId,
|
||||
payload: {
|
||||
apiaryId,
|
||||
phase: effective.effectivePhase,
|
||||
segment,
|
||||
previousPhase,
|
||||
previousSegment,
|
||||
source: effective.phaseSource,
|
||||
},
|
||||
// By design at most one SeasonPhaseChanged per apiary and day: a second
|
||||
// change the same day updates the row but adds no event.
|
||||
dedupeKey: dailyDedupeKey('SeasonPhaseChanged', apiaryId, today),
|
||||
});
|
||||
}
|
||||
return {
|
||||
changed,
|
||||
state: seasonStateFromRow(
|
||||
{ apiaryId, year, ...data },
|
||||
config,
|
||||
hasCoordinates,
|
||||
today,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes every active apiary, one transaction each. A failing apiary is
|
||||
* logged and does not stop the others.
|
||||
*/
|
||||
async recomputeAll(today: string): Promise<RecomputeAllResult> {
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const preloaded: PreloadedPhenology = {
|
||||
config: (await readRule(this.prisma, 'PHENOLOGY')).config,
|
||||
reports: await this.loadReports(
|
||||
this.prisma,
|
||||
null,
|
||||
Number(today.slice(0, 4)),
|
||||
today,
|
||||
),
|
||||
};
|
||||
let done = 0;
|
||||
let changes = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of apiaries) {
|
||||
try {
|
||||
const result = await this.prisma.$transaction((tx) =>
|
||||
this.recomputeIn(tx, id, today, { preloaded }),
|
||||
);
|
||||
done += 1;
|
||||
changes += result.changed ? 1 : 0;
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(
|
||||
`Phenology of apiary ${id} failed: ${message}`,
|
||||
stack,
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { apiaries: done, changes, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports of the last years, revoked ones excluded: those that count for
|
||||
* the apiary (its own and those for all apiaries), or all when `apiaryId`
|
||||
* is null (the preload of recomputeAll).
|
||||
*/
|
||||
private async loadReports(
|
||||
db: DbClient,
|
||||
apiaryId: string | null,
|
||||
year: number,
|
||||
today: string,
|
||||
): Promise<DatedReport[]> {
|
||||
const rows = await db.phenologyReport.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
...(apiaryId === null
|
||||
? {}
|
||||
: { OR: [{ apiaryId }, { apiaryId: null }] }),
|
||||
date: {
|
||||
gte: dayToDate(`${year - CALIBRATION_YEARS}-01-01`),
|
||||
lte: dayToDate(today),
|
||||
},
|
||||
},
|
||||
select: { apiaryId: true, phase: true, date: true },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
apiaryId: row.apiaryId,
|
||||
phase: row.phase,
|
||||
date: utcDay(row.date),
|
||||
revoked: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The daily means of this year, and of earlier years that have reports to calibrate on. */
|
||||
private async loadWeather(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
year: number,
|
||||
today: string,
|
||||
config: PhenologyConfig,
|
||||
reports: readonly DatedReport[],
|
||||
): Promise<DailyMean[]> {
|
||||
const calibrate = config.calibrateFromReports;
|
||||
const earliest = calibrate
|
||||
? Math.min(
|
||||
year,
|
||||
...reports
|
||||
.filter((report) => config.calibratePhases.includes(report.phase))
|
||||
.map((report) => Number(report.date.slice(0, 4))),
|
||||
)
|
||||
: year;
|
||||
const rows = await tx.apiaryWeatherDay.findMany({
|
||||
where: {
|
||||
apiaryId,
|
||||
day: { gte: dayToDate(`${earliest}-01-01`), lt: dayToDate(today) },
|
||||
},
|
||||
select: { day: true, meanTempC: true },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
day: utcDay(row.day),
|
||||
meanTempC: row.meanTempC,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { PhenologyStateService } from './phenology-state.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).
|
||||
*/
|
||||
@Module({
|
||||
imports: [DomainEventsModule],
|
||||
providers: [PhenologyStateService, PhenologyStep],
|
||||
exports: [PhenologyStateService, PhenologyStep],
|
||||
})
|
||||
export class PhenologyModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
|
||||
import type { JobResult } from '../jobs/job';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
/**
|
||||
* Step "phenology" of the daily evaluation (first, so the later steps see
|
||||
* today's season): recomputes the season state of every active apiary, one
|
||||
* transaction each. If apiaries failed, the step fails with the counters of
|
||||
* the others; the next run tries again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyStep implements DailyEvaluationStep {
|
||||
readonly name = 'phenology';
|
||||
|
||||
constructor(private readonly state: PhenologyStateService) {}
|
||||
|
||||
async run(today: string): Promise<JobResult> {
|
||||
const { apiaries, changes, failed } = await this.state.recomputeAll(today);
|
||||
const counts: JobResult = {
|
||||
phenologyApiaries: apiaries,
|
||||
seasonChanges: changes,
|
||||
};
|
||||
if (failed > 0) {
|
||||
throw new JobPartialFailure(counts, [
|
||||
`${failed} ${failed === 1 ? 'apiary' : 'apiaries'}`,
|
||||
]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,7 @@ describe('lock keys per table', () => {
|
||||
'StockItem',
|
||||
'Treatment',
|
||||
'VarroaMeasurement',
|
||||
'NotificationDelivery',
|
||||
] as const)('locks %s by "id"', async (table) => {
|
||||
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
@@ -240,6 +241,7 @@ describe('lock keys per table', () => {
|
||||
'PhenologyReport',
|
||||
'Extraction',
|
||||
'HoneyBatch',
|
||||
'NotificationDelivery',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ const LOCKABLE_TABLES = {
|
||||
PhenologyReport: lockable('PhenologyReport'),
|
||||
Extraction: lockable('Extraction'),
|
||||
HoneyBatch: lockable('HoneyBatch'),
|
||||
// T4c (Task 34): the Telegram delivery outbox.
|
||||
NotificationDelivery: lockable('NotificationDelivery'),
|
||||
} as const;
|
||||
|
||||
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
||||
|
||||
@@ -321,4 +321,43 @@ describe('assertStartupEnvironment', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegram bot token', () => {
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
it('starts without a token in every role (Telegram stays disabled)', () => {
|
||||
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||
expect(() => assertStartupEnvironment(role, PROD_ENV)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('the worker accepts a well-formed token in production', () => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('worker', {
|
||||
...PROD_ENV,
|
||||
TELEGRAM_BOT_TOKEN: TOKEN,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('a malformed or placeholder token never stops the worker (Telegram stays off)', () => {
|
||||
for (const bad of ['garbage', 'change-me-telegram-token']) {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('worker', {
|
||||
...PROD_ENV,
|
||||
TELEGRAM_BOT_TOKEN: bad,
|
||||
}),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('the API pod does not look at the token', () => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('api', {
|
||||
...PROD_ENV,
|
||||
TELEGRAM_BOT_TOKEN: 'garbage',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PREVIOUS_KEY_SUFFIX,
|
||||
readRingSecrets,
|
||||
} from '../common/crypto/key-ring';
|
||||
import { assertTelegramToken } from '../telegram/telegram-config';
|
||||
import type { ApicoreRole } from './role';
|
||||
|
||||
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
||||
@@ -130,16 +131,17 @@ function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
|
||||
|
||||
/**
|
||||
* Startup checks shared by every entry point (API, worker, CLI job). All
|
||||
* roles load the same modules and secrets today, so the checks do not differ
|
||||
* per role yet; the role is part of the signature so role-specific checks can
|
||||
* be added without touching the callers.
|
||||
* roles load the same modules and secrets today, so the checks hardly differ
|
||||
* per role; the Telegram bot token is the first role-specific one (only the
|
||||
* worker holds it).
|
||||
*/
|
||||
export function assertStartupEnvironment(
|
||||
_role: ApicoreRole,
|
||||
role: ApicoreRole,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
assertRequiredEnvVars(env);
|
||||
assertProductionSettings(env);
|
||||
assertNoPlaceholderSecrets(env);
|
||||
assertEncryptionKeys(env);
|
||||
assertTelegramToken(role, env);
|
||||
}
|
||||
@@ -4,11 +4,16 @@ import { SeasonService } from './season.service';
|
||||
|
||||
describe('SeasonService', () => {
|
||||
const findUnique = jest.fn();
|
||||
const db = { automationRule: { findUnique } } as unknown as DbClient;
|
||||
const findMany = jest.fn();
|
||||
const db = {
|
||||
automationRule: { findUnique },
|
||||
apiaryPhenology: { findMany },
|
||||
} as unknown as DbClient;
|
||||
const service = new SeasonService();
|
||||
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset().mockResolvedValue(null);
|
||||
findMany.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
|
||||
@@ -55,4 +60,130 @@ describe('SeasonService', () => {
|
||||
springSince: '2026-03-01',
|
||||
});
|
||||
});
|
||||
|
||||
describe('with stored phenology', () => {
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
const row = (overrides: Record<string, unknown> = {}) => ({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: 412.5,
|
||||
gtsThroughDay: d('2026-05-11'),
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: 'APFEL',
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: d('2026-05-02'),
|
||||
updatedAt: d('2026-05-12'),
|
||||
apiary: { latitude: 48.1, longitude: 9.9 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('prefers the row of the current year over the calendar', async () => {
|
||||
findMany.mockResolvedValue([row()]);
|
||||
|
||||
const states = await service.statesFor(
|
||||
db,
|
||||
['apiary-1', 'apiary-2'],
|
||||
'2026-05-12',
|
||||
);
|
||||
|
||||
expect(states.get('apiary-1')).toEqual({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: null,
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-05-02',
|
||||
springSince: '2026-03-20',
|
||||
gts: 412.5,
|
||||
gtsThroughDay: '2026-05-11',
|
||||
note: null,
|
||||
});
|
||||
expect(states.get('apiary-2')).toMatchObject({ phaseSource: 'CALENDAR' });
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks for the rows of the year of today', async () => {
|
||||
await service.statesFor(db, ['apiary-1'], '2026-05-12');
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { apiaryId: { in: ['apiary-1'] }, year: 2026 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the segment start as spring start while in FRUEHJAHR', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({ segment: 'FRUEHJAHR', segmentSince: d('2026-03-28') }),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(db, ['apiary-1'], '2026-04-01');
|
||||
|
||||
expect(states.get('apiary-1')?.springSince).toBe('2026-03-28');
|
||||
});
|
||||
|
||||
it('has no spring start before the spring', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({
|
||||
segment: 'WINTERRUHE',
|
||||
effectivePhase: null,
|
||||
phaseSource: 'CALENDAR',
|
||||
segmentSince: d('2026-01-01'),
|
||||
gts: 60,
|
||||
}),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(db, ['apiary-1'], '2026-02-10');
|
||||
|
||||
expect(states.get('apiary-1')?.springSince).toBeNull();
|
||||
});
|
||||
|
||||
it('explains why the weather was not used', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({ gts: null, gtsThroughDay: d('2026-05-02') }),
|
||||
row({
|
||||
apiaryId: 'apiary-2',
|
||||
apiary: { latitude: null, longitude: null },
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
}),
|
||||
row({ apiaryId: 'apiary-3', gts: null }),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(
|
||||
db,
|
||||
['apiary-1', 'apiary-2', 'apiary-3'],
|
||||
'2026-05-12',
|
||||
);
|
||||
|
||||
expect(states.get('apiary-1')?.note).toBe('WEATHER_STALE');
|
||||
expect(states.get('apiary-2')?.note).toBe('NO_COORDINATES');
|
||||
expect(states.get('apiary-3')?.note).toBe('WEATHER_GAPS');
|
||||
});
|
||||
|
||||
it('uses a row updated 2 days ago but not one updated 3 days ago', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({ updatedAt: new Date('2026-05-10T04:31:00.000Z') }),
|
||||
row({
|
||||
apiaryId: 'apiary-2',
|
||||
updatedAt: new Date('2026-05-09T04:31:00.000Z'),
|
||||
}),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(
|
||||
db,
|
||||
['apiary-1', 'apiary-2'],
|
||||
'2026-05-12',
|
||||
);
|
||||
|
||||
expect(states.get('apiary-1')?.phaseSource).toBe('REPORT');
|
||||
// The job missed its runs: back to the calendar.
|
||||
expect(states.get('apiary-2')?.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { berlinDayOf, daysBetween } from '../common/dates';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { seasonStateFromRow } from '../phenology/phenology-row';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { calendarSeasonState } from './season-calendar';
|
||||
import type { SeasonState } from './season-types';
|
||||
|
||||
/** A stored row not updated for longer than this (days) means the job missed its runs. */
|
||||
const MAX_ROW_AGE_DAYS = 2;
|
||||
|
||||
/**
|
||||
* The season of apiaries. Until the phenology data exists (Task 25) every
|
||||
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
|
||||
* the callers stay as they are.
|
||||
* The season of apiaries: the stored state of the current year
|
||||
* (ApiaryPhenology, written by the phenology step and the reports) where
|
||||
* there is one and the job kept it fresh (updated at most MAX_ROW_AGE_DAYS
|
||||
* days ago), else the calendar state. A stale row falls back to the calendar
|
||||
* instead of recomputing GTS on the fly: that would read a year of weather per
|
||||
* apiary on every read path, the calendar costs nothing. Callers do not see
|
||||
* the difference.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeasonService {
|
||||
@@ -21,9 +30,31 @@ export class SeasonService {
|
||||
if (apiaryIds.length === 0) {
|
||||
return states;
|
||||
}
|
||||
const ids = [...new Set(apiaryIds)];
|
||||
const { config } = await readRule(db, 'PHENOLOGY');
|
||||
for (const apiaryId of new Set(apiaryIds)) {
|
||||
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
|
||||
const rows = await db.apiaryPhenology.findMany({
|
||||
where: { apiaryId: { in: ids }, year: Number(today.slice(0, 4)) },
|
||||
include: { apiary: { select: { latitude: true, longitude: true } } },
|
||||
});
|
||||
const stored = new Map(rows.map((row) => [row.apiaryId, row]));
|
||||
for (const apiaryId of ids) {
|
||||
const found = stored.get(apiaryId);
|
||||
const row =
|
||||
found !== undefined &&
|
||||
daysBetween(berlinDayOf(found.updatedAt), today) <= MAX_ROW_AGE_DAYS
|
||||
? found
|
||||
: undefined;
|
||||
states.set(
|
||||
apiaryId,
|
||||
row === undefined
|
||||
? calendarSeasonState(apiaryId, today, config)
|
||||
: seasonStateFromRow(
|
||||
row,
|
||||
config,
|
||||
row.apiary.latitude !== null && row.apiary.longitude !== null,
|
||||
today,
|
||||
),
|
||||
);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import {
|
||||
describeTelegramFailure,
|
||||
sanitizeTelegramDescription,
|
||||
TelegramApiError,
|
||||
} from './telegram-api-error';
|
||||
|
||||
describe('TelegramApiError', () => {
|
||||
it('carries method, status, description and retry_after, and a message without a URL', () => {
|
||||
const error = new TelegramApiError(
|
||||
'sendMessage',
|
||||
429,
|
||||
'Too Many Requests',
|
||||
7,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe('TelegramApiError');
|
||||
expect(error.method).toBe('sendMessage');
|
||||
expect(error.statusCode).toBe(429);
|
||||
expect(error.description).toBe('Too Many Requests');
|
||||
expect(error.retryAfterSec).toBe(7);
|
||||
expect(error.message).toBe(
|
||||
'Telegram sendMessage failed: Too Many Requests',
|
||||
);
|
||||
expect(loggableError(error).message).not.toMatch(/https?:/);
|
||||
});
|
||||
|
||||
it('allows a network failure without status', () => {
|
||||
const error = new TelegramApiError('getUpdates', null, 'timeout', null);
|
||||
expect(error.statusCode).toBeNull();
|
||||
expect(error.retryAfterSec).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTelegramDescription', () => {
|
||||
it('removes bot URLs, tokens, control characters and caps the length', () => {
|
||||
const token = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
const dirty = `bad https://api.telegram.org/bot${token}/sendMessage \u0007x ${token} ${'y'.repeat(400)}`;
|
||||
const clean = sanitizeTelegramDescription(dirty);
|
||||
|
||||
expect(clean).not.toContain('AAEhBOweik6ad9r');
|
||||
expect(clean).not.toContain('api.telegram.org');
|
||||
expect(clean).not.toContain('\u0007');
|
||||
expect(clean.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('turns a non-string into a fixed text', () => {
|
||||
expect(sanitizeTelegramDescription(undefined)).toBe('unknown error');
|
||||
expect(sanitizeTelegramDescription(42)).toBe('unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTelegramFailure', () => {
|
||||
it('classifies abort and timeout without using the underlying message', () => {
|
||||
const timeout = Object.assign(
|
||||
new Error('boom bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET'),
|
||||
{ name: 'TimeoutError' },
|
||||
);
|
||||
const abort = Object.assign(new Error('x'), { name: 'AbortError' });
|
||||
|
||||
expect(describeTelegramFailure(timeout)).toBe('timeout');
|
||||
expect(describeTelegramFailure(abort)).toBe('aborted');
|
||||
expect(describeTelegramFailure(new TypeError('fetch failed'))).toBe(
|
||||
'network error',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([['text'], [null], [undefined], [42], [{ message: 'x' }]])(
|
||||
'classifies a non-Error rejection (%p) as a network error',
|
||||
(value) => {
|
||||
expect(describeTelegramFailure(value)).toBe('network error');
|
||||
},
|
||||
);
|
||||
|
||||
it('finds a timeout or abort in the cause chain, and never returns its message', () => {
|
||||
const secret = 'bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET';
|
||||
const timeout = Object.assign(new Error(secret), { name: 'TimeoutError' });
|
||||
const wrapped = new TypeError('fetch failed', {
|
||||
cause: new Error('socket', { cause: timeout }),
|
||||
});
|
||||
|
||||
expect(describeTelegramFailure(wrapped)).toBe('timeout');
|
||||
});
|
||||
|
||||
it('survives a cyclic cause chain', () => {
|
||||
const a = new Error('a');
|
||||
const b = new Error('b', { cause: a });
|
||||
(a as { cause?: unknown }).cause = b;
|
||||
expect(describeTelegramFailure(a)).toBe('network error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const MAX_DESCRIPTION_CHARS = 200;
|
||||
const UNKNOWN_DESCRIPTION = 'unknown error';
|
||||
const MAX_CAUSE_DEPTH = 5;
|
||||
|
||||
// A request URL carries the bot token ("…/bot<id>:<secret>/<method>"), and a
|
||||
// description could quote one. Both shapes are removed wherever text from
|
||||
// outside ends up in an error.
|
||||
const BOT_URL = /https?:\/\/\S*/gi;
|
||||
const BOT_TOKEN = /\b(?:bot)?\d{5,}:[A-Za-z0-9_-]{20,}/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
|
||||
|
||||
/**
|
||||
* A failed Telegram call. Carries the method and what Telegram answered -
|
||||
* never the request URL, so the bot token cannot reach a log or lastError
|
||||
* through it.
|
||||
*/
|
||||
export class TelegramApiError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly statusCode: number | null,
|
||||
readonly description: string,
|
||||
readonly retryAfterSec: number | null,
|
||||
) {
|
||||
super(`Telegram ${method} failed: ${description}`);
|
||||
this.name = 'TelegramApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Text from Telegram, safe for an error: no URL, no token, no control characters, short. */
|
||||
export function sanitizeTelegramDescription(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return UNKNOWN_DESCRIPTION;
|
||||
}
|
||||
const clean = value
|
||||
.replace(BOT_URL, '')
|
||||
.replace(BOT_TOKEN, '')
|
||||
.replace(CONTROL_CHARACTERS, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, MAX_DESCRIPTION_CHARS);
|
||||
return clean === '' ? UNKNOWN_DESCRIPTION : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed description for a call that failed before an answer arrived. The
|
||||
* underlying message is dropped on purpose: a fetch error can quote the URL.
|
||||
*/
|
||||
export function describeTelegramFailure(error: unknown): string {
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
// fetch wraps the real reason in `cause`; the depth limit and the visited
|
||||
// set keep a cyclic chain from looping.
|
||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (!(current instanceof Error) || seen.has(current)) {
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
if (current.name === 'TimeoutError') {
|
||||
return 'timeout';
|
||||
}
|
||||
if (current.name === 'AbortError') {
|
||||
return 'aborted';
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
return 'network error';
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { TelegramApiError } from './telegram-api-error';
|
||||
import {
|
||||
sanitizeTelegramText,
|
||||
TELEGRAM_MAX_MESSAGE_CHARS,
|
||||
TelegramClient,
|
||||
} from './telegram-client';
|
||||
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('TelegramClient', () => {
|
||||
let fetchMock: jest.SpyInstance;
|
||||
let client: TelegramClient;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(globalThis, 'fetch');
|
||||
client = new TelegramClient({ token: TOKEN, botUsername: 'apicore_bot' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('is configured only with a well-formed token', () => {
|
||||
expect(client.isConfigured()).toBe(true);
|
||||
expect(
|
||||
new TelegramClient({ token: null, botUsername: 'b' }).isConfigured(),
|
||||
).toBe(false);
|
||||
expect(
|
||||
new TelegramClient({ token: 'junk', botUsername: 'b' }).isConfigured(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('the token never appears in TelegramApiError or loggableError', async () => {
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError(
|
||||
`connect failed https://api.telegram.org/bot${TOKEN}/sendMessage`,
|
||||
),
|
||||
);
|
||||
|
||||
const error = await client.sendMessage(1n, 'x').catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(TelegramApiError);
|
||||
const apiError = error as TelegramApiError;
|
||||
expect(
|
||||
JSON.stringify({
|
||||
...apiError,
|
||||
message: apiError.message,
|
||||
log: loggableError(apiError),
|
||||
}),
|
||||
).not.toContain(TOKEN);
|
||||
expect(apiError.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it('the token does not leak through a description returned by Telegram', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ok: false, description: `Unauthorized ${TOKEN}` }, 401),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error.statusCode).toBe(401);
|
||||
expect(`${error.message}${error.description}`).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('sends plain text without parse_mode and with link preview disabled', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
|
||||
|
||||
await client.sendMessage(4242n, 'Hallo');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendMessage`);
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
chat_id: 4242,
|
||||
text: 'Hallo',
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
expect(body).not.toHaveProperty('parse_mode');
|
||||
});
|
||||
|
||||
it('does not follow redirects (the URL carries the token)', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
|
||||
|
||||
await client.sendMessage(1n, 'x');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.redirect).toBe('error');
|
||||
});
|
||||
|
||||
it('a redirect error becomes a network error without the URL', async () => {
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError('fetch failed', {
|
||||
cause: new Error(`redirect to https://evil.example/bot${TOKEN}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error.description).toBe('network error');
|
||||
expect(JSON.stringify({ ...error, m: error.message })).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('maps 429 with retry_after into TelegramApiError.retryAfterSec', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: 'Too Many Requests: retry after 12',
|
||||
parameters: { retry_after: 12 },
|
||||
},
|
||||
429,
|
||||
),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error).toBeInstanceOf(TelegramApiError);
|
||||
expect(error.statusCode).toBe(429);
|
||||
expect(error.retryAfterSec).toBe(12);
|
||||
expect(error.method).toBe('sendMessage');
|
||||
});
|
||||
|
||||
it('maps 429 without parameters to retryAfterSec null', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ok: false, description: 'Too Many Requests' }, 429),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
retryAfterSec: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[12, 12],
|
||||
[0, 1],
|
||||
[-5, 1],
|
||||
[999_999, 3600],
|
||||
['12', null],
|
||||
[1.5, null],
|
||||
[null, null],
|
||||
])('clamps retry_after %p to %p', async (raw, expected) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ ok: false, description: 'x', parameters: { retry_after: raw } },
|
||||
429,
|
||||
),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
retryAfterSec: expected,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a 403 description through unchanged (callers decide)', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ ok: false, description: 'Forbidden: bot was blocked by the user' },
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
description: 'Forbidden: bot was blocked by the user',
|
||||
retryAfterSec: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an ok:false answer and a non-JSON body as TelegramApiError', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false }));
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toBeInstanceOf(
|
||||
TelegramApiError,
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 502 }));
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
description: 'invalid response',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an oversized response body', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response('x'.repeat(300_000), { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'invalid response',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops reading a streamed body past the cap even without Content-Length', async () => {
|
||||
const chunk = new Uint8Array(100 * 1024).fill(120);
|
||||
let sent = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
sent++;
|
||||
controller.enqueue(chunk);
|
||||
if (sent >= 50) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'invalid response',
|
||||
});
|
||||
expect(sent).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('classifies a caller abort and a timeout without status', async () => {
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('x'), { name: 'AbortError' }),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: null,
|
||||
description: 'aborted',
|
||||
});
|
||||
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('x'), { name: 'TimeoutError' }),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: null,
|
||||
description: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a TelegramApiError when no token is configured, without calling out', async () => {
|
||||
const bare = new TelegramClient({ token: null, botUsername: null });
|
||||
|
||||
await expect(bare.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'bot token not configured',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call out with a malformed token either', async () => {
|
||||
const bad = new TelegramClient({ token: 'junk', botUsername: 'b' });
|
||||
|
||||
await expect(bad.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'bot token not configured',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a chat id that does not fit a JSON number', async () => {
|
||||
await expect(client.sendMessage(2n ** 60n, 'x')).rejects.toBeInstanceOf(
|
||||
TelegramApiError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('getUpdates', () => {
|
||||
it('asks for messages only with a 25 s long poll and parses the updates', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
update_id: 100,
|
||||
message: {
|
||||
text: '/start abc',
|
||||
chat: { id: 555, type: 'private' },
|
||||
from: { first_name: 'Imke' },
|
||||
},
|
||||
},
|
||||
{ update_id: 101, edited_message: {} },
|
||||
{ update_id: 102, message: { chat: { id: -9, type: 'group' } } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const updates = await client.getUpdates(100n);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/getUpdates`);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
offset: 100,
|
||||
timeout: 25,
|
||||
allowed_updates: ['message'],
|
||||
});
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
updateId: 100n,
|
||||
chatId: 555n,
|
||||
chatType: 'private',
|
||||
text: '/start abc',
|
||||
firstName: 'Imke',
|
||||
},
|
||||
{
|
||||
updateId: 102n,
|
||||
chatId: -9n,
|
||||
chatType: 'group',
|
||||
text: null,
|
||||
firstName: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing arrived', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: [] }));
|
||||
await expect(client.getUpdates(0n)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTelegramText', () => {
|
||||
it('strips control characters but keeps line breaks', () => {
|
||||
expect(sanitizeTelegramText('a\u0000b\u001bc\r\nd\te')).toBe('abc\nd e');
|
||||
});
|
||||
|
||||
it('caps the text at the maximum length', () => {
|
||||
const out = sanitizeTelegramText('x'.repeat(2000));
|
||||
expect(out.length).toBe(TELEGRAM_MAX_MESSAGE_CHARS);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts by code point, never inside a surrogate pair', () => {
|
||||
const out = sanitizeTelegramText('😀'.repeat(700));
|
||||
expect([...out]).toHaveLength(TELEGRAM_MAX_MESSAGE_CHARS);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
expect(out).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/);
|
||||
});
|
||||
|
||||
it('strips bidi overrides, isolates and zero-width characters', () => {
|
||||
const dirty = 'abcdefghijklmno';
|
||||
expect(sanitizeTelegramText(dirty)).toBe('abcdefghijklmno');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
describeTelegramFailure,
|
||||
sanitizeTelegramDescription,
|
||||
TelegramApiError,
|
||||
} from './telegram-api-error';
|
||||
import { TELEGRAM_TOKEN_PATTERN, type TelegramConfig } from './telegram-config';
|
||||
|
||||
export const TELEGRAM_CONFIG = Symbol('TELEGRAM_CONFIG');
|
||||
|
||||
export const TELEGRAM_SEND_TIMEOUT_MS = 10_000;
|
||||
export const TELEGRAM_POLL_TIMEOUT_SEC = 25;
|
||||
export const TELEGRAM_POLL_FETCH_TIMEOUT_MS = 35_000;
|
||||
export const TELEGRAM_MAX_MESSAGE_CHARS = 600;
|
||||
const MIN_RETRY_AFTER_SEC = 1;
|
||||
const MAX_RETRY_AFTER_SEC = 3600;
|
||||
|
||||
const API_BASE = 'https://api.telegram.org';
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
const INVALID_RESPONSE = 'invalid response';
|
||||
|
||||
export interface TelegramUpdate {
|
||||
updateId: bigint;
|
||||
chatId: bigint;
|
||||
chatType: string;
|
||||
text: string | null;
|
||||
firstName: string | null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
||||
// Zero-width characters, bidi marks, overrides and isolates: invisible, and
|
||||
// they can make a text read differently from what it says.
|
||||
const INVISIBLE_CHARACTERS = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
|
||||
|
||||
/**
|
||||
* Plain text for a message: tabs and line breaks kept as space and newline,
|
||||
* other control characters and invisible/bidi characters removed, at most
|
||||
* 600 characters (cut by code point, never inside a surrogate pair).
|
||||
*/
|
||||
export function sanitizeTelegramText(text: string): string {
|
||||
const characters = [
|
||||
...text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/\t/g, ' ')
|
||||
.replace(CONTROL_CHARACTERS, '')
|
||||
.replace(INVISIBLE_CHARACTERS, ''),
|
||||
];
|
||||
return characters.length > TELEGRAM_MAX_MESSAGE_CHARS
|
||||
? `${characters.slice(0, TELEGRAM_MAX_MESSAGE_CHARS - 1).join('')}…`
|
||||
: characters.join('');
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function asObject(value: unknown): JsonObject | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as JsonObject)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): bigint | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value)
|
||||
? BigInt(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Reads the body up to MAX_RESPONSE_BYTES; null if it is larger or unreadable. */
|
||||
async function readLimitedText(response: Response): Promise<string | null> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return '';
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
size += value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/** parameters.retry_after as whole seconds clamped to 1..3600; null if absent or not an integer. */
|
||||
function retryAfterOf(parsed: JsonObject): number | null {
|
||||
const seconds = asInteger(asObject(parsed.parameters)?.retry_after);
|
||||
if (seconds === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
MAX_RETRY_AFTER_SEC,
|
||||
Math.max(MIN_RETRY_AFTER_SEC, Number(seconds)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseUpdate(raw: unknown): TelegramUpdate | null {
|
||||
const update = asObject(raw);
|
||||
const updateId = asInteger(update?.update_id);
|
||||
const message = asObject(update?.message);
|
||||
const chat = asObject(message?.chat);
|
||||
const chatId = asInteger(chat?.id);
|
||||
const chatType = asString(chat?.type);
|
||||
if (updateId === null || chatId === null || chatType === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
updateId,
|
||||
chatId,
|
||||
chatType,
|
||||
text: asString(message?.text),
|
||||
firstName: asString(asObject(message?.from)?.first_name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram Bot API client. Used by the worker loops only (never from a
|
||||
* request, handler or job; see no-network-outside-loops.spec). The request
|
||||
* URL contains the token and stays inside this class: every failure becomes
|
||||
* a TelegramApiError that carries no URL, and text taken from the answer is
|
||||
* sanitized.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramClient {
|
||||
constructor(
|
||||
@Inject(TELEGRAM_CONFIG) private readonly config: TelegramConfig,
|
||||
) {}
|
||||
|
||||
/** Worker only: a token is present and well-formed. */
|
||||
isConfigured(): boolean {
|
||||
return (
|
||||
this.config.token !== null &&
|
||||
TELEGRAM_TOKEN_PATTERN.test(this.config.token)
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain text, no parse_mode, link preview off. Throws TelegramApiError. */
|
||||
async sendMessage(
|
||||
chatId: bigint,
|
||||
text: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await this.call(
|
||||
'sendMessage',
|
||||
{
|
||||
chat_id: this.jsonNumber('sendMessage', chatId),
|
||||
text: sanitizeTelegramText(text),
|
||||
link_preview_options: { is_disabled: true },
|
||||
},
|
||||
TELEGRAM_SEND_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** Long poll for messages from `offset` on. Throws TelegramApiError. */
|
||||
async getUpdates(
|
||||
offset: bigint,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TelegramUpdate[]> {
|
||||
const result = await this.call(
|
||||
'getUpdates',
|
||||
{
|
||||
offset: this.jsonNumber('getUpdates', offset),
|
||||
timeout: TELEGRAM_POLL_TIMEOUT_SEC,
|
||||
allowed_updates: ['message'],
|
||||
},
|
||||
TELEGRAM_POLL_FETCH_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (!Array.isArray(result)) {
|
||||
throw new TelegramApiError('getUpdates', null, INVALID_RESPONSE, null);
|
||||
}
|
||||
return result
|
||||
.map(parseUpdate)
|
||||
.filter((update): update is TelegramUpdate => update !== null);
|
||||
}
|
||||
|
||||
private jsonNumber(method: string, value: bigint): number {
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number)) {
|
||||
throw new TelegramApiError(method, null, 'id out of range', null);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
private async call(
|
||||
method: string,
|
||||
body: JsonObject,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const { token } = this.config;
|
||||
if (token === null || !this.isConfigured()) {
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
null,
|
||||
'bot token not configured',
|
||||
null,
|
||||
);
|
||||
}
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
let response: Response;
|
||||
let text: string | null;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}/bot${token}/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
// The URL carries the token: never follow it somewhere else.
|
||||
redirect: 'error',
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
text = await readLimitedText(response);
|
||||
} catch (error) {
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
null,
|
||||
describeTelegramFailure(error),
|
||||
null,
|
||||
);
|
||||
}
|
||||
return this.interpret(method, response.status, text);
|
||||
}
|
||||
|
||||
/** Reads only ok, description, parameters.retry_after and result. */
|
||||
private interpret(
|
||||
method: string,
|
||||
status: number,
|
||||
text: string | null,
|
||||
): unknown {
|
||||
let parsed: JsonObject | null = null;
|
||||
try {
|
||||
parsed = text === null ? null : asObject(JSON.parse(text));
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed === null) {
|
||||
throw new TelegramApiError(method, status, INVALID_RESPONSE, null);
|
||||
}
|
||||
if (status >= 200 && status < 300 && parsed.ok === true) {
|
||||
return parsed.result;
|
||||
}
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
status,
|
||||
sanitizeTelegramDescription(parsed.description),
|
||||
retryAfterOf(parsed),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
assertTelegramToken,
|
||||
telegramBotAvailable,
|
||||
telegramConfig,
|
||||
} from './telegram-config';
|
||||
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
describe('telegramConfig', () => {
|
||||
it('reads token and bot username, trimmed', () => {
|
||||
expect(
|
||||
telegramConfig({
|
||||
TELEGRAM_BOT_TOKEN: ` ${TOKEN} `,
|
||||
TELEGRAM_BOT_USERNAME: ' apicore_bot ',
|
||||
}),
|
||||
).toEqual({ token: TOKEN, botUsername: 'apicore_bot' });
|
||||
});
|
||||
|
||||
it('maps empty and missing values to null', () => {
|
||||
expect(
|
||||
telegramConfig({ TELEGRAM_BOT_TOKEN: ' ', TELEGRAM_BOT_USERNAME: '' }),
|
||||
).toEqual({ token: null, botUsername: null });
|
||||
expect(telegramConfig({})).toEqual({ token: null, botUsername: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramBotAvailable', () => {
|
||||
it('is true with the username set and the token unset (API pod)', () => {
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: 'apicore_bot' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false with an empty or blank username, even with a token', () => {
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: ' ' })).toBe(false);
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_TOKEN: TOKEN })).toBe(false);
|
||||
expect(telegramBotAvailable({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramConfig bot username', () => {
|
||||
it.each([
|
||||
['@apicore_bot', 'apicore_bot'],
|
||||
['ApiCore_Bot', 'ApiCore_Bot'],
|
||||
['a'.repeat(32), 'a'.repeat(32)],
|
||||
])('accepts %s', (raw, expected) => {
|
||||
expect(telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'abcd',
|
||||
'1abcde',
|
||||
'a'.repeat(33),
|
||||
'api core bot',
|
||||
'bot/../x',
|
||||
'@@apicore_bot',
|
||||
'https://t.me/x',
|
||||
'_apicore',
|
||||
])('treats %s as unset', (raw) => {
|
||||
expect(
|
||||
telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername,
|
||||
).toBeNull();
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: raw })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertTelegramToken', () => {
|
||||
const production = { NODE_ENV: 'production' };
|
||||
let logger: { warn: jest.Mock; error: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
logger = { warn: jest.fn(), error: jest.fn() };
|
||||
});
|
||||
|
||||
it('accepts a missing token in every role (Telegram is optional)', () => {
|
||||
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||
expect(() => assertTelegramToken(role, production, logger)).not.toThrow();
|
||||
}
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a well-formed token in the worker without a log', () => {
|
||||
assertTelegramToken(
|
||||
'worker',
|
||||
{
|
||||
...production,
|
||||
TELEGRAM_BOT_TOKEN: TOKEN,
|
||||
TELEGRAM_BOT_USERNAME: 'apicore_bot',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['worker', 'not-a-token-SECRETPART', {}],
|
||||
['all', 'not-a-token-SECRETPART', {}],
|
||||
['worker', 'change-me-SECRETPART', production],
|
||||
] as const)(
|
||||
'the %s never crashes on a malformed token (%s): logs an error without the value',
|
||||
(role, bad, extra) => {
|
||||
expect(() =>
|
||||
assertTelegramToken(
|
||||
role,
|
||||
{ ...extra, TELEGRAM_BOT_TOKEN: bad },
|
||||
logger,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
const logged = (logger.error.mock.calls as [string][])[0][0];
|
||||
expect(logged).toMatch(/TELEGRAM_BOT_TOKEN/);
|
||||
expect(logged).toMatch(/disabled/);
|
||||
expect(logged).not.toContain('SECRETPART');
|
||||
},
|
||||
);
|
||||
|
||||
it('warns about an invalid bot username in every role', () => {
|
||||
assertTelegramToken('api', { TELEGRAM_BOT_USERNAME: 'no good' }, logger);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect((logger.warn.mock.calls as [string][])[0][0]).toMatch(
|
||||
/TELEGRAM_BOT_USERNAME/,
|
||||
);
|
||||
});
|
||||
|
||||
it('the API pod and jobs ignore the token entirely (they never have one)', () => {
|
||||
assertTelegramToken('api', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
|
||||
assertTelegramToken('job', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ApicoreRole } from '../runtime/role';
|
||||
|
||||
/** Bot token format issued by BotFather: "<bot id>:<secret>". */
|
||||
export const TELEGRAM_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/;
|
||||
|
||||
const PLACEHOLDER_PATTERN = /^change-me/i;
|
||||
/** Bot usernames: 5-32 characters, letters, digits, underscore, starting with a letter. */
|
||||
const BOT_USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{4,31}$/;
|
||||
const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all'];
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string | null;
|
||||
botUsername: string | null;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string | null {
|
||||
const text = value?.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/** The configured bot name without a leading "@"; null if unset or not a valid username. */
|
||||
function validBotUsername(raw: string | undefined): string | null {
|
||||
const name = trimmed(raw)?.replace(/^@/, '') ?? null;
|
||||
return name !== null && BOT_USERNAME_PATTERN.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token and bot name from the environment. Empty values count as unset, and
|
||||
* so does a bot name that is not a valid username (it goes into a link).
|
||||
*/
|
||||
export function telegramConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): TelegramConfig {
|
||||
return {
|
||||
token: trimmed(env.TELEGRAM_BOT_TOKEN),
|
||||
botUsername: validBotUsername(env.TELEGRAM_BOT_USERNAME),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Telegram is set up" as the API sees it: the bot name is known. Never
|
||||
* reads the token - the API pod does not have it (Secret apicore-telegram
|
||||
* goes to the worker only). Whether updates really arrive shows
|
||||
* TelegramPollState.
|
||||
*/
|
||||
export function telegramBotAvailable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return telegramConfig(env).botUsername !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup check. Telegram is optional and a bad setting never stops a pod:
|
||||
* a bot username that is not valid is ignored with a warning (every role);
|
||||
* in the worker, a token that is malformed - or, in production, an example
|
||||
* placeholder - is logged as an error and Telegram stays off (the client
|
||||
* refuses to call out with it). Logs name the variable, never its value.
|
||||
* API and job pods never hold the token and skip its check.
|
||||
*/
|
||||
export function assertTelegramToken(
|
||||
role: ApicoreRole,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
logger: Pick<Logger, 'warn' | 'error'> = new Logger('TelegramStartup'),
|
||||
): void {
|
||||
const { token, botUsername } = telegramConfig(env);
|
||||
if (botUsername === null && trimmed(env.TELEGRAM_BOT_USERNAME) !== null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_USERNAME is not a valid bot username (5-32 letters, digits or underscores): ignored, users cannot connect.',
|
||||
);
|
||||
}
|
||||
if (!WORKER_ROLES.includes(role)) {
|
||||
return;
|
||||
}
|
||||
if (token === null) {
|
||||
if (botUsername !== null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_USERNAME is set but TELEGRAM_BOT_TOKEN is not: Telegram delivery and receiving stay disabled.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (env.NODE_ENV === 'production' && PLACEHOLDER_PATTERN.test(token)) {
|
||||
logger.error(
|
||||
'TELEGRAM_BOT_TOKEN is still set to an example placeholder: Telegram stays disabled - set the real token from BotFather or remove it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!TELEGRAM_TOKEN_PATTERN.test(token)) {
|
||||
logger.error(
|
||||
'TELEGRAM_BOT_TOKEN is malformed (expected "<bot id>:<secret>" as issued by BotFather): Telegram stays disabled.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (botUsername === null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_TOKEN is set but TELEGRAM_BOT_USERNAME is not: users cannot connect, replies still work.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { telegramConfig } from './telegram-config';
|
||||
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
|
||||
TelegramClient,
|
||||
],
|
||||
exports: [TelegramClient],
|
||||
})
|
||||
export class TelegramModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { abortableDelay } from './abortable-delay';
|
||||
|
||||
describe('abortableDelay', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('resolves after the delay', async () => {
|
||||
const done = jest.fn();
|
||||
void abortableDelay(300, new AbortController().signal).then(done);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(299);
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves at once when the signal aborts, and when it is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
const done = jest.fn();
|
||||
void abortableDelay(60_000, controller.signal).then(done);
|
||||
controller.abort();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
await expect(
|
||||
abortableDelay(60_000, controller.signal),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Resolves after `ms`, or early (without error) when the signal aborts. */
|
||||
export function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, ms);
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { addDays, berlinToday } from '../../common/dates';
|
||||
import {
|
||||
RECENT_WINDOW_DAYS,
|
||||
WEATHER_FETCH_TIMEOUT_MS,
|
||||
WEATHER_MAX_RETRY_AFTER_MS,
|
||||
WEATHER_MAX_RESPONSE_BYTES,
|
||||
WeatherHistoryConfig,
|
||||
} from './weather-history.config';
|
||||
import {
|
||||
OpenMeteoHistorySource,
|
||||
WeatherSourceError,
|
||||
} from './open-meteo-history.source';
|
||||
|
||||
const RECENT = { window: 'recent' } as const;
|
||||
const ARCHIVE = { window: 'archive' } as const;
|
||||
const SECRET = 'topsecretkey';
|
||||
const POINT = { lat: 52.52, lon: 13.4 };
|
||||
|
||||
function config(apiKey: string | null = null): WeatherHistoryConfig {
|
||||
return {
|
||||
source: 'open-meteo',
|
||||
forecastUrl: new URL('https://forecast.example/v1/forecast'),
|
||||
archiveUrl: new URL('https://archive.example/v1/archive'),
|
||||
apiKey,
|
||||
userAgent: 'ApiCore/test (self-hosted beekeeping app)',
|
||||
};
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
describe('OpenMeteoHistorySource', () => {
|
||||
let fetchMock: jest.SpiedFunction<typeof fetch>;
|
||||
const today = berlinToday();
|
||||
const recentFrom = addDays(today, -10);
|
||||
const recentTo = addDays(today, -8);
|
||||
const oldFrom = addDays(today, -RECENT_WINDOW_DAYS - 20);
|
||||
const oldTo = addDays(today, -RECENT_WINDOW_DAYS - 18);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(global, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const requestedUrl = (): URL => fetchMock.mock.calls[0][0] as URL;
|
||||
|
||||
it('reads recent days from the forecast endpoint with past_days=92', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({
|
||||
daily: {
|
||||
time: [addDays(today, -11), recentFrom, addDays(today, -9), recentTo],
|
||||
temperature_2m_mean: [1, 8.5, 9, 10],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const source = new OpenMeteoHistorySource(config());
|
||||
|
||||
const result = await source.dailyMeans(POINT, recentFrom, recentTo, RECENT);
|
||||
|
||||
expect(source.name).toBe('open-meteo');
|
||||
const url = requestedUrl();
|
||||
expect(url.origin).toBe('https://forecast.example');
|
||||
expect(url.searchParams.get('latitude')).toBe('52.52');
|
||||
expect(url.searchParams.get('longitude')).toBe('13.40');
|
||||
expect(url.searchParams.get('daily')).toBe('temperature_2m_mean');
|
||||
expect(url.searchParams.get('timezone')).toBe('Europe/Berlin');
|
||||
expect(url.searchParams.get('past_days')).toBe('92');
|
||||
expect(url.searchParams.has('apikey')).toBe(false);
|
||||
// Only the requested range comes back.
|
||||
expect(result).toEqual([
|
||||
{ day: recentFrom, meanTempC: 8.5 },
|
||||
{ day: addDays(today, -9), meanTempC: 9 },
|
||||
{ day: recentTo, meanTempC: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads older days from the archive endpoint with start_date and end_date', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [oldFrom], temperature_2m_mean: [3] } }),
|
||||
);
|
||||
|
||||
await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
oldFrom,
|
||||
oldTo,
|
||||
ARCHIVE,
|
||||
);
|
||||
|
||||
const url = requestedUrl();
|
||||
expect(url.origin).toBe('https://archive.example');
|
||||
expect(url.searchParams.get('start_date')).toBe(oldFrom);
|
||||
expect(url.searchParams.get('end_date')).toBe(oldTo);
|
||||
expect(url.searchParams.has('past_days')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends apikey only when one is configured', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [], temperature_2m_mean: [] } }),
|
||||
);
|
||||
await new OpenMeteoHistorySource(config(SECRET)).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
);
|
||||
expect(requestedUrl().searchParams.get('apikey')).toBe(SECRET);
|
||||
});
|
||||
|
||||
it('maps values outside -50..50 °C and non-numbers to null', async () => {
|
||||
const days = [0, 1, 2, 3, 4, 5].map((n) => addDays(recentFrom, n));
|
||||
fetchMock.mockResolvedValue(
|
||||
json({
|
||||
daily: {
|
||||
time: days,
|
||||
temperature_2m_mean: [-50, 50, -50.1, 50.1, null, '12'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
days[5],
|
||||
RECENT,
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.meanTempC)).toEqual([
|
||||
-50,
|
||||
50,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('never puts the API key or the URL into an error message', async () => {
|
||||
const source = new OpenMeteoHistorySource(config(SECRET));
|
||||
const failures: unknown[] = [];
|
||||
const attempt = async () =>
|
||||
source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e);
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
new Error(`fetch failed https://forecast.example/?apikey=${SECRET}`),
|
||||
);
|
||||
failures.push(await attempt());
|
||||
fetchMock.mockResolvedValueOnce(new Response('x', { status: 500 }));
|
||||
failures.push(await attempt());
|
||||
fetchMock.mockResolvedValueOnce(new Response('not json', { status: 200 }));
|
||||
failures.push(await attempt());
|
||||
fetchMock.mockResolvedValueOnce(json({ daily: { time: 'x' } }));
|
||||
failures.push(await attempt());
|
||||
|
||||
for (const failure of failures) {
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
const { message, stack, cause } = failure as Error;
|
||||
expect(message).not.toContain(SECRET);
|
||||
expect(message).not.toContain('forecast.example');
|
||||
expect(stack ?? '').not.toContain(SECRET);
|
||||
expect(cause).toBeUndefined();
|
||||
}
|
||||
expect((failures[1] as Error).message).toContain('500');
|
||||
});
|
||||
|
||||
it('stops reading after 1 000 000 bytes', async () => {
|
||||
let pulls = 0;
|
||||
let cancelled = false;
|
||||
const chunk = new Uint8Array(100_000).fill(32);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pulls += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(
|
||||
new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
),
|
||||
).rejects.toThrow('zu groß');
|
||||
|
||||
expect(cancelled).toBe(true);
|
||||
// 10 chunks reach the limit, the 11th exceeds it; the stream is not drained.
|
||||
expect(pulls).toBeLessThanOrEqual(
|
||||
Math.ceil(WEATHER_MAX_RESPONSE_BYTES / 100_000) + 3,
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts after 8 s and when the caller aborts', async () => {
|
||||
const timeout = new AbortController();
|
||||
const timeoutSpy = jest
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockReturnValue(timeout.signal);
|
||||
fetchMock.mockImplementation(
|
||||
(_url, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const source = new OpenMeteoHistorySource(config(SECRET));
|
||||
|
||||
const timedOut = source.dailyMeans(POINT, recentFrom, recentTo, RECENT);
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(WEATHER_FETCH_TIMEOUT_MS);
|
||||
timeout.abort();
|
||||
await expect(timedOut).rejects.toThrow('Zeitüberschreitung');
|
||||
|
||||
const caller = new AbortController();
|
||||
timeoutSpy.mockReturnValue(new AbortController().signal);
|
||||
const cancelled = source.dailyMeans(POINT, recentFrom, recentTo, {
|
||||
window: 'recent',
|
||||
signal: caller.signal,
|
||||
});
|
||||
caller.abort();
|
||||
await expect(cancelled).rejects.toThrow('Zeitüberschreitung');
|
||||
});
|
||||
|
||||
it('does not follow redirects (the key travels in the query)', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [], temperature_2m_mean: [] } }),
|
||||
);
|
||||
await new OpenMeteoHistorySource(config(SECRET)).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ redirect: 'error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the configured User-Agent', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [], temperature_2m_mean: [] } }),
|
||||
);
|
||||
await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: { 'User-Agent': 'ApiCore/test (self-hosted beekeeping app)' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports status and Retry-After (seconds or date, capped) of a 429', async () => {
|
||||
const source = new OpenMeteoHistorySource(config(SECRET));
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '120' } }),
|
||||
);
|
||||
const bySeconds = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(bySeconds).toBeInstanceOf(WeatherSourceError);
|
||||
expect(bySeconds.status).toBe(429);
|
||||
expect(bySeconds.retryAfterMs).toBe(120_000);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': new Date(Date.now() + 60_000).toUTCString() },
|
||||
}),
|
||||
);
|
||||
const byDate = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(byDate.retryAfterMs).toBeGreaterThan(50_000);
|
||||
expect(byDate.retryAfterMs).toBeLessThanOrEqual(61_000);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '99999999' } }),
|
||||
);
|
||||
const capped = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(capped.retryAfterMs).toBe(WEATHER_MAX_RETRY_AFTER_MS);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status: 503 }));
|
||||
const none = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(none.retryAfterMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('names a refused redirect "Weiterleitung"', async () => {
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: new Error('unexpected redirect to https://x.example/?apikey=1'),
|
||||
}),
|
||||
);
|
||||
const error = (await new OpenMeteoHistorySource(config(SECRET))
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(error.message).toContain('Weiterleitung');
|
||||
expect(error.message).not.toContain('x.example');
|
||||
});
|
||||
|
||||
// Daily values are plain calendar days: the 23 h and 25 h days of the
|
||||
// clock changes (last Sundays of March and October) are days like any other.
|
||||
it.each([
|
||||
['2026-03-28', '2026-03-30'],
|
||||
['2026-10-24', '2026-10-26'],
|
||||
])('keeps every day around the clock change %s..%s', async (from, to) => {
|
||||
const time = [from, addDays(from, 1), to];
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time, temperature_2m_mean: [1, 2, 3] } }),
|
||||
);
|
||||
|
||||
const result = await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
from,
|
||||
to,
|
||||
ARCHIVE,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ day: from, meanTempC: 1 },
|
||||
{ day: time[1], meanTempC: 2 },
|
||||
{ day: to, meanTempC: 3 },
|
||||
]);
|
||||
expect(addDays(from, 1)).toBe(time[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { dayToDate } from '../../common/dates';
|
||||
import type { DailyMean } from '../../season/season-types';
|
||||
import {
|
||||
FORECAST_PAST_DAYS,
|
||||
WEATHER_MAX_RETRY_AFTER_MS,
|
||||
WEATHER_FETCH_TIMEOUT_MS,
|
||||
WEATHER_MAX_RESPONSE_BYTES,
|
||||
WeatherHistoryConfig,
|
||||
} from './weather-history.config';
|
||||
import type {
|
||||
DailyMeansOptions,
|
||||
GeoPoint,
|
||||
WeatherHistorySource,
|
||||
} from './weather-history-source';
|
||||
|
||||
const MIN_PLAUSIBLE_TEMP_C = -50;
|
||||
const MAX_PLAUSIBLE_TEMP_C = 50;
|
||||
|
||||
/**
|
||||
* Every failure of this adapter. The message is fixed text plus an HTTP
|
||||
* status: no URL (it carries the API key as a query parameter), no cause
|
||||
* (a fetch error names the host). `status` and `retryAfterMs` let the caller
|
||||
* back off.
|
||||
*/
|
||||
export class WeatherSourceError extends Error {
|
||||
constructor(
|
||||
readonly reason: string,
|
||||
readonly status?: number,
|
||||
readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(`Wetterquelle open-meteo: ${reason}`);
|
||||
this.name = 'WeatherSourceError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry-After as seconds or HTTP date; capped; undefined when absent or unreadable. */
|
||||
function retryAfterMs(
|
||||
header: string | null,
|
||||
now = Date.now(),
|
||||
): number | undefined {
|
||||
if (!header) return undefined;
|
||||
const value = header.trim();
|
||||
let ms: number;
|
||||
if (/^\d+$/.test(value)) {
|
||||
ms = Number(value) * 1000;
|
||||
} else {
|
||||
const at = Date.parse(value);
|
||||
if (Number.isNaN(at)) return undefined;
|
||||
ms = at - now;
|
||||
}
|
||||
return Math.min(Math.max(ms, 0), WEATHER_MAX_RETRY_AFTER_MS);
|
||||
}
|
||||
|
||||
/** undici reports a refused redirect as TypeError("fetch failed") with the cause. */
|
||||
function isRedirectError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const { cause } = error;
|
||||
const causeMessage = cause instanceof Error ? cause.message : '';
|
||||
return /redirect/i.test(`${error.message} ${causeMessage}`);
|
||||
}
|
||||
|
||||
interface DailyPayload {
|
||||
time: unknown[];
|
||||
values: unknown[];
|
||||
}
|
||||
|
||||
function temperature(value: unknown): number | null {
|
||||
return typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
value >= MIN_PLAUSIBLE_TEMP_C &&
|
||||
value <= MAX_PLAUSIBLE_TEMP_C
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseDaily(text: string): DailyPayload {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
throw new WeatherSourceError('Antwort ist kein JSON');
|
||||
}
|
||||
const daily = (body as { daily?: unknown } | null)?.daily as
|
||||
{ time?: unknown; temperature_2m_mean?: unknown } | undefined;
|
||||
if (
|
||||
!daily ||
|
||||
!Array.isArray(daily.time) ||
|
||||
!Array.isArray(daily.temperature_2m_mean)
|
||||
) {
|
||||
throw new WeatherSourceError('Antwort hat ein unerwartetes Format');
|
||||
}
|
||||
return { time: daily.time, values: daily.temperature_2m_mean };
|
||||
}
|
||||
|
||||
/** Reads the body up to the byte limit; a bigger answer is cut off, not buffered. */
|
||||
async function readLimited(response: Response): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new WeatherSourceError('Antwort ohne Inhalt');
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > WEATHER_MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new WeatherSourceError('Antwort ist zu groß');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily mean temperatures from the free Open-Meteo API (forecast endpoint
|
||||
* for the last 90 days, archive endpoint for older ones). The owner accepted
|
||||
* its non-commercial terms; the adapter interface keeps a switch cheap.
|
||||
*/
|
||||
export class OpenMeteoHistorySource implements WeatherHistorySource {
|
||||
readonly name = 'open-meteo';
|
||||
|
||||
constructor(private readonly config: WeatherHistoryConfig) {}
|
||||
|
||||
async dailyMeans(
|
||||
point: GeoPoint,
|
||||
fromDay: string,
|
||||
toDay: string,
|
||||
{ window, signal }: DailyMeansOptions,
|
||||
): Promise<DailyMean[]> {
|
||||
dayToDate(fromDay);
|
||||
dayToDate(toDay);
|
||||
const recent = window === 'recent';
|
||||
const url = new URL(
|
||||
recent ? this.config.forecastUrl : this.config.archiveUrl,
|
||||
);
|
||||
url.searchParams.set('latitude', point.lat.toFixed(2));
|
||||
url.searchParams.set('longitude', point.lon.toFixed(2));
|
||||
url.searchParams.set('daily', 'temperature_2m_mean');
|
||||
url.searchParams.set('timezone', 'Europe/Berlin');
|
||||
if (recent) {
|
||||
url.searchParams.set('past_days', String(FORECAST_PAST_DAYS));
|
||||
url.searchParams.set('forecast_days', '1');
|
||||
} else {
|
||||
url.searchParams.set('start_date', fromDay);
|
||||
url.searchParams.set('end_date', toDay);
|
||||
}
|
||||
if (this.config.apiKey) {
|
||||
url.searchParams.set('apikey', this.config.apiKey);
|
||||
}
|
||||
|
||||
const payload = parseDaily(await this.fetchText(url, signal));
|
||||
const means: DailyMean[] = [];
|
||||
payload.time.forEach((day, index) => {
|
||||
if (typeof day === 'string' && day >= fromDay && day <= toDay) {
|
||||
means.push({ day, meanTempC: temperature(payload.values[index]) });
|
||||
}
|
||||
});
|
||||
return means;
|
||||
}
|
||||
|
||||
private async fetchText(url: URL, signal?: AbortSignal): Promise<string> {
|
||||
const timeout = AbortSignal.timeout(WEATHER_FETCH_TIMEOUT_MS);
|
||||
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
try {
|
||||
// No redirects: the key travels in the query string.
|
||||
const response = await fetch(url, {
|
||||
signal: combined,
|
||||
redirect: 'error',
|
||||
headers: { 'User-Agent': this.config.userAgent },
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new WeatherSourceError(
|
||||
`HTTP ${response.status}`,
|
||||
response.status,
|
||||
retryAfterMs(response.headers.get('Retry-After')),
|
||||
);
|
||||
}
|
||||
return await readLimited(response);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WeatherSourceError) throw error;
|
||||
if (combined.aborted) {
|
||||
throw new WeatherSourceError('Zeitüberschreitung oder Abbruch');
|
||||
}
|
||||
throw new WeatherSourceError(
|
||||
isRedirectError(error) ? 'Weiterleitung' : 'Netzwerkfehler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DailyMean } from '../../season/season-types';
|
||||
|
||||
/** A place, rounded to 0.01 degrees (roundPoint) before it leaves the app. */
|
||||
export interface GeoPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where daily mean temperatures come from. The phenology feature depends on
|
||||
* this interface only, so the free Open-Meteo API can be swapped for another
|
||||
* adapter (WEATHER_HISTORY_SOURCE) without touching the callers.
|
||||
*/
|
||||
/**
|
||||
* Which endpoint family serves a range: `recent` (the last 90 days, forecast
|
||||
* endpoint with past days) or `archive`. The caller decides, so the adapter
|
||||
* needs no clock.
|
||||
*/
|
||||
export type HistoryWindow = 'recent' | 'archive';
|
||||
|
||||
export interface DailyMeansOptions {
|
||||
window: HistoryWindow;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WeatherHistorySource {
|
||||
readonly name: string;
|
||||
/**
|
||||
* Daily means from `fromDay` to `toDay` (both YYYY-MM-DD, Europe/Berlin,
|
||||
* inclusive). Throws on any failure; the message never carries a URL or
|
||||
* a key.
|
||||
*/
|
||||
dailyMeans(
|
||||
point: GeoPoint,
|
||||
fromDay: string,
|
||||
toDay: string,
|
||||
options: DailyMeansOptions,
|
||||
): Promise<DailyMean[]>;
|
||||
}
|
||||
|
||||
export const WEATHER_HISTORY_SOURCE = Symbol('WEATHER_HISTORY_SOURCE');
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
FORECAST_PAST_DAYS,
|
||||
RECENT_WINDOW_DAYS,
|
||||
roundPoint,
|
||||
WEATHER_FETCH_TIMEOUT_MS,
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
WEATHER_MAX_RESPONSE_BYTES,
|
||||
WEATHER_RETENTION_YEARS,
|
||||
weatherHistoryConfig,
|
||||
} from './weather-history.config';
|
||||
|
||||
describe('weatherHistoryConfig', () => {
|
||||
it('keeps the documented constants', () => {
|
||||
expect(WEATHER_HISTORY_POLL_MS).toBe(3_600_000);
|
||||
expect(WEATHER_FETCH_TIMEOUT_MS).toBe(8_000);
|
||||
expect(WEATHER_MAX_RESPONSE_BYTES).toBe(1_000_000);
|
||||
expect(FORECAST_PAST_DAYS).toBe(92);
|
||||
expect(RECENT_WINDOW_DAYS).toBe(90);
|
||||
expect(WEATHER_RETENTION_YEARS).toBe(4);
|
||||
expect(WEATHER_FIRST_TICK_DELAY_MS).toBe(10_000);
|
||||
});
|
||||
|
||||
it('selects open-meteo by WEATHER_HISTORY_SOURCE and refuses http: URLs', () => {
|
||||
const config = weatherHistoryConfig({});
|
||||
expect(config.source).toBe('open-meteo');
|
||||
expect(config.forecastUrl.href).toBe(
|
||||
'https://api.open-meteo.com/v1/forecast',
|
||||
);
|
||||
expect(config.archiveUrl.href).toBe(
|
||||
'https://archive-api.open-meteo.com/v1/archive',
|
||||
);
|
||||
expect(config.apiKey).toBeNull();
|
||||
expect(
|
||||
weatherHistoryConfig({ WEATHER_HISTORY_SOURCE: 'open-meteo' }).source,
|
||||
).toBe('open-meteo');
|
||||
|
||||
expect(() =>
|
||||
weatherHistoryConfig({ WEATHER_HISTORY_SOURCE: 'dwd' }),
|
||||
).toThrow('WEATHER_HISTORY_SOURCE');
|
||||
expect(() =>
|
||||
weatherHistoryConfig({
|
||||
OPEN_METEO_FORECAST_URL: 'http://api.open-meteo.com/v1/forecast',
|
||||
}),
|
||||
).toThrow('OPEN_METEO_FORECAST_URL');
|
||||
expect(() =>
|
||||
weatherHistoryConfig({ OPEN_METEO_ARCHIVE_URL: 'ftp://example.com/x' }),
|
||||
).toThrow('OPEN_METEO_ARCHIVE_URL');
|
||||
expect(() =>
|
||||
weatherHistoryConfig({ OPEN_METEO_ARCHIVE_URL: 'not a url' }),
|
||||
).toThrow('OPEN_METEO_ARCHIVE_URL');
|
||||
});
|
||||
|
||||
it('takes URLs and key from the environment; an empty key is none', () => {
|
||||
const config = weatherHistoryConfig({
|
||||
OPEN_METEO_FORECAST_URL: 'https://weather.example/forecast',
|
||||
OPEN_METEO_API_KEY: ' secret-key ',
|
||||
});
|
||||
expect(config.forecastUrl.href).toBe('https://weather.example/forecast');
|
||||
expect(config.apiKey).toBe('secret-key');
|
||||
expect(
|
||||
weatherHistoryConfig({ OPEN_METEO_API_KEY: ' ' }).apiKey,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('never puts the API key or the URL into an error message', () => {
|
||||
let message = '';
|
||||
try {
|
||||
weatherHistoryConfig({
|
||||
OPEN_METEO_API_KEY: 'topsecretkey',
|
||||
OPEN_METEO_FORECAST_URL: 'http://user:[email protected]/forecast?x=1',
|
||||
});
|
||||
} catch (error) {
|
||||
message = (error as Error).message;
|
||||
}
|
||||
expect(message).not.toBe('');
|
||||
expect(message).not.toContain('topsecretkey');
|
||||
expect(message).not.toContain('evil.example');
|
||||
expect(message).not.toContain('user:pw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weatherUserAgent', () => {
|
||||
it('names the app and version, with the contact when configured', () => {
|
||||
expect(weatherHistoryConfig({}).userAgent).toMatch(
|
||||
/^ApiCore\/\S+ \(self-hosted beekeeping app\)$/,
|
||||
);
|
||||
expect(
|
||||
weatherHistoryConfig({
|
||||
WEATHER_USER_AGENT_CONTACT: '[email protected]',
|
||||
}).userAgent,
|
||||
).toMatch(/^ApiCore\/\S+ \(\+imker@example\.org\)$/);
|
||||
});
|
||||
|
||||
it('strips control characters from the contact (header injection)', () => {
|
||||
const { userAgent } = weatherHistoryConfig({
|
||||
WEATHER_USER_AGENT_CONTACT: '[email protected]\r\nX-Evil: 1',
|
||||
});
|
||||
expect(userAgent).not.toMatch(/[\r\n]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundPoint', () => {
|
||||
it('rounds to 0.01 degrees', () => {
|
||||
expect(roundPoint(52.5234, 13.4049)).toEqual({ lat: 52.52, lon: 13.4 });
|
||||
expect(roundPoint(-33.8688, 151.2093)).toEqual({
|
||||
lat: -33.87,
|
||||
lon: 151.21,
|
||||
});
|
||||
});
|
||||
|
||||
it('never yields negative zero', () => {
|
||||
const point = roundPoint(-0.004, -0.001);
|
||||
expect(Object.is(point.lat, 0)).toBe(true);
|
||||
expect(Object.is(point.lon, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { addDays } from '../../common/dates';
|
||||
import type { GeoPoint } from './weather-history-source';
|
||||
|
||||
export const WEATHER_HISTORY_POLL_MS = 3_600_000;
|
||||
export const WEATHER_FETCH_TIMEOUT_MS = 8_000;
|
||||
export const WEATHER_MAX_RESPONSE_BYTES = 1_000_000;
|
||||
/** The forecast endpoint reaches this far back (past_days). */
|
||||
export const FORECAST_PAST_DAYS = 92;
|
||||
/** Days up to this age come from the forecast endpoint, older ones from the archive. */
|
||||
export const RECENT_WINDOW_DAYS = 90;
|
||||
export const WEATHER_RETENTION_YEARS = 4;
|
||||
/** Pause between two points of one pass: no burst against the free API. */
|
||||
export const WEATHER_POINT_DELAY_MS = 300;
|
||||
/** This many failed points in a row end the pass; the next hourly tick retries. */
|
||||
export const WEATHER_MAX_CONSECUTIVE_FAILURES = 3;
|
||||
/** Upserts per transaction. */
|
||||
export const WEATHER_WRITE_CHUNK_SIZE = 200;
|
||||
/** An old forecast row is offered to the archive again after this long. */
|
||||
export const FORECAST_RECHECK_MS = 7 * 24 * 3_600_000;
|
||||
/** The first pass waits this long after the worker started. */
|
||||
export const WEATHER_FIRST_TICK_DELAY_MS = 10_000;
|
||||
/** A Retry-After longer than this is capped. */
|
||||
export const WEATHER_MAX_RETRY_AFTER_MS = 24 * 3_600_000;
|
||||
|
||||
const DEFAULT_SOURCE = 'open-meteo';
|
||||
const DEFAULT_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const DEFAULT_ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||
|
||||
export interface WeatherHistoryConfig {
|
||||
source: 'open-meteo';
|
||||
forecastUrl: URL;
|
||||
archiveUrl: URL;
|
||||
apiKey: string | null;
|
||||
/** Identifies the app (and, if configured, a contact) to the provider. */
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only https: URLs, only from the configuration. Errors name the variable,
|
||||
* never its value (a URL can carry credentials).
|
||||
*/
|
||||
function httpsUrl(env: NodeJS.ProcessEnv, name: string, fallback: string): URL {
|
||||
const raw = env[name]?.trim() || fallback;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`${name} ist keine gültige URL.`);
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
throw new Error(`${name} muss eine https:-URL sein.`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function appVersion(): string {
|
||||
let dir = __dirname;
|
||||
for (let level = 0; level < 6; level += 1) {
|
||||
const file = join(dir, 'package.json');
|
||||
if (existsSync(file)) {
|
||||
try {
|
||||
const { version } = JSON.parse(readFileSync(file, 'utf8')) as {
|
||||
version?: unknown;
|
||||
};
|
||||
if (typeof version === 'string') return version;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Printable ASCII only: the value ends up in a request header. */
|
||||
function headerSafe(value: string): string {
|
||||
return value.replace(/[^ -~]/g, '').trim();
|
||||
}
|
||||
|
||||
export function weatherUserAgent(env: NodeJS.ProcessEnv): string {
|
||||
const contact = headerSafe(env.WEATHER_USER_AGENT_CONTACT ?? '');
|
||||
return `ApiCore/${appVersion()} (${contact ? `+${contact}` : 'self-hosted beekeeping app'})`;
|
||||
}
|
||||
|
||||
/** Read once at startup (module factory, WeatherService); throws on bad settings. */
|
||||
export function weatherHistoryConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): WeatherHistoryConfig {
|
||||
const source = env.WEATHER_HISTORY_SOURCE?.trim() || DEFAULT_SOURCE;
|
||||
if (source !== DEFAULT_SOURCE) {
|
||||
throw new Error(
|
||||
`WEATHER_HISTORY_SOURCE: unbekannte Wetterquelle (erlaubt: ${DEFAULT_SOURCE}).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
source,
|
||||
forecastUrl: httpsUrl(env, 'OPEN_METEO_FORECAST_URL', DEFAULT_FORECAST_URL),
|
||||
archiveUrl: httpsUrl(env, 'OPEN_METEO_ARCHIVE_URL', DEFAULT_ARCHIVE_URL),
|
||||
apiKey: env.OPEN_METEO_API_KEY?.trim() || null,
|
||||
userAgent: weatherUserAgent(env),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coordinates at 0.01 degrees (about 1 km): enough for a daily mean, and no exact address leaves the app. */
|
||||
export function roundPoint(lat: number, lon: number): GeoPoint {
|
||||
// "+ 0" turns -0 into 0.
|
||||
return {
|
||||
lat: Math.round(lat * 100) / 100 + 0,
|
||||
lon: Math.round(lon * 100) / 100 + 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** First day of the window served by the forecast endpoint. */
|
||||
export function recentWindowStart(today: string): string {
|
||||
return addDays(today, -RECENT_WINDOW_DAYS);
|
||||
}
|
||||
|
||||
/** True when a range starting at `fromDay` is read from the forecast endpoint. */
|
||||
export function isRecentRange(fromDay: string, today: string): boolean {
|
||||
return fromDay >= recentWindowStart(today);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OpenMeteoHistorySource } from './open-meteo-history.source';
|
||||
import { weatherHistoryConfig } from './weather-history.config';
|
||||
import { WeatherHistoryService } from './weather-history.service';
|
||||
import { WEATHER_HISTORY_SOURCE } from './weather-history-source';
|
||||
|
||||
/**
|
||||
* Daily mean temperatures for the phenology. Imported by WorkerRuntimeModule
|
||||
* only (the loop `phenology-weather`); the API never calls the source. The
|
||||
* factory reads WEATHER_HISTORY_SOURCE and the URLs once at startup: an
|
||||
* unknown source or an http: URL stops the process.
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: WEATHER_HISTORY_SOURCE,
|
||||
useFactory: () => new OpenMeteoHistorySource(weatherHistoryConfig()),
|
||||
},
|
||||
WeatherHistoryService,
|
||||
],
|
||||
exports: [WeatherHistoryService],
|
||||
})
|
||||
export class WeatherHistoryModule {}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { addDays, dayToDate } from '../../common/dates';
|
||||
import type { PrismaService } from '../../prisma/prisma.service';
|
||||
import { RECENT_WINDOW_DAYS } from './weather-history.config';
|
||||
import type { WeatherHistorySource } from './weather-history-source';
|
||||
import { abortableDelay } from './abortable-delay';
|
||||
import { WeatherSourceError } from './open-meteo-history.source';
|
||||
import { WeatherHistoryService } from './weather-history.service';
|
||||
|
||||
interface ApiaryRow {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
interface DayRow {
|
||||
apiaryId: string;
|
||||
day: Date;
|
||||
meanTempC: number | null;
|
||||
source: string;
|
||||
fetchedAt?: Date;
|
||||
}
|
||||
interface Upsert {
|
||||
create: {
|
||||
apiaryId: string;
|
||||
day: Date;
|
||||
meanTempC: number | null;
|
||||
source: string;
|
||||
};
|
||||
}
|
||||
|
||||
const dayKey = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
jest.mock('./abortable-delay', () => ({
|
||||
abortableDelay: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
describe('WeatherHistoryService', () => {
|
||||
const today = '2026-05-20';
|
||||
const yesterday = '2026-05-19';
|
||||
const recentStart = addDays(today, -RECENT_WINDOW_DAYS);
|
||||
const signal = new AbortController().signal;
|
||||
let apiaries: ApiaryRow[];
|
||||
let existing: DayRow[];
|
||||
let dailyMeans: jest.Mock;
|
||||
let apiaryFindMany: jest.Mock;
|
||||
let upserts: Upsert[];
|
||||
let transactionSizes: number[];
|
||||
let touches: unknown[];
|
||||
let service: WeatherHistoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiaries = [];
|
||||
existing = [];
|
||||
upserts = [];
|
||||
(abortableDelay as jest.Mock).mockClear();
|
||||
transactionSizes = [];
|
||||
touches = [];
|
||||
dailyMeans = jest
|
||||
.fn()
|
||||
.mockImplementation((_point: unknown, from: string, to: string) => {
|
||||
const out: { day: string; meanTempC: number | null }[] = [];
|
||||
for (let d = from; d <= to; d = addDays(d, 1)) {
|
||||
out.push({ day: d, meanTempC: 5 });
|
||||
}
|
||||
return Promise.resolve(out);
|
||||
});
|
||||
apiaryFindMany = jest.fn(() => Promise.resolve(apiaries));
|
||||
const prisma = {
|
||||
apiary: { findMany: apiaryFindMany },
|
||||
apiaryWeatherDay: {
|
||||
findMany: jest.fn(() =>
|
||||
Promise.resolve(
|
||||
existing.map((row) => ({ fetchedAt: new Date(0), ...row })),
|
||||
),
|
||||
),
|
||||
update: jest.fn((args: unknown) => {
|
||||
touches.push(args);
|
||||
return Promise.resolve(args);
|
||||
}),
|
||||
upsert: jest.fn((args: Upsert) => {
|
||||
upserts.push(args);
|
||||
return Promise.resolve(args);
|
||||
}),
|
||||
},
|
||||
$transaction: jest.fn((ops: Promise<unknown>[]) => {
|
||||
transactionSizes.push(ops.length);
|
||||
return Promise.all(ops);
|
||||
}),
|
||||
};
|
||||
const source: WeatherHistorySource = { name: 'fake', dailyMeans };
|
||||
service = new WeatherHistoryService(
|
||||
prisma as unknown as PrismaService,
|
||||
source,
|
||||
);
|
||||
});
|
||||
|
||||
it('apiaries on the same rounded point share one request', async () => {
|
||||
apiaries = [
|
||||
{ id: 'a', latitude: 52.5234, longitude: 13.4049 },
|
||||
{ id: 'b', latitude: 52.5199, longitude: 13.3951 },
|
||||
{ id: 'c', latitude: 48.1, longitude: 11.5 },
|
||||
];
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
// Two points; each needs an archive range and a forecast range.
|
||||
expect(result.points).toBe(2);
|
||||
expect(result.failedPoints).toBe(0);
|
||||
const forLat = (lat: number) =>
|
||||
dailyMeans.mock.calls.filter(
|
||||
([point]) => (point as { lat: number }).lat === lat,
|
||||
) as [
|
||||
{ lat: number; lon: number },
|
||||
string,
|
||||
string,
|
||||
{ window: string; signal: AbortSignal },
|
||||
][];
|
||||
expect(forLat(52.52)).toHaveLength(2);
|
||||
expect(forLat(48.1)).toHaveLength(2);
|
||||
const [point, from, to, options] = forLat(52.52)[0];
|
||||
expect(point).toEqual({ lat: 52.52, lon: 13.4 });
|
||||
expect([from, to]).toEqual(['2026-01-01', addDays(recentStart, -1)]);
|
||||
expect(options).toEqual({ window: 'archive', signal });
|
||||
expect(forLat(52.52)[1][3].window).toBe('recent');
|
||||
expect(forLat(52.52).map(([, f, t]) => [f, t])).toContainEqual([
|
||||
recentStart,
|
||||
yesterday,
|
||||
]);
|
||||
// Both apiaries of the shared point got their rows (Jan 1 .. May 19).
|
||||
const rowsOf = (id: string) =>
|
||||
upserts.filter((u) => u.create.apiaryId === id);
|
||||
expect(rowsOf('a')).toHaveLength(139);
|
||||
expect(rowsOf('b')).toHaveLength(139);
|
||||
expect(result.daysWritten).toBe(139 * 3);
|
||||
});
|
||||
|
||||
it('fetches only missing days and labels the source by window', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
// Complete, except one old day, one recent day and one recent null day.
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
if (d === '2026-01-10' || d === '2026-04-01') continue;
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: d === '2026-05-01' ? null : 4,
|
||||
source: d < recentStart ? 'archive' : 'forecast',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(
|
||||
(dailyMeans.mock.calls as [unknown, string, string][]).map(([, f, t]) => [
|
||||
f,
|
||||
t,
|
||||
]),
|
||||
).toEqual([
|
||||
['2026-01-10', '2026-01-10'],
|
||||
['2026-04-01', '2026-05-01'],
|
||||
]);
|
||||
const written = upserts.map((u) => [dayKey(u.create.day), u.create.source]);
|
||||
// Days inside the range that already have a value are not rewritten.
|
||||
expect(written).toEqual([
|
||||
['2026-01-10', 'archive'],
|
||||
['2026-04-01', 'forecast'],
|
||||
['2026-05-01', 'forecast'],
|
||||
]);
|
||||
expect(result.daysWritten).toBe(3);
|
||||
});
|
||||
|
||||
it('does not refetch old days whose value is null', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: d < recentStart ? null : 4,
|
||||
source: d < recentStart ? 'archive' : 'forecast',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
points: 0,
|
||||
daysWritten: 0,
|
||||
failedPoints: 0,
|
||||
endedEarly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing for a point on an error and tries the others', async () => {
|
||||
apiaries = [
|
||||
{ id: 'a', latitude: 52.5, longitude: 13.4 },
|
||||
{ id: 'c', latitude: 48.1, longitude: 11.5 },
|
||||
];
|
||||
dailyMeans.mockImplementation(
|
||||
(point: { lat: number }, from: string, to: string) =>
|
||||
point.lat === 52.5 && from !== '2026-01-01'
|
||||
? Promise.reject(new Error('boom 52.5'))
|
||||
: Promise.resolve([
|
||||
{ day: from, meanTempC: 1 },
|
||||
{ day: to, meanTempC: 1 },
|
||||
]),
|
||||
);
|
||||
const warn = jest
|
||||
.spyOn(service['logger'], 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(result.failedPoints).toBe(1);
|
||||
expect(upserts.length).toBeGreaterThan(0);
|
||||
expect(upserts.every((u) => u.create.apiaryId === 'c')).toBe(true);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads active apiaries with coordinates and stops when aborted', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const result = await service.syncOnce(today, controller.signal);
|
||||
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
expect(result.points).toBe(0);
|
||||
expect(apiaryFindMany.mock.calls[0]).toEqual([
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: 'ACTIVE',
|
||||
latitude: { not: null },
|
||||
longitude: { not: null },
|
||||
}) as unknown,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('backoff', () => {
|
||||
const manyPoints = (count: number): ApiaryRow[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
latitude: 40 + i,
|
||||
longitude: 10,
|
||||
}));
|
||||
const quiet = () =>
|
||||
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
||||
|
||||
it('pauses between points, abortable', async () => {
|
||||
apiaries = manyPoints(3);
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
|
||||
expect(abortableDelay).toHaveBeenCalledTimes(2);
|
||||
expect(abortableDelay).toHaveBeenCalledWith(300, signal);
|
||||
});
|
||||
|
||||
it('ends the pass after 3 failed points in a row', async () => {
|
||||
apiaries = manyPoints(6);
|
||||
dailyMeans.mockRejectedValue(new WeatherSourceError('HTTP 500', 500));
|
||||
quiet();
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(result).toEqual({
|
||||
points: 3,
|
||||
daysWritten: 0,
|
||||
failedPoints: 3,
|
||||
endedEarly: true,
|
||||
});
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('a success resets the failure streak', async () => {
|
||||
apiaries = manyPoints(5);
|
||||
dailyMeans.mockImplementation(
|
||||
(point: { lat: number }, from: string, to: string) =>
|
||||
point.lat === 42
|
||||
? Promise.resolve([
|
||||
{ day: from, meanTempC: 1 },
|
||||
{ day: to, meanTempC: 1 },
|
||||
])
|
||||
: Promise.reject(new WeatherSourceError('HTTP 500', 500)),
|
||||
);
|
||||
quiet();
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(result.points).toBe(5);
|
||||
expect(result.failedPoints).toBe(4);
|
||||
expect(result.endedEarly).toBe(false);
|
||||
});
|
||||
|
||||
it('stops on a 429 and sends no request before Retry-After has passed', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
apiaries = manyPoints(4);
|
||||
dailyMeans.mockRejectedValueOnce(
|
||||
new WeatherSourceError('HTTP 429', 429, 120_000),
|
||||
);
|
||||
quiet();
|
||||
|
||||
const first = await service.syncOnce(today, signal);
|
||||
expect(first.endedEarly).toBe(true);
|
||||
expect(first.points).toBe(1);
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(119_000);
|
||||
const blocked = await service.syncOnce(today, signal);
|
||||
expect(blocked.endedEarly).toBe(true);
|
||||
expect(blocked.points).toBe(0);
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(2_000);
|
||||
dailyMeans.mockClear();
|
||||
const resumed = await service.syncOnce(today, signal);
|
||||
expect(resumed.points).toBe(4);
|
||||
expect(dailyMeans).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces forecast values older than the recent window with archive values', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: 4,
|
||||
source: d === '2026-01-15' || d >= recentStart ? 'forecast' : 'archive',
|
||||
});
|
||||
}
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
|
||||
const calls = dailyMeans.mock.calls as [
|
||||
unknown,
|
||||
string,
|
||||
string,
|
||||
{ window: string },
|
||||
][];
|
||||
expect(calls.map(([, f, t, o]) => [f, t, o.window])).toEqual([
|
||||
['2026-01-15', '2026-01-15', 'archive'],
|
||||
]);
|
||||
expect(upserts.map((u) => [dayKey(u.create.day), u.create.source])).toEqual(
|
||||
[['2026-01-15', 'archive']],
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a large backfill in several transactions', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
|
||||
const result = await service.syncOnce('2026-12-31', signal);
|
||||
|
||||
expect(result.daysWritten).toBe(364);
|
||||
const sizes = transactionSizes;
|
||||
expect(sizes.length).toBeGreaterThan(1);
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(200);
|
||||
expect(sizes.reduce((a, b) => a + b, 0)).toBe(364);
|
||||
});
|
||||
|
||||
it('counts the days of the clock changes like any other day', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
|
||||
await service.syncOnce('2026-11-02', signal);
|
||||
|
||||
const written = upserts.map((u) => dayKey(u.create.day));
|
||||
expect(written).toHaveLength(305); // Jan 1 .. Nov 1
|
||||
expect(new Set(written).size).toBe(305);
|
||||
expect(written).toEqual(
|
||||
expect.arrayContaining(['2026-03-28', '2026-03-29', '2026-03-30']),
|
||||
);
|
||||
expect(written).toEqual(
|
||||
expect.arrayContaining(['2026-10-24', '2026-10-25', '2026-10-26']),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the pass with points, daysWritten, failedPoints and endedEarly', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
const log = jest
|
||||
.spyOn(service['logger'], 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/points=1, daysWritten=139, failedPoints=0, endedEarly=false/,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('old forecast rows', () => {
|
||||
const oldForecastRows = (fetchedAt?: Date): void => {
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: 4,
|
||||
source: d === '2026-01-15' ? 'forecast' : 'archive',
|
||||
...(d === '2026-01-15' && fetchedAt ? { fetchedAt } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
it('keeps the value when the archive has none and only notes the check', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
oldForecastRows();
|
||||
dailyMeans.mockResolvedValue([{ day: '2026-01-15', meanTempC: null }]);
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(upserts).toHaveLength(0);
|
||||
expect(touches).toEqual([
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
apiaryId_day: { apiaryId: 'a', day: dayToDate('2026-01-15') },
|
||||
},
|
||||
data: { fetchedAt: expect.any(Date) as unknown },
|
||||
}),
|
||||
]);
|
||||
expect(result.daysWritten).toBe(0);
|
||||
});
|
||||
|
||||
it('does not ask again within 7 days of the last check', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
oldForecastRows(new Date(Date.now() - 6 * 24 * 3_600_000));
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
|
||||
existing = [];
|
||||
oldForecastRows(new Date(Date.now() - 8 * 24 * 3_600_000));
|
||||
await service.syncOnce(today, signal);
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('honours Retry-After of a 503 like a 429, but not a 503 without it', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
apiaries = [
|
||||
{ id: 'a', latitude: 40, longitude: 10 },
|
||||
{ id: 'b', latitude: 41, longitude: 10 },
|
||||
];
|
||||
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
||||
dailyMeans.mockRejectedValueOnce(
|
||||
new WeatherSourceError('HTTP 503', 503, 60_000),
|
||||
);
|
||||
|
||||
const first = await service.syncOnce(today, signal);
|
||||
expect(first).toEqual(
|
||||
expect.objectContaining({ points: 1, endedEarly: true }),
|
||||
);
|
||||
jest.advanceTimersByTime(30_000);
|
||||
dailyMeans.mockClear();
|
||||
expect((await service.syncOnce(today, signal)).points).toBe(0);
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(31_000);
|
||||
dailyMeans.mockRejectedValueOnce(new WeatherSourceError('HTTP 503', 503));
|
||||
const plain = await service.syncOnce(today, signal);
|
||||
expect(plain.points).toBe(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { addDays, dayToDate } from '../../common/dates';
|
||||
import { loggableError } from '../../common/loggable-error';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
type GeoPoint,
|
||||
WEATHER_HISTORY_SOURCE,
|
||||
type WeatherHistorySource,
|
||||
} from './weather-history-source';
|
||||
import { WeatherSourceError } from './open-meteo-history.source';
|
||||
import { abortableDelay } from './abortable-delay';
|
||||
import {
|
||||
isRecentRange,
|
||||
recentWindowStart,
|
||||
roundPoint,
|
||||
FORECAST_RECHECK_MS,
|
||||
WEATHER_MAX_CONSECUTIVE_FAILURES,
|
||||
WEATHER_POINT_DELAY_MS,
|
||||
WEATHER_WRITE_CHUNK_SIZE,
|
||||
} from './weather-history.config';
|
||||
|
||||
export interface WeatherSyncResult {
|
||||
points: number;
|
||||
daysWritten: number;
|
||||
failedPoints: number;
|
||||
/** The pass stopped before the last point (rate limit, repeated failures, shutdown). */
|
||||
endedEarly: boolean;
|
||||
}
|
||||
|
||||
interface PlannedApiary {
|
||||
id: string;
|
||||
/** Days this apiary lacks (no row, or no value inside the recent window). */
|
||||
missing: Set<string>;
|
||||
/** Days that already have a row (with no value): not rewritten by another null. */
|
||||
stored: Set<string>;
|
||||
/** Forecast values past the window that the archive is asked to replace. */
|
||||
replaceable: Set<string>;
|
||||
}
|
||||
|
||||
interface PlannedPoint {
|
||||
point: GeoPoint;
|
||||
apiaries: PlannedApiary[];
|
||||
}
|
||||
|
||||
interface Range {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
type WeatherLabel = 'forecast' | 'archive';
|
||||
|
||||
const dayKey = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
/** The smallest range covering the given days, or null for none. */
|
||||
function spanOf(days: Iterable<string>): Range | null {
|
||||
let from: string | null = null;
|
||||
let to: string | null = null;
|
||||
for (const day of days) {
|
||||
if (from === null || day < from) from = day;
|
||||
if (to === null || day > to) to = day;
|
||||
}
|
||||
return from === null || to === null ? null : { from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the daily mean temperatures of the active apiaries (ApiaryWeatherDay)
|
||||
* complete from January 1st to yesterday, for the growing-degree-day sum of
|
||||
* the phenology. Runs in the worker loop `phenology-weather`; the only code
|
||||
* that talks to the weather source for history.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WeatherHistoryService {
|
||||
private readonly logger = new Logger(WeatherHistoryService.name);
|
||||
/** Wall-clock ms before which no request goes out (Retry-After of a 429). */
|
||||
private blockedUntil = 0;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(WEATHER_HISTORY_SOURCE)
|
||||
private readonly source: WeatherHistorySource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One pass. A point whose request fails writes nothing and is tried again
|
||||
* by the next pass; no event, no notification.
|
||||
*/
|
||||
async syncOnce(
|
||||
today: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<WeatherSyncResult> {
|
||||
const result: WeatherSyncResult = {
|
||||
points: 0,
|
||||
daysWritten: 0,
|
||||
failedPoints: 0,
|
||||
endedEarly: false,
|
||||
};
|
||||
if (Date.now() < this.blockedUntil) {
|
||||
this.logger.log('Wetterabgleich pausiert (Retry-After der Quelle).');
|
||||
return { ...result, endedEarly: true };
|
||||
}
|
||||
const plan = await this.plan(today);
|
||||
let consecutiveFailures = 0;
|
||||
for (const planned of plan) {
|
||||
if (signal.aborted) {
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
if (result.points > 0) {
|
||||
await abortableDelay(WEATHER_POINT_DELAY_MS, signal);
|
||||
if (signal.aborted) {
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.points += 1;
|
||||
try {
|
||||
result.daysWritten += await this.syncPoint(planned, today, signal);
|
||||
consecutiveFailures = 0;
|
||||
} catch (error: unknown) {
|
||||
result.failedPoints += 1;
|
||||
consecutiveFailures += 1;
|
||||
// Message only (no stack): nothing of the request goes to the log.
|
||||
this.logger.warn(
|
||||
`Wetterabruf für einen Standortpunkt fehlgeschlagen: ${loggableError(error).message}`,
|
||||
);
|
||||
if (
|
||||
error instanceof WeatherSourceError &&
|
||||
(error.status === 429 ||
|
||||
(error.status === 503 && error.retryAfterMs !== undefined))
|
||||
) {
|
||||
// The source asks us to slow down: stop, and stay quiet until
|
||||
// Retry-After has passed (at least until the next tick).
|
||||
this.blockedUntil = Date.now() + (error.retryAfterMs ?? 0);
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
if (consecutiveFailures >= WEATHER_MAX_CONSECUTIVE_FAILURES) {
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.points > 0 || result.endedEarly) {
|
||||
this.logger.log(
|
||||
`Wetterabgleich: points=${result.points}, daysWritten=${result.daysWritten}, failedPoints=${result.failedPoints}, endedEarly=${result.endedEarly}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Active apiaries with coordinates, grouped by rounded point, minus what is complete. */
|
||||
private async plan(today: string): Promise<PlannedPoint[]> {
|
||||
const yesterday = addDays(today, -1);
|
||||
const januaryFirst = `${today.slice(0, 4)}-01-01`;
|
||||
if (yesterday < januaryFirst) return [];
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
latitude: { not: null },
|
||||
longitude: { not: null },
|
||||
},
|
||||
select: { id: true, latitude: true, longitude: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (apiaries.length === 0) return [];
|
||||
const stored = await this.prisma.apiaryWeatherDay.findMany({
|
||||
where: {
|
||||
apiaryId: { in: apiaries.map(({ id }) => id) },
|
||||
day: { gte: dayToDate(januaryFirst), lte: dayToDate(yesterday) },
|
||||
},
|
||||
select: {
|
||||
apiaryId: true,
|
||||
day: true,
|
||||
meanTempC: true,
|
||||
source: true,
|
||||
fetchedAt: true,
|
||||
},
|
||||
});
|
||||
const complete = new Map<string, Set<string>>();
|
||||
const storedDays = new Map<string, Set<string>>();
|
||||
const replaceable = new Map<string, Set<string>>();
|
||||
const now = Date.now();
|
||||
const recentStart = recentWindowStart(today);
|
||||
for (const row of stored) {
|
||||
const day = dayKey(row.day);
|
||||
const rows = storedDays.get(row.apiaryId) ?? new Set<string>();
|
||||
rows.add(day);
|
||||
storedDays.set(row.apiaryId, rows);
|
||||
// A null inside the recent window is asked for again; an older one
|
||||
// stays (the archive does not know it either).
|
||||
if (row.meanTempC === null && day >= recentStart) continue;
|
||||
// A forecast value older than the window is replaced by the archive's
|
||||
// (the forecast endpoint's model values are not the reanalysis).
|
||||
// Asked at most once per FORECAST_RECHECK_DAYS: when the archive has
|
||||
// no value either, the row is touched (fetchedAt) and left alone.
|
||||
if (
|
||||
row.source === 'forecast' &&
|
||||
day < recentStart &&
|
||||
now - row.fetchedAt.getTime() >= FORECAST_RECHECK_MS
|
||||
) {
|
||||
const days = replaceable.get(row.apiaryId) ?? new Set<string>();
|
||||
days.add(day);
|
||||
replaceable.set(row.apiaryId, days);
|
||||
continue;
|
||||
}
|
||||
const days = complete.get(row.apiaryId) ?? new Set<string>();
|
||||
days.add(day);
|
||||
complete.set(row.apiaryId, days);
|
||||
}
|
||||
const allDays: string[] = [];
|
||||
for (let day = januaryFirst; day <= yesterday; day = addDays(day, 1)) {
|
||||
allDays.push(day);
|
||||
}
|
||||
const byPoint = new Map<string, PlannedPoint>();
|
||||
for (const apiary of apiaries) {
|
||||
if (apiary.latitude === null || apiary.longitude === null) continue;
|
||||
const point = roundPoint(apiary.latitude, apiary.longitude);
|
||||
const have = complete.get(apiary.id);
|
||||
const missing = new Set(allDays.filter((day) => !have?.has(day)));
|
||||
if (missing.size === 0) continue;
|
||||
const key = `${point.lat}/${point.lon}`;
|
||||
const planned = byPoint.get(key) ?? { point, apiaries: [] };
|
||||
planned.apiaries.push({
|
||||
id: apiary.id,
|
||||
missing,
|
||||
stored: storedDays.get(apiary.id) ?? new Set<string>(),
|
||||
replaceable: replaceable.get(apiary.id) ?? new Set<string>(),
|
||||
});
|
||||
byPoint.set(key, planned);
|
||||
}
|
||||
return [...byPoint.values()];
|
||||
}
|
||||
|
||||
/** Fetches both ranges of a point first, then writes: all or nothing. */
|
||||
private async syncPoint(
|
||||
planned: PlannedPoint,
|
||||
today: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<number> {
|
||||
const union = new Set(
|
||||
planned.apiaries.flatMap(({ missing }) => [...missing]),
|
||||
);
|
||||
const ranges: Range[] = [];
|
||||
const older = spanOf([...union].filter((d) => !isRecentRange(d, today)));
|
||||
const recent = spanOf([...union].filter((d) => isRecentRange(d, today)));
|
||||
for (const range of [older, recent]) {
|
||||
if (range) ranges.push(range);
|
||||
}
|
||||
|
||||
const fetched = new Map<
|
||||
string,
|
||||
{ value: number | null; label: WeatherLabel }
|
||||
>();
|
||||
for (const range of ranges) {
|
||||
const recent = isRecentRange(range.from, today);
|
||||
const label: WeatherLabel = recent ? 'forecast' : 'archive';
|
||||
const means = await this.source.dailyMeans(
|
||||
planned.point,
|
||||
range.from,
|
||||
range.to,
|
||||
{ window: recent ? 'recent' : 'archive', signal },
|
||||
);
|
||||
for (const { day, meanTempC } of means) {
|
||||
fetched.set(day, { value: meanTempC, label });
|
||||
}
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
for (const { id, missing, stored, replaceable } of planned.apiaries) {
|
||||
let touched = 0;
|
||||
const writes = [...missing].flatMap((day) => {
|
||||
const entry = fetched.get(day);
|
||||
if (!entry) return [];
|
||||
if (entry.value === null && stored.has(day)) {
|
||||
// The archive has no value for an old forecast row: keep the
|
||||
// value, only note the check so it is not repeated every hour.
|
||||
if (!replaceable.has(day)) return [];
|
||||
touched += 1;
|
||||
return [this.touchDay(id, day)];
|
||||
}
|
||||
return [this.upsertDay(id, day, entry.value, entry.label)];
|
||||
});
|
||||
// Chunked: a year of backfill is no single transaction.
|
||||
for (let i = 0; i < writes.length; i += WEATHER_WRITE_CHUNK_SIZE) {
|
||||
await this.prisma.$transaction(
|
||||
writes.slice(i, i + WEATHER_WRITE_CHUNK_SIZE),
|
||||
);
|
||||
}
|
||||
written += writes.length - touched;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
private touchDay(apiaryId: string, day: string) {
|
||||
return this.prisma.apiaryWeatherDay.update({
|
||||
where: { apiaryId_day: { apiaryId, day: dayToDate(day) } },
|
||||
data: { fetchedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private upsertDay(
|
||||
apiaryId: string,
|
||||
day: string,
|
||||
meanTempC: number | null,
|
||||
source: WeatherLabel,
|
||||
) {
|
||||
const date = dayToDate(day);
|
||||
return this.prisma.apiaryWeatherDay.upsert({
|
||||
where: { apiaryId_day: { apiaryId, day: date } },
|
||||
create: { apiaryId, day: date, meanTempC, source },
|
||||
update: { meanTempC, source, fetchedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,77 @@ describe('WeatherService', () => {
|
||||
expect(url.toString()).toContain('longitude=13.4');
|
||||
});
|
||||
|
||||
it('takes the forecast URL and API key from weatherHistoryConfig', async () => {
|
||||
const saved = {
|
||||
url: process.env.OPEN_METEO_FORECAST_URL,
|
||||
key: process.env.OPEN_METEO_API_KEY,
|
||||
};
|
||||
process.env.OPEN_METEO_FORECAST_URL = 'https://weather.example/forecast';
|
||||
process.env.OPEN_METEO_API_KEY = 'configured-key';
|
||||
try {
|
||||
const configured = new WeatherService(
|
||||
prisma as unknown as PrismaService,
|
||||
);
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||
);
|
||||
|
||||
await configured.getWeather('apiary-1');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [URL];
|
||||
expect(url.origin).toBe('https://weather.example');
|
||||
expect(url.searchParams.get('apikey')).toBe('configured-key');
|
||||
} finally {
|
||||
for (const [name, value] of [
|
||||
['OPEN_METEO_FORECAST_URL', saved.url],
|
||||
['OPEN_METEO_API_KEY', saved.key],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rounds the coordinates to 0.01 degrees and sends a User-Agent', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5234,
|
||||
longitude: 13.4049,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||
);
|
||||
|
||||
await weatherService.getWeather('apiary-1');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [
|
||||
URL,
|
||||
{ headers: Record<string, string> },
|
||||
];
|
||||
expect(url.searchParams.get('latitude')).toBe('52.52');
|
||||
expect(url.searchParams.get('longitude')).toBe('13.4');
|
||||
expect(init.headers['User-Agent']).toMatch(/^ApiCore\/\S+ \(/);
|
||||
});
|
||||
|
||||
it('sends no apikey parameter when none is configured', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||
);
|
||||
|
||||
await weatherService.getWeather('apiary-1');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [URL];
|
||||
expect(url.origin).toBe('https://api.open-meteo.com');
|
||||
expect(url.searchParams.has('apikey')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps daily weather_code to weatherCode', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
WeatherHistoryConfig,
|
||||
weatherHistoryConfig,
|
||||
roundPoint,
|
||||
} from './history/weather-history.config';
|
||||
|
||||
const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const PAST_DAYS = 14;
|
||||
const FORECAST_DAYS = 7;
|
||||
const RECENT_WINDOW_DAYS = 7;
|
||||
@@ -66,6 +70,10 @@ interface OpenMeteoResponse {
|
||||
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
// Same variables as the history source (one place for URL and key); a bad
|
||||
// setting stops the process at startup.
|
||||
private readonly config: WeatherHistoryConfig = weatherHistoryConfig();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getWeather(apiaryId: string): Promise<ApiaryWeather> {
|
||||
@@ -107,9 +115,11 @@ export class WeatherService {
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): Promise<DailyWeather[]> {
|
||||
const url = new URL(OPEN_METEO_FORECAST_URL);
|
||||
url.searchParams.set('latitude', String(latitude));
|
||||
url.searchParams.set('longitude', String(longitude));
|
||||
const url = new URL(this.config.forecastUrl);
|
||||
// 0.01 degrees: no exact stand location leaves the app.
|
||||
const point = roundPoint(latitude, longitude);
|
||||
url.searchParams.set('latitude', String(point.lat));
|
||||
url.searchParams.set('longitude', String(point.lon));
|
||||
url.searchParams.set(
|
||||
'daily',
|
||||
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,weather_code',
|
||||
@@ -118,11 +128,15 @@ export class WeatherService {
|
||||
url.searchParams.set('past_days', String(PAST_DAYS));
|
||||
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
||||
url.searchParams.set('timezone', 'auto');
|
||||
if (this.config.apiKey) {
|
||||
url.searchParams.set('apikey', this.config.apiKey);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: { 'User-Agent': this.config.userAgent },
|
||||
});
|
||||
} catch {
|
||||
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
DomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { honeyBatchInclude } from '../honey-batches/honey-batch-include';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import {
|
||||
DOMAIN_EVENT_WEBHOOKS,
|
||||
@@ -78,6 +79,7 @@ describe('WebhookEventHandlers', () => {
|
||||
HarvestRecorded: 'harvest.created',
|
||||
BatchBottled: 'batch.updated',
|
||||
BottlingCorrected: 'batch.updated',
|
||||
HoneyBatchRecorded: 'batch.created',
|
||||
StockChanged: 'stock.changed',
|
||||
TaskCreated: 'task.created',
|
||||
TaskCompleted: 'task.completed',
|
||||
@@ -232,6 +234,43 @@ describe('WebhookEventHandlers', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('enqueues batch.created with the honey batch and its relations for HoneyBatchRecorded', async () => {
|
||||
const honeyBatch = { id: 'hb1', batchNumber: 'H-1', extraction: {} };
|
||||
tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch);
|
||||
|
||||
await forward(
|
||||
envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }),
|
||||
);
|
||||
|
||||
expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'hb1' },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'batch.created',
|
||||
honeyBatch,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends nothing for HoneyBatchRecorded when the batch is gone', async () => {
|
||||
tx.honeyBatch.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await forward(
|
||||
envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends no webhook for a corrected honey batch (as before)', async () => {
|
||||
await forward(
|
||||
envelope('HoneyBatchCorrected', 'hb1', { extractionId: 'e1' }),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues stock.changed with the bottling batch and product ids, without raw material kinds', async () => {
|
||||
await forward(
|
||||
envelope('StockChanged', 'm1', {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { honeyBatchInclude } from '../honey-batches/honey-batch-include';
|
||||
import { WebhookEvent } from './webhook-events';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@@ -20,6 +21,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly<
|
||||
HarvestRecorded: 'harvest.created',
|
||||
BatchBottled: 'batch.updated',
|
||||
BottlingCorrected: 'batch.updated',
|
||||
HoneyBatchRecorded: 'batch.created',
|
||||
StockChanged: 'stock.changed',
|
||||
TaskCreated: 'task.created',
|
||||
TaskCompleted: 'task.completed',
|
||||
@@ -88,6 +90,13 @@ async function webhookData(
|
||||
return tx.honeyBatch.findUnique({
|
||||
where: { id: event.payload.honeyBatchId },
|
||||
});
|
||||
// The new honey batch with the relations the API shows, as the legacy
|
||||
// path sent it after the create.
|
||||
case 'HoneyBatchRecorded':
|
||||
return tx.honeyBatch.findUnique({
|
||||
...byAggregate,
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
case 'StockChanged':
|
||||
return stockChange(event.payload);
|
||||
case 'TaskCreated':
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('WithdrawalCheckService', () => {
|
||||
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
colony: { findMany: jest.Mock };
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
extractionHarvest: { findMany: jest.Mock };
|
||||
bottlingBatch: { findMany: jest.Mock };
|
||||
};
|
||||
let notifications: { recordForPermission: jest.Mock };
|
||||
@@ -69,6 +70,7 @@ describe('WithdrawalCheckService', () => {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'c1', code: 'B3' }]),
|
||||
},
|
||||
honeyBatch: { findUnique: jest.fn() },
|
||||
extractionHarvest: { findMany: jest.fn() },
|
||||
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
|
||||
@@ -269,6 +271,35 @@ describe('WithdrawalCheckService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflictsForExtraction', () => {
|
||||
it('checks the harvests of the extraction', async () => {
|
||||
db.extractionHarvest.findMany.mockResolvedValue([
|
||||
{ harvestId: 'h1' },
|
||||
{ harvestId: 'h2' },
|
||||
]);
|
||||
db.harvest.findMany.mockResolvedValue([
|
||||
harvestOn('h1', '2026-08-10', ['c1']),
|
||||
]);
|
||||
db.treatment.findMany.mockResolvedValue([treatment({})]);
|
||||
|
||||
const conflicts = await service.conflictsForExtraction(asDb(), 'e1');
|
||||
|
||||
expect(db.extractionHarvest.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { extractionId: 'e1' } }),
|
||||
);
|
||||
expect(db.harvest.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: { in: ['h1', 'h2'] } } }),
|
||||
);
|
||||
expect(conflicts).toEqual([conflict()]);
|
||||
});
|
||||
|
||||
it('answers nothing for an extraction without harvests', async () => {
|
||||
db.extractionHarvest.findMany.mockResolvedValue([]);
|
||||
|
||||
expect(await service.conflictsForExtraction(asDb(), 'e1')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningsFor', () => {
|
||||
it('words the warning with colony code, product and the end day', async () => {
|
||||
const warnings = await service.warningsFor(asDb(), [conflict()]);
|
||||
|
||||
@@ -199,6 +199,22 @@ export class WithdrawalCheckService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Extraction -> harvests. */
|
||||
async conflictsForExtraction(
|
||||
db: DbClient,
|
||||
extractionId: string,
|
||||
): Promise<WithdrawalConflict[]> {
|
||||
const links = await db.extractionHarvest.findMany({
|
||||
where: { extractionId },
|
||||
select: { harvestId: true },
|
||||
orderBy: { harvestId: 'asc' },
|
||||
});
|
||||
return this.conflictsForHarvests(
|
||||
db,
|
||||
links.map((link) => link.harvestId),
|
||||
);
|
||||
}
|
||||
|
||||
async warningsFor(
|
||||
db: DbClient,
|
||||
conflicts: readonly WithdrawalConflict[],
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('WithdrawalEventHandlers', () => {
|
||||
let check: {
|
||||
conflictsForHarvests: jest.Mock;
|
||||
conflictsForHoneyBatch: jest.Mock;
|
||||
conflictsForExtraction: jest.Mock;
|
||||
conflictsForTreatment: jest.Mock;
|
||||
notifyConflictsIn: jest.Mock;
|
||||
};
|
||||
@@ -64,6 +65,7 @@ describe('WithdrawalEventHandlers', () => {
|
||||
check = {
|
||||
conflictsForHarvests: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForExtraction: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForTreatment: jest.fn().mockResolvedValue([conflict]),
|
||||
notifyConflictsIn: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
@@ -133,6 +135,37 @@ describe('WithdrawalEventHandlers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('extraction check', () => {
|
||||
it('registers under its id', () => {
|
||||
expect(
|
||||
domainEventHandlerMetadata(
|
||||
Object.getOwnPropertyDescriptor(
|
||||
WithdrawalEventHandlers.prototype,
|
||||
'extractionCheck',
|
||||
)?.value as object,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
types: ['ExtractionRecorded', 'ExtractionCorrected'],
|
||||
handlerId: 'withdrawal.extraction-check',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['ExtractionRecorded', 'ExtractionCorrected'] as const)(
|
||||
'notifies the conflicts of the extraction on %s',
|
||||
async (type) => {
|
||||
await handlers.extractionCheck(
|
||||
envelope(type, 'e1', { harvestIds: ['h1'] }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(check.conflictsForExtraction).toHaveBeenCalledWith(tx, 'e1');
|
||||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('treatment check', () => {
|
||||
it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)(
|
||||
'notifies harvests that already lie in the window of the treatment on %s',
|
||||
|
||||
@@ -62,8 +62,24 @@ export class WithdrawalEventHandlers {
|
||||
await this.check.notifyConflictsIn(tx, conflicts);
|
||||
}
|
||||
|
||||
// The extraction (Schleuderung) joins the check with T4b; until then the
|
||||
// bottling is the second stage. A removed bottling has nothing to check.
|
||||
// Second stage: the extraction (Schleuderung). Same key as the harvest, so
|
||||
// a conflict found at the harvest is not notified again here.
|
||||
@OnDomainEvent(
|
||||
['ExtractionRecorded', 'ExtractionCorrected'],
|
||||
'withdrawal.extraction-check',
|
||||
)
|
||||
async extractionCheck(
|
||||
event: AnyDomainEventEnvelope<'ExtractionRecorded' | 'ExtractionCorrected'>,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const conflicts = await this.check.conflictsForExtraction(
|
||||
tx,
|
||||
event.aggregateId,
|
||||
);
|
||||
await this.check.notifyConflictsIn(tx, conflicts);
|
||||
}
|
||||
|
||||
// Third stage: the bottling. A removed bottling has nothing to check.
|
||||
@OnDomainEvent(
|
||||
['BatchBottled', 'BottlingCorrected'],
|
||||
'withdrawal.bottling-check',
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface PollingLoopOptions {
|
||||
intervalMs: number;
|
||||
tick: () => Promise<unknown>;
|
||||
logger: Logger;
|
||||
/** Wait before the first tick after start() (default: none). */
|
||||
initialDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +31,7 @@ export class PollingLoop {
|
||||
// schedules the next one, so ticks never overlap across stop()/start().
|
||||
// The first tick after start() runs immediately, also in that case.
|
||||
if (this.running) this.rerunRequested = true;
|
||||
else this.schedule(0);
|
||||
else this.schedule(this.options.initialDelayMs ?? 0);
|
||||
}
|
||||
|
||||
wake(): void {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WeatherHistoryModule } from '../weather/history/weather-history.module';
|
||||
import { WorkerRuntime } from './worker-runtime.service';
|
||||
|
||||
/**
|
||||
* The worker loops. Imported by WorkerModule (dist/worker.js) and
|
||||
* 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.
|
||||
* CalendarSync and Webhooks modules of the importing root module; the weather
|
||||
* source comes with WeatherHistoryModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [WeatherHistoryModule],
|
||||
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 { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
} from '../weather/history/weather-history.config';
|
||||
import type { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
|
||||
import { PgListener, PgListenerOptions } from './pg-listener';
|
||||
import { HEALTH_STALE_MS } from './worker-health';
|
||||
@@ -64,6 +69,7 @@ describe('WorkerRuntime', () => {
|
||||
processOutbox: jest.Mock<Promise<void>, [AbortSignal?]>;
|
||||
};
|
||||
let webhookDeliveries: FakeWebhookDeliveries;
|
||||
let weatherHistory: { syncOnce: jest.Mock };
|
||||
let runtime: WorkerRuntime;
|
||||
let logError: jest.SpyInstance;
|
||||
|
||||
@@ -93,6 +99,11 @@ describe('WorkerRuntime', () => {
|
||||
.mockResolvedValue(undefined),
|
||||
};
|
||||
webhookDeliveries = new FakeWebhookDeliveries();
|
||||
weatherHistory = {
|
||||
syncOnce: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ points: 0, daysWritten: 0, failedPoints: 0 }),
|
||||
};
|
||||
logError = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -102,6 +113,7 @@ describe('WorkerRuntime', () => {
|
||||
dispatcher as unknown as DomainEventDispatcher,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
webhookDeliveries as unknown as WebhookDeliveryWorker,
|
||||
weatherHistory as unknown as WeatherHistoryService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -122,6 +134,7 @@ describe('WorkerRuntime', () => {
|
||||
expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000);
|
||||
expect(WEBHOOK_DELIVERY_POLL_MS).toBe(5_000);
|
||||
expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000);
|
||||
expect(WEATHER_HISTORY_POLL_MS).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('starts the listener and every loop at bootstrap', async () => {
|
||||
@@ -381,4 +394,37 @@ describe('WorkerRuntime', () => {
|
||||
await shutdown;
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
|
||||
it('runs the loop without a wake channel and stops on shutdown', async () => {
|
||||
await bootstrap();
|
||||
|
||||
// The phenology-weather loop has no channel: nothing to LISTEN to.
|
||||
expect(listenerOptions().channels).toEqual([
|
||||
'domain_event',
|
||||
'calendar_sync',
|
||||
'webhook_delivery',
|
||||
]);
|
||||
// The first pass waits 10 s: not in the startup burst of the other loops.
|
||||
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_FIRST_TICK_DELAY_MS - 1);
|
||||
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
|
||||
const [today, signal] = weatherHistory.syncOnce.mock.calls[0] as [
|
||||
string,
|
||||
AbortSignal,
|
||||
];
|
||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS - 1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
|
||||
|
||||
await runtime.onModuleDestroy();
|
||||
expect(signal.aborted).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS * 2);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { withTimeout } from '../common/with-timeout';
|
||||
import { WAKE_CHANNELS, WakeChannel } from '../common/worker-wakeup';
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
DRAIN_MAX_ROUNDS,
|
||||
} from '../domain-events/domain-event-dispatcher';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
} from '../weather/history/weather-history.config';
|
||||
import { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
|
||||
import { PgListener } from './pg-listener';
|
||||
import { PollingLoop } from './polling-loop';
|
||||
@@ -24,11 +30,16 @@ export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
|
||||
/** The health check's SELECT 1 must answer within this. */
|
||||
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** One background loop: its poll interval and the channel that wakes it. */
|
||||
/**
|
||||
* One background loop: its poll interval and, if an outbox feeds it, the
|
||||
* channel that wakes it early. Loops without a channel only poll.
|
||||
*/
|
||||
interface WorkerLoop {
|
||||
name: string;
|
||||
intervalMs: number;
|
||||
channel: WakeChannel;
|
||||
channel?: WakeChannel;
|
||||
/** Wait before the first tick after the worker started. */
|
||||
initialDelayMs?: number;
|
||||
tick: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -50,7 +61,8 @@ function databaseUrl(): string {
|
||||
@Injectable()
|
||||
export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WorkerRuntime.name);
|
||||
private readonly loops: ReadonlyMap<WakeChannel, PollingLoop>;
|
||||
private readonly loops: ReadonlyMap<string, PollingLoop>;
|
||||
private readonly loopsByChannel: ReadonlyMap<WakeChannel, PollingLoop>;
|
||||
private listener: PgListener | undefined;
|
||||
/** Aborted at shutdown: running ticks claim no further work. */
|
||||
private readonly stopping = new AbortController();
|
||||
@@ -64,12 +76,25 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly dispatcher: DomainEventDispatcher,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly webhookDeliveries: WebhookDeliveryWorker,
|
||||
private readonly weatherHistory: WeatherHistoryService,
|
||||
) {
|
||||
const loops = this.workerLoops().map((definition) => ({
|
||||
definition,
|
||||
loop: new PollingLoop({
|
||||
name: definition.name,
|
||||
intervalMs: definition.intervalMs,
|
||||
tick: definition.tick,
|
||||
initialDelayMs: definition.initialDelayMs,
|
||||
logger: this.logger,
|
||||
}),
|
||||
}));
|
||||
this.loops = new Map(
|
||||
this.workerLoops().map(({ channel, name, intervalMs, tick }) => [
|
||||
channel,
|
||||
new PollingLoop({ name, intervalMs, tick, logger: this.logger }),
|
||||
]),
|
||||
loops.map(({ definition, loop }) => [definition.name, loop]),
|
||||
);
|
||||
this.loopsByChannel = new Map(
|
||||
loops.flatMap(({ definition, loop }) =>
|
||||
definition.channel ? [[definition.channel, loop] as const] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +119,15 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
channel: WAKE_CHANNELS.webhookDeliveries,
|
||||
tick: () => this.deliverWebhooks(),
|
||||
},
|
||||
{
|
||||
// Daily mean temperatures for the phenology; hourly, no outbox.
|
||||
name: 'phenology-weather',
|
||||
intervalMs: WEATHER_HISTORY_POLL_MS,
|
||||
// Not in the startup burst of the other loops.
|
||||
initialDelayMs: WEATHER_FIRST_TICK_DELAY_MS,
|
||||
tick: () =>
|
||||
this.weatherHistory.syncOnce(berlinToday(), this.stopping.signal),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -101,8 +135,8 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
this.progressAt = performance.now();
|
||||
this.listener = new PgListener({
|
||||
connectionString: databaseUrl(),
|
||||
channels: [...this.loops.keys()],
|
||||
onNotify: (channel) => this.loops.get(channel)?.wake(),
|
||||
channels: [...this.loopsByChannel.keys()],
|
||||
onNotify: (channel) => this.loopsByChannel.get(channel)?.wake(),
|
||||
logger: this.logger,
|
||||
});
|
||||
// LISTEN first: work committed from now on wakes a loop, and work
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PhenologyModule } from '../phenology/phenology.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
@@ -37,6 +38,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
WithdrawalModule,
|
||||
RemindersModule,
|
||||
WorkerRuntimeModule,
|
||||
|
||||
@@ -5,8 +5,10 @@ import { AutopilotStep } from '../src/autopilot/autopilot.step';
|
||||
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
|
||||
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||
import { JobsModule } from '../src/jobs/jobs.module';
|
||||
import { PhenologyStep } from '../src/phenology/phenology.step';
|
||||
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { JobPartialFailure } from '../src/system/job-run.service';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
@@ -30,6 +32,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
let app: INestApplicationContext;
|
||||
let prisma: PrismaService;
|
||||
let step: TimeEventsStep;
|
||||
let phenologyStep: PhenologyStep;
|
||||
let autopilotStep: AutopilotStep;
|
||||
let expiryStep: SuggestionsExpiryStep;
|
||||
const ids: Record<string, string> = {};
|
||||
@@ -56,6 +59,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
app = await moduleRef.init();
|
||||
prisma = app.get(PrismaService);
|
||||
step = app.get(TimeEventsStep);
|
||||
phenologyStep = app.get(PhenologyStep);
|
||||
expiryStep = app.get(SuggestionsExpiryStep);
|
||||
// The real AutopilotStep, but on the colonies of this file only: other
|
||||
// files share the database and must not be reconciled on a 2001 day.
|
||||
@@ -71,6 +75,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
}),
|
||||
},
|
||||
automationRule: prisma.automationRule,
|
||||
apiaryPhenology: prisma.apiaryPhenology,
|
||||
$transaction: prisma.$transaction.bind(prisma) as unknown,
|
||||
} as unknown as PrismaService,
|
||||
app.get(AutopilotService),
|
||||
@@ -340,6 +345,119 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
|
||||
});
|
||||
|
||||
describe('phenology step', () => {
|
||||
const DAY = '2001-04-30';
|
||||
let weatherApiaryId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: {
|
||||
name: `Daily Evaluation Weather ${suffix}`,
|
||||
latitude: 48.1,
|
||||
longitude: 9.9,
|
||||
},
|
||||
});
|
||||
weatherApiaryId = apiary.id;
|
||||
// 10 degrees every day from January 1st to April 29th: GTS 965.
|
||||
const days: Prisma.ApiaryWeatherDayCreateManyInput[] = [];
|
||||
for (
|
||||
let t = d('2001-01-01').getTime();
|
||||
t <= d('2001-04-29').getTime();
|
||||
t += 86_400_000
|
||||
) {
|
||||
days.push({
|
||||
apiaryId: apiary.id,
|
||||
day: new Date(t),
|
||||
meanTempC: 10,
|
||||
source: 'archive',
|
||||
});
|
||||
}
|
||||
await prisma.apiaryWeatherDay.createMany({ data: days });
|
||||
await prisma.phenologyReport.create({
|
||||
data: {
|
||||
apiaryId: apiary.id,
|
||||
phase: 'HOLUNDER',
|
||||
date: d('2001-04-29'),
|
||||
userId: ids.user,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: weatherApiaryId },
|
||||
});
|
||||
await prisma.phenologyReport.deleteMany({
|
||||
where: { apiaryId: weatherApiaryId },
|
||||
});
|
||||
// Rows of 2001 would feed the season of the other tests of this file.
|
||||
await prisma.apiaryPhenology.deleteMany({ where: { year: 2001 } });
|
||||
await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } });
|
||||
});
|
||||
|
||||
// Other files create and delete apiaries concurrently: an apiary that
|
||||
// vanishes mid-run fails the step, the apiaries of this file do not.
|
||||
const runStep = () =>
|
||||
phenologyStep.run(DAY).catch((error: unknown) => {
|
||||
if (!(error instanceof JobPartialFailure)) {
|
||||
throw error;
|
||||
}
|
||||
return error.result;
|
||||
});
|
||||
|
||||
it('the phenology step writes ApiaryPhenology for every active apiary', async () => {
|
||||
const result = await runStep();
|
||||
|
||||
expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2);
|
||||
const rows = await prisma.apiaryPhenology.findMany({
|
||||
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 2001 },
|
||||
orderBy: { apiaryId: 'asc' },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
const byApiary = new Map(rows.map((row) => [row.apiaryId, row]));
|
||||
// Calendar only (no coordinates): APFEL from April 28th.
|
||||
expect(byApiary.get(ids.apiary)).toMatchObject({
|
||||
gts: null,
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
// A report for HOLUNDER beats GTS and calendar.
|
||||
expect(byApiary.get(weatherApiaryId)).toMatchObject({
|
||||
gts: 965,
|
||||
gtsThroughDay: d('2001-04-29'),
|
||||
estimatedPhase: 'APFEL',
|
||||
reportedPhase: 'HOLUNDER',
|
||||
effectivePhase: 'HOLUNDER',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
});
|
||||
|
||||
it('records the phase change once per apiary and day', async () => {
|
||||
await runStep();
|
||||
await runStep();
|
||||
|
||||
expect(await eventsOf(weatherApiaryId)).toEqual([
|
||||
{
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
payload: {
|
||||
apiaryId: weatherApiaryId,
|
||||
phase: 'HOLUNDER',
|
||||
segment: 'SCHWARMZEIT',
|
||||
previousPhase: 'APFEL',
|
||||
previousSegment: 'SCHWARMZEIT',
|
||||
source: 'REPORT',
|
||||
},
|
||||
dedupeKey: `SeasonPhaseChanged:${weatherApiaryId}:${DAY}`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
expect(await eventsOf(ids.apiary)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autopilot and suggestions-expiry steps', () => {
|
||||
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
|
||||
async function lateColony(name: string): Promise<string> {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { login } from './utils/api';
|
||||
interface ExtractionBody {
|
||||
id: string;
|
||||
harvests: { harvestId: string }[];
|
||||
warnings: { code: string }[];
|
||||
}
|
||||
|
||||
describe('Extractions (e2e)', () => {
|
||||
@@ -113,6 +114,10 @@ describe('Extractions (e2e)', () => {
|
||||
const extraction = response.body as ExtractionBody;
|
||||
extractionId = extraction.id;
|
||||
expect(extraction.harvests).toHaveLength(1);
|
||||
// The harvest has no net weight: a hint, not an error.
|
||||
expect(extraction.warnings.map((warning) => warning.code)).toEqual([
|
||||
'HARVEST_NET_MISSING',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the extraction with its source harvests on lookup', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ interface HoneyBatchBody {
|
||||
batchNumber: string;
|
||||
measurements: { id: string }[];
|
||||
storageContainerId: string | null;
|
||||
warnings: { code: string }[];
|
||||
}
|
||||
|
||||
interface RelocationBody {
|
||||
@@ -163,6 +164,8 @@ describe('Honey batches (e2e)', () => {
|
||||
const honeyBatch = response.body as HoneyBatchBody;
|
||||
honeyBatchId = honeyBatch.id;
|
||||
expect(honeyBatch.batchNumber).toEqual(expect.stringContaining('HC-'));
|
||||
// The answer carries the warnings; nothing to warn about here.
|
||||
expect(honeyBatch.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds a water content measurement to the batch', async () => {
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
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 { drainDomainEvents } from './utils/domain-events';
|
||||
|
||||
interface Warning {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
refs: { type: string; id: string }[];
|
||||
}
|
||||
|
||||
interface WithWarningsBody {
|
||||
id: string;
|
||||
warnings: Warning[];
|
||||
}
|
||||
|
||||
interface ExtractionPrefillBody {
|
||||
amountKg: number | null;
|
||||
honeyType: string | null;
|
||||
sources: { harvestId: string; date: string; netWeight: number | null }[];
|
||||
warnings: Warning[];
|
||||
}
|
||||
|
||||
interface HoneyBatchPrefillBody {
|
||||
remainingKg: number | null;
|
||||
honeyType: string | null;
|
||||
waterContent: number | null;
|
||||
warnings: Warning[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The honey chain against a real PostgreSQL: the prefill proposals, the
|
||||
* plausibility warnings of extraction and honey batch (never a 4xx), the
|
||||
* withdrawal check at the extraction stage and the webhook batch.created
|
||||
* that now comes from the event handler.
|
||||
*/
|
||||
describe('Honey chain (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
let apiaryId: string;
|
||||
let adminId: string;
|
||||
let staffId: string;
|
||||
let readerId: string;
|
||||
let roleId: string;
|
||||
let subscriptionId: string;
|
||||
|
||||
const colonyIds: string[] = [];
|
||||
const harvestIds: string[] = [];
|
||||
const treatmentIds: string[] = [];
|
||||
const extractionIds: string[] = [];
|
||||
const honeyBatchIds: string[] = [];
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const asAdmin = { Authorization: '' };
|
||||
const asStaff = { Authorization: '' };
|
||||
|
||||
async function harvestWith(
|
||||
colonyId: string,
|
||||
day: string,
|
||||
netWeight: number | null,
|
||||
honeyType: string | null,
|
||||
): Promise<string> {
|
||||
const harvest = await prisma.harvest.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
userId: adminId,
|
||||
date: new Date(`${day}T00:00:00.000Z`),
|
||||
netWeight,
|
||||
honeyType,
|
||||
},
|
||||
});
|
||||
await prisma.harvestColony.create({
|
||||
data: { harvestId: harvest.id, colonyId },
|
||||
});
|
||||
harvestIds.push(harvest.id);
|
||||
return harvest.id;
|
||||
}
|
||||
|
||||
async function postExtraction(
|
||||
harvests: string[],
|
||||
amount?: number,
|
||||
): Promise<WithWarningsBody> {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/extractions')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
date: '2026-08-01T00:00:00.000Z',
|
||||
harvestIds: harvests,
|
||||
amount,
|
||||
})
|
||||
.expect(201);
|
||||
const body = response.body as WithWarningsBody;
|
||||
extractionIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postBatch(
|
||||
extractionId: string,
|
||||
amount: number,
|
||||
label: string,
|
||||
): Promise<WithWarningsBody> {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/honey-batches')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
batchNumber: `HC-${label}-${suffix}`,
|
||||
extractionId,
|
||||
amount,
|
||||
})
|
||||
.expect(201);
|
||||
const body = response.body as WithWarningsBody;
|
||||
honeyBatchIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Deliveries of this file's subscription that carry the given batch. */
|
||||
async function deliveriesOfBatch(batchId: string) {
|
||||
const deliveries = await prisma.webhookDelivery.findMany({
|
||||
where: { subscriptionId },
|
||||
});
|
||||
return deliveries.filter((delivery) => {
|
||||
const sent = JSON.parse(delivery.body) as { data?: { id?: string } };
|
||||
return sent.data?.id === batchId;
|
||||
});
|
||||
}
|
||||
|
||||
const conflictRowsOf = (harvestId: string, colonyId: string) =>
|
||||
prisma.notification.findMany({
|
||||
where: {
|
||||
userId: readerId,
|
||||
dedupeKey: {
|
||||
startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: `honey-chain-admin-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Honey chain admin',
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
});
|
||||
adminId = admin.id;
|
||||
const staff = await prisma.user.create({
|
||||
data: {
|
||||
email: `honey-chain-staff-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Honey chain staff',
|
||||
role: { connect: { key: 'STAFF' } },
|
||||
},
|
||||
});
|
||||
staffId = staff.id;
|
||||
asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`;
|
||||
asStaff.Authorization = `Bearer ${(await login(app, staff.email, password)).accessToken}`;
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `HC_READER_${suffix}`,
|
||||
name: 'Honigkette Leser',
|
||||
permissions: {
|
||||
create: ['harvests:read', 'treatments:read'].map((key) => ({
|
||||
permission: { connect: { key } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleId = role.id;
|
||||
readerId = (
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: `honey-chain-reader-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Honey chain reader',
|
||||
roleId,
|
||||
},
|
||||
})
|
||||
).id;
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({ data: { name: `Honigkette ${suffix}` } })
|
||||
).id;
|
||||
subscriptionId = (
|
||||
await prisma.webhookSubscription.create({
|
||||
data: {
|
||||
url: 'https://hooks.example.com/honey-chain-e2e',
|
||||
events: ['batch.created'],
|
||||
secret: `honey-chain-${suffix}`,
|
||||
createdByUserId: adminId,
|
||||
},
|
||||
})
|
||||
).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.notification.deleteMany({
|
||||
where: {
|
||||
category: 'WITHDRAWAL',
|
||||
relatedId: { in: [...harvestIds, ...colonyIds] },
|
||||
},
|
||||
});
|
||||
const rootIds = [...extractionIds, ...honeyBatchIds, ...harvestIds];
|
||||
const roots = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: { in: rootIds } },
|
||||
select: { correlationId: true },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ aggregateId: { in: rootIds } },
|
||||
{ correlationId: { in: roots.map((root) => root.correlationId) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.webhookDelivery.deleteMany({ where: { subscriptionId } });
|
||||
await prisma.webhookSubscription.deleteMany({
|
||||
where: { id: subscriptionId },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: { in: [adminId, staffId, readerId] } },
|
||||
{
|
||||
entityId: {
|
||||
in: [...extractionIds, ...honeyBatchIds, ...treatmentIds],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.honeyBatch.deleteMany({
|
||||
where: { id: { in: honeyBatchIds } },
|
||||
});
|
||||
await prisma.extractionHarvest.deleteMany({
|
||||
where: { extractionId: { in: extractionIds } },
|
||||
});
|
||||
await prisma.extraction.deleteMany({
|
||||
where: { id: { in: extractionIds } },
|
||||
});
|
||||
await prisma.harvestColony.deleteMany({
|
||||
where: { harvestId: { in: harvestIds } },
|
||||
});
|
||||
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
|
||||
await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } });
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: [adminId, staffId, readerId] } },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [adminId, staffId, readerId] } },
|
||||
});
|
||||
await prisma.role.deleteMany({ where: { id: roleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
let colonyId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `HC-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
colonyIds.push(colony.id);
|
||||
});
|
||||
|
||||
describe('prefill', () => {
|
||||
let harvestA: string;
|
||||
let harvestB: string;
|
||||
let harvestC: string;
|
||||
let harvestNoNet: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
harvestA = await harvestWith(colonyId, '2026-05-10', 20, 'Blütenhonig');
|
||||
harvestB = await harvestWith(colonyId, '2026-05-12', 22.5, 'Blütenhonig');
|
||||
harvestC = await harvestWith(colonyId, '2026-05-14', 5, 'Waldhonig');
|
||||
harvestNoNet = await harvestWith(colonyId, '2026-05-16', null, null);
|
||||
});
|
||||
|
||||
it('prefill sums the net weights of the chosen harvests and keeps a shared honey type', async () => {
|
||||
const response = await request(server())
|
||||
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestB}`)
|
||||
.set(asAdmin)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body as ExtractionPrefillBody).toEqual({
|
||||
amountKg: 42.5,
|
||||
honeyType: 'Blütenhonig',
|
||||
sources: [
|
||||
{ harvestId: harvestA, date: '2026-05-10', netWeight: 20 },
|
||||
{ harvestId: harvestB, date: '2026-05-12', netWeight: 22.5 },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('offers no honey type for different types and no amount when a net weight is missing', async () => {
|
||||
const mixed = await request(server())
|
||||
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestC}`)
|
||||
.set(asAdmin)
|
||||
.expect(200);
|
||||
expect((mixed.body as ExtractionPrefillBody).honeyType).toBeNull();
|
||||
expect((mixed.body as ExtractionPrefillBody).amountKg).toBe(25);
|
||||
|
||||
const missing = await request(server())
|
||||
.get(
|
||||
`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestNoNet}`,
|
||||
)
|
||||
.set(asAdmin)
|
||||
.expect(200);
|
||||
const body = missing.body as ExtractionPrefillBody;
|
||||
expect(body.amountKg).toBeNull();
|
||||
expect(body.warnings.map((warning) => warning.code)).toEqual([
|
||||
'HARVEST_NET_MISSING',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unknown, missing and too many harvest ids, and a user without extractions:create', async () => {
|
||||
await request(server())
|
||||
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},nope`)
|
||||
.set(asAdmin)
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.get('/api/v1/extractions/prefill')
|
||||
.set(asAdmin)
|
||||
.expect(400);
|
||||
const tooMany = Array.from({ length: 51 }, (_, i) => `h${i}`).join(',');
|
||||
await request(server())
|
||||
.get(`/api/v1/extractions/prefill?harvestIds=${tooMany}`)
|
||||
.set(asAdmin)
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA}`)
|
||||
.set(asStaff)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('batch prefill proposes the rest of the extraction with its honey type and water content', async () => {
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: {
|
||||
date: new Date('2026-08-01T00:00:00.000Z'),
|
||||
userId: adminId,
|
||||
amount: 42.5,
|
||||
waterContent: 17.2,
|
||||
},
|
||||
});
|
||||
extractionIds.push(extraction.id);
|
||||
await prisma.extractionHarvest.createMany({
|
||||
data: [harvestA, harvestB].map((harvestId) => ({
|
||||
extractionId: extraction.id,
|
||||
harvestId,
|
||||
})),
|
||||
});
|
||||
await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `HC-PRE-1-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
amount: 20,
|
||||
initialAmount: 20,
|
||||
},
|
||||
});
|
||||
// A batch without a recorded amount does not count.
|
||||
await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `HC-PRE-2-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
},
|
||||
});
|
||||
honeyBatchIds.push(
|
||||
...(
|
||||
await prisma.honeyBatch.findMany({
|
||||
where: { extractionId: extraction.id },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((batch) => batch.id),
|
||||
);
|
||||
|
||||
const response = await request(server())
|
||||
.get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`)
|
||||
.set(asAdmin)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body as HoneyBatchPrefillBody).toEqual({
|
||||
remainingKg: 22.5,
|
||||
honeyType: 'Blütenhonig',
|
||||
waterContent: 17.2,
|
||||
warnings: [],
|
||||
});
|
||||
await request(server())
|
||||
.get('/api/v1/honey-batches/prefill?extractionId=nope')
|
||||
.set(asAdmin)
|
||||
.expect(404);
|
||||
await request(server())
|
||||
.get('/api/v1/honey-batches/prefill')
|
||||
.set(asAdmin)
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`)
|
||||
.set(asStaff)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plausibility warnings', () => {
|
||||
let harvestA: string;
|
||||
let harvestNoNet: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
harvestA = await harvestWith(colonyId, '2026-06-10', 20, null);
|
||||
harvestNoNet = await harvestWith(colonyId, '2026-06-12', null, null);
|
||||
});
|
||||
|
||||
it('extraction and batch answers carry both plausibility warnings, never 4xx', async () => {
|
||||
// 30 kg out of 20 kg net.
|
||||
const extraction = await postExtraction([harvestA], 30);
|
||||
expect(extraction.warnings.map((warning) => warning.code)).toEqual([
|
||||
'EXTRACTION_EXCEEDS_HARVEST',
|
||||
]);
|
||||
expect(extraction.warnings[0]).toMatchObject({
|
||||
severity: 'WARNUNG',
|
||||
refs: [{ type: 'Harvest', id: harvestA }],
|
||||
});
|
||||
|
||||
// 25 + 10 kg of batches out of 30 kg.
|
||||
const first = await postBatch(extraction.id, 25, 'W1');
|
||||
expect(first.warnings).toEqual([]);
|
||||
const second = await postBatch(extraction.id, 10, 'W2');
|
||||
expect(second.warnings).toHaveLength(1);
|
||||
expect(second.warnings[0]).toMatchObject({
|
||||
code: 'BATCHES_EXCEED_EXTRACTION',
|
||||
severity: 'WARNUNG',
|
||||
});
|
||||
// The write happened all the same.
|
||||
expect(await prisma.honeyBatch.count({ where: { id: second.id } })).toBe(
|
||||
1,
|
||||
);
|
||||
|
||||
const patched = await request(server())
|
||||
.patch(`/api/v1/honey-batches/${second.id}`)
|
||||
.set(asAdmin)
|
||||
.send({ honeyType: 'Blütenhonig' })
|
||||
.expect(200);
|
||||
expect(
|
||||
(patched.body as WithWarningsBody).warnings.map((w) => w.code),
|
||||
).toEqual(['BATCHES_EXCEED_EXTRACTION']);
|
||||
|
||||
const corrected = await request(server())
|
||||
.patch(`/api/v1/extractions/${extraction.id}`)
|
||||
.set(asAdmin)
|
||||
.send({ amount: 20 })
|
||||
.expect(200);
|
||||
expect(
|
||||
(corrected.body as WithWarningsBody).warnings.map((w) => w.code),
|
||||
).toEqual([]);
|
||||
|
||||
const noNet = await request(server())
|
||||
.patch(`/api/v1/extractions/${extraction.id}`)
|
||||
.set(asAdmin)
|
||||
.send({ harvestIds: [harvestA, harvestNoNet], amount: 500 })
|
||||
.expect(200);
|
||||
expect(
|
||||
(noNet.body as WithWarningsBody).warnings.map((w) => w.code),
|
||||
).toEqual(['HARVEST_NET_MISSING']);
|
||||
});
|
||||
|
||||
it('records the events with audit entries on create and update', async () => {
|
||||
const own = await postExtraction([harvestA], 5);
|
||||
const id = own.id;
|
||||
await request(server())
|
||||
.patch(`/api/v1/extractions/${id}`)
|
||||
.set(asAdmin)
|
||||
.send({ amount: 6 })
|
||||
.expect(200);
|
||||
await request(server())
|
||||
.patch(`/api/v1/extractions/${id}`)
|
||||
.set(asAdmin)
|
||||
.send({ harvestIds: [harvestA, harvestNoNet] })
|
||||
.expect(200);
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { aggregateType: 'Extraction', aggregateId: id },
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
});
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'ExtractionRecorded',
|
||||
'ExtractionCorrected',
|
||||
'ExtractionCorrected',
|
||||
]);
|
||||
expect(events[2].payload).toEqual({
|
||||
harvestIds: expect.arrayContaining([
|
||||
harvestA,
|
||||
harvestNoNet,
|
||||
]) as string[],
|
||||
});
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { entityType: 'Extraction', entityId: id },
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdrawal at the extraction stage', () => {
|
||||
it('an extraction of a harvest inside a withdrawal window warns but notifies no second time', async () => {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `HC-W-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyIds.push(colony.id);
|
||||
// 2026-07-10 + 20 days: harvestable again from 2026-07-30.
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
userId: adminId,
|
||||
date: new Date('2026-07-10T00:00:00.000Z'),
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 20,
|
||||
},
|
||||
});
|
||||
treatmentIds.push(treatment.id);
|
||||
|
||||
const harvestResponse = await request(server())
|
||||
.post('/api/v1/harvests')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
apiaryId,
|
||||
date: '2026-07-20T00:00:00.000Z',
|
||||
colonyIds: [colony.id],
|
||||
netWeight: 12,
|
||||
})
|
||||
.expect(201);
|
||||
const harvestId = (harvestResponse.body as WithWarningsBody).id;
|
||||
harvestIds.push(harvestId);
|
||||
await drainDomainEvents(app, [harvestId]);
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
|
||||
|
||||
const extraction = await postExtraction([harvestId], 10);
|
||||
|
||||
expect(extraction.warnings).toHaveLength(1);
|
||||
expect(extraction.warnings[0]).toMatchObject({
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
});
|
||||
expect(extraction.warnings[0].refs).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: 'Colony', id: colony.id },
|
||||
{ type: 'Treatment', id: treatment.id },
|
||||
{ type: 'Harvest', id: harvestId },
|
||||
]),
|
||||
);
|
||||
await drainDomainEvents(app, [extraction.id]);
|
||||
await drainDomainEvents(app, [extraction.id]);
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('the prefill already warns about a harvest inside a withdrawal window', async () => {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `HC-P-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyIds.push(colony.id);
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
userId: adminId,
|
||||
date: new Date('2026-07-10T00:00:00.000Z'),
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 20,
|
||||
},
|
||||
});
|
||||
treatmentIds.push(treatment.id);
|
||||
const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null);
|
||||
|
||||
const response = await request(server())
|
||||
.get(`/api/v1/extractions/prefill?harvestIds=${harvestId}`)
|
||||
.set(asAdmin)
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as ExtractionPrefillBody;
|
||||
expect(body.warnings.map((warning) => warning.code)).toEqual([
|
||||
'WITHDRAWAL_ACTIVE',
|
||||
]);
|
||||
// Proposals only: nothing was written or notified.
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('an extraction notifies a conflict the harvest did not (harvest entered without an event)', async () => {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `HC-X-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyIds.push(colony.id);
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
userId: adminId,
|
||||
date: new Date('2026-07-10T00:00:00.000Z'),
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 20,
|
||||
},
|
||||
});
|
||||
treatmentIds.push(treatment.id);
|
||||
const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null);
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0);
|
||||
|
||||
const extraction = await postExtraction([harvestId], 10);
|
||||
await drainDomainEvents(app, [extraction.id]);
|
||||
await drainDomainEvents(app, [extraction.id]);
|
||||
|
||||
const rows = await conflictRowsOf(harvestId, colony.id);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
category: 'WITHDRAWAL',
|
||||
relatedType: 'Harvest',
|
||||
relatedId: harvestId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook batch.created', () => {
|
||||
let batchId: string;
|
||||
|
||||
it('a new honey batch delivers batch.created through the handler', async () => {
|
||||
const harvestId = await harvestWith(colonyId, '2026-09-01', 8, null);
|
||||
const extraction = await postExtraction([harvestId], 8);
|
||||
|
||||
const batch = await postBatch(extraction.id, 8, 'WH');
|
||||
batchId = batch.id;
|
||||
// The request itself queued nothing.
|
||||
expect(await deliveriesOfBatch(batchId)).toHaveLength(0);
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { aggregateType: 'HoneyBatch', aggregateId: batchId },
|
||||
});
|
||||
expect(events.map((event) => event.type)).toEqual(['HoneyBatchRecorded']);
|
||||
expect(events[0].payload).toEqual({ extractionId: extraction.id });
|
||||
|
||||
await drainDomainEvents(app, [batchId]);
|
||||
await drainDomainEvents(app, [batchId]);
|
||||
|
||||
const deliveries = await deliveriesOfBatch(batchId);
|
||||
expect(deliveries).toHaveLength(1);
|
||||
expect(deliveries[0].event).toBe('batch.created');
|
||||
});
|
||||
|
||||
it('the batch.created payload has the same shape as before (honeyBatchInclude)', async () => {
|
||||
const [delivery] = await deliveriesOfBatch(batchId);
|
||||
const sent = JSON.parse(delivery.body) as {
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
const response = await request(server())
|
||||
.get(`/api/v1/honey-batches/${batchId}`)
|
||||
.set(asAdmin)
|
||||
.expect(200);
|
||||
|
||||
expect(sent.event).toBe('batch.created');
|
||||
expect(sent.data).toEqual(response.body);
|
||||
expect(Object.keys(sent.data).sort()).toEqual(
|
||||
expect.arrayContaining([
|
||||
'measurements',
|
||||
'storageContainer',
|
||||
'extraction',
|
||||
'batchNumber',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('a correction records HoneyBatchCorrected and sends no second webhook', async () => {
|
||||
await request(server())
|
||||
.patch(`/api/v1/honey-batches/${batchId}`)
|
||||
.set(asAdmin)
|
||||
.send({ honeyType: 'Sommertracht' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [batchId]);
|
||||
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { aggregateType: 'HoneyBatch', aggregateId: batchId },
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
});
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'HoneyBatchRecorded',
|
||||
'HoneyBatchCorrected',
|
||||
]);
|
||||
expect(await deliveriesOfBatch(batchId)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import {
|
||||
deferClaim,
|
||||
claimBatch,
|
||||
releaseStaleClaims,
|
||||
settleSkipped,
|
||||
settleSuccess,
|
||||
} from '../src/common/outbox/outbox-claims';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
interface Claimed {
|
||||
id: string;
|
||||
claimedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level guarantees of the T4 Telegram migration and the outbox
|
||||
* helpers on NotificationDelivery: the target CHECK, the poll-state
|
||||
* singleton, cascade with the user, compare-and-set settling.
|
||||
*/
|
||||
describe('Telegram schema and delivery outbox (e2e)', () => {
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
let userId: string;
|
||||
let notificationId: string;
|
||||
const chatBase = BigInt(9_000_000_000) + BigInt(Date.now() % 1_000_000);
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = new PrismaService();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `tg-schema-${suffix}@example.com`,
|
||||
passwordHash: 'x',
|
||||
name: 'Telegram Schema',
|
||||
role: { connect: { key: 'READONLY' } },
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
},
|
||||
});
|
||||
notificationId = notification.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { id: userId } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.notificationDelivery.deleteMany({
|
||||
where: { OR: [{ userId }, { chatId: { gte: chatBase } }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('has exactly one TelegramPollState row, created by the migration', async () => {
|
||||
const rows = await prisma.telegramPollState.findMany();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ id: 'singleton', updateOffset: 0n });
|
||||
});
|
||||
|
||||
describe('NotificationDelivery target CHECK', () => {
|
||||
const base = { channel: 'TELEGRAM' } as const;
|
||||
|
||||
it('accepts a notification for a user', async () => {
|
||||
await expect(
|
||||
prisma.notificationDelivery.create({
|
||||
data: { ...base, userId, notificationId },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 'PENDING', attempts: 0 });
|
||||
});
|
||||
|
||||
it('accepts a system reply for a chat', async () => {
|
||||
await expect(
|
||||
prisma.notificationDelivery.create({
|
||||
data: { ...base, chatId: chatBase, templateCode: 'HELP' },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 'PENDING' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty row', {}],
|
||||
['notification without user', { notificationId: 'n' }],
|
||||
['template without chat', { templateCode: 'HELP' }],
|
||||
['chat without template', { chatId: chatBase }],
|
||||
[
|
||||
'notification and template',
|
||||
{
|
||||
userId: 'u',
|
||||
notificationId: 'n',
|
||||
templateCode: 'HELP',
|
||||
chatId: chatBase,
|
||||
},
|
||||
],
|
||||
])('rejects %s', async (_name, extra) => {
|
||||
const insert = prisma.$executeRaw`INSERT INTO "NotificationDelivery" ("id","channel","userId","chatId","notificationId","templateCode")
|
||||
VALUES (${`chk-${suffix}-${Math.random()}`}, 'TELEGRAM',
|
||||
${(extra as { userId?: string }).userId ?? null},
|
||||
${(extra as { chatId?: bigint }).chatId ?? null},
|
||||
${(extra as { notificationId?: string }).notificationId ?? null},
|
||||
${(extra as { templateCode?: string }).templateCode ?? null})`;
|
||||
await expect(insert).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows only the singleton row in TelegramPollState', async () => {
|
||||
await expect(
|
||||
prisma.$executeRaw`INSERT INTO "TelegramPollState" ("id") VALUES (${`other-${suffix}`})`,
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates at most one delivery per notification and channel', async () => {
|
||||
await prisma.notificationDelivery.create({
|
||||
data: { channel: 'TELEGRAM', userId, notificationId },
|
||||
});
|
||||
|
||||
await expect(
|
||||
prisma.notificationDelivery.create({
|
||||
data: { channel: 'TELEGRAM', userId, notificationId },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('indexes NotificationDelivery by notificationId', async () => {
|
||||
const rows = await prisma.$queryRaw<{ indexdef: string }[]>`
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE tablename = 'NotificationDelivery' AND indexdef LIKE '%("notificationId")%'`;
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
describe('outbox helpers', () => {
|
||||
async function claimOne(): Promise<Claimed> {
|
||||
const created = await prisma.notificationDelivery.create({
|
||||
data: { channel: 'TELEGRAM', userId, notificationId },
|
||||
});
|
||||
const [claimed] = await claimBatch<Claimed>(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
{
|
||||
limit: 1,
|
||||
filter: Prisma.sql`"id" = ${created.id}`,
|
||||
orderBy: 'createdAt',
|
||||
},
|
||||
);
|
||||
expect(claimed.id).toBe(created.id);
|
||||
return claimed;
|
||||
}
|
||||
|
||||
it('settleSkipped needs the matching claim and records the reason', async () => {
|
||||
const claimed = await claimOne();
|
||||
|
||||
await expect(
|
||||
settleSkipped(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
claimed.id,
|
||||
new Date(claimed.claimedAt.getTime() + 1),
|
||||
'nope',
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
settleSkipped(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
claimed.id,
|
||||
claimed.claimedAt,
|
||||
'chat gone',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
prisma.notificationDelivery.findUnique({ where: { id: claimed.id } }),
|
||||
).resolves.toMatchObject({
|
||||
status: 'SKIPPED',
|
||||
lastError: 'chat gone',
|
||||
claimedAt: null,
|
||||
attempts: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('deferClaim puts the entry back for later without counting an attempt', async () => {
|
||||
const claimed = await claimOne();
|
||||
|
||||
await expect(
|
||||
deferClaim(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
claimed.id,
|
||||
new Date(0),
|
||||
1000,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
deferClaim(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
claimed.id,
|
||||
claimed.claimedAt,
|
||||
60_000,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const row = await prisma.notificationDelivery.findUniqueOrThrow({
|
||||
where: { id: claimed.id },
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
claimedAt: null,
|
||||
});
|
||||
const [{ ahead }] = await prisma.$queryRaw<{ ahead: number }[]>`
|
||||
SELECT EXTRACT(EPOCH FROM ("availableAt" - now()))::float8 AS ahead
|
||||
FROM "NotificationDelivery" WHERE "id" = ${claimed.id}`;
|
||||
expect(ahead).toBeGreaterThan(50);
|
||||
expect(ahead).toBeLessThanOrEqual(61);
|
||||
});
|
||||
|
||||
it('settleSuccess marks DELIVERED with deliveredAt', async () => {
|
||||
const claimed = await claimOne();
|
||||
|
||||
await expect(
|
||||
settleSuccess(
|
||||
prisma,
|
||||
'NotificationDelivery',
|
||||
claimed.id,
|
||||
claimed.claimedAt,
|
||||
'DELIVERED',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const row = await prisma.notificationDelivery.findUniqueOrThrow({
|
||||
where: { id: claimed.id },
|
||||
});
|
||||
expect(row.status).toBe('DELIVERED');
|
||||
expect(row.deliveredAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('the stale release works on NotificationDelivery', async () => {
|
||||
const claimed = await claimOne();
|
||||
await prisma.$executeRaw`UPDATE "NotificationDelivery" SET "claimedAt" = now() - interval '1 day' WHERE "id" = ${claimed.id}`;
|
||||
|
||||
expect(
|
||||
await releaseStaleClaims(prisma, 'NotificationDelivery'),
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await expect(
|
||||
prisma.notificationDelivery.findUniqueOrThrow({
|
||||
where: { id: claimed.id },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 'PENDING', attempts: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting the user removes the chat binding, tokens and deliveries', async () => {
|
||||
const other = await prisma.user.create({
|
||||
data: {
|
||||
email: `tg-cascade-${suffix}@example.com`,
|
||||
passwordHash: 'x',
|
||||
name: 'Cascade',
|
||||
role: { connect: { key: 'READONLY' } },
|
||||
},
|
||||
});
|
||||
const note = await prisma.notification.create({
|
||||
data: {
|
||||
userId: other.id,
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
},
|
||||
});
|
||||
await prisma.telegramLink.create({
|
||||
data: { userId: other.id, chatId: chatBase + 1n },
|
||||
});
|
||||
await prisma.telegramLinkToken.create({
|
||||
data: {
|
||||
userId: other.id,
|
||||
tokenHash: `hash-${suffix}`,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
await prisma.notificationDelivery.create({
|
||||
data: { channel: 'TELEGRAM', userId: other.id, notificationId: note.id },
|
||||
});
|
||||
|
||||
await prisma.user.delete({ where: { id: other.id } });
|
||||
|
||||
expect(
|
||||
await prisma.telegramLink.count({ where: { userId: other.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.telegramLinkToken.count({ where: { userId: other.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.notificationDelivery.count({ where: { userId: other.id } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('one chat belongs to exactly one user', async () => {
|
||||
const link = await prisma.telegramLink.create({
|
||||
data: { userId, chatId: chatBase + 2n },
|
||||
});
|
||||
const second = await prisma.user.create({
|
||||
data: {
|
||||
email: `tg-chat-${suffix}@example.com`,
|
||||
passwordHash: 'x',
|
||||
name: 'Second',
|
||||
role: { connect: { key: 'READONLY' } },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
prisma.telegramLink.create({
|
||||
data: { userId: second.id, chatId: link.chatId },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await prisma.telegramLink.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: second.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
addDays,
|
||||
berlinToday,
|
||||
daysBetween,
|
||||
dayToDate,
|
||||
} from '../src/common/dates';
|
||||
import { PrismaModule } from '../src/prisma/prisma.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { RECENT_WINDOW_DAYS } from '../src/weather/history/weather-history.config';
|
||||
import { WeatherHistoryModule } from '../src/weather/history/weather-history.module';
|
||||
import { WeatherHistoryService } from '../src/weather/history/weather-history.service';
|
||||
|
||||
// Two stands on one rounded point (61.23 / 5.31) and one on a point that fails.
|
||||
// Other e2e files share the database, so nothing here counts requests or rows
|
||||
// of other stands.
|
||||
const GOOD = { lat: '61.23', lon: '5.31' };
|
||||
const BAD = { lat: '62.11', lon: '6.12' };
|
||||
|
||||
function json(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}
|
||||
|
||||
function days(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let day = from; day <= to; day = addDays(day, 1)) out.push(day);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('Weather history (e2e)', () => {
|
||||
const today = berlinToday();
|
||||
const yesterday = addDays(today, -1);
|
||||
const januaryFirst = `${today.slice(0, 4)}-01-01`;
|
||||
const expectedDays = Math.max(0, daysBetween(januaryFirst, yesterday) + 1);
|
||||
const suffix = `${Date.now()}`;
|
||||
let moduleRef: TestingModule;
|
||||
let prisma: PrismaService;
|
||||
let service: WeatherHistoryService;
|
||||
let fetchMock: jest.SpiedFunction<typeof fetch>;
|
||||
let apiaryIds: string[];
|
||||
let badApiaryId: string;
|
||||
|
||||
const ownCalls = (point: { lat: string; lon: string }): URL[] =>
|
||||
fetchMock.mock.calls
|
||||
.map(([url]) => url as URL)
|
||||
.filter(
|
||||
(url) =>
|
||||
url.searchParams.get('latitude') === point.lat &&
|
||||
url.searchParams.get('longitude') === point.lon,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [PrismaModule, WeatherHistoryModule],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
service = moduleRef.get(WeatherHistoryService);
|
||||
const a = await prisma.apiary.create({
|
||||
data: {
|
||||
name: `Weather history A ${suffix}`,
|
||||
latitude: 61.2345,
|
||||
longitude: 5.3109,
|
||||
},
|
||||
});
|
||||
const b = await prisma.apiary.create({
|
||||
data: {
|
||||
name: `Weather history B ${suffix}`,
|
||||
latitude: 61.2349,
|
||||
longitude: 5.3101,
|
||||
},
|
||||
});
|
||||
const bad = await prisma.apiary.create({
|
||||
data: {
|
||||
name: `Weather history bad ${suffix}`,
|
||||
latitude: 62.1149,
|
||||
longitude: 6.1201,
|
||||
},
|
||||
});
|
||||
apiaryIds = [a.id, b.id];
|
||||
badApiaryId = bad.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.apiary.deleteMany({
|
||||
where: { id: { in: [...apiaryIds, badApiaryId] } },
|
||||
});
|
||||
await moduleRef.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||
const url = input as URL;
|
||||
const lat = url.searchParams.get('latitude');
|
||||
if (lat === BAD.lat) {
|
||||
return Promise.resolve(new Response('nope', { status: 500 }));
|
||||
}
|
||||
if (lat !== GOOD.lat) {
|
||||
// Stands of other e2e files: no data.
|
||||
return Promise.resolve(
|
||||
json({ daily: { time: [], temperature_2m_mean: [] } }),
|
||||
);
|
||||
}
|
||||
const archive = url.searchParams.has('start_date');
|
||||
const time = archive
|
||||
? days(
|
||||
url.searchParams.get('start_date') as string,
|
||||
url.searchParams.get('end_date') as string,
|
||||
)
|
||||
: days(addDays(today, -92), today);
|
||||
return Promise.resolve(
|
||||
json({
|
||||
daily: { time, temperature_2m_mean: time.map(() => 10.5) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('fetches missing days once, with rounded coordinates, and writes nothing on an error', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const result = await service.syncOnce(today, controller.signal);
|
||||
|
||||
// The failing point wrote nothing and counted as failed.
|
||||
expect(result.failedPoints).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
await prisma.apiaryWeatherDay.count({ where: { apiaryId: badApiaryId } }),
|
||||
).toBe(0);
|
||||
|
||||
// One archive and one forecast request for the shared rounded point.
|
||||
const calls = ownCalls(GOOD);
|
||||
expect(calls.length).toBe(expectedDays > 0 ? 2 : 0);
|
||||
for (const url of calls) {
|
||||
expect(url.searchParams.get('latitude')).toBe('61.23');
|
||||
expect(url.searchParams.get('longitude')).toBe('5.31');
|
||||
expect(url.searchParams.get('daily')).toBe('temperature_2m_mean');
|
||||
expect(url.searchParams.get('timezone')).toBe('Europe/Berlin');
|
||||
}
|
||||
|
||||
// Both stands of the point carry every day from January 1st to yesterday.
|
||||
const recentStart = addDays(today, -RECENT_WINDOW_DAYS);
|
||||
for (const apiaryId of apiaryIds) {
|
||||
const rows = await prisma.apiaryWeatherDay.findMany({
|
||||
where: { apiaryId },
|
||||
orderBy: { day: 'asc' },
|
||||
});
|
||||
expect(rows).toHaveLength(expectedDays);
|
||||
for (const row of rows) {
|
||||
const day = row.day.toISOString().slice(0, 10);
|
||||
expect(row.meanTempC).toBe(10.5);
|
||||
expect(row.source).toBe(day >= recentStart ? 'forecast' : 'archive');
|
||||
expect(day >= januaryFirst && day <= yesterday).toBe(true);
|
||||
}
|
||||
}
|
||||
if (expectedDays > 0) {
|
||||
expect(
|
||||
await prisma.apiaryWeatherDay.count({
|
||||
where: { apiaryId: apiaryIds[0], day: dayToDate(yesterday) },
|
||||
}),
|
||||
).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('asks for nothing when every day is already stored', async () => {
|
||||
await service.syncOnce(today, new AbortController().signal);
|
||||
|
||||
expect(ownCalls(GOOD)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type { DomainEventEnvelope } from '../src/domain-events/domain-event-type
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { WEATHER_HISTORY_SOURCE } from '../src/weather/history/weather-history-source';
|
||||
import { WebhookDeliveryWorker } from '../src/webhooks/webhook-delivery.worker';
|
||||
import { WorkerModule } from '../src/worker/worker.module';
|
||||
import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service';
|
||||
@@ -159,6 +160,10 @@ describe('Worker runtime (e2e)', () => {
|
||||
})
|
||||
.overrideProvider(DomainEventDispatcher)
|
||||
.useClass(IsolatedDispatcher)
|
||||
// The phenology-weather loop ticks right after bootstrap: no real
|
||||
// network call in tests (weather-history.e2e-spec.ts covers the sync).
|
||||
.overrideProvider(WEATHER_HISTORY_SOURCE)
|
||||
.useValue({ name: 'test', dailyMeans: () => Promise.resolve([]) })
|
||||
.compile();
|
||||
prisma = worker.get(PrismaService);
|
||||
// The outbox itself is covered by calendar-sync.e2e-spec.ts; here only
|
||||
|
||||
@@ -33,3 +33,18 @@ data:
|
||||
# bleibt SSO deaktiviert, /auth/sso/status meldet dann enabled: false.
|
||||
OIDC_ISSUER_URL: "https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||
OIDC_REDIRECT_URI: "https://api.apicore.example.com/api/v1/auth/sso/callback"
|
||||
# Wetterquelle (Pflanzenentwicklung, Wetteranzeige). Nur die freie
|
||||
# Open-Meteo-API; nur https:-URLs, ein unbekannter Quellenname oder eine
|
||||
# http:-URL stoppt den Start. Standardwerte gelten ohne Eintrag. Der
|
||||
# optionale Schluessel OPEN_METEO_API_KEY steht im Secret.
|
||||
WEATHER_HISTORY_SOURCE: "open-meteo"
|
||||
OPEN_METEO_FORECAST_URL: "https://api.open-meteo.com/v1/forecast"
|
||||
OPEN_METEO_ARCHIVE_URL: "https://archive-api.open-meteo.com/v1/archive"
|
||||
# Optional: Kontakt (E-Mail oder URL) im User-Agent der Wetteranfragen,
|
||||
# "ApiCore/<Version> (+<Kontakt>)". Ohne Wert: "(self-hosted beekeeping app)".
|
||||
# WEATHER_USER_AGENT_CONTACT: ""
|
||||
# Telegram-Erinnerungen (optional). Name des Bots ohne @ (5-32 Zeichen:
|
||||
# Buchstaben, Ziffern, Unterstrich) fuer den Deep Link; API und Worker lesen
|
||||
# ihn aus dieser ConfigMap. Das Token steht NICHT hier, siehe Kommentar in
|
||||
# secret.example.yaml (Secret apicore-telegram, nur Worker).
|
||||
# TELEGRAM_BOT_USERNAME: "<bot-username>"
|
||||
@@ -37,3 +37,20 @@ stringData:
|
||||
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig).
|
||||
OIDC_CLIENT_ID: "change-me-entra-client-id"
|
||||
OIDC_CLIENT_SECRET: "change-me-entra-client-secret"
|
||||
# Optionaler Schluessel der Open-Meteo-API (kommerzieller Tarif). Leer
|
||||
# lassen, solange die freie API genutzt wird; dann wird kein apikey gesendet.
|
||||
OPEN_METEO_API_KEY: ""
|
||||
|
||||
# Telegram-Bot-Token (optional) - NICHT in diesem Secret, sondern im eigenen
|
||||
# Secret apicore-telegram, das nur der Worker einbindet (Secret mit
|
||||
# TELEGRAM_BOT_TOKEN, envFrom/secretKeyRef mit optional: true). Der API-Pod
|
||||
# darf es nicht einbinden. Der Inhaber legt den Bot bei BotFather und das
|
||||
# Secret selbst an; ohne Token laeuft Telegram nicht, alles andere schon.
|
||||
#
|
||||
# apiVersion: v1
|
||||
# kind: Secret
|
||||
# metadata:
|
||||
# name: apicore-telegram
|
||||
# type: Opaque
|
||||
# stringData:
|
||||
# TELEGRAM_BOT_TOKEN: "<token-from-botfather>"
|
||||
@@ -19,3 +19,9 @@ data:
|
||||
TRUST_PROXY: "loopback, 10.42.0.0/16, 173.245.48.0/20, 103.21.244.0/22, 103.22.200.0/22, 103.31.4.0/22, 141.101.64.0/18, 108.162.192.0/18, 190.93.240.0/20, 188.114.96.0/20, 197.234.240.0/22, 198.41.128.0/17, 162.158.0.0/15, 104.16.0.0/13, 104.24.0.0/14, 172.64.0.0/13, 131.0.72.0/22, 2400:cb00::/32, 2606:4700::/32, 2803:f800::/32, 2405:b500::/32, 2405:8100::/32, 2a06:98c0::/29, 2c0f:f248::/32"
|
||||
OIDC_ISSUER_URL: "https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||
OIDC_REDIRECT_URI: "https://api.apicore.example.com/api/v1/auth/sso/callback"
|
||||
WEATHER_HISTORY_SOURCE: "open-meteo"
|
||||
OPEN_METEO_FORECAST_URL: "https://api.open-meteo.com/v1/forecast"
|
||||
OPEN_METEO_ARCHIVE_URL: "https://archive-api.open-meteo.com/v1/archive"
|
||||
# Telegram: Name des Bots (ohne @) fuer den Deep Link, sobald der Bot bei
|
||||
# BotFather angelegt ist. Das Token liegt im Secret apicore-telegram (nur Worker).
|
||||
# TELEGRAM_BOT_USERNAME: "<bot-username>"
|
||||
Reference in new issue
Block a user