Compare commits

..
3 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 16cc557462 feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 21:13:23 +02:00
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 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]>
2026-10-14 20:00:46 +02:00
Nick SzaunigandClaude Opus 5.5 22382d53a2 feat: T4b wave 7 - schema for phenology, honey movement ledger, shelf life
- Phenology reports, apiary phenology state and weather days with
  permissions for system roles
- Honey movement ledger: BOTTLING movements and initialAmount backfilled
  from existing bottlings with the same gram rounding; the migration
  reports counts and aborts if the ledger invariant fails
- Shelf life and event catalog columns; backup export/import of the new
  tables, old bundles keep importing, invariant check after imports
- e2e: test timeout 20 s (app start in hooks exceeded 5 s under load)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:19:32 +02:00
162 changed files with 19135 additions and 391 deletions

No files matched your search

+164 -27
View File
@@ -28,8 +28,11 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
| `SuggestionsModule` | Vorschläge (Modell, API, Aufräumen) und Ketten-Handler, Abschnitt 11 | Tasks, Notifications |
| `AutopilotModule` | Saison-Autopilot (A1–A3), „Die nächsten Schritte“, „Heute“ | Season, Suggestions |
| `AutopilotModule` | Saison-Autopilot (A1–A10), „Die nächsten Schritte“, „Heute“ | Season, Suggestions |
| `SeasonModule` | Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin | – |
| `PhenologyModule` | Saisonzustand je Stand (GTS, Phase, Kalibrierung), Job-Schritt `phenology`, Meldungen (API, Neuberechnung, Handler), Abschnitt 11.11 | Season, Weather, Notifications |
| `WeatherHistoryModule` | Wetterquelle (`WeatherHistorySource`) und Rückholdienst für die Worker-Schleife `phenology-weather`, Abschnitt 11.11 | – |
| `TelegramModule` | `TelegramClient`, `TelegramSender` (Zustellung, nur Worker), Abschnitt 11.13 | Notifications |
| `WithdrawalModule` | Wartezeit-Prüfung bei Ernte, Abfüllung und Behandlung, Meldungen | Notifications |
| `RemindersModule` | Erinnerung je Aufgabe, Morgenübersicht | Notifications, Season, Autopilot |
@@ -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,21 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
33. **Futter-Vokabular**: `Inspection.feedStatus` ist `ColonyCondition` wie auf der Volkskarte (`EXCELLENT` Reichlich, `GOOD` Ausreichend, `AVERAGE` Mittel, `WEAK` Niedrig, `CRITICAL` Kritisch). Die Migration bildet den alten Freitext (getrimmt auf Leerzeichen, Tab, CR, LF; ohne Groß-/Kleinschreibung) ab: „ausreichend“ → `GOOD`, „niedrig“ → `WEAK`, „kritisch“ → `CRITICAL`; leer → `NULL`; alles andere → `NULL` und `Futter (alt): <Text>` wird nach einem Zeilenumbruch an `notes` gehängt (der Lauf meldet die Zahl der verschobenen Texte per `RAISE NOTICE`). Dieselbe Abbildung gilt für den CSV-Völkerimport (unbekannter Text landet in den Notizen, die Zeile trägt die Meldung „Futterangabe … nicht erkannt, in die Notizen übernommen.“) und für alte Backup-Bundles (ein Wert, der kein Text ist, wird `NULL` mit Notiz `Futter (alt): <JSON>`). Die API akzeptiert bei `POST/PATCH /inspections` die Enum-Namen in jeder Schreibweise und die drei alten Wörter (so bleiben Einträge der Offline-Warteschlange alter Clients sendbar); leerer Text wird `NULL`, anderer Freitext 400. Die Regel `FEED_STATUS_CRITICAL` feuert bei `feedStatus = CRITICAL`; der Webhook `inspection.created` trägt `feedStatus` als Enum-Wert (der Shop nutzt das Ereignis nicht).
34. **Volksstärke in besetzten Waben**: `Colony.occupiedFrames` und `Inspection.colonyStrength` zählen besetzte Waben, 0 bis 40 (CHECK in der Datenbank; 400 „Höchstens 40 besetzte Waben.“). Das alte `Colony.strength` (1–10) bleibt unverändert, wird in Swagger als veraltet markiert und nie umgerechnet; die Oberfläche zeigt es nur, wenn es gespeichert ist, als „Volksstärke (alt)“. Vor dem Ausrollen listet der Kopf der Migration `20261014100700_t4_occupied_frames` die Abfrage nach `Inspection.colonyStrength > 40`.
35. **Übernahme Durchsicht → Volkskarte**: Siehe Abschnitt 11.10. Annahme: Das Brutbild wird zum Brutzustand der Volkskarte (5 `EXCELLENT`, 4 `GOOD`, 3 `AVERAGE`, 2 `WEAK`, 1 `CRITICAL`); diese Zuordnung ist nicht aus dem Lastenheft abgeleitet.
36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). 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 Korrektur oder Rücknahme einer Meldung (`allowBackward`, gesetzt nur vom Handler bei `PhenologyCorrected`; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).
37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis).
38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft).
39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`.
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. Die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit steuern in dieser Lieferung nichts in der App; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken erst auf die Telegram-Zustellung (T4c).
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).
41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen.
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
43. **Pflanzenentwicklung: Tabellen und Fremdschlüssel**: Abweichend vom Entwurf (Spec 4.2) verweisen `ApiaryPhenology.apiaryId` und `ApiaryWeatherDay.apiaryId` mit `ON DELETE CASCADE` auf `Apiary` (abgeleitete Daten und Cache, keine Waisen, ein Restore von Ständen wird nie blockiert). `PhenologyReport.apiaryId` verweist mit `ON DELETE RESTRICT` auf `Apiary`: Der Prisma-Standard für eine optionale Relation, `SetNull`, machte aus einer Meldung still eine Meldung „für alle Stände“. Die Migration `20261015100000_t4_phenology` vergibt `phenology:read`, `phenology:create` und `phenology:update` (Gruppe „Pflanzenentwicklung“) an alle Systemrollen; `read` geht an Rollen, die `colonies:read` halten, `create` und `update` an Rollen mit `inspections:create`, jeweils nur Nicht-Systemrollen. Die Migration legt die Rechte selbst an, weil der Katalog-Abgleich beim Start neue Rechte nur für Zeilen vergibt, die er selbst anlegt (`PermissionCatalogSyncService`).
44. **Bewegungsbuch der Honigcharge**: `HoneyBatch.amount` bleibt der laufende Rest in kg, `HoneyBatch.initialAmount` (kg) ist die Anfangsmenge. `BatchMovement.quantity` ist bei Honig eine vorzeichenbehaftete Menge in Gramm mit `honeyBatchId`. Invariante: `amount = initialAmount + Summe(quantity) / 1000`, auf das Gramm gerundet, nie unter 0, Toleranz 0,5 g. Eine Charge ohne `amount` und `initialAmount` wird nicht gebucht. Jede `BatchMovement` gehört zu genau einer Quelle aus Abfüllung, Herstellungscharge, Rohstoff-Charge und Honigcharge (CHECK `BatchMovement_exactly_one_batch`, `num_nonnulls(…) = 1`). Rückrechnung für Altbestand (Migration `20261015100100_t4_honey_ledger`, Import alter Bundles): `initialAmount = amount + Summe(Gramm der Abfüllungen) / 1000`, je Abfüllung eine Buchung `BOTTLING` mit `sourceBottlingBatchId`, Datum und Benutzer der Abfüllung (Grammwerte halb aufgerundet, `floor(x·1000 + 0,5)`). Frühere Handkorrekturen von `amount` stecken damit in `initialAmount`. Die Migration endet mit einer Prüfung (`RAISE NOTICE` mit Zählern, `RAISE EXCEPTION` bei verletzter Invariante, dann Rollback). Laufzeit: `HoneyBatchLedgerService` (`src/honey-batches/honey-batch-ledger.service.ts`) ist der einzige Schreiber von `HoneyBatch.amount` (Wächter `honey-batch-amount-writes.spec.ts`). Buchungen laufen in der Transaktion des Aufrufers unter `lockRowForUpdate('HoneyBatch')`: `bookIn` (vorzeichenbehaftete Gramm; `amount` wird aus `initialAmount + Summe(quantity) / 1000` auf das Gramm neu berechnet, nie unter 0), `setInitialIn` (erste Menge: `amount` und `initialAmount` zugleich, keine Buchung) und `clearIn` (Menge entfernen, nur ohne Buchungen, sonst 400 „Die Menge wird ueber das Bewegungsbuch gefuehrt. Bitte eine Korrektur buchen.“). Eine Abbuchung braucht genug Rest (Toleranz 0,5 g), sonst 400 (Abfüllung: „Abfuellmenge uebersteigt die verfuegbare Menge der Honigcharge“, sonst „Die Menge kann nicht unter 0 sinken.“). Gerundet wird überall gleich, `floor(x·1000 + 0,5)` Gramm, auch bei Mengen, die Nutzer eingeben (12,3456 kg werden 12,346 kg). Eine Abfüllung anlegen bucht `BOTTLING` negativ mit `sourceBottlingBatchId`, Ändern die Differenz (bei Chargenwechsel zurück auf die alte, von der neuen), Entfernen `BOTTLING` positiv mit der Notiz „Abfuellung entfernt“ (ohne Quelle: die Abfüllung wird danach gelöscht). `PATCH amount` bucht `CORRECTION` (neu minus aktuell; `initialAmount` bleibt, Startmenge und Summe der Buchungen weichen danach ab); der erste Wert einer Charge ohne Menge (`null` → Wert) setzt `initialAmount`. Eine Charge ohne `initialAmount` bucht nichts, die Abfüllung setzt trotzdem den Status `BOTTLED`. Eine Abfüllung, die dem Buch nie etwas entnommen hat (Heuristik `drewFromLedger`: keine `BOTTLING`-Buchung mit `honeyBatchId` und ihrer `sourceBottlingBatchId`, etwa weil die Charge beim Abfüllen noch keine Menge hatte), bucht bei Änderung und Entfernung nichts zurück; wird sie in eine andere Charge verschoben, entnimmt sie erst dort. Das folgt aus den Buchungen und ist kein gespeichertes Merkmal. Sperrreihenfolge je Pfad: Abfüllung → Honigcharge (zwei Chargen in einer Anweisung, aufsteigend; die Abfüllung sperrt die Charge schon vor dem Anlegen), Schleuderung → Honigcharge. API des Buchs: Abschnitt 11.12.
45. **Honig-Kette: Vorschläge, Warnungen, nie sperren**: Vorbelegungen (Menge, Honigsorte, Wassergehalt) sind Vorschläge mit Übernehmen-Knopf, nie stillschweigend eingetragen. Plausibilitätswarnungen (`EXTRACTION_EXCEEDS_HARVEST`, `BATCHES_EXCEED_EXTRACTION`, Hinweis `HARVEST_NET_MISSING`) und die Wartezeitwarnung sperren nie (kein 4xx). Die Wartezeitwarnung `WITHDRAWAL_ACTIVE` bekommt jeder, der eine Schleuderung erfasst oder vorbelegt, auch ohne `treatments:read` (Lebensmittelsicherheit vor der Lese-Trennung). Die Vorbelegungs-Endpunkte hängen an den Anlege-Rechten (`extractions:create`, `honey-batches:create`): Sie liefern dieselben Daten wie der Anlege-Ablauf. Die Honigsorte kommt aus den Ernten, weil `Extraction` kein Sortenfeld hat. Abschnitt 11.12.
46. **Telegram: Token nur im Worker, Zustellung über Outbox**: Der Bot-Token (`TELEGRAM_BOT_TOKEN`) liegt nur in der Umgebung des Workers (optionales Secret `apicore-telegram`); der API-Pod kennt nur `TELEGRAM_BOT_USERNAME` für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über `NotificationDelivery`, nie aus Anfragen, Handlern oder Jobs; die Zustellung läuft in der Schleife `telegram-send` (Abschnitt 11.13). Der Sender nimmt genau ein Worker-Replikat an (`replicas: 1`): Chat-Abstand, Senderate und die Pausen nach 401 und 429 liegen im Speicher des Pods, mit mehreren Pods hätte jeder seine eigenen. Die SQL-Regel „eine Zustellung je Nutzer und Batch“ und Telegrams 429-Antworten schützen den Bot auch dann. Ohne `FRONTEND_ORIGIN` geht die Nachricht außerhalb der Entwicklung (`NODE_ENV` leer oder `development`) ohne Link hinaus (ein Warn-Log je Pod), nie mit einem localhost-Link. Abschnitt 11.13.
47. **Pflanzen-Meldungen, Neuberechnung und Saisonwechsel**: Rechte: `phenology:read` (Liste), `phenology:create` (Melden), `phenology:update` (Bearbeiten und Zurückziehen); `GET /phenology/state` braucht `apiaries:read` und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); `apiaryId` leer (`""`) ergibt 400, `null` heißt „alle Stände“. Wer `phenology:update` hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. `PhenologyReported` und `PhenologyCorrected` (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. `PhenologyCorrected` trägt `previousApiaryId` (beim Zurückziehen gleich `apiaryId`): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite `null` (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler `phenology.recompute` rechnet für den späteren von Berliner Tag des Ereignisses (`occurredAt`) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (kein Schlüssel), und `phenology.recompute-apiary` rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. **Bewusste Abweichung von der Spec:** Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. `SeasonPhaseChanged` bleibt für den Autopiloten.
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), mit einer Ausnahme (Entscheidung zu Task 27): `Apiary` wird vor `Colony` gesperrt. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Karte im Code führt `Apiary` weiterhin weiter hinten (Stand dieses Dokuments): Die Ausnahme steht als Regel hier, nicht in der Karte.
## 7. Kubernetes
@@ -229,7 +241,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 +260,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; ohne Token läuft die Worker-Schleife `telegram-send` nicht (Abschnitt 10).
**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.
@@ -296,12 +322,21 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden.
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen mit einer Ausnahme: `Apiary` kommt vor `Colony` (Annahme 50).
**Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.
**Neue und geänderte Ereignisse.** `ColonySplit { parentColonyId, lineageId, queenSource | null, splitOn }` (Aggregat: das neue Volk) schreibt `ColoniesService.split` in der Teil-Transaktion. `QueenAssigned { colonyId, historyId, assignedOn, previousColonyId | null }` (Aggregat: die Königin) schreibt `QueensService.assignColony` nach dem Sperren beider Völker (eine Anweisung, aufsteigend nach id) und der Königin; ein geänderter vorheriger Stand zwischen Vorab-Lesung und Sperre ergibt 409. `ColonyEnded` trägt jetzt `{ status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `InspectionOverdue`, `VarroaMeasurementDue` und `QueenAged` haben zusätzliche, optionale Payload-Felder (Abschnitt „Jobs“, `daily-evaluation`).
**Ereignisse der Honig-Kette und der Pflanzenentwicklung (T4b).** Sieben Typen kommen in den Katalog (`domain-event-types.ts`), Aggregattypen `Extraction`, `HoneyBatch`, `PhenologyReport` und `Apiary` dazu; die Lieferung der Meldungs-API und der Autopilot-Regeln A4–A10 ergänzt `PhenologyRecomputeRequested` und `WinterCheckRecorded` (Aggregattyp `WinterCheck`):
- `ExtractionRecorded` und `ExtractionCorrected` (Aggregat `Extraction`, Payload `harvestIds`, bei der Korrektur nach der Änderung) schreibt `ExtractionsService` in der Transaktion des Schreibens, zusammen mit dem Audit-Eintrag.
- `HoneyBatchRecorded` und `HoneyBatchCorrected` (Aggregat `HoneyBatch`, Payload `extractionId`) schreibt `HoneyBatchesService` ebenso. `update` sperrt zuerst die Zeile und liest „vorher“ danach (Abschnitt 11.12).
- `PhenologyReported` und `PhenologyCorrected` (Aggregat `PhenologyReport`) schreibt `PhenologyService` mit Datensatz und Audit-Eintrag in einer Transaktion: Anlegen → `PhenologyReported`, Bearbeiten und Zurückziehen → `PhenologyCorrected` mit `previousApiaryId` (Annahme 47, Abschnitt 11.11). Ändert ein Bearbeiten nichts, entsteht kein Ereignis.
- `SeasonPhaseChanged` (Aggregat `Apiary`, Payload `apiaryId`, `phase`, `segment`, `previousPhase`, `previousSegment`, `source`) schreibt `PhenologyStateService`, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über `dailyDedupeKey`. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Der Handler `autopilot.apiary-sync` hört darauf; eine Einzelmeldung je Saisonwechsel gibt es bewusst nicht (Annahme 47).
- `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (Aggregat `Apiary`, ohne Schlüssel) schreibt der Handler `phenology.recompute` je aktivem Stand, wenn eine Meldung für alle Stände gilt; `phenology.recompute-apiary` rechnet damit einen Stand je Zustellung.
- `WinterCheckRecorded { colonyId }` (Aggregat `WinterCheck`) schreiben `WinterChecksService.create` und `update` in ihrer Transaktion (ein Verschieben auf ein anderes Volk meldet auch das alte); `autopilot.colony-sync` löst damit A9/A10 sofort auf.
`Apiary`, `PhenologyReport`, `Extraction` und `HoneyBatch` stehen am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `NotificationDelivery` steht ganz zuletzt (Abschnitt 11.13).
**Handler-Übersicht (T4).** Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über `tx` schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (`lockRowForUpdate`; fehlt es, ist nichts zu tun), Status über `tx` neu lesen (beendet: nichts), Regel über `readRule(tx, …)` lesen (abgeschaltet: nichts vorschlagen).
| Handler-ID | Ereignis | Wirkung |
@@ -314,21 +349,31 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
| `chains.queen-cells` | `InspectionRecorded` | Erledigt zuerst offene Ketten-Vorschläge (Notiz „Mit Durchsicht erledigt“): `SWARM_CHECK` bei Weiselzellen alle, sonst die bis zum Durchsichtstag fälligen; `QUEEN_ACCEPTANCE` (fällig bis Durchsichtstag) nur bei `queenSeen` oder `eggsPresent`. Bei `queenCellsPresent = true` und aktiver Regel `QUEEN_CELLS_SWARM_CHECK`: PLAN `SWARM_CHECK` (`inspections:read`), Anker Durchsicht-ID, fällig Durchsichtstag + `dueInDays` (7). |
| `chains.queen-acceptance` | `QueenAssigned` | Regel `QUEEN_ACCEPTANCE_CHECK` aktiv: PLAN `QUEEN_ACCEPTANCE` (`queens:read`), Anker `historyId`, fällig `assignedOn` + `dueInDays` (7). |
| `chains.split-schedule` | `ColonySplit` | Regel `SPLIT_SCHEDULE` aktiv und `queenSource` gesetzt: `RAISE_OWN` und `QUEEN_CELL` → PLAN `SPLIT_PLAN` (`colonies:read`), `QUEEN` → PLAN `QUEEN_ACCEPTANCE` (`queens:read`); Anker `lineageId`, Plan = Schritte der Variante ab `splitOn` (Abschnitt 11.6). |
| `autopilot.colony-sync` | 19 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` | Ruft `AutopilotService.syncColonyIn(tx, colonyId, berlinToday())` je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei `ColonySplit` neues und Muttervolk, bei `QueenAssigned` Volk und vorheriges Volk; Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). |
| `autopilot.colony-sync` | 20 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue`, `WinterCheckRecorded` | Betrifft ein Ereignis mehrere Völker, sperrt der Handler sie zuerst in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend); dann ruft er `AutopilotService.syncColonyIn(tx, colonyId, berlinToday())` je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei `ColonySplit` neues und Muttervolk, bei `QueenAssigned` Volk und vorheriges Volk, bei `WinterCheckRecorded` das Volk der Prüfung (A9/A10 lösen sich sofort auf); Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). |
| `autopilot.apiary-sync` | `SeasonPhaseChanged` | Gleicht alle lebenden Völker des Stands ab (`AutopilotService.syncApiaryIn(tx, apiaryId, berlinToday())`): Stand sperren, Völker lesen, alle in einer Anweisung sperren (Id aufsteigend), Regeln und Saison einmal lesen, dann je Volk `syncColonyIn`. Ergänzt `autopilot.colony-sync`; es entsteht höchstens ein Ereignis je Stand und Tag, spätere Wechsel fängt der tägliche Schritt `autopilot` auf (Annahmen 48, 50). |
| `phenology.recompute` | `PhenologyReported`, `PhenologyCorrected` | Berechnet den Saisonzustand der betroffenen Stände in der Zustell-Transaktion neu (`PhenologyStateService.recomputeIn`, am späteren von Ereignistag und heute); bei `PhenologyCorrected` darf die Phase zurückfallen (`allowBackward`). Betroffen: Stand und, bei einer Korrektur, `previousApiaryId`; gilt eine Seite für alle Stände, schreibt er je aktivem Stand ein `PhenologyRecomputeRequested`. Fehler gehen als Wiederholung durch (Annahme 47). |
| `phenology.recompute-apiary` | `PhenologyRecomputeRequested` | Rechnet genau einen Stand neu; ein nicht mehr aktiver oder gelöschter Stand wird übersprungen. |
| `withdrawal.harvest-check` | `HarvestRecorded`, `HarvestCorrected` | Prüft Wartezeit-Konflikte der Ernte und schreibt die Meldung (Abschnitt 11.8). |
| `withdrawal.bottling-check` | `BatchBottled`, `BottlingCorrected` | Wie oben für die Honigcharge der Abfüllung; bei `removed: true` nichts. |
| `withdrawal.treatment-check` | `TreatmentRecorded`, `TreatmentCorrected` | Prüft Ernten des Volkes, die das (nachträglich erfasste oder verlängerte) Wartezeitfenster treffen. |
| `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. Zu den LISTEN-Kanälen kommt `notification_delivery` (Abschnitt 11.13). Der Test `test/worker-runtime.e2e-spec.ts` ersetzt `WEATHER_HISTORY_SOURCE` durch eine Attrappe, weil die Schleife sonst für jeden Stand der geteilten Test-DB den echten Dienst aufriefe.
**Zustellplanung.** Kein Handler: `NotificationsService.write` plant nach dem Einfügen der Meldungen (dieselbe Transaktion; ohne Aufrufer-Transaktion öffnet es eine eigene, damit Meldungen und Zustellungen zusammen bestehen) über den `NotificationDeliveryPlanner` je neu eingefügter Meldung höchstens eine `NotificationDelivery` für Telegram und weckt den Worker per `pg_notify('notification_delivery', '')`, nur wenn Zeilen entstanden sind. Eine wiederholte Meldung (gleicher `dedupeKey`) plant nichts. Regeln: Annahme 49, Abschnitt 11.13.
**Worker-Schleife `telegram-send`.** Takt 5 000 ms und auf `notification_delivery`; sie ist nur registriert, wenn `TELEGRAM_BOT_TOKEN` gültig gesetzt ist (`TelegramClient.isConfigured()`). Ein Takt ruft `TelegramSender.releaseStaleClaims()` und dann `drain(stopping.signal)` auf. Mit `telegram-updates` (Task 36, noch offen) ist sie der einzige Netzzugriff zur Bot-API. Rechte kommen aus dem globalen `JobPermissionsModule` des Workers, `TelegramModule` importiert kein `PermissionsModule`.
### Webhook-Zustellung
**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.
@@ -344,18 +389,19 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
**`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit.
**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PENDING`- und `PROCESSING`-Einträge bleiben immer stehen.
**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PROCESSING`-Einträge bleiben immer stehen, `PENDING` ebenso, mit der einen Ausnahme der Telegram-Zustellungen unten.
- Läufe, die seit mehr als 6 h `RUNNING` sind (`JOB_RUN_STALE_HOURS`, weit über jeder `activeDeadlineSeconds`), werden `FAILED` mit `error: 'aborted (pod ended without result)'`; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert.
- Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (`IdempotencyCleanupService`; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen).
- `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten.
- `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen.
- `JobRun`-Zeilen nach 90 Tagen.
- `NotificationDelivery` (nach `createdAt`, auch Antworten des Bots ohne Nutzer): `DELIVERED` und `SKIPPED` nach 30 Tagen, `FAILED` nach 90 Tagen, `PENDING` nach 7 Tagen (`PENDING_DELIVERY_MAX_AGE_DAYS`; eine so alte Zustellung ist hängen geblieben, sie wird mit Warn-Log samt Zahl gelöscht). `TelegramLinkToken` 30 Tage nach Ablauf.
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`).
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`, `notificationDeliveries.done`, `notificationDeliveries.failed`, `notificationDeliveries.pending`, `telegramLinkTokens`).
**`task-templates`** (täglich 05:00) ruft `TasksService.generateFromTemplates()` auf und meldet `{ created }`. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor `GET /tasks`.
**`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`).
@@ -367,7 +413,7 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
- Handler: `TaskDue` → `notifications.task-due`; `WithdrawalEnded` → `notifications.withdrawal-ended` und `autopilot.colony-sync`; `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` → `autopilot.colony-sync`.
- **`autopilot`** (`AutopilotStep`): lädt Regeln und Saisonzustände einmal vorab (`preloadFor`) und gleicht dann jedes lebende Volk in einer eigenen Transaktion ab (`syncColonyIn`, Abschnitt 11.5). Zähler `autopilotColonies`, `suggestionsOpened`, `suggestionsResolved`. Scheitert ein Volk, läuft der Schritt weiter und endet mit `JobPartialFailure`.
- **`suggestions-expiry`**: `OPEN` mit `expiresAt` vor heute → `EXPIRED`, gibt den `dedupeKey` frei. Zähler `suggestionsExpired`; zweimal am selben Tag ändert nichts.
- **`digest`**: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler `digestsWritten`; ein Wiederholungslauf am selben Tag schreibt 0.
- **`digest`**: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler `digestsWritten` und, nur wenn mindestens eine Übersicht geschrieben wurde, `digestWindowEnd` (Beginn des Schritts, ISO-Zeit; Zeitfenster der Saison-Zeile, Abschnitt 11.9); ein Wiederholungslauf am selben Tag schreibt 0 und merkt kein Fensterende.
**`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“.
@@ -379,9 +425,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 Telegram (11.13). Noch offen sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`) und das Verknüpfen und Abfragen von Telegram (Task 36).
### 11.1 Vorschläge
@@ -389,7 +435,7 @@ Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensc
**PLAN und DO.** `PLAN` hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus `PLAN_STEP_TITLES`, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. `DO` ist genau eine Aufgabe und braucht ein Datum (eigenes oder `dueDate` des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus `renderSuggestion` mit dem `actionTemplate` der Regel. Die Aufgabe entsteht über `TasksService.createInTx` (Audit, `TaskCreated`, danach Kalender und Webhook). `sourceInspectionId` wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter `FOR SHARE` geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein `assignedUserId`, der nicht existiert oder inaktiv ist: 400 mit `issues`.
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference).
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference; Ausnahme: `Apiary` vor `Colony`, Annahme 50).
| von | nach | Auslöser |
|---|---|---|
@@ -425,7 +471,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`.
@@ -446,17 +492,24 @@ Quelle der Grenzen ist die LWG Veitshöchheim; die Zahlen stehen als Bänder in
### 11.5 Saison-Autopilot und „Die nächsten Schritte“
**Abgleich.** `AutopilotService.syncColonyIn(tx, colonyId, today)` (`AutopilotModule`) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (`loadAutopilotInputIn`) und die Regeln (`SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `VARROA_THRESHOLD` nur `recheckAfterDays`, `PHENOLOGY`). Ein beendetes Volk wird übersprungen (das Aufräumen ist `suggestions.colony-ended`). Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren `dedupeKey` nicht mehr vorkommt, werden `RESOLVED` („Bedingung entfällt“); bei gleichem Schlüssel frischt `refreshIn` Belege, Fälligkeit, Priorität und `reasonCode` auf; ein neuer Kandidat geht über `proposeIn` (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; `rules.A<n> = false` schaltet eine Regel ab, `INSPECTION_INTERVAL.active = false` nur A1. Der Handler `autopilot.colony-sync` und der Job-Schritt `autopilot` rufen ihn auf (Abschnitte 10 und „Jobs“).
**Abgleich.** `AutopilotService.syncColonyIn(tx, colonyId, today)` (`AutopilotModule`) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (`loadAutopilotInputIn`) und die Regeln (`SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `VARROA_THRESHOLD` nur `recheckAfterDays`, `PHENOLOGY`). Ein beendetes Volk wird übersprungen (das Aufräumen ist `suggestions.colony-ended`). Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren `dedupeKey` nicht mehr vorkommt, werden `RESOLVED` („Bedingung entfällt“); bei gleichem Schlüssel frischt `refreshIn` Belege, Fälligkeit, Priorität und `reasonCode` auf; ein neuer Kandidat geht über `proposeIn` (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; `rules.A<n> = false` schaltet eine Regel ab, `INSPECTION_INTERVAL.active = false` nur A1. Der Handler `autopilot.colony-sync` und der Job-Schritt `autopilot` rufen ihn auf (Abschnitte 10 und „Jobs“). Wechselt ein Stand seine Saison (`SeasonPhaseChanged`), gleicht `autopilot.apiary-sync` alle seine lebenden Völker ab (`syncApiaryIn`, Sperrreihenfolge Stand, dann alle Völker in einer Anweisung: Annahme 50). Eine Winterprüfung (`WinterCheckRecorded`) gleicht das betroffene Volk sofort ab, sodass A9/A10 ohne Warten auf den Tagesjob verschwinden.
**Regeln A1–A3** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`; A4–A10 folgen später):
**Regeln A1–A10** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`, Annahme 48):
| Nr | Absicht | Wann | Anker | Fälligkeit, Priorität |
|---|---|---|---|---|
| A1 | `INSPECT` | Abschnitt ≠ `WINTERRUHE`, `inspectionDueOn` ≤ heute + 2 (`INSPECTION_DUE`, bzw. `INSPECTION_FIRST_OF_YEAR`) | ID der letzten Durchsicht oder `none` | `inspectionDueOn`; überfällig = `HIGH`, sonst `NORMAL` |
| A2 | `VARROA_MEASURE` | `varroaMeasurementDue` ≤ heute (`VARROA_MEASURE_DUE`, im Winterfenster `VARROA_WINTER_MEASURE`) | ID der letzten Messung oder `winter-<Jahr>` | Fälligkeitstag; wird von A3 unterdrückt, solange A3 gilt |
| A3 | `VARROA_RECHECK` | Behandlungsende + `recheckAfterDays` ≤ heute, keine Messung nach dem Ende, Ende höchstens 60 Tage her (Annahme 38); `requiredPermission` `varroa:read` | Behandlungs-ID | Tag, ab dem die Bedingung gilt |
| A4 | `SUMMER_TREATMENT` (`PLAN`) | Abschnitt `SPAETSOMMER`, keine Behandlung seit Abschnittsbeginn (`SUMMER_TREATMENT_DUE`) | Jahr | heute; `HIGH` |
| A5 | `FEED` (`DO`) | `SPAETSOMMER` oder `HERBST`, keine Fütterung seit Beginn von `SPAETSOMMER` (`AUTUMN_FEED_MISSING`); in `HERBST` gilt der Kalendertag von `TRACHTENDE`, höchstens der Beginn von `HERBST` (Annahme 48) | Jahr | heute |
| A6 | `FEED` (`DO`) | nicht `WINTERRUHE`; Futter der letzten Durchsicht `WEAK` oder `CRITICAL`, sonst Volkskarte `CRITICAL` (`FEED_LOW`, Beleg `source` `INSPECTION` oder `COLONY_CARD`); keine Fütterung seit der Durchsicht (ohne Durchsicht: seit dem späteren von Anlagetag und heute − 60 Tage) | ID der Durchsicht oder `none` | heute |
| A7 | `HONEY_SUPER` (`DO`) | Phase ab `KIRSCHE` bis vor `LINDE` (Reihenfolge `PHENOLOGY_PHASES`), `colonyStrength` einer Durchsicht ab `springSince` des Jahres (sonst ab 1. Januar) ≥ `honeySuperMinFrames` (`HONEY_SUPER_CHECK`) | Jahr | heute |
| A8 | `QUEEN_REPLACE` (`PLAN`) | `SCHWARMZEIT` oder `SOMMER`, Königin gealtert (`isQueenAged`, `queenMaxAgeYears`; `QUEEN_OLD`) | `<Königin>-<Jahr>` | heute |
| A9 | `WINTER_CHECK` (`DO`) | `HERBST`, kein `WinterCheck` `AUTUMN` mit `winterYear` = Jahr (`WINTER_CHECK_AUTUMN_MISSING`) | `<Jahr>-AUTUMN` | heute |
| A10 | `WINTER_CHECK` (`DO`) | `FRUEHJAHR`, Einwinterung des Vorwinters vorhanden, `SPRING` fehlt (`WINTER_CHECK_SPRING_MISSING`) | `<Jahr−1>-SPRING` | heute |
Als „letzte Behandlung“ zählt das späteste Ende (`endDate ?? date`); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf `expiresAt` siehe Annahme 38.
Als „letzte Behandlung“ zählt das späteste Ende (`endDate ?? date`); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf `expiresAt` siehe Annahme 38; alle Regeln sind einzeln über `config.rules` schaltbar. Der Grund von A4 und A5 nennt die Quelle der Phase aus `SeasonState.phaseSource` (gemeldet, geschätzt, Kalender).
**Nächste Schritte.** `NextStepsService` liefert `GET /colonies/:id/next-steps` (`ColonyNextSteps`, höchstens `maxStepsPerColony` = 3) und `GET /autopilot/today` (`AutopilotToday`: dringlichster Schritt über alle lebenden Völker mit demselben Vergleich, fällige Durchsichten je Stand, Zahl offener Vorschläge, Abschnitt je Stand); beide `colonies:read`, 404 bei unbekanntem Volk. Die Zahl der Abfragen ist unabhängig von der Zahl der Völker.
- Schritte = offene Aufgaben (`OPEN`, `IN_PROGRESS`) plus sichtbare offene Vorschläge, nur mit `tasks:read` (ohne: keine Schritte). Ein offener Vorschlag entfällt, wenn für das Volk eine offene Aufgabe derselben Absicht besteht.
@@ -487,20 +540,22 @@ 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.
### 11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` wird nie geschrieben. Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
**Erinnerung je Aufgabe.** Handler `notifications.task-due` (`RemindersModule`, von `AppModule`, `WorkerModule` und `JobsModule` importiert) schreibt zu `TaskDue` je Empfänger eine Meldung (HINWEIS, `TASK_DUE`, Link `/tasks`, Schlüssel `task-due:<Aufgabe>:<dueOn>:<userId>`): „Heute fällig: <Titel>“, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: <Titel>“. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit `tasks:complete`; immer nur mit `tasks:read` und eingeschaltetem `taskDueEnabled` (fehlende Einstellung = an). Nur offene Aufgaben (`OPEN`, `IN_PROGRESS`); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen.
**Morgenübersicht.** Der Schritt `digest` (`DigestService.runFor`) schreibt je aktivem Nutzer mit aktiver Rolle und `digestEnabled` höchstens eine Meldung je Tag (INFO, `DIGEST`, Link `/dashboard`, Schlüssel `digest:<Tag>:<userId>`). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (`tasks:read`), „Durchsicht fällig: n Völker am Stand …“ (`colonies:read` und `inspections:read`), „n Vorschläge warten.“ (`tasks:read`, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (`apiaries:read`, nur am Tag des Abschnittsbeginns). Ohne Zeile keine Übersicht. `requiredPermissions` der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird `FAILED`.
**Morgenübersicht.** Der Schritt `digest` (`DigestService.runFor`) schreibt je aktivem Nutzer mit aktiver Rolle und `digestEnabled` höchstens eine Meldung je Tag (INFO, `DIGEST`, Link `/dashboard`, Schlüssel `digest:<Tag>:<userId>`). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (`tasks:read`), „Durchsicht fällig: n Völker am Stand …“ (`colonies:read` und `inspections:read`), „n Vorschläge warten.“ (`tasks:read`, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (`apiaries:read`, Saison-Zeile, siehe unten). Ohne Zeile keine Übersicht. `requiredPermissions` der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird `FAILED`.
**Saison-Zeile.** Saisonwechsel kommen nur hier vor, nie als Einzelmeldung (bewusste Abweichung von der Spec, Annahme 47). Die Zeile entsteht aus den `SeasonPhaseChanged`-Ereignissen mit `occurredAt` im Fenster `[von, bis)`: `bis` ist der Beginn des Schritts, `von` das `digestWindowEnd` des jüngsten abgeschlossenen `daily-evaluation`-Laufs (`SUCCEEDED` oder `FAILED`, unter den letzten 10), der eines gemerkt hat, sonst `bis` − 24 Stunden. Der nächste Lauf beginnt damit, wo der vorige aufhörte; jeder Wechsel steht genau einmal da, auch einer, den eine Meldung tagsüber nach 04:30 ausgelöst hat. Ein Lauf, der keine Übersicht schrieb (Wiederholung am selben Tag, kein Inhalt), merkt kein Fensterende, sodass das nächste Fenster weiter zurückreicht. Je Stand werden die Ereignisse zusammengezogen (erster vorheriger bis letzter Abschnitt); reine Phasenwechsel, der erste Zustand eines Jahres (kein vorheriger Abschnitt) und Wechsel ohne Nettoänderung ergeben keine Zeile, ebenso Stände ohne lebende Völker. Ein Wechsel vorwärts lautet „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“, ein Rückschritt (zurückgezogene Meldung; `HERBST` → `WINTERRUHE` zählt als vorwärts) neutral „Stand {Name}: wieder {Abschnitt}.“. Bekannte Ränder: Ein manueller Wiederholungslauf am selben Tag schreibt nichts, ein Ereignis, das nach `bis` committet, aber davor gestempelt ist (eine über 04:30 offene Transaktion), kann fehlen; beides ist selten und hingenommen.
**Job-Ausfall.** Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (Abschnitt 10, Jobs) einmal je Job und Berliner Tag (Kategorie `SYSTEM`).
@@ -511,3 +566,85 @@ 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, Job-Schritt und die Meldungs-API (Melden, Bearbeiten, Zurückziehen mit Neuberechnung) sind fertig.
**Tabellen** (Migration `20261015100000_t4_phenology`, Annahme 43). `PhenologyReport` hält eine gemeldete Phase (Stand oder alle Stände, Datum, Rücknahme per `revokedAt`; Index `(apiaryId, phase, date)`). `ApiaryPhenology (apiaryId, year)` ist der abgeleitete Saisonzustand. `ApiaryWeatherDay` ist der Wetter-Cache (Tagesmittel, `source` `forecast` oder `archive`, `fetchedAt`). Rechte `phenology:read`, `phenology:create`, `phenology:update` (Gruppe „Pflanzenentwicklung“). Backup: Abschnitt 8.
**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. Der Handler bei `PhenologyCorrected` ruft `recomputeIn` mit `allowBackward: true` auf.
**Meldungen** (`PhenologyModule`, Annahme 47). Routen: `GET /phenology/state` (`apiaries:read`; je aktivem Stand Zustand ohne Koordinaten, `reportedOn` nur bei Quelle `REPORT` der früheste Meldetag der geltenden Phase), `GET /phenology/reports?year=` (`phenology:read`, Standard laufendes Jahr, auch zurückgezogene mit `revokedAt`, Stand- und Nutzername), `POST /phenology/reports` (`phenology:create`, `@Idempotent()`; Phase, Datum `JJJJ-MM-TT`, `apiaryId` oder `null` für alle, Notiz höchstens 500 Zeichen), `PATCH /phenology/reports/:id` (`phenology:update`; `null` auf Phase oder Datum: 400, auf Stand oder Notiz: leert), `POST /phenology/reports/:id/revoke` (`phenology:update`, `@Idempotent()`, schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (`lockRowForUpdate`, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler `phenology.recompute` und `phenology.recompute-apiary` (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9).
### 11.12 Honig-Kette und Bewegungsbuch
Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook und die Laufzeit-Buchungen des Bewegungsbuchs sind fertig. Offen ist die Bestätigung beim Wassergehalt der Abfüllung (409, `acknowledgedWarnings`).
**Ereignisse.** `ExtractionRecorded`/`ExtractionCorrected` und `HoneyBatchRecorded`/`HoneyBatchCorrected` entstehen in der Schreib-Transaktion mit dem Audit-Eintrag (Abschnitt 10). Der Webhook `batch.created` kommt über `webhooks.forward` aus `HoneyBatchRecorded`; `HoneyBatchCorrected` sendet nichts, wie vorher beim Ändern.
**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. Eine Charge ohne `initialAmount` zählt nicht (auch nicht für den Rest der Vorbelegung); der frühere Rückfall auf `amount` ist entfernt.
- `WITHDRAWAL_ACTIVE` bei der Schleuderung: Der Handler `withdrawal.extraction-check` und die Antwort prüfen über `WithdrawalCheckService.conflictsForExtraction`. Die Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`. Die Meldung nutzt denselben Schlüssel wie Ernte und Abfüllung (Abschnitt 11.8), ein Konflikt wird also nur einmal gemeldet.
**Bewegungsbuch.** Annahme 44. Schema und Rückrechnung: Migration `20261015100100_t4_honey_ledger`, `BatchMovement.honeyBatchId` (Fremdschlüssel `Restrict`, Index) und `sourceBottlingBatchId` (Fremdschlüssel `SetNull`, Index). Die Invariante `amount = initialAmount + Summe(quantity) / 1000` prüfen die Migration und der Import alter Bundles (Abschnitt 8) mit einer SQL-Abfrage (`INVARIANT_SQL` im Test-Helfer `test/utils/honey-ledger.ts`). Haltbarkeit: `Product.shelfLifeMonths` (Ganzzahl, optional; Migration `20261015100200_t4_product_shelf_life`). Laufzeit-Buchungen: Annahme 44.
**API des Buchs.** `GET /honey-batches/:id/movements` (`honey-batches:read`; 404 für eine unbekannte Charge) antwortet `{ items, hasMore }` mit höchstens 200 Buchungen, neueste zuerst (`id`, `type`, `quantityGrams`, `reason`, `notes`, `sourceBottlingBatchId`, `createdAt`, `userId`). `POST /honey-batches/:id/movements` (`honey-batches:update`, `@Idempotent()`) nimmt `{ type: 'WITHDRAWAL' | 'CORRECTION', deltaKg, reason?, notes? }` (`deltaKg` endlich, ungleich 0, Betrag höchstens 100 000; Notiz höchstens 500 Zeichen) und antwortet mit der Buchung. Eine `WITHDRAWAL` braucht einen Grund („Bitte einen Grund angeben.“) und eine negative Menge („Eine Entnahme braucht eine negative Menge.“); bei einer `CORRECTION` ist ein Grund erlaubt und wird gespeichert. Eine Charge ohne erfasste Menge ergibt 400 „Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.“; ein widersprüchlicher Bestand (`initialAmount` gesetzt, `amount` leer) 409. Die Buchung schreibt Audit `HoneyBatch UPDATE` und `HoneyBatchCorrected`. Die Oberfläche zeigt die Startmenge getrennt von der Liste der Buchungen und liest `.items`.
**Antwort-Form.** Antworten von `POST/PATCH /extractions` und `/honey-batches` tragen `warnings`. Eine Bestätigung (`acknowledgedWarnings`, 409) gibt es hier nicht; sie gehört zur Abfüllung.
### 11.13 Telegram
Stand: Schema, Client, Prüfung beim Start, Planung und Senden sind fertig (`TelegramModule` importieren `WorkerRuntimeModule` und `WorkerModule`). Das Verknüpfen eines Chats und das Abfragen von Nachrichten (Task 36) fehlen noch: Es gibt noch keinen Weg, eine `TelegramLink` anzulegen. `NotificationPreference.telegramEnabled` setzt bisher nur der Sender, beim Lösen der Verbindung auf aus (Abschnitt 11.9).
**Tabellen** (Migration `20261015110000_t4_telegram`). Enums `NotificationChannel` und `NotificationDeliveryStatus`. `TelegramLink` (Chat-Bindung eines Nutzers; eindeutig je Chat), `TelegramLinkToken` (Einmal-Token zum Verknüpfen), `NotificationDelivery` (Zustell-Warteschlange), `TelegramPollState` (genau eine Zeile `singleton`, per CHECK `id = 'singleton'`). Alle Fremdschlüssel auf `User` laufen mit `ON DELETE CASCADE`, ebenso `Notification.deliveries`. Keine dieser Tabellen ist Teil des Backups (Abschnitt 8).
**`NotificationDelivery`.** Eindeutig je `(notificationId, channel)`, Index auf `notificationId`. Der CHECK `NotificationDelivery_target_check` verlangt entweder Meldung und Nutzer oder Vorlage und Chat. Zustellungen mit Vorlage haben `chatId` und kein `userId`; sie entfernt die Nutzer-Kaskade nicht. Ihre Aufräumregeln nach Alter stehen im Job `cleanup` (Abschnitt 10).
**Dritte Outbox.** `NotificationDelivery` nutzt `src/common/outbox/outbox-claims.ts` wie Ereignisse, Webhooks und Kalender: `claimBatch`, `settleSuccess`, `settleFailure`, `releaseStaleClaims`. Neu sind `deferClaim` (zurück auf `PENDING` nach einer Wartezeit, Versuche unverändert, Wartezeit höchstens 24 h) und `settleSkipped` (`SKIPPED` mit Grund, höchstens 200 Zeichen). Beide sind Compare-and-Set auf `id` + `claimedAt` und nutzen die DB-Uhr. Weckkanal `notification_delivery` (`worker-wakeup.ts`); `NotificationDelivery` steht in der Sperr-Karte ganz zuletzt.
**`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).
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE` und `DIGEST`; `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
**Texte** (`src/telegram/telegram-texts.ts`). `TELEGRAM_TEMPLATES` hält die festen Texte, `renderTelegramText` baut daraus den Klartext: ohne Details nur „Neue Meldung in ApiCore.“ und Link, Standardlink `/dashboard`, ein Schrägstrich am Ende von `FRONTEND_ORIGIN` wird toleriert. Höchstens 600 Zeichen nach Codepunkten; die Meldung wird mit Auslassungspunkten gekürzt, der Link bleibt ganz, Steuer- und unsichtbare Zeichen entfallen. Ohne `FRONTEND_ORIGIN` entfällt der Link (Annahme 46).
**Senden** (`TelegramSender`, Schleife `telegram-send`, Abschnitt 10). Ein Durchlauf übernimmt höchstens 25 Zustellungen (`claimBatch`, älteste zuerst). Je Batch geht höchstens eine Zustellung je Nutzer bzw. Chat hinaus, die übrigen kommen 1,1 s später (`deferClaim`); ein Speicher je Chat (10 s) hält auch aufeinanderfolgende Durchläufe und Bot-Antworten auseinander, und zwischen zwei Sendungen liegen mindestens 40 ms (25 pro Sekunde). `drain` wartet nach einer Runde mit zurückgehaltenen Nachrichten 1,1 s, damit ein Schwall für eine Person nicht auf den nächsten 5-s-Takt wartet; ein Batch ist auf 120 s begrenzt (`TELEGRAM_BATCH_BUDGET_MS`), danach gehen die übrigen ohne Fehlversuch zurück, ebenso beim Herunterfahren. Vor jeder Sendung prüft eine SQL-Abfrage mit frisch gelesenen Rechten (`PermissionResolverService.getFreshPermissions`) die Gründe aus Annahme 49 in dieser Reihenfolge: `user inactive`, `telegram disabled`, `not linked`, `category off`, `permission revoked`, `stale`, `task already done`, dazu `notification gone` und `unknown template`; ein Treffer ergibt `SKIPPED` mit Grund. Antworten des Bots (`templateCode`) überspringen alle Prüfungen. Die Frische rechnet in SQL (Berliner Tag bzw. 24 Stunden gegen `now()`).
**Antworten von Telegram.**
- 200: `DELIVERED`.
- 429: `deferClaim` um `retry_after` (ohne Angabe 5 s), kein Fehlversuch; der Sender pausiert für den ganzen Pod bis dahin (`pausedUntil`, der Rest des Batches geht zurück).
- 403 und 400 „chat not found“ (Bot blockiert, Chat gelöscht): Bei einer Nutzer-Zustellung sperrt eine Transaktion `NotificationPreference` (`lockRowForUpdate`), löscht die `TelegramLink` mit `userId` und `chatId` und schaltet nur dann `telegramEnabled` aus und schreibt die Meldung „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ (`SYSTEM`, HINWEIS), wenn die Verbindung noch auf diesen Chat zeigt; die Zustellung wird `SKIPPED` („chat unavailable“). Antworten des Bots werden nur `SKIPPED`.
- 401 (Token ungültig): `deferClaim` um 15 min, der Sender pausiert 15 min (monotone Uhr, kein Claim), und `system:manage` bekommt einmal je Berliner Tag „Telegram: Bot-Token ungültig.“ (Schlüsselpräfix `telegram-token-invalid:<Tag>`). Scheitert die Meldung, wird das nur geloggt.
- Alles andere (5xx, Zeitüberschreitung, Netzfehler, sonstige 4xx, unerwartete Ausnahmen): `settleFailure` mit `loggableError(...).message`, Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`).
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
+25
View File
@@ -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,105 @@
-- T4 migration 4/n: plant development (phenology) and the season segment.
-- PhenologyReport holds the beekeeper's own reports; ApiaryPhenology and
-- ApiaryWeatherDay are derived/cached state written only by the
-- daily-evaluation step and the weather loop.
-- CreateEnum
CREATE TYPE "PhenologyPhase" AS ENUM ('SALWEIDE', 'KIRSCHE', 'APFEL', 'HOLUNDER', 'LINDE', 'TRACHTENDE', 'HERBST', 'WINTER');
-- CreateEnum
CREATE TYPE "SeasonSegment" AS ENUM ('WINTERRUHE', 'FRUEHJAHR', 'SCHWARMZEIT', 'SOMMER', 'SPAETSOMMER', 'HERBST');
-- CreateEnum
CREATE TYPE "PhaseSource" AS ENUM ('REPORT', 'GTS', 'CALENDAR');
-- CreateTable
CREATE TABLE "PhenologyReport" (
"id" TEXT NOT NULL,
"apiaryId" TEXT,
"phase" "PhenologyPhase" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"userId" TEXT NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PhenologyReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiaryPhenology" (
"apiaryId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"gts" DOUBLE PRECISION,
"gtsThroughDay" TIMESTAMP(3),
"missingDays" INTEGER NOT NULL DEFAULT 0,
"estimatedPhase" "PhenologyPhase",
"reportedPhase" "PhenologyPhase",
"effectivePhase" "PhenologyPhase",
"phaseSource" "PhaseSource",
"segment" "SeasonSegment" NOT NULL,
"segmentSince" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiaryPhenology_pkey" PRIMARY KEY ("apiaryId","year")
);
-- CreateTable
CREATE TABLE "ApiaryWeatherDay" (
"apiaryId" TEXT NOT NULL,
"day" TIMESTAMP(3) NOT NULL,
"meanTempC" DOUBLE PRECISION,
"source" TEXT NOT NULL,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiaryWeatherDay_pkey" PRIMARY KEY ("apiaryId","day")
);
-- CreateIndex
CREATE INDEX "PhenologyReport_date_idx" ON "PhenologyReport"("date");
-- CreateIndex
CREATE INDEX "PhenologyReport_apiaryId_phase_date_idx" ON "PhenologyReport"("apiaryId", "phase", "date");
-- AddForeignKey
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryPhenology" ADD CONSTRAINT "ApiaryPhenology_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryWeatherDay" ADD CONSTRAINT "ApiaryWeatherDay_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Permissions: the boot-time catalog sync grants new permissions to system
-- roles only for rows it creates itself, and these rows exist before the
-- first boot. So system roles (ADMIN: full access) get all three here; this
-- mirrors what PermissionCatalogSyncService does for permissions it creates.
-- Phenology reports are everyday beekeeping records, so non-system roles get
-- them like winter-checks (assumption 16): read with colonies:read,
-- create/update with inspections:create.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'phenology:read', 'phenology', 'read', 'Ansehen', 'Pflanzenentwicklung', 860),
(gen_random_uuid()::text, 'phenology:create', 'phenology', 'create', 'Melden', 'Pflanzenentwicklung', 861),
(gen_random_uuid()::text, 'phenology:update', 'phenology', 'update', 'Bearbeiten', 'Pflanzenentwicklung', 862)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r."isSystem" = true AND p.resource = 'phenology'
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Role" r ON r.id = rp."roleId" AND r."isSystem" = false
JOIN "Permission" have ON have.id = rp."permissionId"
JOIN "Permission" p ON p.resource = 'phenology'
WHERE (have.key = 'colonies:read' AND p.action = 'read')
OR (have.key = 'inspections:create' AND p.action IN ('create', 'update'))
ON CONFLICT DO NOTHING;
@@ -0,0 +1,84 @@
-- T4 migration 6/n: ledger of the honey batch. HoneyBatch.amount (kg) stays
-- the running rest; initialAmount (kg) is the starting quantity, and
-- amount = initialAmount + sum(BatchMovement.quantity) / 1000
-- (quantity in grams, signed). Every bottling is booked as a BOTTLING
-- movement. Earlier hand corrections of amount are folded into initialAmount.
--
-- Invariant check afterwards (must return 0 rows):
-- SELECT hb."id" FROM "HoneyBatch" hb
-- LEFT JOIN (SELECT "honeyBatchId", sum("quantity") g FROM "BatchMovement"
-- WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
-- WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
-- AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
-- AlterTable
ALTER TABLE "BatchMovement" ADD COLUMN "honeyBatchId" TEXT,
ADD COLUMN "sourceBottlingBatchId" TEXT;
-- AlterTable
ALTER TABLE "HoneyBatch" ADD COLUMN "initialAmount" DOUBLE PRECISION;
-- CreateIndex
CREATE INDEX "BatchMovement_honeyBatchId_idx" ON "BatchMovement"("honeyBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_sourceBottlingBatchId_idx" ON "BatchMovement"("sourceBottlingBatchId");
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_sourceBottlingBatchId_fkey" FOREIGN KEY ("sourceBottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- A movement now belongs to exactly one of four batch kinds (name kept from
-- 20261005100000_raw_materials_production).
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId", "honeyBatchId") = 1);
-- Recalculation, only for batches with a recorded amount (a batch without
-- one is not booked, as before).
-- 1. initialAmount = amount + the bottlings rounded to the gram exactly like
-- the bookings below (kg), so the invariant holds without a rounding gap.
UPDATE "HoneyBatch" hb
SET "initialAmount" = hb."amount" + coalesce((
SELECT sum(floor(b."totalAmount" * 1000 + 0.5)) FROM "BottlingBatch" b WHERE b."honeyBatchId" = hb."id"
), 0) / 1000.0
WHERE hb."amount" IS NOT NULL;
-- 2. One BOTTLING movement per bottling, in grams, dated like the bottling.
-- floor(x + 0.5) rounds half up like the application (Math.round), whereas
-- round(double precision) may round half to even.
INSERT INTO "BatchMovement" ("id", "honeyBatchId", "sourceBottlingBatchId", "type", "quantity", "notes", "userId", "createdAt")
SELECT gen_random_uuid()::text, b."honeyBatchId", b."id", 'BOTTLING',
-(floor(b."totalAmount" * 1000 + 0.5))::integer,
'Übernahme aus Abfüllung (T4)', b."userId", b."createdAt"
FROM "BottlingBatch" b
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId"
WHERE hb."amount" IS NOT NULL;
-- Report and self-check: the counts go to the migration log; a broken
-- invariant aborts the whole migration (the file runs in one transaction).
DO $$
DECLARE
batches integer;
bookings integer;
skipped integer;
broken integer;
BEGIN
SELECT count(*) INTO batches FROM "HoneyBatch" WHERE "initialAmount" IS NOT NULL;
SELECT count(*) INTO bookings FROM "BatchMovement" WHERE "honeyBatchId" IS NOT NULL AND "type" = 'BOTTLING';
SELECT count(*) INTO skipped FROM "BottlingBatch" b
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId" WHERE hb."amount" IS NULL;
RAISE NOTICE 'honey ledger: % batches with initialAmount, % bookings created, % bottlings skipped (batch amount is NULL)',
batches, bookings, skipped;
SELECT count(*) INTO broken FROM "HoneyBatch" hb
LEFT JOIN (SELECT "honeyBatchId", sum("quantity") AS g FROM "BatchMovement"
WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
IF broken > 0 THEN
RAISE EXCEPTION 'honey ledger invariant broken for % batches', broken;
END IF;
END $$;
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part shelf life): Product.shelfLifeMonths, the shelf life
-- in months, used to pre-fill the best-before date. No default value.
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "shelfLifeMonths" INTEGER;
@@ -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');
+177
View File
@@ -111,6 +111,7 @@ model User {
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
phenologyReports PhenologyReport[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
@@ -126,6 +127,9 @@ model User {
rawMaterialLots RawMaterialLot[]
notifications Notification[]
notificationPreference NotificationPreference?
telegramLink TelegramLink?
telegramLinkTokens TelegramLinkToken[]
notificationDeliveries NotificationDelivery[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
@@ -245,6 +249,9 @@ model Apiary {
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
phenologyReports PhenologyReport[]
phenologyStates ApiaryPhenology[]
weatherDays ApiaryWeatherDay[]
@@index([status])
}
@@ -928,6 +935,9 @@ model HoneyBatch {
extraction Extraction @relation(fields: [extractionId], references: [id])
honeyType String?
amount Float?
// Ausgangsmenge in kg; amount = initialAmount + Summe der Buchungen / 1000.
// null = Menge nicht erfasst, dann wird nicht gebucht.
initialAmount Float?
waterContent Float?
storageContainerId String?
storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id])
@@ -939,6 +949,7 @@ model HoneyBatch {
bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[]
movements BatchMovement[]
@@index([status])
@@index([extractionId])
@@ -1112,6 +1123,8 @@ model Product {
packagingSize Float?
weight Float?
salesUnit String?
// Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung).
shelfLifeMonths Int?
status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
@@ -1175,6 +1188,7 @@ model BottlingBatch {
createdAt DateTime @default(now())
movements BatchMovement[]
causedMovements BatchMovement[] @relation("BatchMovementSourceBottling")
@@index([honeyBatchId])
@@index([productId])
@@ -1295,6 +1309,12 @@ model BatchMovement {
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
// Honig: Bewegungsbuch der Honigcharge, quantity dann in Gramm.
honeyBatchId String?
honeyBatch HoneyBatch? @relation(fields: [honeyBatchId], references: [id], onDelete: Restrict)
// Rueckverweis auf die verursachende Abfuellung (BOTTLING).
sourceBottlingBatchId String?
sourceBottlingBatch BottlingBatch? @relation("BatchMovementSourceBottling", fields: [sourceBottlingBatchId], references: [id], onDelete: SetNull)
type BatchMovementType
// Mit Vorzeichen: minus = raus, plus = rein.
quantity Int
@@ -1310,6 +1330,8 @@ model BatchMovement {
@@index([bottlingBatchId])
@@index([productionBatchId])
@@index([rawMaterialLotId])
@@index([honeyBatchId])
@@index([sourceBottlingBatchId])
@@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id])
@@ -1402,6 +1424,7 @@ model Notification {
dedupeKey String? @unique
read Boolean @default(false)
createdAt DateTime @default(now())
deliveries NotificationDelivery[]
@@index([userId, read])
@@index([priority])
@@ -1422,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
// ---------------------------------------------------------------------------
@@ -1789,3 +1884,85 @@ model ThrottlerHit {
@@id([key, throttlerName])
@@index([expiresAt])
}
// ---------------------------------------------------------------------------
// Pflanzenentwicklung und Saisonabschnitt (T4b, Spec 4.2)
// ---------------------------------------------------------------------------
enum PhenologyPhase {
SALWEIDE
KIRSCHE
APFEL
HOLUNDER
LINDE
TRACHTENDE
HERBST
WINTER
}
enum SeasonSegment {
WINTERRUHE
FRUEHJAHR
SCHWARMZEIT
SOMMER
SPAETSOMMER
HERBST
}
enum PhaseSource {
REPORT
GTS
CALENDAR
}
// Eigene Meldung per Antippen; zurueckgezogen statt geloescht.
model PhenologyReport {
id String @id @default(cuid())
// null = alle Staende
apiaryId String?
apiary Apiary? @relation(fields: [apiaryId], references: [id], onDelete: Restrict)
phase PhenologyPhase
date DateTime
notes String?
userId String
user User @relation(fields: [userId], references: [id])
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([date])
@@index([apiaryId, phase, date])
}
// Abgeleiteter Zustand, schreibt nur der Job/Handler (nicht im Backup).
model ApiaryPhenology {
apiaryId String
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade)
year Int
gts Float?
gtsThroughDay DateTime?
missingDays Int @default(0)
estimatedPhase PhenologyPhase?
reportedPhase PhenologyPhase?
effectivePhase PhenologyPhase?
phaseSource PhaseSource?
segment SeasonSegment
segmentSince DateTime
updatedAt DateTime @updatedAt
@@id([apiaryId, year])
}
// Tagesmittel je Stand, schreibt nur die Worker-Schleife (nicht im Backup).
model ApiaryWeatherDay {
apiaryId String
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade)
// Kalendertag Europe/Berlin
day DateTime
meanTempC Float?
// 'forecast' | 'archive'
source String
fetchedAt DateTime @default(now())
@@id([apiaryId, day])
}
+2
View File
@@ -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,
@@ -123,6 +123,18 @@ export const PERMISSION_CATALOG = [
],
850,
),
// Seeded with role grants by the t4_phenology migration (read with
// colonies:read, create/update with inspections:create).
...group(
'Pflanzenentwicklung',
'phenology',
[
['read', 'Ansehen'],
['create', 'Melden'],
['update', 'Bearbeiten'],
],
860,
),
...group(
'Behandlungen',
'treatments',
@@ -94,6 +94,59 @@ describe('PermissionResolverService', () => {
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('getFreshPermissions', () => {
it('the permission check reads fresh, not the 30 s cache', async () => {
prisma.user.findUnique.mockResolvedValueOnce(
activeUserWithPermissions('tasks:read'),
);
await resolver.getPermissions('user-1');
// The right is revoked on another replica: this process has no
// invalidateUser call, the cache still holds the old set.
prisma.user.findUnique.mockResolvedValueOnce(
activeUserWithPermissions('colonies:read'),
);
const fresh = await resolver.getFreshPermissions('user-1');
expect([...fresh]).toEqual(['colonies:read']);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
it('fills the cache for getPermissions', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('tasks:read'),
);
await resolver.getFreshPermissions('user-1');
const cached = await resolver.getPermissions('user-1');
expect([...cached]).toEqual(['tasks:read']);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('is empty for an inactive user, an inactive role and a missing user', async () => {
prisma.user.findUnique.mockResolvedValueOnce({
isActive: false,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'tasks:read' } }],
},
});
prisma.user.findUnique.mockResolvedValueOnce({
isActive: true,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'tasks:read' } }],
},
});
prisma.user.findUnique.mockResolvedValueOnce(null);
expect((await resolver.getFreshPermissions('u1')).size).toBe(0);
expect((await resolver.getFreshPermissions('u2')).size).toBe(0);
expect((await resolver.getFreshPermissions('u3')).size).toBe(0);
});
});
describe('isTokenValid', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
@@ -37,6 +37,19 @@ export class PermissionResolverService {
return entry.permissions;
}
/**
* The permissions read fresh (one primary-key lookup), never from the
* cache, and stored as the cache entry. For a process that does not hear
* invalidateUser of the API pods (the worker): a right revoked there must
* stop mattering at once, not after the 30 s cache window. Empty for an
* inactive user or role.
*/
async getFreshPermissions(userId: string): Promise<ReadonlySet<string>> {
const entry = await this.loadEntry(userId);
this.cache.set(userId, entry);
return entry.permissions;
}
/**
* Always read fresh (one primary-key lookup) and stored as the cache
* entry the PermissionsGuard then uses: a deactivation, role change or
@@ -1,6 +1,6 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { loadAutopilotInputIn } from './autopilot-input';
import { lateSummerStart, loadAutopilotInputIn } from './autopilot-input';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const season = calendarSeasonState(
@@ -55,7 +55,13 @@ describe('loadAutopilotInputIn', () => {
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
]);
const input = await loadAutopilotInputIn(db as never, 'c1', season);
const input = await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(input?.lastTreatment).toEqual({
id: 'old',
@@ -74,7 +80,13 @@ describe('loadAutopilotInputIn', () => {
},
]);
await loadAutopilotInputIn(db as never, 'c1', season);
await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
@@ -82,9 +94,119 @@ describe('loadAutopilotInputIn', () => {
});
it('has no last treatment without treatments', async () => {
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
const input = await loadAutopilotInputIn(
tx([]) as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(input?.lastTreatment).toBeNull();
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
});
it('counts feedings and treatments from the real start of SPAETSOMMER', async () => {
const lateSummer = {
...season,
segment: 'SPAETSOMMER' as const,
segmentSince: '2026-07-25',
};
const db = tx([]);
await loadAutopilotInputIn(
db as never,
'c1',
lateSummer,
'08-01',
'2026-08-20',
);
expect(db.feeding.count).toHaveBeenLastCalledWith({
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
});
expect(db.treatment.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
});
});
it('counts nothing for late summer outside SPAETSOMMER and HERBST', async () => {
const db = tx([]);
const input = await loadAutopilotInputIn(
db as never,
'c1',
{ ...season, segment: 'SOMMER' },
'08-01',
'2026-08-20',
);
expect(input?.feedingsSinceLateSummer).toBe(0);
expect(input?.treatmentsSinceLateSummer).toBe(0);
expect(db.treatment.count).not.toHaveBeenCalled();
});
it('without an inspection counts feedings from the later of creation and the last 60 days', async () => {
const db = tx([]);
await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
// created 2026-04-01, today 2026-08-20: 60 days back wins
expect(db.feeding.count).toHaveBeenNthCalledWith(1, {
where: { colonyId: 'c1', date: { gte: d('2026-06-21') } },
});
const young = tx([]);
young.colony.findUnique.mockResolvedValue({
id: 'c1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdAt: d('2026-08-10'),
feedStatus: null,
currentQueen: null,
});
await loadAutopilotInputIn(
young as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(young.feeding.count).toHaveBeenNthCalledWith(1, {
where: { colonyId: 'c1', date: { gte: d('2026-08-10') } },
});
});
});
describe('lateSummerStart', () => {
const state = (
segment: 'SPAETSOMMER' | 'HERBST' | 'SOMMER',
segmentSince: string,
) => ({ ...season, year: 2026, segment, segmentSince });
it('is the start of the segment in SPAETSOMMER', () => {
expect(lateSummerStart(state('SPAETSOMMER', '2026-07-25'), '08-01')).toBe(
'2026-07-25',
);
});
it('is the calendar day of TRACHTENDE in HERBST, at the latest the start of HERBST', () => {
expect(lateSummerStart(state('HERBST', '2026-09-15'), '08-01')).toBe(
'2026-08-01',
);
expect(lateSummerStart(state('HERBST', '2026-07-20'), '08-01')).toBe(
'2026-07-20',
);
});
it('is null in every other segment', () => {
expect(lateSummerStart(state('SOMMER', '2026-06-20'), '08-01')).toBeNull();
});
});
@@ -1,21 +1,35 @@
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates';
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types';
import type { AutopilotInput } from './autopilot.types';
/**
* Until Task 27 passes the real start of the late summer, the feedings and
* treatments of A4-A6 are counted from this day of the year (the built-in
* calendar day of TRACHTENDE).
*/
const LATE_SUMMER_FALLBACK_START = '08-01';
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
function lateSummerStart(season: SeasonState): string | null {
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST'
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
: null;
/** Without an inspection a feeding counts for A6 if it is at most this old. */
const FEEDING_LOOKBACK_DAYS = 60;
/**
* The day SPAETSOMMER began, for the feedings and treatments of A4/A5. In
* SPAETSOMMER that is the real start of the segment. The stored state keeps
* only the start of the current segment, so in HERBST it is the calendar day
* of TRACHTENDE (`calendarTrachtende`, MM-DD), at the latest the start of
* HERBST.
*
* TODO: store the real start as `lateSummerSince` on ApiaryPhenology (needs
* a migration) so HERBST no longer depends on the calendar day.
*/
export function lateSummerStart(
season: SeasonState,
calendarTrachtende: string,
): string | null {
if (season.segment === 'SPAETSOMMER') {
return season.segmentSince;
}
if (season.segment !== 'HERBST') {
return null;
}
const calendarDay = `${season.year}-${calendarTrachtende}`;
return calendarDay < season.segmentSince ? calendarDay : season.segmentSince;
}
/**
@@ -28,6 +42,8 @@ export async function loadAutopilotInputIn(
tx: Prisma.TransactionClient,
colonyId: string,
season: SeasonState,
calendarTrachtende: string,
today: string,
): Promise<AutopilotInput | null> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
@@ -86,13 +102,18 @@ export async function loadAutopilotInputIn(
(await tx.varroaMeasurement.count({
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
})) > 0;
// Feedings that answer a low feed status: since the inspection; without
// one only recent ones, from the colony's creation at the earliest.
const createdOn = berlinDayOf(colony.createdAt);
const lookbackFrom = addDays(today, -FEEDING_LOOKBACK_DAYS);
const feedingsSince =
inspection === null
? dayToDate(createdOn > lookbackFrom ? createdOn : lookbackFrom)
: inspection.date;
const feedingsSinceInspection = await tx.feeding.count({
where: {
colonyId,
...(inspection === null ? {} : { date: { gte: inspection.date } }),
},
where: { colonyId, date: { gte: feedingsSince } },
});
const lateSummer = lateSummerStart(season);
const lateSummer = lateSummerStart(season, calendarTrachtende);
const sinceLateSummer =
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
const feedingsSinceLateSummer =
@@ -1,5 +1,11 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import type {
PhaseSourceKey,
PhenologyPhaseKey,
SeasonSegmentKey,
SeasonState,
} from '../season/season-types';
import { evaluateAutopilot } from './autopilot-rules';
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
@@ -62,6 +68,12 @@ const inspectionOn = (date: string) => ({
queenCellsPresent: null,
});
/** Treated and fed in late summer: keeps A4 and A5 out of the A3 tests. */
const LATE_SUMMER_DONE = {
treatmentsSinceLateSummer: 1,
feedingsSinceLateSummer: 1,
};
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
describe('autopilot rules', () => {
@@ -227,6 +239,7 @@ describe('autopilot rules', () => {
lastInspection: inspectionOn('2026-08-10'),
lastVarroa: { id: 'var_3', date: '2026-08-01' },
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}),
configs(),
today,
@@ -256,6 +269,8 @@ describe('autopilot rules', () => {
input(early, {
lastInspection: inspectionOn('2026-08-10'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
}),
configs(),
early,
@@ -268,6 +283,8 @@ describe('autopilot rules', () => {
input(late, {
lastInspection: inspectionOn('2026-08-15'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
varroaSinceLastTreatmentEnd: true,
}),
configs(),
@@ -286,6 +303,7 @@ describe('autopilot rules', () => {
input(today, {
lastInspection: inspectionOn('2026-08-05'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}),
cfg,
today,
@@ -370,3 +388,437 @@ describe('autopilot rules', () => {
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
});
});
describe('autopilot rules A4-A10', () => {
/** A stored season state; the helper keeps the tests free of calendar maths. */
function inSeason(
segment: SeasonSegmentKey,
phase: PhenologyPhaseKey | null,
segmentSince: string,
phaseSource: PhaseSourceKey = 'REPORT',
): SeasonState {
return {
apiaryId: 'api_1',
year: Number(segmentSince.slice(0, 4)),
phase,
phaseSource,
reportedOn: null,
segment,
segmentSince,
springSince: `${segmentSince.slice(0, 4)}-03-20`,
gts: null,
gtsThroughDay: null,
note: null,
};
}
/** Keeps A1 and A2 quiet: a fresh inspection and measurement. */
function quiet(today: string, patch: Partial<AutopilotInput>) {
return input(today, {
lastInspection: inspectionOn(today),
lastVarroa: { id: 'var_1', date: today },
...patch,
});
}
const only = (rule: string, candidates: { rule: string }[]) =>
candidates.filter((candidate) => candidate.rule === rule);
it('A4: SPAETSOMMER without treatment since segment start -> SUMMER_TREATMENT, PLAN, HIGH, anchor year', () => {
const today = '2026-08-05';
const result = evaluateAutopilot(
quiet(today, {
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
}),
configs(),
today,
);
expect(only('A4', result)).toEqual([
{
rule: 'A4',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'SUMMER_TREATMENT',
mode: 'PLAN',
reasonCode: 'SUMMER_TREATMENT_DUE',
evidence: {
colonyId: 'col_1',
phase: 'TRACHTENDE',
phaseSource: 'REPORT',
since: '2026-07-25',
reportedOn: null,
},
dueDate: today,
priority: 'HIGH',
anchor: '2026',
requiredPermission: 'treatments:read',
},
]);
});
it('A4: a treatment since the segment start, another segment or the rule off -> nothing', () => {
const today = '2026-08-05';
const season = inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25');
const a4 = (candidates: { rule: string }[]) => only('A4', candidates);
expect(
a4(
evaluateAutopilot(
quiet(today, { season, treatmentsSinceLateSummer: 1 }),
configs(),
today,
),
),
).toEqual([]);
expect(
a4(
evaluateAutopilot(
quiet(today, { season: inSeason('SOMMER', 'LINDE', '2026-06-20') }),
configs(),
today,
),
),
).toEqual([]);
expect(
a4(
evaluateAutopilot(
quiet(today, { season }),
configs({
rules: { ...configs().autopilot.config.rules, A4: false },
}),
today,
),
),
).toEqual([]);
});
it('A5: SPAETSOMMER or HERBST without feeding since late summer -> FEED, DO, NORMAL, anchor year', () => {
for (const [segment, since, today] of [
['SPAETSOMMER', '2026-07-25', '2026-08-05'],
['HERBST', '2026-09-15', '2026-09-20'],
] as const) {
const result = evaluateAutopilot(
quiet(today, { season: inSeason(segment, 'TRACHTENDE', since) }),
configs(),
today,
);
expect(only('A5', result)).toMatchObject([
{
rule: 'A5',
intent: 'FEED',
mode: 'DO',
reasonCode: 'AUTUMN_FEED_MISSING',
priority: 'NORMAL',
anchor: '2026',
dueDate: today,
requiredPermission: 'feedings:read',
},
]);
}
});
it('A5 and A6 never fire in WINTERRUHE; A5 is quiet after a feeding', () => {
const today = '2026-12-05';
const winter = quiet(today, {
season: inSeason('WINTERRUHE', 'WINTER', '2026-11-15'),
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
lastInspection: { ...inspectionOn('2026-11-01'), feedStatus: 'CRITICAL' },
});
expect(
evaluateAutopilot(winter, configs(), today).filter((c) =>
['A5', 'A6'].includes(c.rule),
),
).toEqual([]);
const augustToday = '2026-08-05';
const fed = quiet(augustToday, {
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
feedingsSinceLateSummer: 1,
});
expect(only('A5', evaluateAutopilot(fed, configs(), augustToday))).toEqual(
[],
);
});
it('A6 anchors on the inspection: WEAK or CRITICAL feed of the last inspection, no feeding since', () => {
const today = '2026-04-10';
const base: Partial<AutopilotInput> = {
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'WEAK' },
lastVarroa: { id: 'var_1', date: today },
};
const result = only(
'A6',
evaluateAutopilot(input(today, base), configs(), today),
);
expect(result).toMatchObject([
{
intent: 'FEED',
mode: 'DO',
reasonCode: 'FEED_LOW',
anchor: 'insp_9',
priority: 'NORMAL',
dueDate: today,
requiredPermission: 'inspections:read',
evidence: {
colonyId: 'col_1',
inspectionId: 'insp_9',
inspectedOn: '2026-04-03',
condition: 'WEAK',
source: 'INSPECTION',
},
},
]);
const fedSince = input(today, { ...base, feedingsSinceInspection: 1 });
expect(only('A6', evaluateAutopilot(fedSince, configs(), today))).toEqual(
[],
);
const good = input(today, {
...base,
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'GOOD' },
});
expect(only('A6', evaluateAutopilot(good, configs(), today))).toEqual([]);
});
it('A6: a CRITICAL colony card counts too, anchored on the inspection or none', () => {
const today = '2026-04-10';
const critical = input(today, {
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
lastInspection: inspectionOn('2026-04-03'),
});
expect(
only('A6', evaluateAutopilot(critical, configs(), today)),
).toMatchObject([
{
anchor: 'insp_9',
evidence: { condition: 'CRITICAL', source: 'COLONY_CARD' },
},
]);
const noInspection = { ...critical, lastInspection: null };
expect(
only('A6', evaluateAutopilot(noInspection, configs(), today)),
).toMatchObject([{ anchor: 'none' }]);
});
it('A7: KIRSCHE and 9 frames -> HONEY_SUPER; 7 frames -> nothing; LINDE -> nothing', () => {
const today = '2026-04-28';
const withFrames = (frames: number | null, phase: PhenologyPhaseKey) =>
only(
'A7',
evaluateAutopilot(
quiet(today, {
season: inSeason('SCHWARMZEIT', phase, '2026-04-15', 'GTS'),
lastInspection: { ...inspectionOn(today), colonyStrength: frames },
}),
configs(),
today,
),
);
expect(withFrames(9, 'KIRSCHE')).toMatchObject([
{
rule: 'A7',
intent: 'HONEY_SUPER',
mode: 'DO',
reasonCode: 'HONEY_SUPER_CHECK',
anchor: '2026',
priority: 'NORMAL',
dueDate: today,
requiredPermission: 'inspections:read',
evidence: {
colonyId: 'col_1',
inspectionId: 'insp_9',
frames: 9,
phase: 'KIRSCHE',
phaseSource: 'GTS',
},
},
]);
expect(withFrames(8, 'APFEL')).toHaveLength(1);
expect(withFrames(7, 'KIRSCHE')).toEqual([]);
expect(withFrames(null, 'KIRSCHE')).toEqual([]);
expect(withFrames(9, 'SALWEIDE')).toEqual([]);
expect(withFrames(9, 'LINDE')).toEqual([]);
});
it('A7 ignores an inspection from before the spring of this year', () => {
const today = '2026-04-28';
// inSeason puts springSince on 2026-03-20
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
const withInspection = (date: string) =>
only(
'A7',
evaluateAutopilot(
quiet(today, {
season,
lastInspection: { ...inspectionOn(date), colonyStrength: 12 },
}),
configs(),
today,
),
);
expect(withInspection('2025-09-10')).toEqual([]);
expect(withInspection('2026-03-19')).toEqual([]);
expect(withInspection('2026-03-20')).toHaveLength(1);
});
it('A7 honours honeySuperMinFrames and a missing inspection', () => {
const today = '2026-04-28';
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
const nine = quiet(today, {
season,
lastInspection: { ...inspectionOn(today), colonyStrength: 9 },
});
expect(
only(
'A7',
evaluateAutopilot(nine, configs({ honeySuperMinFrames: 10 }), today),
),
).toEqual([]);
const none = input(today, { season, lastInspection: null });
expect(only('A7', evaluateAutopilot(none, configs(), today))).toEqual([]);
});
it('A8: hatchYear 2024 in 2026 SCHWARMZEIT -> QUEEN_REPLACE, PLAN, anchor queen-year', () => {
const today = '2026-05-12';
const aged = quiet(today, {
season: inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15'),
queen: { id: 'q_12', hatchYear: 2024 },
});
expect(only('A8', evaluateAutopilot(aged, configs(), today))).toMatchObject(
[
{
rule: 'A8',
intent: 'QUEEN_REPLACE',
mode: 'PLAN',
reasonCode: 'QUEEN_OLD',
anchor: 'q_12-2026',
priority: 'NORMAL',
requiredPermission: 'queens:read',
evidence: { colonyId: 'col_1', queenId: 'q_12', hatchYear: 2024 },
},
],
);
const sommerDay = '2026-07-01';
const sommer = {
...aged,
season: inSeason('SOMMER', 'LINDE', '2026-06-20'),
};
expect(
only('A8', evaluateAutopilot(sommer, configs(), sommerDay)),
).toHaveLength(1);
});
it('A8: young queen, unknown hatch year, other segment or queenMaxAgeYears 3 -> nothing', () => {
const today = '2026-05-12';
const swarm = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
const a8 = (
queen: AutopilotInput['queen'],
over?: object,
season = swarm,
) =>
only(
'A8',
evaluateAutopilot(
quiet(today, { season, queen }),
configs(over),
today,
),
);
expect(a8({ id: 'q', hatchYear: 2025 })).toEqual([]);
expect(a8({ id: 'q', hatchYear: null })).toEqual([]);
expect(a8(null)).toEqual([]);
expect(a8({ id: 'q', hatchYear: 2024 }, { queenMaxAgeYears: 3 })).toEqual(
[],
);
expect(
a8(
{ id: 'q', hatchYear: 2024 },
undefined,
inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
),
).toEqual([]);
});
it('A9 and A10 follow the WinterCheck rows', () => {
const autumnDay = '2026-09-20';
const autumn = (winterChecks: AutopilotInput['winterChecks']) =>
only(
'A9',
evaluateAutopilot(
quiet(autumnDay, {
season: inSeason('HERBST', 'HERBST', '2026-09-15'),
winterChecks,
}),
configs(),
autumnDay,
),
);
expect(autumn([])).toMatchObject([
{
rule: 'A9',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
anchor: '2026-AUTUMN',
priority: 'NORMAL',
dueDate: autumnDay,
requiredPermission: 'winter-checks:read',
evidence: { colonyId: 'col_1', winterYear: 2026 },
},
]);
expect(autumn([{ winterYear: 2026, season: 'AUTUMN' }])).toEqual([]);
// The previous winter does not count for this one.
expect(autumn([{ winterYear: 2025, season: 'AUTUMN' }])).toHaveLength(1);
const springDay = '2026-03-25';
const spring = (winterChecks: AutopilotInput['winterChecks']) =>
only(
'A10',
evaluateAutopilot(
quiet(springDay, {
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
winterChecks,
}),
configs(),
springDay,
),
);
expect(spring([{ winterYear: 2025, season: 'AUTUMN' }])).toMatchObject([
{
rule: 'A10',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
anchor: '2025-SPRING',
dueDate: springDay,
requiredPermission: 'winter-checks:read',
evidence: { colonyId: 'col_1', winterYear: 2025, phase: 'SALWEIDE' },
},
]);
// No wintering-in last autumn -> nothing to wind out; done -> nothing.
expect(spring([])).toEqual([]);
expect(
spring([
{ winterYear: 2025, season: 'AUTUMN' },
{ winterYear: 2025, season: 'SPRING' },
]),
).toEqual([]);
});
it('keeps the order A1...A10', () => {
const today = '2026-08-05';
const result = evaluateAutopilot(
input(today, {
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
lastInspection: inspectionOn('2026-07-01'),
lastVarroa: { id: 'var_0', date: '2026-06-01' },
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2', 'A4', 'A5']);
});
});
@@ -1,6 +1,8 @@
import type { AutopilotRuleId } from '../automation/rule-config';
import { addDays, daysBetween } from '../common/dates';
import { inspectionDueOn } from '../season/inspection-due';
import { isQueenAged } from '../season/queen-age';
import { PHENOLOGY_PHASES } from '../season/season-types';
import { varroaMeasurementDue } from '../season/varroa-due';
import type {
AutopilotCandidate,
@@ -127,13 +129,245 @@ const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
};
};
/** A4-A10 follow with Task 27. */
export const AUTOPILOT_RULES: Readonly<
Partial<Record<AutopilotRuleId, AutopilotRule>>
> = {
/** The phase window of A7: from KIRSCHE up to, not including, LINDE. */
const HONEY_SUPER_FROM_PHASE = PHENOLOGY_PHASES.indexOf('KIRSCHE');
const HONEY_SUPER_UNTIL_PHASE = PHENOLOGY_PHASES.indexOf('LINDE');
/** The season facts the texts of A4, A5, A7 and A10 quote. */
function seasonEvidence(input: AutopilotInput): {
phase: string | null;
phaseSource: string | null;
} {
return {
phase: input.season.phase,
phaseSource: input.season.phaseSource,
};
}
/** A4: the honey flow is over and nothing was treated since. */
const a4SummerTreatment: AutopilotRule = (input, _configs, today) => {
const { season } = input;
if (season.segment !== 'SPAETSOMMER' || input.treatmentsSinceLateSummer > 0) {
return null;
}
return {
rule: 'A4',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'SUMMER_TREATMENT',
mode: 'PLAN',
reasonCode: 'SUMMER_TREATMENT_DUE',
evidence: {
colonyId: input.colony.id,
...seasonEvidence(input),
since: season.segmentSince,
reportedOn: season.reportedOn,
},
dueDate: today,
priority: 'HIGH',
anchor: String(season.year),
requiredPermission: 'treatments:read',
};
};
/** A5: late summer or autumn and no feeding since the honey flow ended. */
const a5AutumnFeeding: AutopilotRule = (input, _configs, today) => {
const { season } = input;
if (
(season.segment !== 'SPAETSOMMER' && season.segment !== 'HERBST') ||
input.feedingsSinceLateSummer > 0
) {
return null;
}
return {
rule: 'A5',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'FEED',
mode: 'DO',
reasonCode: 'AUTUMN_FEED_MISSING',
evidence: {
colonyId: input.colony.id,
...seasonEvidence(input),
since: season.segmentSince,
},
dueDate: today,
priority: 'NORMAL',
anchor: String(season.year),
requiredPermission: 'feedings:read',
};
};
/** A6: low feed at the last inspection (or a critical colony card), not fed since. */
const a6LowFeed: AutopilotRule = (input, _configs, today) => {
if (
input.season.segment === 'WINTERRUHE' ||
input.feedingsSinceInspection > 0
) {
return null;
}
const { lastInspection, colony } = input;
const inspectionFeed = lastInspection?.feedStatus ?? null;
const isInspectionLow =
lastInspection !== null &&
(inspectionFeed === 'WEAK' || inspectionFeed === 'CRITICAL');
if (!isInspectionLow && colony.feedStatus !== 'CRITICAL') {
return null;
}
return {
rule: 'A6',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'FEED',
mode: 'DO',
reasonCode: 'FEED_LOW',
evidence: {
colonyId: colony.id,
inspectionId: lastInspection?.id ?? null,
inspectedOn: isInspectionLow ? lastInspection.date : null,
condition: isInspectionLow ? inspectionFeed : 'CRITICAL',
source: isInspectionLow ? 'INSPECTION' : 'COLONY_CARD',
},
dueDate: today,
priority: 'NORMAL',
anchor: lastInspection?.id ?? 'none',
requiredPermission: 'inspections:read',
};
};
/** A7: the cherry blooms and the colony covers enough frames for a honey super. */
const a7HoneySuper: AutopilotRule = (input, configs, today) => {
const phaseIndex =
input.season.phase === null
? -1
: PHENOLOGY_PHASES.indexOf(input.season.phase);
// Only an inspection of this season says how strong the colony is now.
const seasonStart = input.season.springSince ?? `${input.season.year}-01-01`;
const inspection =
input.lastInspection !== null && input.lastInspection.date >= seasonStart
? input.lastInspection
: null;
const frames = inspection?.colonyStrength ?? null;
if (
phaseIndex < HONEY_SUPER_FROM_PHASE ||
phaseIndex >= HONEY_SUPER_UNTIL_PHASE ||
frames === null ||
frames < configs.autopilot.config.honeySuperMinFrames
) {
return null;
}
return {
rule: 'A7',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'HONEY_SUPER',
mode: 'DO',
reasonCode: 'HONEY_SUPER_CHECK',
evidence: {
colonyId: input.colony.id,
inspectionId: inspection?.id ?? null,
frames,
...seasonEvidence(input),
},
dueDate: today,
priority: 'NORMAL',
anchor: String(input.season.year),
requiredPermission: 'inspections:read',
};
};
/** A8: the queen has reached the age the beekeeper plans to replace her at. */
const a8QueenReplace: AutopilotRule = (input, configs, today) => {
const { queen, season } = input;
if (
queen === null ||
(season.segment !== 'SCHWARMZEIT' && season.segment !== 'SOMMER') ||
!isQueenAged(
queen.hatchYear,
today,
configs.autopilot.config.queenMaxAgeYears,
)
) {
return null;
}
return {
rule: 'A8',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'QUEEN_REPLACE',
mode: 'PLAN',
reasonCode: 'QUEEN_OLD',
evidence: {
colonyId: input.colony.id,
queenId: queen.id,
hatchYear: queen.hatchYear,
},
dueDate: today,
priority: 'NORMAL',
anchor: `${queen.id}-${season.year}`,
requiredPermission: 'queens:read',
};
};
/** A9: autumn and no wintering-in entry for the winter that begins now. */
const a9WinteringIn: AutopilotRule = (input, _configs, today) => {
const { season } = input;
const isRecorded = input.winterChecks.some(
(check) => check.winterYear === season.year && check.season === 'AUTUMN',
);
if (season.segment !== 'HERBST' || isRecorded) {
return null;
}
return {
rule: 'A9',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
evidence: { colonyId: input.colony.id, winterYear: season.year },
dueDate: today,
priority: 'NORMAL',
anchor: `${season.year}-AUTUMN`,
requiredPermission: 'winter-checks:read',
};
};
/** A10: spring, wintered in last autumn, not yet wintered out. */
const a10WinteringOut: AutopilotRule = (input, _configs, today) => {
const { season } = input;
const winterYear = season.year - 1;
const has = (kind: 'AUTUMN' | 'SPRING') =>
input.winterChecks.some(
(check) => check.winterYear === winterYear && check.season === kind,
);
if (season.segment !== 'FRUEHJAHR' || !has('AUTUMN') || has('SPRING')) {
return null;
}
return {
rule: 'A10',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
evidence: {
colonyId: input.colony.id,
winterYear,
...seasonEvidence(input),
},
dueDate: today,
priority: 'NORMAL',
anchor: `${winterYear}-SPRING`,
requiredPermission: 'winter-checks:read',
};
};
export const AUTOPILOT_RULES: Readonly<Record<AutopilotRuleId, AutopilotRule>> =
{
A1: a1Inspection,
A2: a2VarroaMeasure,
A3: a3VarroaRecheck,
A4: a4SummerTreatment,
A5: a5AutumnFeeding,
A6: a6LowFeed,
A7: a7HoneySuper,
A8: a8QueenReplace,
A9: a9WinteringIn,
A10: a10WinteringOut,
};
const RULE_ORDER: readonly AutopilotRuleId[] = [
@@ -164,11 +398,10 @@ export function evaluateAutopilot(
}
const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) {
const rule = AUTOPILOT_RULES[id];
if (rule === undefined || !configs.autopilot.config.rules[id]) {
if (!configs.autopilot.config.rules[id]) {
continue;
}
const candidate = rule(input, configs, today);
const candidate = AUTOPILOT_RULES[id](input, configs, today);
if (candidate !== null) {
candidates.push(candidate);
}
@@ -4,9 +4,13 @@ import type {
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { AutopilotEventHandlers } from './autopilot.handlers';
import type { AutopilotService } from './autopilot.service';
jest.mock('../prisma/row-lock');
const lockRows = jest.mocked(lockRowsForUpdate);
const EXPECTED_TYPES: DomainEventType[] = [
'InspectionRecorded',
'InspectionCorrected',
@@ -27,6 +31,7 @@ const EXPECTED_TYPES: DomainEventType[] = [
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
'WinterCheckRecorded',
];
function event(
@@ -48,13 +53,17 @@ function event(
}
describe('AutopilotEventHandlers', () => {
let autopilot: { syncColonyIn: jest.Mock };
let autopilot: { syncColonyIn: jest.Mock; syncApiaryIn: jest.Mock };
let tx: { task: { findUnique: jest.Mock } };
let handlers: AutopilotEventHandlers;
beforeEach(() => {
lockRows.mockReset();
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
autopilot = {
syncColonyIn: jest.fn().mockResolvedValue({}),
syncApiaryIn: jest.fn().mockResolvedValue({}),
};
tx = { task: { findUnique: jest.fn() } };
handlers = new AutopilotEventHandlers(
autopilot as unknown as AutopilotService,
@@ -93,6 +102,7 @@ describe('AutopilotEventHandlers', () => {
['TreatmentCorrected', { colonyId: 'c1' }],
['FeedingRecorded', { colonyId: 'c1' }],
['FeedingCorrected', { colonyId: 'c1' }],
['WinterCheckRecorded', { colonyId: 'c1' }],
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
] as [DomainEventType, object][])(
@@ -130,6 +140,26 @@ describe('AutopilotEventHandlers', () => {
expect(synced()).toEqual(['c1', 'c2', 'c3']);
});
it('locks all colonies of an event in one statement before syncing any', async () => {
await run(
event('HarvestRecorded', 'h1', {
apiaryId: 'a',
colonyIds: ['c3', 'c1', 'c2'],
}),
);
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['c1', 'c2', 'c3']);
expect(lockRows.mock.invocationCallOrder[0]).toBeLessThan(
autopilot.syncColonyIn.mock.invocationCallOrder[0],
);
});
it('takes no extra lock for a single colony', async () => {
await run(event('InspectionRecorded', 'i1', { colonyId: 'c1' }));
expect(lockRows).not.toHaveBeenCalled();
});
it('syncs the new and the parent colony of a split', async () => {
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
@@ -178,4 +208,54 @@ describe('AutopilotEventHandlers', () => {
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
).rejects.toThrow('db down');
});
describe('apiarySync', () => {
it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
AutopilotEventHandlers.prototype.apiarySync,
);
expect(entries).toHaveLength(1);
expect(entries[0].handlerId).toBe('autopilot.apiary-sync');
expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']);
});
// One event per apiary and day (dailyDedupeKey): a change later the same
// day is caught by the daily autopilot step.
it('syncs the apiary of the payload for the Berlin day', async () => {
await handlers.apiarySync(
event('SeasonPhaseChanged', 'api-1', {
apiaryId: 'api-1',
phase: 'KIRSCHE',
segment: 'SCHWARMZEIT',
previousPhase: 'SALWEIDE',
previousSegment: 'FRUEHJAHR',
source: 'REPORT',
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
tx as unknown as Prisma.TransactionClient,
);
expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1);
expect(autopilot.syncApiaryIn).toHaveBeenCalledWith(
tx,
'api-1',
'2026-05-12',
);
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
});
it('lets errors propagate so the delivery is retried', async () => {
autopilot.syncApiaryIn.mockRejectedValue(new Error('db down'));
await expect(
handlers.apiarySync(
event('SeasonPhaseChanged', 'api-1', {
apiaryId: 'api-1',
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
tx as unknown as Prisma.TransactionClient,
),
).rejects.toThrow('db down');
});
});
});
@@ -6,6 +6,7 @@ import type {
DomainEventType,
} from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { AutopilotService } from './autopilot.service';
const COLONY_SYNC_EVENTS = [
@@ -28,6 +29,7 @@ const COLONY_SYNC_EVENTS = [
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
'WinterCheckRecorded',
] as const satisfies readonly DomainEventType[];
type ColonySyncEvent = AnyDomainEventEnvelope<
@@ -61,6 +63,7 @@ async function colonyIdsOf(
case 'FeedingRecorded':
case 'FeedingCorrected':
case 'WithdrawalEnded':
case 'WinterCheckRecorded':
case 'QueenAged':
return [event.payload.colonyId];
case 'HarvestRecorded':
@@ -85,7 +88,9 @@ async function colonyIdsOf(
}
/**
* Reconciles the autopilot suggestions of the colonies an event touches.
* Reconciles the autopilot suggestions of the colonies an event touches
* (autopilot.colony-sync) and, for a season change, of all colonies of the
* apiary (autopilot.apiary-sync).
* Pure DB writer through the delivery transaction; the colonies are synced
* in id order, so two deliveries never lock them the other way round.
*/
@@ -104,9 +109,31 @@ export class AutopilotEventHandlers {
if (colonyIds.length === 0) {
return;
}
if (colonyIds.length > 1) {
// All colonies in one statement before any sync, like syncApiaryIn.
await lockRowsForUpdate(tx, 'Colony', colonyIds);
}
const today = berlinToday();
for (const colonyId of colonyIds) {
await this.autopilot.syncColonyIn(tx, colonyId, today);
}
}
/**
* A season change (new phase or segment) alters the rules for every colony
* of the apiary: reconcile them all. There is one such event per apiary and
* day (dailyDedupeKey); a change later the same day is caught by the daily
* autopilot step.
*/
@OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync')
async apiarySync(
event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.autopilot.syncApiaryIn(
tx,
event.payload.apiaryId,
berlinToday(),
);
}
}
@@ -4,7 +4,7 @@ import {
readRule,
ruleConfigOf,
} from '../automation/rule-config';
import { lockRowForUpdate } from '../prisma/row-lock';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { calendarSeasonState } from '../season/season-calendar';
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { loadAutopilotInputIn } from './autopilot-input';
@@ -19,6 +19,7 @@ jest.mock('../automation/rule-config', () => ({
jest.mock('./autopilot-input');
const lockRow = jest.mocked(lockRowForUpdate);
const lockRows = jest.mocked(lockRowsForUpdate);
const readRuleMock = jest.mocked(readRule);
const loadInput = jest.mocked(loadAutopilotInputIn);
@@ -73,7 +74,7 @@ function input(lastInspectedOn: string | null): AutopilotInput {
describe('AutopilotService', () => {
let tx: {
$connect?: undefined;
colony: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock; findMany: jest.Mock };
suggestion: { findMany: jest.Mock };
};
let suggestions: {
@@ -92,6 +93,7 @@ describe('AutopilotService', () => {
findUnique: jest
.fn()
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
};
@@ -301,7 +303,7 @@ describe('AutopilotService', () => {
expect(result.opened).toBe(1);
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season, '08-01', TODAY);
});
it('preloadFor reads the four rules and the seasons once', async () => {
@@ -330,4 +332,66 @@ describe('AutopilotService', () => {
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
describe('syncApiaryIn', () => {
it('syncs every living colony of the apiary in id order with one read of rules and seasons', async () => {
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
loadInput.mockResolvedValue(input(null));
readRuleMock.mockClear();
const result = await service.syncApiaryIn(tx as never, 'api-1', TODAY);
expect(tx.colony.findMany).toHaveBeenCalledWith({
where: {
apiaryId: 'api-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
select: { id: true },
orderBy: { id: 'asc' },
});
expect(lockRow.mock.calls.map((call) => call.slice(1))).toEqual([
['Apiary', 'api-1'],
['Colony', 'col-1'],
['Colony', 'col-2'],
]);
expect(readRuleMock).toHaveBeenCalledTimes(4);
expect(seasons.statesFor).toHaveBeenCalledTimes(1);
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
expect(result).toEqual({ opened: 2, refreshed: 0, resolved: 0 });
});
it('locks the apiary, then all its colonies in one statement, before any colony is synced', async () => {
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
loadInput.mockResolvedValue(input(null));
await service.syncApiaryIn(tx as never, 'api-1', TODAY);
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['col-1', 'col-2']);
const apiaryLock = lockRow.mock.invocationCallOrder[0];
const listing = tx.colony.findMany.mock.invocationCallOrder[0];
const colonyLock = lockRows.mock.invocationCallOrder[0];
const firstSync = lockRow.mock.invocationCallOrder[1];
expect(apiaryLock).toBeLessThan(listing);
expect(listing).toBeLessThan(colonyLock);
expect(colonyLock).toBeLessThan(firstSync);
});
it('does nothing and reads nothing for an apiary without living colonies', async () => {
readRuleMock.mockClear();
expect(await service.syncApiaryIn(tx as never, 'api-1', TODAY)).toEqual({
opened: 0,
refreshed: 0,
resolved: 0,
});
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
});
it('refuses a client that is not a transaction', async () => {
await expect(
service.syncApiaryIn({ $connect: jest.fn() } as never, 'api-1', TODAY),
).rejects.toThrow('AutopilotService.syncApiaryIn');
});
});
});
@@ -1,11 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { isLivingColonyStatus } from '../colonies/colony-status';
import {
isLivingColonyStatus,
LIVING_COLONY_WHERE,
} from '../colonies/colony-status';
import { addDays } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import type { SeasonState } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SuggestionsService } from '../suggestions/suggestions.service';
@@ -113,7 +116,13 @@ export class AutopilotService {
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
return NOTHING;
}
const input = await loadAutopilotInputIn(tx, colonyId, season);
const input = await loadAutopilotInputIn(
tx,
colonyId,
season,
configs.phenology.calendarFallback.TRACHTENDE,
today,
);
if (input === null) {
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
return NOTHING;
@@ -173,6 +182,51 @@ export class AutopilotService {
return { opened, refreshed, resolved };
}
/**
* Reconciles every living colony of the apiary, for a season change.
* Lock order: the apiary row, then (after reading the list under that
* lock) all its colonies in ONE statement in ascending id order, before any
* colony is synced. A concurrent single-colony or multi-colony sync takes
* its colony locks the same way, so no two syncs wait for each other while
* the proposals lock suggestion and rule rows. Rules and season are read
* once for all colonies. About ten queries per colony run in this one
* transaction.
*/
async syncApiaryIn(
tx: Prisma.TransactionClient,
apiaryId: string,
today: string,
): Promise<AutopilotSyncResult> {
assertTransactionClient(tx, 'AutopilotService.syncApiaryIn');
if (!(await lockRowForUpdate(tx, 'Apiary', apiaryId))) {
return NOTHING;
}
const colonies = await tx.colony.findMany({
where: { apiaryId, ...LIVING_COLONY_WHERE },
select: { id: true },
orderBy: { id: 'asc' },
});
if (colonies.length === 0) {
return NOTHING;
}
await lockRowsForUpdate(
tx,
'Colony',
colonies.map((colony) => colony.id),
);
const preload = await this.preloadFor(tx, [apiaryId], today);
let total = NOTHING;
for (const { id } of colonies) {
const result = await this.syncColonyIn(tx, id, today, preload);
total = {
opened: total.opened + result.opened,
refreshed: total.refreshed + result.refreshed,
resolved: total.resolved + result.resolved,
};
}
return total;
}
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
return {
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
@@ -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(
@@ -34,6 +34,7 @@ const BUSINESS_ENTITY_KEYS = [
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'phenologyReport',
'treatment',
'feeding',
'harvest',
@@ -118,6 +119,9 @@ describe('BackupService', () => {
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
findUnique: jest.fn().mockResolvedValue(null),
} as never;
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([]);
tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null);
@@ -1451,6 +1455,189 @@ describe('BackupService', () => {
});
});
describe('phenology and honey ledger', () => {
const importData = (data: Record<string, Row[]>, trusted = true) =>
service.importAll(
{ version: 3, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
{ trusted },
);
const created = (key: string): Row[] =>
(tx[key].upsert.mock.calls as [{ create: Row }][]).map(
([arg]) => arg.create,
);
it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', async () => {
prisma.phenologyReport.findMany.mockResolvedValue([
{ id: 'p1', phase: 'KIRSCHE', userId: 'known-user' },
]);
const bundle = await service.exportAll();
expect(bundle.data.phenologyReport).toHaveLength(1);
expect(bundle.data).not.toHaveProperty('apiaryPhenology');
expect(bundle.data).not.toHaveProperty('apiaryWeatherDay');
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryPhenology).toMatch(
/recomputed by daily-evaluation/,
);
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryWeatherDay).toMatch(
/refetched by the worker/,
);
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' }],
phenologyReport: [
{ id: 'p1', apiaryId: 'apiary-1', userId: 'known-user' },
{ id: 'p2', apiaryId: null, userId: 'ghost' },
],
});
expect(created('phenologyReport').map((row) => row.userId)).toEqual([
'known-user',
'admin-1',
]);
expect(tx.apiary.upsert.mock.invocationCallOrder[0]).toBeLessThan(
tx.phenologyReport.upsert.mock.invocationCallOrder[0],
);
});
it('an old bundle without initialAmount is recalculated like migration 6', async () => {
await importData({
honeyBatch: [
{ id: 'hb-1', amount: 20 },
{ id: 'hb-2', amount: null },
{ id: 'hb-3', amount: 5 },
],
bottlingBatch: [
{
id: 'bt-1',
honeyBatchId: 'hb-1',
totalAmount: 7.5,
createdAt: '2026-08-01T10:00:00.000Z',
},
{
id: 'bt-2',
honeyBatchId: 'hb-1',
totalAmount: 2.0004,
createdAt: '2026-08-02T10:00:00.000Z',
},
{ id: 'bt-3', honeyBatchId: 'hb-2', totalAmount: 1 },
],
});
expect(
created('honeyBatch').map((row) => [row.id, row.initialAmount]),
).toEqual([
['hb-1', 29.5],
['hb-2', undefined],
['hb-3', 5],
]);
expect(created('batchMovement')).toEqual([
expect.objectContaining({
honeyBatchId: 'hb-1',
sourceBottlingBatchId: 'bt-1',
type: 'BOTTLING',
quantity: -7500,
createdAt: '2026-08-01T10:00:00.000Z',
notes: 'Übernahme aus Abfüllung (T4)',
}),
expect.objectContaining({
honeyBatchId: 'hb-1',
sourceBottlingBatchId: 'bt-2',
type: 'BOTTLING',
quantity: -2000,
}),
]);
// After the batch and the bottling it points at.
expect(tx.bottlingBatch.upsert.mock.invocationCallOrder[0]).toBeLessThan(
tx.batchMovement.upsert.mock.invocationCallOrder[0],
);
});
it('leaves a bundle that carries initialAmount untouched', async () => {
await importData({
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
bottlingBatch: [{ id: 'bt-1', honeyBatchId: 'hb-1', totalAmount: 10 }],
batchMovement: [
{
id: 'm1',
honeyBatchId: 'hb-1',
type: 'BOTTLING',
quantity: -10000,
},
],
});
expect(created('honeyBatch')[0].initialAmount).toBe(30);
expect(created('batchMovement').map((row) => row.id)).toEqual(['m1']);
});
it('sets initialAmount from an unsealed file but books no movements', async () => {
await importData(
{
honeyBatch: [{ id: 'hb-new', amount: 20 }],
bottlingBatch: [
{ id: 'bt-2', honeyBatchId: 'hb-new', totalAmount: 5 },
],
},
false,
);
expect(created('honeyBatch')[0].initialAmount).toBe(25);
expect(tx.batchMovement.upsert).not.toHaveBeenCalled();
});
it('reports honey batches that break the invariant after the import, without failing it', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([{ id: 'hb-1' }]);
const summary = await importData({
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
});
expect(summary.imported.honeyLedgerViolations).toBe(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hb-1'));
warn.mockRestore();
});
it('adds no violation count when the ledger is consistent', async () => {
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([]);
const summary = await importData({
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 20 }],
});
expect(summary.imported).not.toHaveProperty('honeyLedgerViolations');
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
+92 -2
View File
@@ -21,6 +21,7 @@ import {
openBackup,
sealBackup,
} from './backup-crypto';
import { legacyHoneyLedger } from './legacy-honey-ledger';
// 3: production, raw material, stock ledger, number sequence, calendar sync
// and backup schedule tables. Older bundles simply lack those sections and
@@ -71,6 +72,9 @@ export interface BackupImportSummary {
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links.
* - document: writes files to the server's disk.
* - honeyBatch.initialAmount: an unsealed file may set it (derived from
* its bottlings) but never books ledger rows - batchMovement stays sealed
* only, so the BOTTLING bookings of an old bundle come with sealed files.
* - notification, notificationPreference: messages shown to users as coming
* from ApiCore, and settings of a user.
*/
@@ -140,6 +144,14 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
'Delivery log and retry queue - restoring it would resend old events to receivers.',
JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.",
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>(
@@ -244,6 +256,32 @@ export class BackupService {
});
}
/** Imported honey batches whose amount differs from the ledger by more than 0.5 g. */
private async honeyLedgerViolations(
tx: Prisma.TransactionClient,
ids: string[],
): Promise<string[]> {
if (ids.length === 0) {
return [];
}
const rows = await tx.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT hb."id"
FROM "HoneyBatch" hb
LEFT JOIN (
SELECT "honeyBatchId", sum("quantity") AS grams
FROM "BatchMovement"
WHERE "honeyBatchId" = ANY(${ids})
GROUP BY "honeyBatchId"
) m ON m."honeyBatchId" = hb."id"
WHERE hb."id" = ANY(${ids})
AND hb."initialAmount" IS NOT NULL
AND hb."amount" IS NOT NULL
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.grams, 0) / 1000.0)) > 0.0005
ORDER BY hb."id"
`);
return rows.map((row) => row.id);
}
async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {};
@@ -282,6 +320,7 @@ export class BackupService {
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.winterCheck = await this.prisma.winterCheck.findMany();
data.phenologyReport = await this.prisma.phenologyReport.findMany();
data.treatment = await this.prisma.treatment.findMany();
data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany();
@@ -459,6 +498,17 @@ export class BackupService {
update: {},
});
}
const violations = await this.honeyLedgerViolations(
tx,
rowsOf('honeyBatch').map((row) => row.id as string),
);
if (violations.length > 0) {
imported.honeyLedgerViolations = violations.length;
this.logger.warn(
`Honigcharge(n) mit verletzter Mengen-Invariante nach dem Import: ${violations.join(', ')}`,
);
}
imported.rolePermission = rowsOf('rolePermission').length;
// Built after role/user import so a user id that only exists
@@ -709,6 +759,19 @@ export class BackupService {
}
imported.winterCheck = rowsOf('winterCheck').length;
for (const row of rowsOf('phenologyReport')) {
const report = {
...row,
userId: resolveUserId(asString(row, 'userId')),
};
await tx.phenologyReport.upsert({
where: { id: row.id as string },
create: report as never,
update: updateWith(report) as never,
});
}
imported.phenologyReport = rowsOf('phenologyReport').length;
for (const row of rowsOf('treatment')) {
await tx.treatment.upsert({
where: { id: row.id as string },
@@ -796,11 +859,25 @@ export class BackupService {
}
imported.storageContainer = rowsOf('storageContainer').length;
// Bundles from before the honey ledger: initialAmount is derived
// like migration 6 does. Only a sealed file also gets the BOTTLING
// bookings; an unsealed one may not write ledger rows.
const legacyLedger = legacyHoneyLedger(
rowsOf('honeyBatch'),
rowsOf('bottlingBatch'),
);
for (const row of rowsOf('honeyBatch')) {
const initialAmount = legacyLedger.initialAmounts.get(
row.id as string,
);
const batch =
!('initialAmount' in row) && initialAmount !== undefined
? { ...row, initialAmount }
: row;
await tx.honeyBatch.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
create: batch as never,
update: updateWith(batch) as never,
});
}
imported.honeyBatch = rowsOf('honeyBatch').length;
@@ -967,6 +1044,19 @@ export class BackupService {
}
imported.batchMovement = rowsOf('batchMovement').length;
// After the batches and bottlings they point at; never rewritten.
for (const row of trusted ? legacyLedger.movements : []) {
const movement = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.batchMovement.upsert({
where: { id: row.id as string },
create: movement as never,
update: {},
});
}
for (const row of rowsOf('equipment')) {
await tx.equipment.upsert({
where: { id: row.id as string },
@@ -0,0 +1,57 @@
import {
LEGACY_BOTTLING_NOTE,
legacyBottlingMovementId,
legacyHoneyLedger,
} from './legacy-honey-ledger';
describe('legacyHoneyLedger', () => {
it('derives initialAmount = amount + bottlings and one BOTTLING booking per bottling in grams', () => {
const ledger = legacyHoneyLedger(
[{ id: 'hb', amount: 20 }],
[
{ id: 'a', honeyBatchId: 'hb', totalAmount: 7.5, createdAt: 't1' },
{ id: 'b', honeyBatchId: 'hb', totalAmount: 2.5, createdAt: 't2' },
],
);
expect(ledger.initialAmounts.get('hb')).toBe(30);
expect(ledger.movements).toEqual([
{
id: legacyBottlingMovementId('a'),
honeyBatchId: 'hb',
sourceBottlingBatchId: 'a',
type: 'BOTTLING',
quantity: -7500,
notes: LEGACY_BOTTLING_NOTE,
userId: null,
createdAt: 't1',
},
expect.objectContaining({ quantity: -2500, createdAt: 't2' }),
]);
});
it('ignores a batch with initialAmount (new format) and a batch without amount', () => {
const ledger = legacyHoneyLedger(
[
{ id: 'new', amount: 5, initialAmount: 8 },
{ id: 'none', amount: null },
],
[
{ id: 'a', honeyBatchId: 'new', totalAmount: 3 },
{ id: 'b', honeyBatchId: 'none', totalAmount: 3 },
],
);
expect(ledger.initialAmounts.size).toBe(0);
expect(ledger.movements).toEqual([]);
});
it('rounds half up to the gram like the migration', () => {
const ledger = legacyHoneyLedger(
[{ id: 'hb', amount: 1 }],
[{ id: 'a', honeyBatchId: 'hb', totalAmount: 0.0125 }],
);
expect(ledger.movements[0].quantity).toBe(-13);
});
});
@@ -0,0 +1,74 @@
// Bundles written before the honey ledger (T4 migration 6) carry no
// HoneyBatch.initialAmount and no BOTTLING movements. The import derives
// both with the rule of the migration, so amount = initialAmount + sum of the
// movements holds for the restored batches too.
type Row = Record<string, unknown>;
export const LEGACY_BOTTLING_NOTE = 'Übernahme aus Abfüllung (T4)';
export interface LegacyHoneyLedger {
/** initialAmount per honey batch id of a legacy batch with a recorded amount. */
initialAmounts: ReadonlyMap<string, number>;
/** The BOTTLING bookings of those batches, one per bottling. */
movements: Row[];
}
function isLegacyBatch(row: Row): boolean {
return !('initialAmount' in row);
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
// Sums of kilograms carry float noise; a microgram grid hides it.
function roundKg(value: number): number {
return Math.round(value * 1e6) / 1e6;
}
/** Id of the booking made for a bottling; stable so a repeated restore adds nothing. */
export function legacyBottlingMovementId(bottlingBatchId: string): string {
return `legacy-bottling:${bottlingBatchId}`;
}
/**
* @param honeyBatches rows of the bundle's `honeyBatch` section
* @param bottlingBatches rows of the bundle's `bottlingBatch` section
*/
export function legacyHoneyLedger(
honeyBatches: readonly Row[],
bottlingBatches: readonly Row[],
): LegacyHoneyLedger {
const initialAmounts = new Map<string, number>();
for (const batch of honeyBatches) {
const amount = asNumber(batch.amount);
if (isLegacyBatch(batch) && amount !== null) {
initialAmounts.set(batch.id as string, amount);
}
}
const movements: Row[] = [];
for (const bottling of bottlingBatches) {
const honeyBatchId = bottling.honeyBatchId as string;
const base = initialAmounts.get(honeyBatchId);
const total = asNumber(bottling.totalAmount);
if (base === undefined || total === null) {
continue;
}
// The same gram rounding as the booking, so the invariant holds exactly.
const grams = Math.round(total * 1000);
initialAmounts.set(honeyBatchId, roundKg(base + grams / 1000));
movements.push({
id: legacyBottlingMovementId(bottling.id as string),
honeyBatchId,
sourceBottlingBatchId: bottling.id,
type: 'BOTTLING',
quantity: -grams,
notes: LEGACY_BOTTLING_NOTE,
userId: bottling.userId ?? null,
createdAt: bottling.createdAt,
});
}
return { initialAmounts, movements };
}
@@ -1,12 +1,18 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { HoneyBatchesModule } from '../honey-batches/honey-batches.module';
import { StockModule } from '../stock/stock.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service';
@Module({
imports: [AutomationModule, StockModule, WithdrawalModule],
imports: [
AutomationModule,
HoneyBatchesModule,
StockModule,
WithdrawalModule,
],
controllers: [BottlingController],
providers: [BottlingService],
})
@@ -11,18 +11,10 @@ import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
type AmountChange = number | { increment?: number; decrement?: number };
function applyAmountChange(
current: number | null,
change: AmountChange | undefined,
): number | null {
if (change === undefined) return current;
if (typeof change === 'number') return change;
if (current === null) return null;
return current + (change.increment ?? 0) - (change.decrement ?? 0);
}
import {
HoneyBatchLedgerService,
type LedgerBooking,
} from '../honey-batches/honey-batch-ledger.service';
/**
* What one bottling write records in a single recordAll - so both share a
@@ -78,12 +70,10 @@ describe('BottlingService', () => {
update: jest.Mock;
delete: jest.Mock;
};
honeyBatch: {
findUnique: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
batchMovement: { count: jest.Mock };
};
let ledger: { bookIn: jest.Mock };
let stock: {
remainingByBottling: jest.Mock;
stockChangedEvent: jest.Mock;
@@ -101,9 +91,11 @@ describe('BottlingService', () => {
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock');
return Promise.resolve([]);
return Promise.resolve([{ id: 'row' }]);
}),
product: { findUnique: jest.fn() },
// By default a bottling has drawn from the ledger (own BOTTLING booking).
batchMovement: { count: jest.fn().mockResolvedValue(1) },
bottlingBatch: {
findUnique: jest.fn(),
create: jest.fn(),
@@ -115,47 +107,50 @@ describe('BottlingService', () => {
const row = honeyBatches.get(where.id);
return Promise.resolve(row ? { id: where.id, ...row } : null);
}),
// Only the status is written here; the amount belongs to the ledger.
update: jest.fn(
({
where,
data,
}: {
where: { id: string };
data: { amount?: AmountChange; status?: string };
data: { status?: string };
}) => {
const row = honeyBatches.get(where.id) ?? { amount: null };
honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount),
...row,
status: data.status ?? row.status,
});
return Promise.resolve({});
},
),
updateMany: jest.fn(
({
where,
data,
}: {
where: { id: string; amount?: { gte?: number } };
data: { amount: AmountChange; status?: string };
}) => {
const row = honeyBatches.get(where.id);
const minimum = where.amount?.gte;
if (
!row ||
row.amount === null ||
(minimum !== undefined && row.amount < minimum)
) {
return Promise.resolve({ count: 0 });
},
};
// The ledger as the movement book keeps it, reduced to the rest: a
// negative booking needs enough rest, an untracked batch stays untracked.
ledger = {
bookIn: jest.fn((_tx: unknown, booking: LedgerBooking) => {
const row = honeyBatches.get(booking.honeyBatchId);
if (!row) {
return Promise.reject(new NotFoundException('Honey batch not found'));
}
honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount),
status: data.status ?? row.status,
});
return Promise.resolve({ count: 1 });
},
if (row.amount === null) {
return Promise.resolve({ amountKg: null, movementId: null });
}
if (booking.deltaKg < 0 && row.amount < -booking.deltaKg - 0.0005) {
return Promise.reject(
new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
),
},
);
}
const amountKg = Math.max(
0,
Math.round((row.amount + booking.deltaKg) * 1000) / 1000,
);
honeyBatches.set(booking.honeyBatchId, { ...row, amount: amountKg });
return Promise.resolve({ amountKg, movementId: 'm1' });
}),
};
prisma = {
honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
@@ -202,6 +197,7 @@ describe('BottlingService', () => {
automation as unknown as AutomationService,
stock as unknown as StockService,
withdrawalCheck as unknown as WithdrawalCheckService,
ledger as unknown as HoneyBatchLedgerService,
);
});
@@ -237,6 +233,12 @@ describe('BottlingService', () => {
id: 'h1',
amount: 5,
});
honeyBatches.set('h1', { amount: 5 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 10,
});
await expect(
service.create(
@@ -327,14 +329,19 @@ describe('BottlingService', () => {
);
});
it('refuses a bottling when the stored amount shrank after the first check', async () => {
// The pre-check saw 25 kg, but a concurrent bottling already took most
// of it - the conditional booking inside the transaction must catch that.
it('refuses a bottling when the rest under the lock is too small and writes nothing', async () => {
// The first read saw 25 kg, but a concurrent bottling already took most
// of it - the ledger decides under the batch lock.
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
honeyBatches.set('h1', { amount: 3 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 10,
});
await expect(
service.create(
@@ -349,9 +356,66 @@ describe('BottlingService', () => {
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(honeyBatches.get('h1')?.amount).toBe(3);
expect(events.recordAll).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('create books the bottling in the ledger against the bottling it created', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: -5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
});
});
it('a batch without a recorded amount is bottled without a booking and marked BOTTLED', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: null });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(honeyBatches.get('h1')).toEqual({ amount: null, status: 'BOTTLED' });
});
it('update writes an audit log entry with before and after data in its transaction', async () => {
@@ -417,7 +481,7 @@ describe('BottlingService', () => {
tx,
);
// Nothing moved: the stale honey batch is never touched.
expect(tx.honeyBatch.updateMany).not.toHaveBeenCalled();
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('update records BottlingCorrected and StockChanged in its transaction', async () => {
@@ -554,6 +618,7 @@ describe('BottlingService', () => {
jarCount: 10,
totalAmount: 5,
});
honeyBatches.set('h1', { amount: 0 });
await expect(
service.update('b1', { jarCount: 40 }, 'user-1'),
@@ -687,6 +752,184 @@ describe('BottlingService', () => {
);
});
describe('ledger bookings', () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
it('a smaller jar count gives the difference back as a BOTTLING booking', async () => {
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
honeyBatches.set('h1', { amount: 2 });
await service.update('b1', { jarCount: 6 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: 2,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
});
});
it('books nothing when the amount of the bottling stays the same', async () => {
lockedRows(before, { ...before, lotNumber: 'L-2' });
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('a change of the honey batch gives back to the old one and takes from the new one', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
([, booking]) => booking,
),
).toEqual([
{
honeyBatchId: 'h1',
deltaKg: 5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
},
{
honeyBatchId: 'h2',
deltaKg: -5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
},
]);
});
it('locks the bottling, then both honey batches in one statement, before it books', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
ledger.bookIn.mock.invocationCallOrder[0],
);
});
it('a failed take from the new honey batch leaves the bottling row unchanged', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 1 });
await expect(
service.update('b1', { honeyBatchId: 'h2' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
});
it('create locks the honey batch before it writes the bottling', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.bottlingBatch.create.mock.invocationCallOrder[0],
);
});
it('a bottling that never drew from the ledger books nothing when it changes', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
honeyBatches.set('h1', { amount: 2 });
await service.update('b1', { jarCount: 6 }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
expect(tx.batchMovement.count).toHaveBeenCalledWith({
where: {
honeyBatchId: 'h1',
sourceBottlingBatchId: 'b1',
type: 'BOTTLING',
},
});
});
it('moving a bottling that never drew from the ledger only takes from the new batch', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
([, booking]) => [booking.honeyBatchId, booking.deltaKg],
),
).toEqual([['h2', -5]]);
});
it('removing a bottling that never drew from the ledger gives nothing back', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
await service.remove('b1', 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
expect(tx.bottlingBatch.delete).toHaveBeenCalled();
});
it('remove books the amount back with the note "Abfüllung entfernt"', async () => {
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
await service.remove('b1', 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: 5,
type: 'BOTTLING',
notes: 'Abfüllung entfernt',
actingUserId: 'user-1',
});
// The booking is made while the bottling still exists (FK); the delete
// follows.
expect(ledger.bookIn.mock.invocationCallOrder[0]).toBeLessThan(
tx.bottlingBatch.delete.mock.invocationCallOrder[0],
);
});
});
describe('withdrawal warnings', () => {
const conflict = {
harvestId: 'harvest-1',
+117 -105
View File
@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
@@ -15,6 +15,7 @@ import { StockService } from '../stock/stock.service';
import { withWarnings } from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import { Prisma } from '../../generated/prisma';
import { HoneyBatchLedgerService } from '../honey-batches/honey-batch-ledger.service';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -27,82 +28,14 @@ export interface BottlingBatchFilter {
honeyBatchId?: string;
}
/**
* Amounts are kept in kilograms with gram precision. Rounding on every write
* keeps a honey batch's remaining amount from drifting when a bottling batch
* is corrected or removed and its amount is given back.
*/
/** Amounts are kept in kilograms with gram precision. */
function roundKg(amount: number): number {
return Math.round(amount * 1000) / 1000;
}
const INSUFFICIENT_AMOUNT_MESSAGE =
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
type Tx = Prisma.TransactionClient;
/**
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
* database - not a value read earlier - decides whether enough is left.
* A batch without a recorded amount (null) is not tracked and stays null.
*/
async function takeFromHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
const current = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (!current) {
throw new NotFoundException('Honey batch not found');
}
if (current.amount === null) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
return;
}
// Tolerance of half a gram absorbs float noise from earlier writes.
const booked = await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
data: { amount: { decrement: amount }, status: 'BOTTLED' },
});
if (booked.count === 0) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
await normalizeAmount(tx, honeyBatchId);
}
async function giveBackToHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { not: null } },
data: { amount: { increment: amount } },
});
await normalizeAmount(tx, honeyBatchId);
}
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
const row = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (row?.amount == null) return;
const normalized = Math.max(0, roundKg(row.amount));
if (normalized !== row.amount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: normalized },
});
}
}
const REMOVED_NOTE = 'Abfüllung entfernt';
/**
* Bottlings of a honey batch. Every write records its audit entry and its
@@ -119,8 +52,59 @@ export class BottlingService {
private readonly automation: AutomationService,
private readonly stock: StockService,
private readonly withdrawalCheck: WithdrawalCheckService,
private readonly ledger: HoneyBatchLedgerService,
) {}
/**
* Books a bottling's honey in the movement book of its honey batch: minus
* for a bottling taking honey, plus for giving it back. The ledger locks
* the batch and decides whether the rest is enough.
*/
private async book(
tx: Tx,
honeyBatchId: string,
deltaKg: number,
actingUserId: string,
source: { sourceBottlingBatchId: string } | { notes: string },
): Promise<void> {
await this.ledger.bookIn(tx, {
honeyBatchId,
deltaKg,
type: 'BOTTLING',
actingUserId,
...source,
});
}
/**
* Whether this bottling took honey out of the batch's ledger. A bottling
* made while the batch had no recorded amount has no BOTTLING booking of
* its own: later changes or its removal must not give back or take
* anything the ledger never lost.
*/
private async drewFromLedger(
tx: Tx,
honeyBatchId: string,
bottlingId: string,
): Promise<boolean> {
const bookings = await tx.batchMovement.count({
where: {
honeyBatchId,
sourceBottlingBatchId: bottlingId,
type: 'BOTTLING',
},
});
return bookings > 0;
}
/** A bottled batch changes its status, whether or not its amount is kept. */
private async markBottled(tx: Tx, honeyBatchId: string): Promise<void> {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
}
/**
* The bottling as the response body plus the withdrawal warnings of the
* harvests behind its honey batch. Bottling inside a window is written all
@@ -195,6 +179,7 @@ export class BottlingService {
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
const honeyBatch = await this.prisma.honeyBatch.findUnique({
where: { id: dto.honeyBatchId },
select: { id: true },
});
if (!honeyBatch) {
throw new NotFoundException('Honey batch not found');
@@ -204,9 +189,6 @@ export class BottlingService {
}
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
const lotNumber =
dto.lotNumber ??
@@ -218,11 +200,11 @@ export class BottlingService {
}
return this.prisma.$transaction(async (tx) => {
// Booked against the stored amount inside the transaction, never
// against the value read above: two bottlings submitted at the same
// time (double tap, two devices) must not both pass the check and
// overwrite each other's deduction.
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
// The honey batch first (like update and remove, which hold the
// bottling and then the batch): the ledger books under this lock.
if (!(await lockRowForUpdate(tx, 'HoneyBatch', dto.honeyBatchId))) {
throw new NotFoundException('Honey batch not found');
}
const created = await tx.bottlingBatch.create({
data: {
honeyBatchId: dto.honeyBatchId,
@@ -236,6 +218,14 @@ export class BottlingService {
userId: actingUserId,
},
});
// Booked in the ledger under the batch lock, never against a value read
// earlier: two bottlings submitted at the same time (double tap, two
// devices) must not both pass the check. A refusal rolls the bottling
// back with it.
await this.book(tx, dto.honeyBatchId, -totalAmount, actingUserId, {
sourceBottlingBatchId: created.id,
});
await this.markBottled(tx, dto.honeyBatchId);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
@@ -289,39 +279,51 @@ export class BottlingService {
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch
? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
if (movedToOtherHoneyBatch) {
// Both batches in one statement in ascending id order, so two
// bottlings moving the other way cannot wait for each other.
await lockRowsForUpdate(tx, 'HoneyBatch', [
before.honeyBatchId,
honeyBatchId,
]);
const target = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { id: true },
});
if (!target) {
throw new NotFoundException('Honey batch not found');
}
// Early, friendly check; the booking below is still conditional on
// the stored amount (takeFromHoneyBatch).
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
const amountChanged =
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
if (amountChanged) {
// Give the previous amount back first, then take the corrected one -
// a correction never double-charges the source batch.
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
}
const tracked = await this.drewFromLedger(tx, before.honeyBatchId, id);
if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
// Give the previous amount back first (only if it was taken), then
// take the corrected one - a correction never double-charges.
if (tracked) {
await this.book(
tx,
before.honeyBatchId,
before.totalAmount,
actingUserId,
{ sourceBottlingBatchId: id },
);
}
await this.book(tx, honeyBatchId, -totalAmount, actingUserId, {
sourceBottlingBatchId: id,
});
} else if (amountChanged && tracked) {
await this.book(
tx,
honeyBatchId,
roundKg(before.totalAmount - totalAmount),
actingUserId,
{ sourceBottlingBatchId: id },
);
}
if (amountChanged) {
await this.markBottled(tx, honeyBatchId);
}
await tx.bottlingBatch.update({
where: { id },
@@ -387,8 +389,18 @@ export class BottlingService {
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
// Booked first, while the bottling still exists; deleting it then
// clears the reference of the booking (ON DELETE SET NULL).
if (await this.drewFromLedger(tx, before.honeyBatchId, id)) {
await this.book(
tx,
before.honeyBatchId,
before.totalAmount,
actingUserId,
{ notes: REMOVED_NOTE },
);
}
await tx.bottlingBatch.delete({ where: { id } });
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
@@ -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;
}
@@ -16,6 +16,7 @@ import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
import { UpdateNotificationSettingsDto } from '../../notifications/dto/update-notification-settings.dto';
import { UpdatePhenologyReportDto } from '../../phenology/dto/update-phenology-report.dto';
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
import { UpdateProductDto } from '../../products/dto/update-product.dto';
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
@@ -210,6 +211,12 @@ const contracts: Record<string, NullContract> = {
notNull: ['name', 'status', 'kind', 'shopCategory'],
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
},
// apiaryId: null widens the report to all apiaries; notes: null clears them.
UpdatePhenologyReportDto: {
dto: UpdatePhenologyReportDto,
notNull: ['phase', 'date'],
clearable: ['apiaryId', 'notes'],
},
UpdateProductionBatchDto: {
dto: UpdateProductionBatchDto,
notNull: ['lotNumber', 'date', 'quantity'],
@@ -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];
@@ -1,8 +1,11 @@
import {
ColonyCondition,
ColonyStatus,
PhaseSource,
PhenologyPhase,
Prisma,
RawMaterialKind,
SeasonSegment,
SplitQueenSource,
TaskRelatedType,
TaskStatus,
@@ -89,6 +92,43 @@ export interface DomainEventPayloads {
assignedOn: string;
previousColonyId: string | null;
};
// Aggregate Extraction.
ExtractionRecorded: { harvestIds: string[] };
ExtractionCorrected: { harvestIds: string[] };
// Aggregate HoneyBatch.
HoneyBatchRecorded: { extractionId: string };
HoneyBatchCorrected: { extractionId: string };
// Aggregate PhenologyReport; apiaryId null = all apiaries.
PhenologyReported: {
apiaryId: string | null;
phase: PhenologyPhase;
reportedOn: string;
};
// previousApiaryId: the scope before a correction (null = all apiaries).
PhenologyCorrected: {
apiaryId: string | null;
previousApiaryId: string | null;
phase: PhenologyPhase;
reportedOn: string;
};
// Aggregate Apiary; internal follow-up of a report for all apiaries: one
// apiary is recomputed per delivery.
PhenologyRecomputeRequested: {
apiaryId: string;
allowBackward: boolean;
day: string;
};
// Aggregate WinterCheck: a wintering-in or -out entry was written or changed.
WinterCheckRecorded: { colonyId: string };
// Aggregate Apiary; time-based (dedupeKey per apiary and day).
SeasonPhaseChanged: {
apiaryId: string;
phase: PhenologyPhase | null;
segment: SeasonSegment;
previousPhase: PhenologyPhase | null;
previousSegment: SeasonSegment | null;
source: PhaseSource | null;
};
}
export type DomainEventType = keyof DomainEventPayloads;
@@ -120,6 +160,15 @@ const CATALOG: Record<DomainEventType, true> = {
InspectionOverdue: true,
ColonySplit: true,
QueenAssigned: true,
ExtractionRecorded: true,
ExtractionCorrected: true,
HoneyBatchRecorded: true,
HoneyBatchCorrected: true,
PhenologyReported: true,
PhenologyCorrected: true,
PhenologyRecomputeRequested: true,
SeasonPhaseChanged: true,
WinterCheckRecorded: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -138,7 +187,12 @@ export type AggregateType =
| 'BatchMovement'
| 'ShopStockRequest'
| 'Task'
| 'Queen';
| 'Queen'
| 'Extraction'
| 'HoneyBatch'
| 'PhenologyReport'
| 'WinterCheck'
| 'Apiary';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
type: T;
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(25);
expect(DOMAIN_EVENT_TYPES).toHaveLength(34);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
@@ -193,6 +193,15 @@ describe('domain event catalog', () => {
'InspectionOverdue',
'ColonySplit',
'QueenAssigned',
'ExtractionRecorded',
'ExtractionCorrected',
'HoneyBatchRecorded',
'HoneyBatchCorrected',
'PhenologyReported',
'PhenologyCorrected',
'PhenologyRecomputeRequested',
'SeasonPhaseChanged',
'WinterCheckRecorded',
]),
);
});
@@ -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({
await this.auditLog.record(
{
entityType: 'Extraction',
entityId: extraction.id,
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: extraction,
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);
});
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,
});
const extraction = await this.findById(id);
await this.auditLog.record({
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 extraction;
return this.withAllWarnings(tx, 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' }],
});
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,54 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsIn,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
NotEquals,
} from 'class-validator';
import { WithdrawalReason } from '../../../generated/prisma';
/** The limit of one booking, in kg: far above any batch, below float noise. */
export const MAX_MOVEMENT_KG = 100000;
export const MOVEMENT_TYPES = ['WITHDRAWAL', 'CORRECTION'] as const;
export type ManualMovementType = (typeof MOVEMENT_TYPES)[number];
/**
* A manual booking in the movement book of a honey batch. Bottlings book
* themselves; this is for loose goods, samples, breakage (WITHDRAWAL, minus)
* and for weighing again (CORRECTION, plus or minus).
*/
export class CreateHoneyBatchMovementDto {
@ApiProperty({ enum: MOVEMENT_TYPES })
@IsIn(MOVEMENT_TYPES)
type!: ManualMovementType;
@ApiProperty({
description:
'Änderung der Restmenge in kg mit Vorzeichen, nicht 0 (Entnahme: negativ)',
})
@IsNumber({ allowNaN: false, allowInfinity: false })
@NotEquals(0)
@Min(-MAX_MOVEMENT_KG)
@Max(MAX_MOVEMENT_KG)
deltaKg!: number;
@ApiPropertyOptional({
enum: WithdrawalReason,
description: 'Pflicht bei einer Entnahme',
})
@IsOptional()
@IsEnum(WithdrawalReason)
reason?: WithdrawalReason;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
@@ -0,0 +1,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,49 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// HoneyBatch.amount is the running rest of the movement book (spec 7.7): only
// HoneyBatchLedgerService writes it. A second writer would break the
// invariant amount = initialAmount + sum(quantity) / 1000.
//
// The regexes see an `amount` inside the call itself. A spread such as
// `data: { ...dto }` is invisible to them; honey-batches.service.spec.ts
// covers that case.
const SRC = join(__dirname, '..');
const LEDGER = 'src/honey-batches/honey-batch-ledger.service.ts';
function productionSources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return productionSources(path);
}
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [path]
: [];
});
}
function writersOf(...patterns: RegExp[]): string[] {
return productionSources(SRC)
.filter((file) => {
const source = readFileSync(file, 'utf8');
return patterns.some((pattern) => pattern.test(source));
})
.map((file) => `src/${relative(SRC, file).split(sep).join('/')}`)
.sort();
}
describe('honey batch amount writes', () => {
it('honey batch amount is written only by the ledger', () =>
expect(
writersOf(
/honeyBatch\.(update|updateMany|upsert)\([^)]*amount/s,
/UPDATE "HoneyBatch"[^;]*"amount"/,
),
).toEqual([LEDGER]));
it('the guard sees a write: the ledger itself is found', () =>
expect(writersOf(/honeyBatch\.update\(\{[^)]*data: \{ amount/s)).toContain(
LEDGER,
));
});
@@ -0,0 +1,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;
@@ -0,0 +1,417 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
import type { PrismaService } from '../prisma/prisma.service';
interface BatchState {
amount: number | null;
initialAmount: number | null;
/** Sum of the booked grams so far. */
grams: number;
}
describe('HoneyBatchLedgerService', () => {
let service: HoneyBatchLedgerService;
let state: BatchState;
let exists: boolean;
let steps: string[];
let tx: {
$queryRaw: jest.Mock;
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
batchMovement: { aggregate: jest.Mock; create: jest.Mock };
};
let prisma: {
honeyBatch: { findUnique: jest.Mock };
batchMovement: { findMany: jest.Mock };
};
beforeEach(() => {
state = { amount: 25, initialAmount: 25, grams: 0 };
exists = true;
steps = [];
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock');
return Promise.resolve(exists ? [{ id: 'h1' }] : []);
}),
honeyBatch: {
findUnique: jest.fn(() => {
steps.push('read');
return Promise.resolve({
amount: state.amount,
initialAmount: state.initialAmount,
});
}),
update: jest.fn(({ data }: { data: Partial<BatchState> }) => {
steps.push('write');
state = { ...state, ...data };
return Promise.resolve({});
}),
},
batchMovement: {
aggregate: jest.fn(() =>
Promise.resolve({
_sum: { quantity: state.grams },
_count: { _all: state.grams === 0 ? 0 : 1 },
}),
),
create: jest.fn(({ data }: { data: { quantity: number } }) => {
state = { ...state, grams: state.grams + data.quantity };
return Promise.resolve({ id: 'm1', ...data });
}),
},
};
prisma = {
honeyBatch: { findUnique: jest.fn() },
batchMovement: { findMany: jest.fn() },
};
service = new HoneyBatchLedgerService(prisma as unknown as PrismaService);
});
const book = (
deltaKg: number,
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL' = 'BOTTLING',
extra: Record<string, unknown> = {},
) =>
service.bookIn(tx as never, {
honeyBatchId: 'h1',
deltaKg,
type,
actingUserId: 'user-1',
...extra,
});
it('books in grams and rounds the amount to the gram', async () => {
const result = await book(-5.0004, 'BOTTLING', {
sourceBottlingBatchId: 'b1',
});
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: {
honeyBatchId: 'h1',
type: 'BOTTLING',
quantity: -5000,
reason: null,
notes: null,
sourceBottlingBatchId: 'b1',
userId: 'user-1',
},
});
expect(result).toEqual({ amountKg: 20, movementId: 'm1' });
expect(state.amount).toBe(20);
});
it('rounds half a gram up with floor(x*1000+0.5), like the migration', async () => {
await book(0.0125, 'CORRECTION');
await book(-0.0125, 'CORRECTION');
const quantities = (
tx.batchMovement.create.mock.calls as [{ data: { quantity: number } }][]
).map(([arg]) => arg.data.quantity);
expect(quantities).toEqual([13, -12]);
});
it('books exactly the rest when it is off the gram grid, and ends at 0', async () => {
state = { amount: 3.0004, initialAmount: 3.0004, grams: 0 };
const result = await book(-3.0004);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({ quantity: -3000 }) as unknown,
});
expect(result.amountKg).toBe(0);
expect(state.grams).toBe(-3000);
});
it('a reason on a correction is stored', async () => {
await book(1, 'CORRECTION', { reason: 'SONSTIGES' });
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({ reason: 'SONSTIGES' }) as unknown,
});
});
it('locks the batch before it reads it', async () => {
await book(-1);
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
});
it('refuses a booking larger than the rest with the German message', async () => {
state = { amount: 3, initialAmount: 25, grams: -22000 };
await expect(book(-6)).rejects.toThrow(
new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
),
);
expect(tx.batchMovement.create).not.toHaveBeenCalled();
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
});
it('accepts a booking within half a gram of the rest', async () => {
state = { amount: 3, initialAmount: 25, grams: -22000 };
const result = await book(-3.0004);
expect(result.amountKg).toBe(0);
});
it('a correction or withdrawal below zero says the amount cannot go below 0', async () => {
state = { amount: 1, initialAmount: 1, grams: 0 };
await expect(book(-2, 'CORRECTION')).rejects.toThrow(
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
);
await expect(book(-2, 'WITHDRAWAL')).rejects.toThrow(
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
);
});
it('gives back with a plus booking and a note', async () => {
state = { amount: 20, initialAmount: 25, grams: -5000 };
const result = await book(5, 'BOTTLING', { notes: 'Abfüllung entfernt' });
expect(result.amountKg).toBe(25);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
quantity: 5000,
notes: 'Abfüllung entfernt',
}) as unknown,
});
});
it('a correction books the difference new minus current', async () => {
state = { amount: 20, initialAmount: 25, grams: -5000 };
await book(22.5 - 20, 'CORRECTION', { notes: 'Nachgewogen' });
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'CORRECTION',
quantity: 2500,
notes: 'Nachgewogen',
}) as unknown,
});
expect(state.amount).toBe(22.5);
});
it('a withdrawal carries its reason', async () => {
await book(-1, 'WITHDRAWAL', { reason: 'EIGENBEDARF' });
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({ reason: 'EIGENBEDARF' }) as unknown,
});
});
it('sets the amount from the ledger sum, never from a stored value that drifted', async () => {
state = { amount: 9.9, initialAmount: 10, grams: -2000 };
const result = await book(-1);
expect(result.amountKg).toBe(7);
});
it('books nothing for a batch without initialAmount', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
const result = await book(-5);
expect(result).toEqual({ amountKg: null, movementId: null });
expect(tx.batchMovement.create).not.toHaveBeenCalled();
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
});
it('books nothing for a delta below one gram', async () => {
const result = await book(0.0003);
expect(result).toEqual({ amountKg: 25, movementId: null });
expect(tx.batchMovement.create).not.toHaveBeenCalled();
});
it('answers 404 for a missing batch', async () => {
exists = false;
await expect(book(-1)).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses the root client', async () => {
await expect(
service.bookIn({ $connect: jest.fn() } as never, {
honeyBatchId: 'h1',
deltaKg: -1,
type: 'BOTTLING',
actingUserId: null,
}),
).rejects.toThrow('needs the transaction client');
});
describe('setInitialIn', () => {
it('sets amount and initialAmount together, without a booking', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
await service.setInitialIn(tx as never, 'h1', 12.5);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 12.5, initialAmount: 12.5 },
});
expect(tx.batchMovement.create).not.toHaveBeenCalled();
});
it('rounds the amount to the gram, like a booking', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
await service.setInitialIn(tx as never, 'h1', 12.3456);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 12.346, initialAmount: 12.346 },
});
});
it('refuses a batch that already has an initial amount', async () => {
await expect(
service.setInitialIn(tx as never, 'h1', 12.5),
).rejects.toThrow('initialAmount already set');
});
it('refuses a negative or non-finite amount', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
await expect(service.setInitialIn(tx as never, 'h1', -1)).rejects.toThrow(
BadRequestException,
);
await expect(
service.setInitialIn(tx as never, 'h1', Number.NaN),
).rejects.toThrow(BadRequestException);
});
});
describe('clearIn', () => {
it('removes amount and initialAmount while nothing is booked', async () => {
await service.clearIn(tx as never, 'h1');
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: null, initialAmount: null },
});
});
it('refuses with the ledger message once there are bookings', async () => {
state = { ...state, grams: -5000 };
await expect(service.clearIn(tx as never, 'h1')).rejects.toThrow(
new BadRequestException(
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
),
);
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
});
});
describe('movements', () => {
it('lists the bookings as views, newest first', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
const createdAt = new Date('2026-10-01T10:00:00Z');
prisma.batchMovement.findMany.mockResolvedValue([
{
id: 'm1',
type: 'BOTTLING',
quantity: -5000,
reason: null,
notes: null,
sourceBottlingBatchId: 'b1',
createdAt,
userId: 'user-1',
shopRequestId: null,
apiKeyId: null,
},
]);
const result = await service.movements('h1');
expect(prisma.batchMovement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { honeyBatchId: 'h1' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 201,
}),
);
expect(result).toEqual({
items: [
{
id: 'm1',
type: 'BOTTLING',
quantityGrams: -5000,
reason: null,
notes: null,
sourceBottlingBatchId: 'b1',
createdAt,
userId: 'user-1',
},
],
hasMore: false,
});
});
it('returns 200 bookings and says there are more', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
const row = (i: number) => ({
id: `m${i}`,
type: 'BOTTLING',
quantity: -1,
reason: null,
notes: null,
sourceBottlingBatchId: null,
createdAt: new Date(),
userId: null,
});
prisma.batchMovement.findMany.mockResolvedValue(
Array.from({ length: 201 }, (_, i) => row(i)),
);
const result = await service.movements('h1');
expect(result.items).toHaveLength(200);
expect(result.hasMore).toBe(true);
});
it('reads one booking through the given client', async () => {
const createdAt = new Date('2026-10-01T10:00:00Z');
const db = {
batchMovement: {
findUniqueOrThrow: jest.fn().mockResolvedValue({
id: 'm2',
type: 'WITHDRAWAL',
quantity: -1500,
reason: 'BRUCH',
notes: null,
sourceBottlingBatchId: null,
createdAt,
userId: 'user-1',
}),
},
};
const view = await service.movementView(db as never, 'm2');
expect(db.batchMovement.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: 'm2' },
});
expect(view).toMatchObject({
id: 'm2',
quantityGrams: -1500,
reason: 'BRUCH',
});
});
it('answers 404 for an unknown batch', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(service.movements('x')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
});
@@ -0,0 +1,282 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
Prisma,
type BatchMovementType,
type WithdrawalReason,
} from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
/** Half a gram in kg: the tolerance of the ledger (spec 4.6). */
const TOLERANCE_KG = 0.0005;
const GRAMS_PER_KG = 1000;
export const INSUFFICIENT_AMOUNT_MESSAGE =
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
export const BELOW_ZERO_MESSAGE = 'Die Menge kann nicht unter 0 sinken.';
export const LEDGER_MANAGED_MESSAGE =
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.';
export interface LedgerBooking {
honeyBatchId: string;
deltaKg: number;
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL';
reason?: WithdrawalReason | null;
notes?: string | null;
sourceBottlingBatchId?: string | null;
actingUserId: string | null;
}
export interface LedgerResult {
/** The rest after the booking; null for a batch whose amount is not kept. */
amountKg: number | null;
/** null when nothing was booked. */
movementId: string | null;
}
export interface HoneyBatchMovementView {
id: string;
type: BatchMovementType;
quantityGrams: number;
reason: WithdrawalReason | null;
notes: string | null;
sourceBottlingBatchId: string | null;
createdAt: Date;
userId: string | null;
}
function toView(row: {
id: string;
type: BatchMovementType;
quantity: number;
reason: WithdrawalReason | null;
notes: string | null;
sourceBottlingBatchId: string | null;
createdAt: Date;
userId: string | null;
}): HoneyBatchMovementView {
return {
id: row.id,
type: row.type,
quantityGrams: row.quantity,
reason: row.reason,
notes: row.notes,
sourceBottlingBatchId: row.sourceBottlingBatchId,
createdAt: row.createdAt,
userId: row.userId,
};
}
/** One rounding for every gram: floor(x * 1000 + 0.5), as in the migration. */
function toGrams(kg: number): number {
return Math.floor(kg * GRAMS_PER_KG + 0.5);
}
/** A kg amount on the gram grid. */
export function roundToGram(kg: number): number {
return toGrams(kg) / GRAMS_PER_KG;
}
/** Bookings returned by `movements`; more are reported, not sent. */
export const MOVEMENTS_PAGE_SIZE = 200;
/** The rest implied by the ledger, on the gram grid, never below 0. */
function restFromLedger(initialAmountKg: number, grams: number): number {
return Math.max(
0,
Math.round((initialAmountKg + grams / GRAMS_PER_KG) * GRAMS_PER_KG) /
GRAMS_PER_KG,
);
}
/**
* The movement book of a honey batch (spec 4.6, 7.7): the ONE place that
* writes HoneyBatch.amount. Invariant: amount = initialAmount + sum(quantity)
* / 1000, rounded to the gram, never below 0. A batch without initialAmount
* is not booked (its amount is not recorded). Every method runs in the
* caller's transaction, so the booking commits or rolls back together with
* the business write behind it.
*/
@Injectable()
export class HoneyBatchLedgerService {
constructor(private readonly prisma: PrismaService) {}
/**
* Books a change of the rest under the batch row lock. A negative delta
* needs enough rest (half a gram of tolerance), a positive one gives back.
* The stored amount is set from the ledger sum, so it cannot drift.
*/
async bookIn(
tx: Prisma.TransactionClient,
booking: LedgerBooking,
): Promise<LedgerResult> {
assertTransactionClient(tx, 'HoneyBatchLedgerService.bookIn');
const { honeyBatchId } = booking;
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
throw new NotFoundException('Honey batch not found');
}
const batch = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true, initialAmount: true },
});
if (batch === null) {
throw new NotFoundException('Honey batch not found');
}
if (batch.initialAmount === null) {
return { amountKg: null, movementId: null };
}
let grams = toGrams(booking.deltaKg);
if (grams === 0) {
return { amountKg: batch.amount, movementId: null };
}
const rest = batch.amount ?? 0;
if (grams < 0 && rest < -grams / GRAMS_PER_KG - TOLERANCE_KG) {
throw new BadRequestException(
booking.type === 'BOTTLING'
? INSUFFICIENT_AMOUNT_MESSAGE
: BELOW_ZERO_MESSAGE,
);
}
// Within the tolerance a booking takes exactly the rest on the gram
// grid, so the sum of the bookings and the amount stay equal.
const restGrams = toGrams(rest);
if (grams < 0 && -grams > restGrams) {
grams = -restGrams;
}
if (grams === 0) {
return { amountKg: batch.amount, movementId: null };
}
const booked = await tx.batchMovement.aggregate({
where: { honeyBatchId },
_sum: { quantity: true },
});
const amountKg = restFromLedger(
batch.initialAmount,
(booked._sum.quantity ?? 0) + grams,
);
const movement = await tx.batchMovement.create({
data: {
honeyBatchId,
type: booking.type,
quantity: grams,
reason: booking.reason ?? null,
notes: booking.notes ?? null,
sourceBottlingBatchId: booking.sourceBottlingBatchId ?? null,
userId: booking.actingUserId,
},
});
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: amountKg },
});
return { amountKg, movementId: movement.id };
}
/**
* The first amount of a batch that had none: it becomes the rest and the
* initial amount at once, without a booking. For a new batch with an amount
* and for the PATCH from "not recorded" to a value.
*/
async setInitialIn(
tx: Prisma.TransactionClient,
honeyBatchId: string,
amountKg: number,
): Promise<void> {
assertTransactionClient(tx, 'HoneyBatchLedgerService.setInitialIn');
if (!Number.isFinite(amountKg) || amountKg < 0) {
throw new BadRequestException('Die Menge muss eine Zahl ab 0 sein.');
}
const batch = await this.lockedBatch(tx, honeyBatchId);
const rounded = roundToGram(amountKg);
if (batch.initialAmount !== null) {
throw new Error(
`HoneyBatchLedgerService.setInitialIn: initialAmount already set for ${honeyBatchId}`,
);
}
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: rounded, initialAmount: rounded },
});
}
/**
* Takes the amount away again ("not recorded"). Only while nothing is
* booked: with bookings the amount is corrected, not erased.
*/
async clearIn(
tx: Prisma.TransactionClient,
honeyBatchId: string,
): Promise<void> {
assertTransactionClient(tx, 'HoneyBatchLedgerService.clearIn');
const batch = await this.lockedBatch(tx, honeyBatchId);
if (batch.initialAmount === null) {
return;
}
const booked = await tx.batchMovement.aggregate({
where: { honeyBatchId },
_count: { _all: true },
});
if (booked._count._all > 0) {
throw new BadRequestException(LEDGER_MANAGED_MESSAGE);
}
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: null, initialAmount: null },
});
}
/** The bookings of one batch, newest first. */
async movements(honeyBatchId: string): Promise<{
items: HoneyBatchMovementView[];
hasMore: boolean;
}> {
const batch = await this.prisma.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { id: true },
});
if (batch === null) {
throw new NotFoundException('Honey batch not found');
}
const rows = await this.prisma.batchMovement.findMany({
where: { honeyBatchId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: MOVEMENTS_PAGE_SIZE + 1,
});
return {
items: rows.slice(0, MOVEMENTS_PAGE_SIZE).map(toView),
hasMore: rows.length > MOVEMENTS_PAGE_SIZE,
};
}
/** One booking, read through `db` (a transaction sees its own writes). */
async movementView(
db: DbClient,
movementId: string,
): Promise<HoneyBatchMovementView> {
return toView(
await db.batchMovement.findUniqueOrThrow({ where: { id: movementId } }),
);
}
private async lockedBatch(
tx: Prisma.TransactionClient,
honeyBatchId: string,
) {
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
throw new NotFoundException('Honey batch not found');
}
const batch = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true, initialAmount: true },
});
if (batch === null) {
throw new NotFoundException('Honey batch not found');
}
return batch;
}
}
@@ -15,18 +15,25 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
import { HoneyBatchPrefillQuery } from './dto/honey-batch-prefill.query';
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
import { HoneyBatchesService } from './honey-batches.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('honey-batches')
@ApiBearerAuth()
@Controller('honey-batches')
export class HoneyBatchesController {
constructor(private readonly honeyBatchesService: HoneyBatchesService) {}
constructor(
private readonly honeyBatchesService: HoneyBatchesService,
private readonly ledger: HoneyBatchLedgerService,
) {}
@RequirePermission('honey-batches:read')
@Get()
@@ -34,12 +41,39 @@ 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) {
return this.honeyBatchesService.findById(id);
}
// The movement book: every change of the rest, newest first.
@RequirePermission('honey-batches:read')
@Get(':id/movements')
movements(@Param('id') id: string) {
return this.ledger.movements(id);
}
@RequirePermission('honey-batches:update')
@Idempotent()
@Post(':id/movements')
createMovement(
@Param('id') id: string,
@Body() dto: CreateHoneyBatchMovementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.honeyBatchesService.createMovement(id, dto, actor.id);
}
@RequirePermission('honey-batches:read')
@Get(':id/traceability')
traceability(@Param('id') id: string) {
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { HoneyBatchesController } from './honey-batches.controller';
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
import { HoneyBatchesService } from './honey-batches.service';
@Module({
imports: [AutomationModule],
controllers: [HoneyBatchesController],
providers: [HoneyBatchesService],
exports: [HoneyBatchesService],
providers: [HoneyBatchesService, HoneyBatchLedgerService],
exports: [HoneyBatchesService, HoneyBatchLedgerService],
})
export class HoneyBatchesModule {}
@@ -1,9 +1,14 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { HoneyBatchesService } from './honey-batches.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service';
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
describe('HoneyBatchesService', () => {
let service: HoneyBatchesService;
@@ -11,6 +16,7 @@ describe('HoneyBatchesService', () => {
honeyBatch: {
findMany: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
@@ -19,6 +25,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 +35,18 @@ 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 };
let ledger: {
bookIn: jest.Mock;
setInitialIn: jest.Mock;
clearIn: jest.Mock;
movements: jest.Mock;
movementView: jest.Mock;
};
const extraction = {
id: 'extraction-1',
@@ -49,6 +66,7 @@ describe('HoneyBatchesService', () => {
const honeyBatch = {
id: 'batch-1',
batchNumber: 'HC-2026-001',
extractionId: 'extraction-1',
honeyType: 'Blütenhonig',
amount: 20,
status: 'RAW',
@@ -61,6 +79,7 @@ describe('HoneyBatchesService', () => {
honeyBatch: {
findMany: jest.fn(),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
@@ -69,6 +88,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 +97,29 @@ 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) };
ledger = {
bookIn: jest.fn().mockResolvedValue({ amountKg: 20, movementId: 'm1' }),
setInitialIn: jest.fn().mockResolvedValue(undefined),
clearIn: jest.fn().mockResolvedValue(undefined),
movements: jest.fn().mockResolvedValue([]),
movementView: jest.fn(),
};
service = new HoneyBatchesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
ledger as unknown as HoneyBatchLedgerService,
);
});
@@ -97,20 +130,124 @@ 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,
});
});
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: 8, amount: 8 },
]);
const result = await service.create(dto, 'user-1');
expect(result.warnings.map((warning) => warning.code)).toEqual([
'BATCHES_EXCEED_EXTRACTION',
]);
});
it('create writes amount and initialAmount together, through the ledger', async () => {
await service.create({ ...dto, amount: 20 }, 'user-1');
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
[{ data: Record<string, unknown> }],
];
expect(createArg.data).not.toHaveProperty('amount');
expect(createArg.data).not.toHaveProperty('initialAmount');
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 20);
});
it('the audit entry of a new batch shows the amount the ledger set', async () => {
const withAmount = { ...honeyBatch, amount: 20, initialAmount: 20 };
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(withAmount);
await service.create({ ...dto, amount: 20 }, 'user-1');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'CREATE', afterData: withAmount }),
prisma,
);
expect(ledger.setInitialIn.mock.invocationCallOrder[0]).toBeLessThan(
prisma.honeyBatch.findUniqueOrThrow.mock.invocationCallOrder[0],
);
});
it('create without an amount books nothing', async () => {
await service.create(dto, 'user-1');
expect(ledger.setInitialIn).not.toHaveBeenCalled();
});
it('a batch without initialAmount does not count against the extraction', async () => {
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: 10,
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 6, amount: 6 },
{ id: 'batch-1', initialAmount: null, amount: 8 },
]);
const result = await service.create(dto, 'user-1');
expect(result.warnings).toEqual([]);
});
});
it('traceability flattens the extraction, harvest, and colony chain', async () => {
@@ -365,11 +502,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 +612,7 @@ describe('HoneyBatchesService', () => {
entityId: 'batch-1',
action: 'UPDATE',
}),
prisma,
);
});
@@ -433,6 +628,291 @@ describe('HoneyBatchesService', () => {
});
});
describe('update of the amount (movement book)', () => {
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
beforeEach(() => {
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
prisma.honeyBatch.findMany.mockResolvedValue([]);
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
});
});
function dataOfUpdate(): Record<string, unknown> {
const [[arg]] = prisma.honeyBatch.update.mock.calls as [
[{ data: Record<string, unknown> }],
];
return arg.data;
}
it('update never passes amount to Prisma, even when the dto carries it', async () => {
await service.update(
'batch-1',
{ amount: 22, honeyType: 'Waldhonig' },
'user-1',
);
expect(dataOfUpdate()).toEqual({ honeyType: 'Waldhonig' });
});
it('books a CORRECTION for the difference new minus current', async () => {
await service.update('batch-1', { amount: 22.5 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
honeyBatchId: 'batch-1',
deltaKg: 2.5,
type: 'CORRECTION',
actingUserId: 'user-1',
});
});
it('rounds the new amount to the gram before it takes the difference', async () => {
await service.update('batch-1', { amount: 22.5004 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(
prisma,
expect.objectContaining({ deltaKg: 2.5 }),
);
});
it('answers 409 for a batch with an initial amount but no rest', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
initialAmount: 25,
amount: null,
});
await expect(
service.update('batch-1', { amount: 22 }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('books nothing when the amount stays the same', async () => {
await service.update('batch-1', { amount: 20 }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('reads the current rest under the batch lock, then books', async () => {
await service.update('batch-1', { amount: 22 }, 'user-1');
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
reads[reads.length - 1],
);
expect(reads[reads.length - 1]).toBeLessThan(
ledger.bookIn.mock.invocationCallOrder[0],
);
});
it('sets the first amount of a batch that had none without a booking', async () => {
const untracked = { ...honeyBatch, initialAmount: null, amount: null };
prisma.honeyBatch.findUnique.mockResolvedValue(untracked);
await service.update('batch-1', { amount: 12 }, 'user-1');
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 12);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('amount null goes to the ledger, which refuses it once bookings exist', async () => {
await service.update('batch-1', { amount: null as never }, 'user-1');
expect(ledger.clearIn).toHaveBeenCalledWith(prisma, 'batch-1');
expect(prisma.honeyBatch.update).not.toHaveBeenCalled();
});
it('passes the 400 of the ledger on', async () => {
ledger.clearIn.mockRejectedValue(new BadRequestException('x'));
await expect(
service.update('batch-1', { amount: null as never }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(events.record).not.toHaveBeenCalled();
});
});
describe('createMovement', () => {
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
beforeEach(() => {
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
ledger.movementView.mockResolvedValue({
id: 'm1',
type: 'WITHDRAWAL',
quantityGrams: -1000,
});
});
it('books a withdrawal with its reason, with audit and event in the transaction', async () => {
const result = await service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: -1, reason: 'EIGENBEDARF' },
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
honeyBatchId: 'batch-1',
deltaKg: -1,
type: 'WITHDRAWAL',
reason: 'EIGENBEDARF',
notes: null,
actingUserId: 'user-1',
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatch',
entityId: 'batch-1',
action: 'UPDATE',
}),
prisma,
);
expect(events.record).toHaveBeenCalledWith(
prisma,
expect.objectContaining({
type: 'HoneyBatchCorrected',
aggregateId: 'batch-1',
}),
);
expect(result).toEqual(
expect.objectContaining({ id: 'm1', quantityGrams: -1000 }),
);
});
it('a withdrawal needs a reason and a negative amount', async () => {
await expect(
service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: -1 },
'user-1',
),
).rejects.toThrow(new BadRequestException('Bitte einen Grund angeben.'));
await expect(
service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' },
'user-1',
),
).rejects.toThrow(
new BadRequestException('Eine Entnahme braucht eine negative Menge.'),
);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('a correction may carry a reason and stores it', async () => {
await service.createMovement(
'batch-1',
{ type: 'CORRECTION', deltaKg: 1, reason: 'SONSTIGES' },
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledWith(
prisma,
expect.objectContaining({ type: 'CORRECTION', reason: 'SONSTIGES' }),
);
});
it('refuses a batch whose amount is not recorded', async () => {
ledger.bookIn.mockResolvedValue({ amountKg: null, movementId: null });
await expect(
service.createMovement(
'batch-1',
{ type: 'CORRECTION', deltaKg: 1 },
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.',
),
);
expect(events.record).not.toHaveBeenCalled();
});
it('answers 404 for an unknown batch', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(
service.createMovement(
'x',
{ type: 'CORRECTION', deltaKg: 1 },
'user-1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('prefill', () => {
it('proposes the rest of the extraction with its honey type and water content', async () => {
prisma.extraction.findUnique.mockResolvedValue({
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,45 +1,71 @@
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 { HoneyBatchStatus } from '../../generated/prisma';
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, type Prisma } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
import {
HoneyBatchLedgerService,
roundToGram,
type HoneyBatchMovementView,
} from './honey-batch-ledger.service';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
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,
private readonly ledger: HoneyBatchLedgerService,
) {}
async findAll(filter: HoneyBatchFilter = {}) {
@@ -61,6 +87,33 @@ export class HoneyBatchesService {
return honeyBatch;
}
/**
* The batches of one extraction with the amount they started with. A batch
* without initialAmount (its amount was never recorded) does not count.
*/
private async batchesOf(db: DbClient, extractionId: string) {
return db.honeyBatch.findMany({
where: { extractionId },
select: { id: true, initialAmount: true },
orderBy: { id: 'asc' },
});
}
/** 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,23 +123,79 @@ 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);
// The amount never goes to Prisma directly: the ledger sets it together
// with the initial amount.
const { amount, ...rest } = dto;
const honeyBatch = await tx.honeyBatch.create({
data: { ...rest, batchNumber },
});
await this.auditLog.record({
if (typeof amount === 'number') {
await this.ledger.setInitialIn(tx, honeyBatch.id, amount);
}
const created = await tx.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatch.id },
include: honeyBatchInclude,
});
await this.auditLog.record(
{
entityType: 'HoneyBatch',
entityId: honeyBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: honeyBatch,
afterData: created,
},
tx,
);
await this.events.record(tx, {
type: 'HoneyBatchRecorded',
aggregateType: 'HoneyBatch',
aggregateId: honeyBatch.id,
payload: { extractionId: honeyBatch.extractionId },
actorUserId: actingUserId,
});
return withWarnings(
created,
await this.warningsFor(tx, created.extractionId),
);
});
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);
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
@@ -97,11 +206,16 @@ export class HoneyBatchesService {
? dto.storageContainerId
: null;
await this.prisma.$transaction(async (tx) => {
const { amount, ...rest } = dto;
if (amount !== undefined) {
await this.changeAmount(tx, before, amount, actingUserId);
}
if (Object.keys(rest).length > 0) {
await tx.honeyBatch.update({
where: { id },
data: { ...dto },
data: { ...rest },
});
}
// Correcting the container has to leave the same trace a deliberate
// relocation does - otherwise the batch's storage history would skip a
// container it was recorded in.
@@ -116,17 +230,177 @@ export class HoneyBatchesService {
},
});
}
const updated = await tx.honeyBatch.findUniqueOrThrow({
where: { id },
include: honeyBatchInclude,
});
const updated = await this.findById(id);
await this.auditLog.record({
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 updated;
return withWarnings(
updated,
await this.warningsFor(tx, updated.extractionId),
);
});
}
/**
* A PATCH of the amount, as a booking (spec 7.7). `before` was read under
* the batch lock, so the difference is taken against the real rest. From
* "not recorded" to a value sets the initial amount; with a recorded amount
* the difference is a CORRECTION; null takes the amount away, which the
* ledger refuses once bookings exist.
*/
private async changeAmount(
tx: Prisma.TransactionClient,
before: { id: string; amount: number | null; initialAmount: number | null },
amount: number | null,
actingUserId: string,
): Promise<void> {
if (amount === null) {
await this.ledger.clearIn(tx, before.id);
return;
}
if (before.initialAmount === null) {
await this.ledger.setInitialIn(tx, before.id, amount);
return;
}
if (before.amount === null) {
throw new ConflictException(
'Die Menge der Honigcharge ist uneinheitlich gespeichert. Bitte die Charge neu laden oder den Support fragen.',
);
}
const deltaKg = roundKg(roundToGram(amount) - before.amount);
if (deltaKg === 0) {
return;
}
await this.ledger.bookIn(tx, {
honeyBatchId: before.id,
deltaKg,
type: 'CORRECTION',
actingUserId,
});
}
/**
* A manual booking in the movement book: a withdrawal (loose goods, sample,
* breakage) or a correction after weighing again. Bottlings book
* themselves. Booking, audit entry and event commit together.
*/
async createMovement(
id: string,
dto: CreateHoneyBatchMovementDto,
actingUserId: string,
): Promise<HoneyBatchMovementView> {
if (dto.type === 'WITHDRAWAL') {
if (dto.reason === undefined) {
throw new BadRequestException('Bitte einen Grund angeben.');
}
if (dto.deltaKg > 0) {
throw new BadRequestException(
'Eine Entnahme braucht eine negative Menge.',
);
}
}
return this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'HoneyBatch', id);
const before = await tx.honeyBatch.findUnique({
where: { id },
include: honeyBatchInclude,
});
if (!before) {
throw new NotFoundException('Honey batch not found');
}
const booked = await this.ledger.bookIn(tx, {
honeyBatchId: id,
deltaKg: dto.deltaKg,
type: dto.type,
reason: dto.reason ?? null,
notes: dto.notes ?? null,
actingUserId,
});
if (booked.movementId === null) {
throw new BadRequestException(
booked.amountKg === null
? 'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.'
: 'Die Buchung ist kleiner als ein Gramm.',
);
}
const updated = await tx.honeyBatch.findUniqueOrThrow({
where: { id },
include: honeyBatchInclude,
});
await this.auditLog.record(
{
entityType: 'HoneyBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
await this.events.record(tx, {
type: 'HoneyBatchCorrected',
aggregateType: 'HoneyBatch',
aggregateId: id,
payload: { extractionId: updated.extractionId },
actorUserId: actingUserId,
});
return this.ledger.movementView(tx, booked.movementId);
});
}
/**
* What the form may offer for a new batch of the extraction (spec 7.6): the
* rest of its amount after the existing batches, and its honey type (the
* 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 } } } },
},
});
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 })),
],
},
];
}
+57 -2
View File
@@ -1,12 +1,15 @@
import { Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import {
CleanupJob,
DOMAIN_EVENT_RETENTION_DAYS,
FAILED_RETENTION_DAYS,
JOB_RUN_RETENTION_DAYS,
JOB_RUN_STALE_HOURS,
PENDING_DELIVERY_MAX_AGE_DAYS,
WEBHOOK_DELIVERY_RETENTION_DAYS,
} from './cleanup.job';
@@ -59,6 +62,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();
@@ -74,8 +87,13 @@ describe('CleanupJob', () => {
'domainEvents.failed': 12,
'webhookDeliveries.delivered': 13,
'webhookDeliveries.failed': 14,
'notificationDeliveries.done': 15,
'notificationDeliveries.failed': 16,
'notificationDeliveries.pending': 17,
telegramLinkTokens: 18,
'jobRuns.aborted': 1,
'jobRuns.deleted': 15,
'jobRuns.deleted': 19,
weatherDays: 20,
});
});
@@ -112,11 +130,48 @@ describe('CleanupJob', () => {
'"createdAt" < now() - (? * interval \'1 day\')',
);
// PENDING and PROCESSING entries are never touched, however old.
for (const { sql } of statements()) {
for (const { sql } of statements().filter(
({ sql }) => !sql.includes('FROM "NotificationDelivery"'),
)) {
expect(sql).not.toMatch(/PENDING|PROCESSING/);
}
});
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', async () => {
await job.run();
const failed = statementOn('NotificationDelivery', 'FAILED');
expect(failed.sql).toContain(
'"createdAt" < now() - (? * interval \'1 day\')',
);
expect(failed.values).toEqual([FAILED_RETENTION_DAYS]);
const delivered = statements().find(
({ sql }) =>
sql.includes('DELETE FROM "NotificationDelivery"') &&
sql.includes("IN ('DELIVERED', 'SKIPPED')"),
);
expect(delivered?.values).toEqual([WEBHOOK_DELIVERY_RETENTION_DAYS]);
expect(delivered?.sql).toContain('"createdAt" < now()');
const tokens = statementOn('TelegramLinkToken');
expect(tokens.sql).toContain('"expiresAt" < now() - interval \'30 days\'');
expect(tokens.values).toEqual([]);
});
it('removes PENDING NotificationDelivery rows older than 7 days and logs the count', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
await job.run();
const { sql, values } = statementOn('NotificationDelivery', 'PENDING');
expect(sql).toContain('"createdAt" < now() - (? * interval \'1 day\')');
expect(values).toEqual([PENDING_DELIVERY_MAX_AGE_DAYS]);
expect(PENDING_DELIVERY_MAX_AGE_DAYS).toBe(7);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('17 Telegram'));
warn.mockRestore();
});
it('deletes job runs after 90 days, only after stale runs were aborted', async () => {
await job.run();
+41 -1
View File
@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
@@ -13,6 +14,8 @@ export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
/** FAILED events and deliveries stay longer, for diagnosis and retry. */
export const FAILED_RETENTION_DAYS = 90;
export const JOB_RUN_RETENTION_DAYS = 90;
/** A Telegram delivery that waited this long is stale anyway (24 h at most). */
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
export const JOB_RUN_STALE_HOURS = 6;
@@ -23,6 +26,7 @@ export const JOB_RUN_STALE_HOURS = 6;
*/
@Injectable()
export class CleanupJob implements Job {
private readonly logger = new Logger(CleanupJob.name);
readonly name: JobName = 'cleanup';
constructor(
@@ -59,10 +63,41 @@ export class CleanupJob implements Job {
DELETE FROM "WebhookDelivery"
WHERE "status" = 'FAILED'
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
// Template deliveries (a reply to a chat) carry no user, so the user
// cascade never reaches them: they go by age like all the others.
const notificationDeliveriesDone = await this.prisma.$executeRaw`
DELETE FROM "NotificationDelivery"
WHERE "status" IN ('DELIVERED', 'SKIPPED')
AND "createdAt" < now() - (${WEBHOOK_DELIVERY_RETENTION_DAYS} * interval '1 day')`;
const notificationDeliveriesFailed = await this.prisma.$executeRaw`
DELETE FROM "NotificationDelivery"
WHERE "status" = 'FAILED'
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
// Unsent for a week (bot token never fixed, loop off): the sender would
// skip it as stale anyway. The only PENDING rows this job removes.
const notificationDeliveriesPending = await this.prisma.$executeRaw`
DELETE FROM "NotificationDelivery"
WHERE "status" = 'PENDING'
AND "createdAt" < now() - (${PENDING_DELIVERY_MAX_AGE_DAYS} * interval '1 day')`;
if (notificationDeliveriesPending > 0) {
this.logger.warn(
`Removed ${notificationDeliveriesPending} Telegram deliveries that stayed pending for over ${PENDING_DELIVERY_MAX_AGE_DAYS} days`,
);
}
// A link token lives 10 minutes; used or not, it is of no use after that.
const telegramLinkTokens = await this.prisma.$executeRaw`
DELETE FROM "TelegramLinkToken"
WHERE "expiresAt" < now() - interval '30 days'`;
// After abortStale: no run this old can still be RUNNING.
const jobRunsDeleted = await this.prisma.$executeRaw`
DELETE FROM "JobRun"
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,
@@ -71,8 +106,13 @@ export class CleanupJob implements Job {
'domainEvents.failed': domainEventsFailed,
'webhookDeliveries.delivered': webhookDeliveriesDelivered,
'webhookDeliveries.failed': webhookDeliveriesFailed,
'notificationDeliveries.done': notificationDeliveriesDone,
'notificationDeliveries.failed': notificationDeliveriesFailed,
'notificationDeliveries.pending': notificationDeliveriesPending,
telegramLinkTokens,
'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted,
weatherDays,
};
}
}
@@ -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];
+2
View File
@@ -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,202 @@
import type { Notification } from '../../generated/prisma';
import { berlinDateTime } from '../common/dates';
import type { DbClient } from '../prisma/db-client';
import { NotificationDeliveryPlanner } from './delivery-planner';
const NOW = berlinDateTime('2026-10-13', '04:30');
function notification(
id: string,
userId: string,
category: Notification['category'],
): Notification {
return { id, userId, category } as Notification;
}
interface Preference {
userId: string;
telegramCategories: Notification['category'][];
digestTime: string;
quietFrom: string | null;
quietUntil: string | null;
}
const preference = (
userId: string,
overrides: Partial<Preference> = {},
): Preference => ({
userId,
telegramCategories: ['TASK_DUE', 'DIGEST'],
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
...overrides,
});
describe('NotificationDeliveryPlanner', () => {
let db: {
notificationPreference: { findMany: jest.Mock };
notificationDelivery: { createMany: jest.Mock };
$queryRaw: jest.Mock;
$executeRaw: jest.Mock;
};
let planner: NotificationDeliveryPlanner;
const created = () =>
(
db.notificationDelivery.createMany.mock.calls[0] as [
{
data: {
channel: string;
userId: string;
notificationId: string;
availableAt: Date;
}[];
skipDuplicates: boolean;
},
]
)[0];
beforeEach(() => {
db = {
notificationPreference: { findMany: jest.fn().mockResolvedValue([]) },
notificationDelivery: {
createMany: jest
.fn()
.mockImplementation(({ data }: { data: unknown[] }) =>
Promise.resolve({ count: data.length }),
),
},
$queryRaw: jest.fn().mockResolvedValue([{ now: NOW }]),
$executeRaw: jest.fn().mockResolvedValue(1),
};
planner = new NotificationDeliveryPlanner();
});
const plan = (notifications: Notification[]) =>
planner.planIn(db as unknown as DbClient, notifications);
it('plans nothing for an empty list and asks no question', async () => {
expect(await plan([])).toBe(0);
expect(db.notificationPreference.findMany).not.toHaveBeenCalled();
});
it('asks only for users with Telegram switched on and a link', async () => {
await plan([notification('n1', 'u1', 'TASK_DUE')]);
expect(db.notificationPreference.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: { in: ['u1'] },
telegramEnabled: true,
user: { telegramLink: { isNot: null } },
},
}),
);
});
it('plans nothing without an eligible user (no row, nothing sent to the worker)', async () => {
expect(await plan([notification('n1', 'u1', 'TASK_DUE')])).toBe(0);
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
expect(db.$executeRaw).not.toHaveBeenCalled();
});
it('plans a TELEGRAM row for a category the user chose, available after the quiet hours', async () => {
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
const count = await plan([notification('n1', 'u1', 'TASK_DUE')]);
expect(count).toBe(1);
expect(created().skipDuplicates).toBe(true);
expect(created().data).toEqual([
{
channel: 'TELEGRAM',
userId: 'u1',
notificationId: 'n1',
availableAt: berlinDateTime('2026-10-13', '06:31'),
},
]);
});
it('skips a category the user did not switch on', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1', { telegramCategories: ['DIGEST'] }),
]);
const count = await plan([
notification('n1', 'u1', 'TASK_DUE'),
notification('n2', 'u1', 'SUGGESTION'),
]);
expect(count).toBe(0);
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
});
it('plans each notification with the settings of its own user', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1', { quietFrom: null, quietUntil: null }),
preference('u2', { telegramCategories: ['SYSTEM'] }),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'SYSTEM'),
notification('n3', 'u3', 'SYSTEM'),
]);
expect(created().data).toEqual([
expect.objectContaining({
userId: 'u1',
availableAt: berlinDateTime('2026-10-13', '06:30'),
}),
expect.objectContaining({
userId: 'u2',
// 04:30 is inside the quiet hours of u2.
availableAt: berlinDateTime('2026-10-13', '06:30'),
}),
]);
});
it('reads the clock of the database once for the whole list', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1'),
preference('u2'),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'DIGEST'),
]);
expect(db.$queryRaw).toHaveBeenCalledTimes(1);
});
it('wakes the worker once through notification_delivery after inserting', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1'),
preference('u2'),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'DIGEST'),
]);
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
const [strings, channel] = db.$executeRaw.mock.calls[0] as [
readonly string[],
string,
];
expect(strings.join('?')).toContain('pg_notify');
expect(channel).toBe('notification_delivery');
});
it('does not wake the worker if every row existed already', async () => {
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
db.notificationDelivery.createMany.mockResolvedValue({ count: 0 });
expect(await plan([notification('n1', 'u1', 'DIGEST')])).toBe(0);
expect(db.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import type { Notification } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import type { DbClient } from '../prisma/db-client';
import { deliveryAvailableAt } from './delivery-time';
/**
* Queues the Telegram delivery of new notifications (Spec 9a.4). Runs in the
* transaction that wrote the notifications, so a rollback takes the
* deliveries with it. Only users who switched Telegram on, hold a link and
* chose the category get a row; its text is rendered at send time.
*/
@Injectable()
export class NotificationDeliveryPlanner {
/** Returns how many deliveries were queued. */
async planIn(
db: DbClient,
notifications: readonly Notification[],
): Promise<number> {
if (notifications.length === 0) {
return 0;
}
const userIds = [...new Set(notifications.map((row) => row.userId))];
const preferences = await db.notificationPreference.findMany({
where: {
userId: { in: userIds },
telegramEnabled: true,
user: { telegramLink: { isNot: null } },
},
select: {
userId: true,
telegramCategories: true,
digestTime: true,
quietFrom: true,
quietUntil: true,
},
});
if (preferences.length === 0) {
return 0;
}
const byUser = new Map(preferences.map((row) => [row.userId, row]));
const now = await this.databaseNow(db);
const data = notifications.flatMap((row) => {
const preference = byUser.get(row.userId);
if (!preference?.telegramCategories.includes(row.category)) {
return [];
}
return [
{
channel: 'TELEGRAM' as const,
userId: row.userId,
notificationId: row.id,
availableAt: deliveryAvailableAt(now, row.category, preference),
},
];
});
if (data.length === 0) {
return 0;
}
// A repeated call (retried handler) finds the row of its notification.
const { count } = await db.notificationDelivery.createMany({
data,
skipDuplicates: true,
});
if (count > 0) {
await notifyWorker(db, WAKE_CHANNELS.notificationDeliveries);
}
return count;
}
/** "now" once, from the database clock the claim compares against. */
private async databaseNow(db: DbClient): Promise<Date> {
const [row] = await db.$queryRaw<{ now: Date }[]>`SELECT now() AS "now"`;
return row.now;
}
}
@@ -0,0 +1,194 @@
import { berlinDateTime } from '../common/dates';
import {
DeliveryPreferences,
deliveryAvailableAt,
isInQuietHours,
} from './delivery-time';
const DEFAULTS: DeliveryPreferences = {
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
};
const NO_QUIET: DeliveryPreferences = {
digestTime: '06:30',
quietFrom: null,
quietUntil: null,
};
const at = (day: string, hhmm: string): Date => berlinDateTime(day, hhmm);
describe('isInQuietHours', () => {
it('is false without quiet hours', () => {
expect(isInQuietHours(at('2026-10-13', '23:30'), NO_QUIET)).toBe(false);
});
it('spans midnight: 21:00 up to, not including, 06:30', () => {
expect(isInQuietHours(at('2026-10-13', '20:59'), DEFAULTS)).toBe(false);
expect(isInQuietHours(at('2026-10-13', '21:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-13', '23:59'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '00:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '06:29'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '06:30'), DEFAULTS)).toBe(false);
});
it('handles a window inside one day', () => {
const prefs = { ...NO_QUIET, quietFrom: '13:00', quietUntil: '15:00' };
expect(isInQuietHours(at('2026-10-13', '12:59'), prefs)).toBe(false);
expect(isInQuietHours(at('2026-10-13', '13:00'), prefs)).toBe(true);
expect(isInQuietHours(at('2026-10-13', '15:00'), prefs)).toBe(false);
});
it('reads the Berlin wall clock in summer and winter time', () => {
expect(isInQuietHours(at('2026-07-01', '22:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-01-15', '22:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-01-15', '12:00'), DEFAULTS)).toBe(false);
});
it('treats equal bounds as no quiet hours', () => {
const prefs = { ...NO_QUIET, quietFrom: '21:00', quietUntil: '21:00' };
expect(isInQuietHours(at('2026-10-13', '22:00'), prefs)).toBe(false);
});
});
describe('deliveryAvailableAt', () => {
it('a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day', () => {
const now = at('2026-10-13', '22:00');
expect(deliveryAvailableAt(now, 'SUGGESTION', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('a delivery created after midnight waits until 06:30 the same day', () => {
const now = at('2026-10-14', '02:15');
expect(deliveryAvailableAt(now, 'SYSTEM', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('TASK_DUE waits until one minute after the digest time, DIGEST until the digest time', () => {
const now = at('2026-10-13', '04:30');
const prefs = { ...NO_QUIET, digestTime: '07:15' };
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
at('2026-10-13', '07:16'),
);
expect(deliveryAvailableAt(now, 'DIGEST', prefs)).toEqual(
at('2026-10-13', '07:15'),
);
});
it('with default quiet hours a task due at 04:30 comes at 06:31 and the digest at 06:30', () => {
const now = at('2026-10-13', '04:30');
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(
at('2026-10-13', '06:31'),
);
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(
at('2026-10-13', '06:30'),
);
});
it('never goes back in time: after the digest time the category waits no longer', () => {
const now = at('2026-10-13', '09:00');
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(now);
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(now);
});
it('a category time that falls into quiet hours moves to the end of them', () => {
const now = at('2026-10-13', '04:30');
const prefs = { ...DEFAULTS, quietUntil: '07:00' };
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
at('2026-10-13', '07:00'),
);
});
it('without quiet hours a SUGGESTION is available now', () => {
const now = at('2026-10-13', '23:00');
expect(deliveryAvailableAt(now, 'SUGGESTION', NO_QUIET)).toEqual(now);
});
it('ends quiet hours at 06:30 Berlin time across the clock change', () => {
const now = at('2026-10-24', '22:00');
const result = deliveryAvailableAt(now, 'SYSTEM', DEFAULTS);
expect(result).toEqual(at('2026-10-25', '06:30'));
expect(result.toISOString()).toBe('2026-10-25T05:30:00.000Z');
});
it('returns a new Date and leaves the input untouched', () => {
const now = at('2026-10-13', '12:00');
const result = deliveryAvailableAt(now, 'SYSTEM', NO_QUIET);
expect(result).not.toBe(now);
expect(result).toEqual(now);
});
});
describe('clock changes', () => {
const quietUntil0230: DeliveryPreferences = {
digestTime: '02:30',
quietFrom: '21:00',
quietUntil: '02:30',
};
const utc = (iso: string): Date => new Date(iso);
it('spring (2026-03-29, 02:00 jumps to 03:00): 01:59 is quiet, 03:00 is not', () => {
expect(isInQuietHours(utc('2026-03-29T00:59:00Z'), quietUntil0230)).toBe(
true,
);
expect(isInQuietHours(utc('2026-03-29T01:00:00Z'), quietUntil0230)).toBe(
false,
);
});
it('spring: an end time inside the gap becomes the instant one hour later (03:30 CEST)', () => {
const result = deliveryAvailableAt(
utc('2026-03-28T21:00:00Z'),
'SYSTEM',
quietUntil0230,
);
expect(result.toISOString()).toBe('2026-03-29T01:30:00.000Z');
});
it('autumn (2026-10-25, 03:00 falls back to 02:00): both 02:30 are outside the quiet hours, 02:29 is inside', () => {
expect(isInQuietHours(utc('2026-10-25T00:29:00Z'), quietUntil0230)).toBe(
true,
);
expect(isInQuietHours(utc('2026-10-25T00:30:00Z'), quietUntil0230)).toBe(
false,
);
expect(isInQuietHours(utc('2026-10-25T01:30:00Z'), quietUntil0230)).toBe(
false,
);
});
it('autumn: the end of quiet hours is the FIRST 02:30 (no extra hour of waiting)', () => {
const result = deliveryAvailableAt(
utc('2026-10-24T20:00:00Z'),
'SYSTEM',
quietUntil0230,
);
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
});
it('autumn: the digest time 02:30 is the first occurrence as well', () => {
const prefs = { ...quietUntil0230, quietFrom: null, quietUntil: null };
const result = deliveryAvailableAt(
utc('2026-10-24T22:10:00Z'),
'DIGEST',
prefs,
);
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
});
});
@@ -0,0 +1,110 @@
import type { NotificationCategory } from '../../generated/prisma';
import { addDays, berlinDateTime, berlinDayOf } from '../common/dates';
export interface DeliveryPreferences {
/** HH:MM, Europe/Berlin. */
digestTime: string;
/** HH:MM; null together with quietUntil: no quiet hours. */
quietFrom: string | null;
quietUntil: string | null;
}
const ONE_MINUTE_MS = 60_000;
const ONE_HOUR_MS = 3_600_000;
const berlinClock = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Berlin',
hourCycle: 'h23',
hour: '2-digit',
minute: '2-digit',
});
/** The Berlin wall clock of an instant as HH:MM (compares as text). */
function berlinClockOf(instant: Date): string {
return berlinClock.format(instant);
}
interface QuietWindow {
from: string;
until: string;
}
/** Equal bounds or a missing bound mean: no quiet hours. */
function quietWindow(prefs: DeliveryPreferences): QuietWindow | null {
const { quietFrom: from, quietUntil: until } = prefs;
if (from === null || until === null || from === until) {
return null;
}
return { from, until };
}
/** Quiet hours may span midnight (21:00-06:30); the end is exclusive. */
export function isInQuietHours(now: Date, prefs: DeliveryPreferences): boolean {
const window = quietWindow(prefs);
if (window === null) {
return false;
}
const time = berlinClockOf(now);
return window.from < window.until
? time >= window.from && time < window.until
: time >= window.from || time < window.until;
}
/**
* The instant the Berlin clock shows `hhmm` on `day`. A wall time that
* happens twice (autumn change) is taken at its FIRST occurrence; one inside
* the spring gap becomes the instant one hour later (02:30 -> 03:30 CEST).
*/
function berlinWallTime(day: string, hhmm: string): Date {
const instant = berlinDateTime(day, hhmm);
const earlier = new Date(instant.getTime() - ONE_HOUR_MS);
return berlinClockOf(earlier) === hhmm && berlinDayOf(earlier) === day
? earlier
: instant;
}
/** The next instant after `instant` at which the Berlin clock shows `hhmm`. */
function nextBerlinTime(instant: Date, hhmm: string): Date {
const day = berlinDayOf(instant);
const today = berlinWallTime(day, hhmm);
return today.getTime() > instant.getTime()
? today
: berlinWallTime(addDays(day, 1), hhmm);
}
/** TASK_DUE follows the morning overview by a minute; the digest is the overview. */
function categoryTime(
now: Date,
category: NotificationCategory,
prefs: DeliveryPreferences,
): Date | null {
if (category !== 'TASK_DUE' && category !== 'DIGEST') {
return null;
}
const digest = berlinWallTime(berlinDayOf(now), prefs.digestTime);
return category === 'TASK_DUE'
? new Date(digest.getTime() + ONE_MINUTE_MS)
: digest;
}
/**
* When a Telegram delivery may go out (Spec 9a.4): not before `now`, not
* before the category's time of day (TASK_DUE: one minute after the digest
* time, DIGEST: the digest time), and not inside quiet hours - an instant
* inside them moves to their end.
*/
export function deliveryAvailableAt(
now: Date,
category: NotificationCategory,
prefs: DeliveryPreferences,
): Date {
const categoryAt = categoryTime(now, category, prefs);
const earliest = new Date(
Math.max(now.getTime(), categoryAt?.getTime() ?? now.getTime()),
);
const window = quietWindow(prefs);
if (window === null || !isInQuietHours(earliest, prefs)) {
return earliest;
}
return nextBerlinTime(earliest, window.until);
}
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { NotificationDeliveryPlanner } from './delivery-planner';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NotificationSettingsController } from './notification-settings.controller';
import { NotificationsController } from './notifications.controller';
@@ -6,7 +7,15 @@ import { NotificationsService } from './notifications.service';
@Module({
controllers: [NotificationsController, NotificationSettingsController],
providers: [NotificationsService, NotificationPreferencesService],
exports: [NotificationsService, NotificationPreferencesService],
providers: [
NotificationsService,
NotificationPreferencesService,
NotificationDeliveryPlanner,
],
exports: [
NotificationsService,
NotificationPreferencesService,
NotificationDeliveryPlanner,
],
})
export class NotificationsModule {}
@@ -3,6 +3,7 @@ import { NotificationsService } from './notifications.service';
import { assertAppLink } from './notification-link';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { NotificationDeliveryPlanner } from './delivery-planner';
type CreateManyArg = {
data: {
@@ -19,6 +20,7 @@ type CreateManyArg = {
describe('NotificationsService', () => {
let service: NotificationsService;
let held: Set<string>;
let planner: { planIn: jest.Mock };
let prisma: {
notification: {
createManyAndReturn: jest.Mock;
@@ -28,9 +30,12 @@ describe('NotificationsService', () => {
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
$connect: jest.Mock;
$transaction: jest.Mock;
};
beforeEach(() => {
planner = { planIn: jest.fn().mockResolvedValue(0) };
prisma = {
notification: {
createManyAndReturn: jest
@@ -44,7 +49,13 @@ describe('NotificationsService', () => {
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
// A transaction client lacks $connect; the root client has it.
$connect: jest.fn(),
$transaction: jest.fn(),
};
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
Promise.resolve(work(prisma)),
);
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
@@ -53,6 +64,7 @@ describe('NotificationsService', () => {
.fn()
.mockImplementation(() => Promise.resolve(held)),
} as unknown as PermissionResolverService,
planner as unknown as NotificationDeliveryPlanner,
);
});
@@ -155,6 +167,64 @@ describe('NotificationsService', () => {
});
});
describe('Telegram delivery planning', () => {
const base = {
priority: 'INFO' as const,
category: 'SYSTEM' as const,
message: 'm',
};
it('plans the deliveries of the inserted rows right after the insert', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
const rows = await service.recordForUsers(['u1', 'u2'], base);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
expect(
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
});
it('plans with the caller transaction and opens none of its own', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
};
await service.record({ userId: 'u1', ...base }, tx as never);
expect(planner.planIn).toHaveBeenCalledWith(tx, [{ id: 'n' }]);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('without a caller transaction, rows and deliveries share one of their own', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.record({ userId: 'u1', ...base });
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledTimes(1);
});
it('plans only what was inserted: a repeated dedupe key plans nothing new', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await service.record({ userId: 'u1', ...base, dedupeKey: 'k:u1' });
expect(planner.planIn).toHaveBeenCalledWith(expect.anything(), []);
});
it('plans nothing when no recipient is eligible', async () => {
await service.recordForUsers(['u1'], base);
expect(planner.planIn).not.toHaveBeenCalled();
});
});
describe('recordForPermission', () => {
const input = {
priority: 'WARNUNG' as const,
@@ -9,6 +9,7 @@ import {
Prisma,
} from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { NotificationDeliveryPlanner } from './delivery-planner';
import { assertAppLink, notificationDedupeKey } from './notification-link';
export interface CreateNotificationInput {
@@ -87,13 +88,15 @@ function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] {
* needs (the rule the PermissionsGuard applies). The stored
* requiredPermissions are the recipient's and the content's permissions
* together, so a later consumer (e.g. the Telegram sender) can check them
* again.
* again. Right after the insert, in the same transaction, the delivery planner
* queues the Telegram delivery for users who asked for it.
*/
@Injectable()
export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly deliveryPlanner: NotificationDeliveryPlanner,
) {}
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
@@ -274,7 +277,8 @@ export class NotificationsService {
...recipientKeys,
...(content.requiredPermissions ?? []),
]);
return db.notification.createManyAndReturn({
const insertAndPlan = async (client: DbClient): Promise<Notification[]> => {
const rows = await client.notification.createManyAndReturn({
data: targets.map(({ userId, dedupeKey }) => ({
userId,
priority: content.priority,
@@ -288,5 +292,15 @@ export class NotificationsService {
})),
skipDuplicates: true,
});
// Only the rows that were inserted: a repeated dedupe key plans nothing.
await this.deliveryPlanner.planIn(client, rows);
return rows;
};
// Rows and deliveries belong together: without the caller's transaction
// they get their own. A caller WITH a transaction gets no nested one: the
// insert and the planning run on its client and commit or roll back with it.
return '$connect' in db
? db.$transaction((tx) => insertAndPlan(tx))
: insertAndPlan(db);
}
}
@@ -0,0 +1,22 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
const errorsFor = async (body: object): Promise<string[]> =>
(await validate(plainToInstance(CreatePhenologyReportDto, body))).map(
(error) => error.property,
);
describe('CreatePhenologyReportDto', () => {
const base = { phase: 'SALWEIDE', date: '2026-03-10' };
it.each([undefined, null, 'a1'])('accepts apiaryId %p', async (apiaryId) => {
await expect(errorsFor({ ...base, apiaryId })).resolves.toEqual([]);
});
it('rejects an empty apiaryId', async () => {
await expect(errorsFor({ ...base, apiaryId: '' })).resolves.toEqual([
'apiaryId',
]);
});
});
@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { PhenologyPhase } from '../../../generated/prisma';
export const MAX_NOTES_LENGTH = 500;
export class CreatePhenologyReportDto {
@ApiProperty({ enum: PhenologyPhase })
@IsEnum(PhenologyPhase)
phase!: PhenologyPhase;
@ApiProperty({
description: 'Day of the observation (YYYY-MM-DD), not after today.',
example: '2026-03-20',
})
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: 'date must be a day in the format YYYY-MM-DD',
})
date!: string;
@ApiPropertyOptional({
nullable: true,
description:
'The apiary the report is for; null or omitted = all apiaries.',
})
@IsOptional()
@IsString()
@IsNotEmpty()
apiaryId?: string | null;
@ApiPropertyOptional({ nullable: true, maxLength: MAX_NOTES_LENGTH })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTES_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListPhenologyReportsQuery {
@ApiPropertyOptional({ description: 'Calendar year; default: this year.' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(2000)
@Max(2100)
year?: number;
}
@@ -0,0 +1,11 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
/**
* apiaryId null widens the report to all apiaries, notes null clears the
* notes; phase and date cannot be null (400).
*/
export class UpdatePhenologyReportDto extends PartialType(
CreatePhenologyReportDto,
{ skipNullProperties: false },
) {}
+122
View File
@@ -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 });
});
});
+77
View File
@@ -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,66 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { berlinToday } from '../common/dates';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import { ListPhenologyReportsQuery } from './dto/list-phenology-reports.query';
import { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
import { PhenologyService } from './phenology.service';
@ApiTags('phenology')
@ApiBearerAuth()
@Controller('phenology')
export class PhenologyController {
constructor(private readonly phenology: PhenologyService) {}
/** Season state of every active apiary; no coordinates. */
@RequirePermission('apiaries:read')
@Get('state')
state() {
return this.phenology.state();
}
@RequirePermission('phenology:read')
@Get('reports')
list(@Query() query: ListPhenologyReportsQuery) {
return this.phenology.list(query.year ?? Number(berlinToday().slice(0, 4)));
}
@RequirePermission('phenology:create')
@Idempotent()
@Post('reports')
create(
@Body() dto: CreatePhenologyReportDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.phenology.create(dto, actor.id);
}
@RequirePermission('phenology:update')
@Patch('reports/:id')
update(
@Param('id') id: string,
@Body() dto: UpdatePhenologyReportDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.phenology.update(id, dto, actor.id);
}
@RequirePermission('phenology:update')
@Idempotent()
@Post('reports/:id/revoke')
revoke(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.phenology.revoke(id, actor.id);
}
}
@@ -0,0 +1,257 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import type { DomainEventsService } from '../domain-events/domain-events.service';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { PhenologyEventHandlers } from './phenology.handlers';
import type { PhenologyStateService } from './phenology-state.service';
// 2026-04-01 in Berlin, but still 2026-03-31 in UTC: the event day is the
// Berlin day.
const OCCURRED_AT = new Date('2026-03-31T22:30:00.000Z');
const EVENT_DAY = '2026-04-01';
// Delivery runs later (the fake clock): the later of the two days counts.
const DELIVERY_DAY = '2026-04-09';
const envelope = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) =>
({
type,
aggregateId,
payload,
occurredAt: OCCURRED_AT,
}) as unknown as AnyDomainEventEnvelope<T>;
describe('PhenologyEventHandlers', () => {
beforeAll(() => {
// Delivery runs much later than the event.
jest.useFakeTimers({ now: new Date('2026-04-09T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let state: { recomputeIn: jest.Mock };
let events: { recordAll: jest.Mock };
let tx: { apiary: { findMany: jest.Mock } };
let handlers: PhenologyEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
beforeEach(() => {
state = { recomputeIn: jest.fn().mockResolvedValue({ changed: false }) };
events = { recordAll: jest.fn().mockResolvedValue(2) };
tx = {
apiary: {
findMany: jest.fn().mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]),
},
};
handlers = new PhenologyEventHandlers(
state as unknown as PhenologyStateService,
events as unknown as DomainEventsService,
);
});
it('registers the handlers under their ids and has no season notification', () => {
const entries = (method: keyof PhenologyEventHandlers) =>
domainEventHandlerMetadata(
Object.getOwnPropertyDescriptor(
PhenologyEventHandlers.prototype,
method,
)?.value as object,
);
expect(entries('recompute')).toEqual([
{
types: ['PhenologyReported', 'PhenologyCorrected'],
handlerId: 'phenology.recompute',
},
]);
expect(entries('recomputeApiary')).toEqual([
{
types: ['PhenologyRecomputeRequested'],
handlerId: 'phenology.recompute-apiary',
},
]);
expect(
Object.getOwnPropertyNames(PhenologyEventHandlers.prototype),
).toEqual(['constructor', 'recompute', 'recomputeApiary']);
});
describe('recompute', () => {
it('a report for one apiary recomputes that apiary on the delivery day', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
);
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', id: { in: ['a1'] } },
}),
);
expect(state.recomputeIn).toHaveBeenCalledTimes(1);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
allowBackward: false,
});
expect(events.recordAll).not.toHaveBeenCalled();
});
it('a report for all apiaries records one follow-up event per active apiary', async () => {
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: null }),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE' },
orderBy: { id: 'asc' },
}),
);
expect(events.recordAll).toHaveBeenCalledWith(tx, [
{
type: 'PhenologyRecomputeRequested',
aggregateType: 'Apiary',
aggregateId: 'a1',
payload: { apiaryId: 'a1', allowBackward: false, day: DELIVERY_DAY },
},
{
type: 'PhenologyRecomputeRequested',
aggregateType: 'Apiary',
aggregateId: 'a2',
payload: { apiaryId: 'a2', allowBackward: false, day: DELIVERY_DAY },
},
]);
});
it('a delayed delivery never writes an older day than the event', async () => {
jest.setSystemTime(new Date('2026-03-30T10:00:00Z'));
try {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', EVENT_DAY, {
allowBackward: false,
});
} finally {
jest.setSystemTime(new Date('2026-04-09T10:00:00Z'));
}
});
it('a correction lets the phase go back', async () => {
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', {
apiaryId: 'a1',
previousApiaryId: 'a1',
}),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
allowBackward: true,
});
});
it('a scope move recomputes the old and the new apiary only', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', {
apiaryId: 'a2',
previousApiaryId: 'a1',
}),
asTx(),
);
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', id: { in: ['a1', 'a2'] } },
}),
);
expect(
(state.recomputeIn.mock.calls as unknown[][]).map((call) => call[1]),
).toEqual(['a1', 'a2']);
});
it.each([
['widened to all', { apiaryId: null, previousApiaryId: 'a1' }],
['narrowed from all', { apiaryId: 'a1', previousApiaryId: null }],
])('a correction %s fans out to all apiaries', async (_name, payload) => {
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', payload),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(events.recordAll).toHaveBeenCalledTimes(1);
const [, recorded] = events.recordAll.mock.calls[0] as [
unknown,
{ payload: { allowBackward: boolean } }[],
];
expect(recorded).toHaveLength(2);
expect(recorded[0].payload.allowBackward).toBe(true);
});
it('lets an error propagate so the delivery is retried', async () => {
state.recomputeIn.mockRejectedValue(new Error('boom'));
await expect(
handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
),
).rejects.toThrow('boom');
});
});
describe('recomputeApiary', () => {
it('recomputes the one apiary on the requested day', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a2' }]);
await handlers.recomputeApiary(
envelope('PhenologyRecomputeRequested', 'a2', {
apiaryId: 'a2',
allowBackward: true,
day: '2026-04-01',
}),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a2', '2026-04-01', {
allowBackward: true,
});
});
it('skips an apiary that is no longer active', async () => {
tx.apiary.findMany.mockResolvedValue([]);
await handlers.recomputeApiary(
envelope('PhenologyRecomputeRequested', 'a2', {
apiaryId: 'a2',
allowBackward: false,
day: '2026-04-01',
}),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'a2', status: 'ACTIVE' },
}),
);
});
});
});
@@ -0,0 +1,97 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, berlinToday } from '../common/dates';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { PhenologyStateService } from './phenology-state.service';
type ReportEvent = AnyDomainEventEnvelope<
'PhenologyReported' | 'PhenologyCorrected'
>;
/** The apiaries a report event touches; null = all of them. */
function scopeOf(event: ReportEvent): string[] | null {
const scopes =
event.type === 'PhenologyCorrected'
? [event.payload.apiaryId, event.payload.previousApiaryId]
: [event.payload.apiaryId];
if (scopes.includes(null)) {
return null;
}
return [...new Set(scopes as string[])].sort();
}
/**
* The reactions to plant development reports. Pure DB writers through the
* delivery transaction; errors propagate so a delivery is retried. A season
* change is not announced one by one: the morning digest carries it.
*/
@Injectable()
export class PhenologyEventHandlers {
constructor(
private readonly state: PhenologyStateService,
private readonly events: DomainEventsService,
) {}
/**
* A report (or its correction or withdrawal) recomputes the season state of
* the apiaries it touched, on the later of the event's day and today (Berlin). After a correction the
* phase may fall back. One or two apiaries are recomputed right here, in id
* order; for all apiaries one PhenologyRecomputeRequested per active apiary
* is recorded, so each apiary is recomputed (and retried) on its own.
*/
@OnDomainEvent(
['PhenologyReported', 'PhenologyCorrected'],
'phenology.recompute',
)
async recompute(
event: ReportEvent,
tx: Prisma.TransactionClient,
): Promise<void> {
// A delayed delivery must not write an older day than the event's own.
const day = [berlinDayOf(event.occurredAt), berlinToday()].sort()[1];
const allowBackward = event.type === 'PhenologyCorrected';
const scope = scopeOf(event);
const apiaries = await tx.apiary.findMany({
where:
scope === null
? { status: 'ACTIVE' }
: { status: 'ACTIVE', id: { in: scope } },
select: { id: true },
orderBy: { id: 'asc' },
});
if (scope === null) {
await this.events.recordAll(
tx,
apiaries.map(({ id }) => ({
type: 'PhenologyRecomputeRequested' as const,
aggregateType: 'Apiary' as const,
aggregateId: id,
payload: { apiaryId: id, allowBackward, day },
})),
);
return;
}
for (const { id } of apiaries) {
await this.state.recomputeIn(tx, id, day, { allowBackward });
}
}
/** One apiary of a fan-out; an apiary that is no longer active is skipped. */
@OnDomainEvent('PhenologyRecomputeRequested', 'phenology.recompute-apiary')
async recomputeApiary(
event: AnyDomainEventEnvelope<'PhenologyRecomputeRequested'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const { apiaryId, allowBackward, day } = event.payload;
const active = await tx.apiary.findMany({
where: { id: apiaryId, status: 'ACTIVE' },
select: { id: true },
});
if (active.length === 0) {
return;
}
await this.state.recomputeIn(tx, apiaryId, day, { allowBackward });
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { SeasonModule } from '../season/season.module';
import { PhenologyStateService } from './phenology-state.service';
import { PhenologyController } from './phenology.controller';
import { PhenologyEventHandlers } from './phenology.handlers';
import { PhenologyService } from './phenology.service';
import { PhenologyStep } from './phenology.step';
/**
* The season state of apiaries (ApiaryPhenology): the service that computes
* it, the first step of the daily evaluation, the plant development reports
* (API) and the event handlers that recompute after a report. Imported by AppModule and WorkerModule (the handlers)
* and by JobsModule (the step).
*/
@Module({
imports: [DomainEventsModule, SeasonModule],
controllers: [PhenologyController],
providers: [
PhenologyStateService,
PhenologyStep,
PhenologyService,
PhenologyEventHandlers,
],
exports: [PhenologyStateService, PhenologyStep],
})
export class PhenologyModule {}
@@ -0,0 +1,455 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import type { AuditLogService } from '../audit/audit-log.service';
import type { DomainEventsService } from '../domain-events/domain-events.service';
import type { PrismaService } from '../prisma/prisma.service';
import { PhenologyService } from './phenology.service';
const day = (value: string) => new Date(`${value}T00:00:00.000Z`);
/** Prisma leaves out the fields that are undefined. */
const defined = (data: object): Record<string, unknown> =>
Object.fromEntries(
Object.entries(data).filter(([, value]) => value !== undefined),
);
const stored = (overrides: Record<string, unknown> = {}) => ({
id: 'r1',
apiaryId: 'a1',
phase: 'SALWEIDE',
date: day('2026-03-10'),
notes: null,
userId: 'u0',
revokedAt: null,
createdAt: day('2026-03-10'),
updatedAt: day('2026-03-10'),
...overrides,
});
const seasonState = (overrides: Record<string, unknown> = {}) => ({
apiaryId: 'a1',
year: 2026,
phase: 'SALWEIDE',
phaseSource: 'REPORT',
reportedOn: null,
segment: 'FRUEHJAHR',
segmentSince: '2026-03-10',
springSince: '2026-03-10',
gts: 210.5,
gtsThroughDay: '2026-03-31',
note: null,
...overrides,
});
describe('PhenologyService', () => {
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-04-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: PhenologyService;
let tx: {
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock };
phenologyReport: {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
$transaction: jest.Mock;
apiary: { findUnique: jest.Mock; findMany: jest.Mock };
phenologyReport: { findMany: jest.Mock };
};
let audit: { record: jest.Mock };
let events: { record: jest.Mock };
let season: { statesFor: jest.Mock };
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'r1' }]),
apiary: { findUnique: jest.fn().mockResolvedValue({ id: 'a1' }) },
phenologyReport: {
create: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(stored(defined(data))),
),
findUnique: jest.fn().mockResolvedValue(stored()),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(stored(defined(data))),
),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
apiary: {
findUnique: jest.fn().mockResolvedValue({ id: 'a1' }),
findMany: jest.fn().mockResolvedValue([]),
},
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
};
audit = { record: jest.fn() };
events = { record: jest.fn() };
season = { statesFor: jest.fn().mockResolvedValue(new Map()) };
service = new PhenologyService(
prisma as unknown as PrismaService,
audit as unknown as AuditLogService,
events as unknown as DomainEventsService,
season,
);
});
describe('create', () => {
it('writes report, audit entry and PhenologyReported in one transaction', async () => {
await service.create(
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'a1' },
'u1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
data: {
phase: 'SALWEIDE',
date: day('2026-03-10'),
apiaryId: 'a1',
notes: null,
userId: 'u1',
},
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'PhenologyReport',
action: 'CREATE',
userId: 'u1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'PhenologyReported',
aggregateType: 'PhenologyReport',
aggregateId: 'r1',
actorUserId: 'u1',
payload: {
apiaryId: 'a1',
phase: 'SALWEIDE',
reportedOn: '2026-03-10',
},
});
});
it('stores apiaryId null for all apiaries', async () => {
await service.create({ phase: 'KIRSCHE', date: '2026-03-31' }, 'u1');
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
data: expect.objectContaining({ apiaryId: null }) as object,
});
expect(tx.apiary.findUnique).not.toHaveBeenCalled();
});
it('rejects a day in the future with the German message', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-04-02' }, 'u1'),
).rejects.toThrow(
new BadRequestException('Das Datum liegt in der Zukunft.'),
);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('accepts today (Berlin)', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-04-01' }, 'u1'),
).resolves.toBeDefined();
});
it('rejects a day that does not exist', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-02-30' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('answers 404 for an unknown apiary', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.create(
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'nope' },
'u1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('update', () => {
it('locks the report, reads before afterwards, audits with beforeData and records PhenologyCorrected', async () => {
await service.update('r1', { phase: 'KIRSCHE' }, 'u1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.phenologyReport.findUnique.mock.invocationCallOrder[0],
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
entityId: 'r1',
beforeData: expect.objectContaining({ phase: 'SALWEIDE' }) as object,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'PhenologyCorrected',
aggregateId: 'r1',
payload: {
apiaryId: 'a1',
previousApiaryId: 'a1',
phase: 'KIRSCHE',
reportedOn: '2026-03-10',
},
}),
);
});
it('apiaryId null widens the report to all apiaries; the event names both scopes', async () => {
await service.update('r1', { apiaryId: null }, 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: expect.objectContaining({ apiaryId: null }) as object,
});
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
payload: expect.objectContaining({
apiaryId: null,
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('moving the report to another apiary: the event names the old and the new one', async () => {
tx.apiary.findUnique.mockResolvedValue({ id: 'a2' });
await service.update('r1', { apiaryId: 'a2' }, 'u1');
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
payload: expect.objectContaining({
apiaryId: 'a2',
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('a patch that changes nothing writes no audit entry and no event', async () => {
const result = await service.update(
'r1',
{
phase: 'SALWEIDE',
date: '2026-03-10',
apiaryId: 'a1',
notes: null,
},
'u1',
);
expect(result).toEqual(stored());
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
expect(audit.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('an unknown new apiary answers 404 inside the transaction', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('r1', { apiaryId: 'nope' }, 'u1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
});
it('notes null clears the notes', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ notes: 'früh' }),
);
await service.update('r1', { notes: null }, 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: expect.objectContaining({ notes: null }) as object,
});
});
it('404 when the report is gone', async () => {
tx.$queryRaw.mockResolvedValue([]);
tx.phenologyReport.findUnique.mockResolvedValue(null);
await expect(
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('409 for a revoked report', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ revokedAt: day('2026-03-11') }),
);
await expect(
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects a future date', async () => {
await expect(
service.update('r1', { date: '2026-05-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('revoke', () => {
it('sets revokedAt, audits with beforeData and records PhenologyCorrected', async () => {
await service.revoke('r1', 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: { revokedAt: expect.any(Date) as Date },
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: expect.objectContaining({ revokedAt: null }) as object,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'PhenologyCorrected',
payload: expect.objectContaining({
apiaryId: 'a1',
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('409 when already revoked', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ revokedAt: day('2026-03-11') }),
);
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
});
it('404 when unknown', async () => {
tx.$queryRaw.mockResolvedValue([]);
tx.phenologyReport.findUnique.mockResolvedValue(null);
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('list', () => {
it('returns the reports of the year with days, apiary name and revokedAt', async () => {
prisma.phenologyReport.findMany.mockResolvedValue([
{
...stored({ revokedAt: day('2026-03-12') }),
apiary: { name: 'Hang' },
user: { name: 'Imker' },
},
{
...stored({ id: 'r2', apiaryId: null }),
apiary: null,
user: { name: 'Imker' },
},
]);
const views = await service.list(2026);
expect(prisma.phenologyReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { date: { gte: day('2026-01-01'), lt: day('2027-01-01') } },
}),
);
expect(views).toEqual([
expect.objectContaining({
id: 'r1',
apiaryName: 'Hang',
date: '2026-03-10',
revokedAt: '2026-03-12T00:00:00.000Z',
userName: 'Imker',
}),
expect.objectContaining({
id: 'r2',
apiaryId: null,
apiaryName: null,
revokedAt: null,
}),
]);
});
});
describe('state', () => {
it('lists active apiaries with phase and segment, the report day, no coordinates', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue(new Map([['a1', seasonState()]]));
prisma.phenologyReport.findMany.mockResolvedValue([
stored({ date: day('2026-03-12') }),
stored({ id: 'r2', date: day('2026-03-10'), apiaryId: null }),
stored({ id: 'r3', phase: 'KIRSCHE', date: day('2026-03-01') }),
]);
const states = await service.state();
expect(prisma.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'ACTIVE' } }),
);
expect(states).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Hang',
phase: 'SALWEIDE',
segment: 'FRUEHJAHR',
phaseSource: 'REPORT',
reportedOn: '2026-03-10',
gts: 210.5,
gtsThroughDay: '2026-03-31',
note: null,
},
]);
expect(JSON.stringify(states)).not.toMatch(/latitude|longitude/);
});
it('reportedOn is null unless the phase comes from a report', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue(
new Map([['a1', seasonState({ phaseSource: 'GTS' })]]),
);
const [state] = await service.state();
expect(state.reportedOn).toBeNull();
});
});
});
@@ -0,0 +1,340 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
PhaseSource,
PhenologyPhase,
PhenologyReport,
Prisma,
SeasonSegment,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { berlinToday, dayToDate } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock';
import type { SeasonNote } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
const FUTURE_MESSAGE = 'Das Datum liegt in der Zukunft.';
const NOT_FOUND_MESSAGE = 'Meldung nicht gefunden.';
const REVOKED_MESSAGE = 'Die Meldung wurde schon zurückgezogen.';
export interface PhenologyReportView {
id: string;
apiaryId: string | null;
/** null = the report counts for all apiaries. */
apiaryName: string | null;
phase: PhenologyPhase;
/** YYYY-MM-DD */
date: string;
notes: string | null;
userId: string;
userName: string;
revokedAt: string | null;
createdAt: string;
}
/** The season state of an apiary as the API shows it: never coordinates. */
export interface PhenologyApiaryState {
apiaryId: string;
apiaryName: string;
phase: PhenologyPhase | null;
segment: SeasonSegment;
phaseSource: PhaseSource | null;
reportedOn: string | null;
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
}
/** A report day must exist in the calendar and must not lie after today. */
function assertReportDay(day: string): Date {
let date: Date;
try {
date = dayToDate(day);
} catch {
throw new BadRequestException('Das Datum ist ungültig.');
}
if (day > berlinToday()) {
throw new BadRequestException(FUTURE_MESSAGE);
}
return date;
}
function yearRange(year: number): { gte: Date; lt: Date } {
return {
gte: dayToDate(`${year}-01-01`),
lt: dayToDate(`${year + 1}-01-01`),
};
}
/**
* The plant development reports of the beekeeper (first bloom of Salweide,
* Kirsche, ...). A report changes the season state of its apiary (or of all);
* the recomputation runs from the events (PhenologyEventHandlers).
*/
@Injectable()
export class PhenologyService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
private readonly season: SeasonService,
) {}
async create(
dto: CreatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = assertReportDay(dto.date);
const apiaryId = dto.apiaryId ?? null;
return this.prisma.$transaction(async (tx) => {
await this.assertApiaryIn(tx, apiaryId);
const created = await tx.phenologyReport.create({
data: {
phase: dto.phase,
date,
apiaryId,
notes: dto.notes ?? null,
userId,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: created.id,
action: 'CREATE',
userId,
afterData: created,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyReported',
aggregateType: 'PhenologyReport',
aggregateId: created.id,
actorUserId: userId,
payload: {
apiaryId: created.apiaryId,
phase: created.phase,
reportedOn: utcDay(created.date),
},
});
return created;
});
}
async update(
id: string,
dto: UpdatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = dto.date === undefined ? undefined : assertReportDay(dto.date);
return this.prisma.$transaction(async (tx) => {
// Lock order: Apiary before PhenologyReport (LOCKABLE_TABLES).
await this.assertApiaryIn(tx, dto.apiaryId ?? null);
const before = await this.lockActive(tx, id);
const next = {
phase: dto.phase ?? before.phase,
date: date ?? before.date,
apiaryId: dto.apiaryId === undefined ? before.apiaryId : dto.apiaryId,
notes: dto.notes === undefined ? before.notes : dto.notes,
};
if (
next.phase === before.phase &&
next.date.getTime() === before.date.getTime() &&
next.apiaryId === before.apiaryId &&
next.notes === before.notes
) {
return before;
}
const updated = await tx.phenologyReport.update({
where: { id },
data: {
phase: dto.phase,
date,
apiaryId: dto.apiaryId,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: updated,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: updated.apiaryId,
previousApiaryId: before.apiaryId,
phase: updated.phase,
reportedOn: utcDay(updated.date),
},
});
return updated;
});
}
/** Withdraws a report; the apiaries it counted for are recomputed. */
async revoke(id: string, userId: string): Promise<PhenologyReport> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockActive(tx, id);
const revoked = await tx.phenologyReport.update({
where: { id },
data: { revokedAt: new Date() },
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: revoked,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: revoked.apiaryId,
previousApiaryId: revoked.apiaryId,
phase: revoked.phase,
reportedOn: utcDay(revoked.date),
},
});
return revoked;
});
}
/** All reports of a year, withdrawn ones included (with revokedAt). */
async list(year: number): Promise<PhenologyReportView[]> {
const rows = await this.prisma.phenologyReport.findMany({
where: { date: yearRange(year) },
include: {
apiary: { select: { name: true } },
user: { select: { name: true } },
},
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
});
return rows.map((row) => ({
id: row.id,
apiaryId: row.apiaryId,
apiaryName: row.apiary?.name ?? null,
phase: row.phase,
date: utcDay(row.date),
notes: row.notes,
userId: row.userId,
userName: row.user.name,
revokedAt: row.revokedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
}));
}
/** The season state of every active apiary. Never coordinates. */
async state(): Promise<PhenologyApiaryState[]> {
const today = berlinToday();
const apiaries = await this.prisma.apiary.findMany({
where: { status: 'ACTIVE' },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
const states = await this.season.statesFor(
this.prisma,
apiaries.map((apiary) => apiary.id),
today,
);
const reports = await this.prisma.phenologyReport.findMany({
where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) },
select: { apiaryId: true, phase: true, date: true },
});
return apiaries.flatMap((apiary) => {
const state = states.get(apiary.id);
if (state === undefined) {
return [];
}
const reportedOn =
state.phaseSource === 'REPORT'
? (reports
.filter(
(report) =>
report.phase === state.phase &&
(report.apiaryId === null || report.apiaryId === apiary.id),
)
.map((report) => utcDay(report.date))
.sort()[0] ?? null)
: null;
return [
{
apiaryId: apiary.id,
apiaryName: apiary.name,
phase: state.phase,
segment: state.segment,
phaseSource: state.phaseSource,
reportedOn,
gts: state.gts,
gtsThroughDay: state.gtsThroughDay,
note: state.note,
},
];
});
}
/** Locks the report and reads it afterwards: 404 if gone, 409 if withdrawn. */
private async lockActive(
tx: Prisma.TransactionClient,
id: string,
): Promise<PhenologyReport> {
const exists = await lockRowForUpdate(tx, 'PhenologyReport', id);
const before = exists
? await tx.phenologyReport.findUnique({ where: { id } })
: null;
if (before === null) {
throw new NotFoundException(NOT_FOUND_MESSAGE);
}
if (before.revokedAt !== null) {
throw new ConflictException(REVOKED_MESSAGE);
}
return before;
}
/**
* 404 for an unknown apiary; the share lock keeps it from being deleted
* before the report that points at it is written.
*/
private async assertApiaryIn(
tx: Prisma.TransactionClient,
apiaryId: string | null,
): Promise<void> {
if (apiaryId === null) {
return;
}
await lockRowsForShare(tx, 'Apiary', [apiaryId]);
const apiary = await tx.apiary.findUnique({
where: { id: apiaryId },
select: { id: true },
});
if (apiary === null) {
throw new NotFoundException('Stand nicht gefunden.');
}
}
}
@@ -0,0 +1,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([]) };
@@ -236,6 +237,11 @@ describe('lock keys per table', () => {
'Treatment',
'VarroaMeasurement',
'NotificationPreference',
'Apiary',
'PhenologyReport',
'Extraction',
'HoneyBatch',
'NotificationDelivery',
]);
});
});
+8
View File
@@ -32,6 +32,14 @@ const LOCKABLE_TABLES = {
VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'),
// T4b tables (Task 23): the apiary season state, phenology reports and the
// honey chain (extraction, honey batch ledger).
Apiary: lockable('Apiary'),
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;
@@ -0,0 +1,150 @@
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { NextStepsService } from '../autopilot/next-steps.service';
import type { NotificationsService } from '../notifications/notifications.service';
import type { PrismaService } from '../prisma/prisma.service';
import { DigestService } from './digest.service';
describe('DigestService season changes since the last digest', () => {
const NOW = new Date('2026-06-10T02:30:00.000Z');
let domainEvents: jest.Mock;
let jobRuns: jest.Mock;
let recordForUsers: jest.Mock;
let service: DigestService;
const change = (
apiaryId: string,
previousSegment: string | null,
segment: string,
occurredAt = '2026-06-09T12:00:00.000Z',
) => ({
occurredAt: new Date(occurredAt),
payload: { apiaryId, previousSegment, segment },
});
const lines = (): string[] =>
(
recordForUsers.mock.calls[0] as [string[], { message: string }]
)[1].message.split('\n');
beforeEach(() => {
domainEvents = jest.fn().mockResolvedValue([]);
jobRuns = jest.fn().mockResolvedValue([]);
recordForUsers = jest.fn().mockResolvedValue([{ id: 'n' }]);
const prisma = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
colony: {
findMany: jest
.fn()
.mockResolvedValue([{ apiaryId: 'a1' }, { apiaryId: 'a2' }]),
},
apiary: {
findMany: jest.fn().mockResolvedValue([
{ id: 'a1', name: 'Wald' },
{ id: 'a2', name: 'Tal' },
]),
},
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
domainEvent: { findMany: domainEvents },
jobRun: { findMany: jobRuns },
$transaction: (work: (client: unknown) => unknown) => work({}),
} as unknown as PrismaService;
service = new DigestService(
prisma,
{ recordForUsers } as unknown as NotificationsService,
{
getPermissions: jest.fn().mockResolvedValue(new Set(['apiaries:read'])),
} as unknown as PermissionResolverService,
{
inspectionDueAll: jest.fn().mockResolvedValue([]),
} as unknown as NextStepsService,
);
});
it('reads the changes between the end of the previous window and now', async () => {
jobRuns.mockResolvedValue([
{
result: {
digestsWritten: 1,
digestWindowEnd: '2026-06-09T05:00:00.000Z',
},
},
]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(domainEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: new Date('2026-06-09T05:00:00.000Z'), lt: NOW },
},
}),
);
});
it('takes the last 24 hours without a previous digest run', async () => {
jobRuns.mockResolvedValue([{ result: { TaskDue: 1 } }, { result: null }]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(domainEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: new Date('2026-06-09T02:30:00.000Z'), lt: NOW },
},
}),
);
});
it('lists a segment change since the last digest, with its interval', async () => {
domainEvents.mockResolvedValue([change('a1', 'FRUEHJAHR', 'SCHWARMZEIT')]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines()).toContain(
'Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.',
);
});
it('ignores phase-only changes and a first state without previous segment', async () => {
domainEvents.mockResolvedValue([
change('a1', 'SOMMER', 'SOMMER'),
change('a2', null, 'FRUEHJAHR'),
]);
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
0,
);
});
it('shows a change back neutrally and nets out several changes of one apiary', async () => {
domainEvents.mockResolvedValue([
change('a1', 'FRUEHJAHR', 'SCHWARMZEIT', '2026-06-09T08:00:00.000Z'),
change('a1', 'SCHWARMZEIT', 'FRUEHJAHR', '2026-06-09T09:00:00.000Z'),
change('a2', 'FRUEHJAHR', 'WINTERRUHE', '2026-06-09T10:00:00.000Z'),
]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines().slice(1)).toEqual(['Stand Tal: wieder Winterruhe.']);
});
it('treats autumn to winter rest as a beginning, not a return', async () => {
domainEvents.mockResolvedValue([change('a1', 'HERBST', 'WINTERRUHE')]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines()).toContain('Stand Wald: Winterruhe beginnt.');
});
it('leaves out apiaries without living colonies', async () => {
domainEvents.mockResolvedValue([change('a9', 'FRUEHJAHR', 'SCHWARMZEIT')]);
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
0,
);
});
});
@@ -160,6 +160,22 @@ describe('buildDigest', () => {
expect(lines(digest?.message)[1]).toBe('Stand Wald: Winterruhe beginnt.');
});
it('words a segment that went back neutrally', () => {
const digest = buildDigest('u1', new Set(['apiaries:read']), {
...EMPTY,
seasonChanges: [
{
apiaryName: 'Wald',
segment: 'WINTERRUHE',
intervalDays: null,
isReturn: true,
},
],
});
expect(lines(digest?.message)[1]).toBe('Stand Wald: wieder Winterruhe.');
});
it('builds no digest without content', () => {
expect(buildDigest('u1', ALL, EMPTY)).toBeNull();
expect(buildDigest('u1', new Set(), DATA)).toBeNull();
@@ -203,6 +219,8 @@ describe('DigestService', () => {
colony: {
findMany: jest.fn().mockResolvedValue([{ apiaryId: 'a1' }]),
},
domainEvent: { findMany: jest.fn().mockResolvedValue([]) },
jobRun: { findMany: jest.fn().mockResolvedValue([]) },
apiary: {
findMany: jest.fn().mockResolvedValue([{ id: 'a1', name: 'Wald' }]),
},
@@ -215,9 +233,6 @@ describe('DigestService', () => {
{
inspectionDueAll: jest.fn().mockResolvedValue([]),
} as unknown as NextStepsService,
{
statesFor: jest.fn().mockResolvedValue(new Map()),
},
);
});
+151 -26
View File
@@ -12,8 +12,7 @@ import { loggableError } from '../common/loggable-error';
import { utcDay } from '../domain-events/domain-event-types';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { SeasonSegmentKey } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types';
import { JobPartialFailure } from '../system/job-run.service';
/** One line of the digest and the permissions that entitled the reader to it. */
@@ -36,6 +35,8 @@ export interface DigestData {
apiaryName: string;
segment: SeasonSegmentKey;
intervalDays: number | null;
/** The segment went back (a revoked report): worded neutrally. */
isReturn?: boolean;
}[];
}
@@ -55,6 +56,17 @@ const SEGMENT_NAMES: Readonly<Record<SeasonSegmentKey, string>> = {
HERBST: 'Herbst',
};
/** The time span of season changes a digest run reports. */
export interface DigestWindow {
from?: Date;
until?: Date;
}
/** Key in the JobRun result: where the season window of that digest ended. */
export const WINDOW_END_KEY = 'digestWindowEnd';
const FALLBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
const PREVIOUS_RUNS_LOOKED_AT = 10;
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
function plural(count: number, one: string, many: string): string {
@@ -133,8 +145,9 @@ function seasonLines(
return [];
}
return data.seasonChanges.map((change) => ({
text:
`Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
text: change.isReturn
? `Stand ${change.apiaryName}: wieder ${SEGMENT_NAMES[change.segment]}.`
: `Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
(change.intervalDays === null
? ''
: ` Durchsicht alle ${change.intervalDays} Tage.`),
@@ -183,16 +196,23 @@ export class DigestService {
private readonly notifications: NotificationsService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
private readonly seasons: SeasonService,
) {}
/**
* The digests of the Berlin day `today` for every active user who did not
* switch them off (`userIds` narrows that, for tests). Returns how many
* were written; a rerun for the same day writes 0 (the keys exist). A failing user is logged and does not stop the others;
* the run then fails with the count of the digests that went through.
* were written; a rerun for the same day writes 0 (the keys exist). A failing
* user is logged and does not stop the others; the run then fails with the
* count of the digests that went through. `window` bounds the season changes
* this digest reports: `until` (default now) is stored by DigestStep as the
* start of the next window; `from` (default: the previous window end, else
* 24 hours before `until`) is for tests.
*/
async runFor(today: string, userIds?: readonly string[]): Promise<number> {
async runFor(
today: string,
userIds?: readonly string[],
window: DigestWindow = {},
): Promise<number> {
const users = await this.prisma.user.findMany({
where: {
isActive: true,
@@ -206,7 +226,7 @@ export class DigestService {
if (users.length === 0) {
return 0;
}
const data = await this.loadData(today);
const data = await this.loadData(today, window);
let written = 0;
let failed = 0;
for (const { id } of users) {
@@ -249,7 +269,10 @@ export class DigestService {
return rows.length;
}
private async loadData(today: string): Promise<DigestData> {
private async loadData(
today: string,
window: DigestWindow,
): Promise<DigestData> {
const [tasks, suggestions, inspectionDue, seasonChanges] =
await Promise.all([
this.loadTasks(today),
@@ -259,7 +282,7 @@ export class DigestService {
}),
// Every colony, whatever the reader may see: the lines are filtered per user.
this.nextSteps.inspectionDueAll(today),
this.loadSeasonChanges(today),
this.loadSeasonChanges(window),
]);
return { today, tasks, inspectionDue, suggestions, seasonChanges };
}
@@ -279,40 +302,142 @@ export class DigestService {
);
}
/** Apiaries with living colonies whose season segment begins today. */
/**
* The start of the window: the end of the window the previous digest run
* reported (stored in its JobRun result), else 24 hours before `until`.
* Only runs that reached the digest step carry a window end.
*/
private async windowStart(until: Date): Promise<Date> {
const runs = await this.prisma.jobRun.findMany({
where: {
job: 'daily-evaluation',
status: { in: ['SUCCEEDED', 'FAILED'] },
},
orderBy: { startedAt: 'desc' },
take: PREVIOUS_RUNS_LOOKED_AT,
select: { result: true },
});
for (const { result } of runs) {
const end =
result !== null && typeof result === 'object' && !Array.isArray(result)
? result[WINDOW_END_KEY]
: undefined;
if (typeof end === 'string' && !Number.isNaN(Date.parse(end))) {
return new Date(end);
}
}
return new Date(until.getTime() - FALLBACK_WINDOW_MS);
}
/**
* Apiaries with living colonies whose season segment changed in the window
* [previous window end, until) according to the SeasonPhaseChanged events.
* Several changes of one apiary count as one (first previous segment to
* last segment); no net change, no line. A change that happened after
* `until` belongs to the next digest, so every change is reported once.
*/
private async loadSeasonChanges(
today: string,
window: DigestWindow,
): Promise<DigestData['seasonChanges']> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
const until = window.until ?? new Date();
const from = window.from ?? (await this.windowStart(until));
const events = await this.prisma.domainEvent.findMany({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: from, lt: until },
},
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
select: { payload: true },
});
const nets = netSegmentChanges(events.map((event) => event.payload));
if (nets.length === 0) {
return [];
}
const withColonies = await this.prisma.colony.findMany({
where: {
...LIVING_COLONY_WHERE,
apiaryId: { in: nets.map((net) => net.apiaryId) },
},
select: { apiaryId: true },
distinct: ['apiaryId'],
});
const apiaryIds = colonies.map((colony) => colony.apiaryId);
const states = await this.seasons.statesFor(this.prisma, apiaryIds, today);
const changed = [...states.values()].filter(
(state) => state.segmentSince === today,
);
const living = new Set(withColonies.map((colony) => colony.apiaryId));
const changed = nets.filter((net) => living.has(net.apiaryId));
if (changed.length === 0) {
return [];
}
const [apiaries, interval] = await Promise.all([
this.prisma.apiary.findMany({
where: { id: { in: changed.map((state) => state.apiaryId) } },
where: { id: { in: changed.map((net) => net.apiaryId) } },
select: { id: true, name: true },
}),
readRule(this.prisma, 'INSPECTION_INTERVAL'),
]);
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
return changed
.map((state) => ({
apiaryName: names.get(state.apiaryId) ?? '',
segment: state.segment,
.map((net) => ({
apiaryName: names.get(net.apiaryId) ?? '',
segment: net.segment,
intervalDays:
state.segment === 'WINTERRUHE' || !interval.active
net.segment === 'WINTERRUHE' || !interval.active
? null
: interval.config.intervalDays[state.segment],
: interval.config.intervalDays[net.segment],
isReturn: net.isReturn,
}))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
}
}
interface NetSegmentChange {
apiaryId: string;
segment: SeasonSegmentKey;
isReturn: boolean;
}
function isSegment(value: unknown): value is SeasonSegmentKey {
return SEASON_SEGMENTS.some((segment) => segment === value);
}
/** Autumn to winter rest is the normal course of the year, not a step back. */
function wentBack(from: SeasonSegmentKey, to: SeasonSegmentKey): boolean {
if (from === 'HERBST' && to === 'WINTERRUHE') {
return false;
}
return SEASON_SEGMENTS.indexOf(to) < SEASON_SEGMENTS.indexOf(from);
}
/**
* First previous segment to last segment per apiary, in event order. Events
* without a valid previous or new segment (a first state, phase-only
* changes) say nothing about a segment change.
*/
function netSegmentChanges(payloads: readonly unknown[]): NetSegmentChange[] {
const firstPrevious = new Map<string, SeasonSegmentKey>();
const last = new Map<string, SeasonSegmentKey>();
for (const payload of payloads) {
if (typeof payload !== 'object' || payload === null) {
continue;
}
const { apiaryId, previousSegment, segment } = payload as Record<
string,
unknown
>;
if (
typeof apiaryId !== 'string' ||
!isSegment(previousSegment) ||
!isSegment(segment)
) {
continue;
}
if (!firstPrevious.has(apiaryId)) {
firstPrevious.set(apiaryId, previousSegment);
}
last.set(apiaryId, segment);
}
return [...last].flatMap(([apiaryId, segment]) => {
const previous = firstPrevious.get(apiaryId);
return previous === undefined || previous === segment
? []
: [{ apiaryId, segment, isReturn: wentBack(previous, segment) }];
});
}
@@ -14,8 +14,26 @@ describe('DigestStep', () => {
runFor,
} as unknown as DigestService).run('2026-06-10');
expect(runFor).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ digestsWritten: 7 });
expect(runFor).toHaveBeenCalledWith('2026-06-10', undefined, {
until: expect.any(Date) as unknown,
});
const until = (
runFor.mock.calls[0] as [string, undefined, { until: Date }]
)[2].until;
expect(result).toEqual({
digestsWritten: 7,
digestWindowEnd: until.toISOString(),
});
});
it('stores no window end when nothing was written, so the next run reads the same window', async () => {
const runFor = jest.fn().mockResolvedValue(0);
const result = await new DigestStep({
runFor,
} as unknown as DigestService).run('2026-06-10');
expect(result).toEqual({ digestsWritten: 0 });
});
it('lets a partial failure through with its counter', async () => {
+12 -2
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { DigestService } from './digest.service';
import { DigestService, WINDOW_END_KEY } from './digest.service';
/**
* Step "digest", the last of the daily evaluation: the in-app morning
@@ -16,6 +16,16 @@ export class DigestStep implements DailyEvaluationStep {
constructor(private readonly digests: DigestService) {}
async run(today: string): Promise<JobResult> {
return { digestsWritten: await this.digests.runFor(today) };
// The window of season changes ends here; the next run starts where
// this one stopped, so every change is reported once.
const until = new Date();
const digestsWritten = await this.digests.runFor(today, undefined, {
until,
});
// A run that wrote nothing (a same-day rerun, or no content) reported
// no change: the next run must still read the earlier window.
return digestsWritten === 0
? { digestsWritten }
: { digestsWritten, [WINDOW_END_KEY]: until.toISOString() };
}
}
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SeasonModule } from '../season/season.module';
import { DigestService } from './digest.service';
import { DigestStep } from './digest.step';
import { TaskDueHandlers } from './task-due.handlers';
@@ -14,7 +13,7 @@ import { TaskDueHandlers } from './task-due.handlers';
* PermissionsModule (API) or JobPermissionsModule (worker, jobs).
*/
@Module({
imports: [NotificationsModule, SeasonModule, AutopilotModule],
imports: [NotificationsModule, AutopilotModule],
providers: [TaskDueHandlers, DigestService, DigestStep],
exports: [DigestService, DigestStep],
})
@@ -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);
}
@@ -0,0 +1,18 @@
import {
PhaseSource,
PhenologyPhase,
SeasonSegment,
} from '../../generated/prisma';
import {
PHASE_SOURCES,
PHENOLOGY_PHASES,
SEASON_SEGMENTS,
} from './season-types';
describe('season enums', () => {
it('Prisma phenology enums equal the TS constants of T4a', () => {
expect(Object.values(PhenologyPhase)).toEqual([...PHENOLOGY_PHASES]);
expect(Object.values(SeasonSegment)).toEqual([...SEASON_SEGMENTS]);
expect(Object.values(PhaseSource)).toEqual([...PHASE_SOURCES]);
});
});
@@ -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');
});
});
});
+36 -5
View File
@@ -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;
}
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more