Compare commits

..
106 Commits
Author SHA1 Message Date
Nick.RosenloecherandClaude Opus 5.5 cda3e02a31 feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11)
Backend Image / build-and-push (push) Successful in 11m29s
Frontend Image / build-and-push (push) Successful in 23m35s
- src/lib/motion/tokens.ts und --motion-* in globals.css: Dauern,
  Kurven und Feder an einer Stelle; Beute-Bühne liest dieselben Werte.
- AppMotion: LazyMotion strict, Bewegungs-Code (domAnimation) als ein
  asynchroner Chunk, 28 kB gzip; check:motion-budget im Docker-Build
  neben dem 3D-Budget (Spec 17.6).
- PresenceList: abgehakte Zeilen blenden aus, die übrigen rücken nur bei
  „Voll“ nach; „Dezent“ blendet nur, „Aus“ sofort. Die Zeile geht nach
  der festen Verzögerung, auch wenn der Server schweigt (Spec 17.5);
  Fokus wandert zum nächsten Haken, auch ohne Bewegungs-Chunk.
- Overlays (Sheet, Dialog, Menü, Select) über Base-UI-Start/Ende-Stile
  mit Token-Zeiten; Verschiebung und Skalierung nur bei motion-full.
- Kein transition-all, kein Hover-Heben, keine Breiten-Animation von
  Daten; Skeleton erst nach 150 ms; Wächter-Regeln für alles davon.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:15:42 +02:00
Nick.RosenloecherandClaude Opus 5.5 7025a6b589 feat(t5): Beute-Bühne folgt Sonne und Darstellung live (T5a A12)
- useHoloTheme liest die --holo-*-Token nur bei einem Wechsel von
  Dunkel/Sonne neu (Attribut-Beobachter plus apicore:appearance), nie pro
  Bild; gleiche Token behalten das Theme, nur Farbmaterialien werden neu
  gebaut und alte freigegeben.
- Sonne nach Beute-Spec 9.3 und T5 17.8: schwarze Bühne ohne Boden,
  Spiegel, Bloom, Vignette, Dunst und Partikel; weiße Kanten 2 px in
  Bildschirmbreite (Hover, aktiver Platz, Vorschau 3 px), Rähmchen
  schwarz-weiß schraffiert, Labels 16 px auf fester Platte, neutrales Licht.
- Glas-Knöpfe ohne Blur und Schein in Sonne.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 20:35:21 +02:00
Nick.RosenloecherandClaude Opus 5.5 fce9d10192 feat(t5): ein Tabs-Baustein für Listen, Einstellungen und Statistik (T5a A8)
Backend E2E / e2e (push) Failing after 13m2s
Backend Image / build-and-push (push) Successful in 7m55s
Frontend Image / build-and-push (push) Successful in 27m46s
- ui/tabs.tsx nur noch Variante segment: Leiste auf --muted, aktiver Tab
  mit Kartenfläche, fetter Schrift und 3-px-Balken in --primary-ink;
  44 px bei Touch, kompakt mit Maus; inaktive Schrift ≥ 6,27:1.
- ActiveHistoryTabs als Vorlage auf Tabs für 10 Listen (Pfeiltasten
  schalten direkt, Daten sind geladen), mit toolbar und heading.
- Einstellungen (Automatisierung, Benutzer & Rollen) und Statistik mit
  manueller Aktivierung: Pfeil bewegt den Fokus, Enter/Leertaste wählt;
  ?tab= über router.replace. TabSwitcher entfernt.
- Menüeinträge (Dropdown, Select) mit innerem Fokusring ≥ 3:1.
- Aufgaben: der Prioritätsfilter lädt die Liste jetzt neu (queryKey).
- Wächter: keine eigenen tablist/tab/tabpanel außerhalb ui/tabs.tsx.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 20:08:36 +02:00
Nick.RosenloecherandClaude Opus 5.5 759d85dc67 test: Unit-Tests im Backend-Image mit 20 s Zeitlimit (PDF-Render im CI-Lauf 837 über 5 s)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 20:04:43 +02:00
Nick.RosenloecherandClaude Opus 5.5 6eecd57df4 fix(t5): Rechte-Matrix nach Containerbreite, „Nicht geprüft“ passt, Glas-Knopf gedrückt
Browser-Prüfung A7:
- Die Rechte-Matrix im 384-px-Dialog nahm drei Spalten nach der
  Fensterbreite und schnitt Labels ab; jetzt Container-Queries.
- Dreifach-Auswahl: drei Spalten nur, wo „Nicht geprüft“ hineinpasst,
  sonst rutscht die dritte Option in die nächste Zeile.
- Gedrückter Glas-Knopf („Aufbau als Liste“) mit 2-px-Rahmen in
  holo-edge-strong statt nur Füllung.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:53:29 +02:00
Nick.RosenloecherandClaude Opus 5.5 476770c1ed ci: Backend-e2e mit 60 s Zeitlimit je Test und Hook auf dem geteilten Runner
Backend E2E / e2e (push) Canceled after 3m58s
Backend Image / build-and-push (push) Successful in 11m2s
Frontend Image / build-and-push (push) Successful in 30m54s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:34:04 +02:00
Nick.RosenloecherandClaude Opus 5.5 848ce168da fix: Bereitschaft überspringt ein Los, dessen Mittel zwischen zwei Lesungen verschwand
Prisma liest das Mittel in einer zweiten Abfrage; ein gleichzeitig mit
seinem Mittel gelöschtes Los kam mit medicine null zurück und führte zu
500 (CI-Lauf 836, compliance-readiness).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:34:04 +02:00
Nick.RosenloecherandClaude Opus 5.5 f307e7da66 feat(t5): ein Checkbox-Baustein für alle 35 Stellen (T5a A7)
- ui/checkbox.tsx und form/checkbox-field.tsx: ganze Zeile, Kachel,
  Aufgabe oder Sheet-Zeile ist der Knopf (Base UI), Haken bzw. Strich für
  teilweise; role="switch" mit Daumen und Haken. ToggleChip und SwitchRow
  entfernt.
- 14 Kacheln, 11 native Checkboxen, 3 Schalter-Zeilen, 6 Base-UI-Stellen
  und die Heute-Aufgaben umgestellt; Werte ans Backend unverändert.
- Enter in einer Checkbox schickt kein Formular ab und schaltet nicht
  (Leertaste schaltet); Nachbarn mit mindestens 4 px Abstand; gesperrte
  Felder mit sichtbarem Grund; gesperrte Kacheln ganz gedimmt.
- Baukasten-Wächter: keine eigenen Checkboxen/Schalter, auch bedingte
  role-Werte und setAttribute; Zeilenlisten mit Abstand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:30:21 +02:00
Nick.RosenloecherandClaude Opus 5.5 11bccbeb50 fix(t5): „Nicht geprüft“ bricht um, gedrückte Knöpfe mit Tintenrahmen
Browser-Prüfung A6: In drei Spalten lief das fette „Nicht geprüft“ in den
gestrichelten Rahmen; die Beschriftung bricht jetzt um. Fett/Kursiv
zeigten den gedrückten Zustand nur über die Füllfarbe; Button pressed
bekommt zusätzlich einen 2-px-Rahmen in Vordergrundfarbe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:03:30 +02:00
Nick.RosenloecherandClaude Opus 5.5 2f95696efb test: notifications-e2e mit 60 s für den Aufbau (CI-Lauf 826)
Backend E2E / e2e (push) Failing after 14m25s
Backend Image / build-and-push (push) Failing after 10m58s
Frontend Image / build-and-push (push) Successful in 25m16s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:46:52 +02:00
Nick.RosenloecherandClaude Opus 5.5 208e9baaf0 feat(t5): Auswahlgruppe mit Haken, Karten-Variante, Plus/Minus (T5a A6)
- ChoiceGroup nach Spec 10.2: gewählt mit Haken und 2-px-Tintenrahmen,
  „Nicht geprüft“ gestrichelt; neue Variante appearance="cards" mit Icon
  und Beschreibung (Winter-Ergebnis, Darstellung, Behördenmeldung).
- Eigenbau-Radios in Behördenmeldung, Phänologie-Bericht und
  Zeilen-Ausrichtung durch ChoiceGroup ersetzt.
- Button pressed → aria-pressed (Fett/Kursiv, „Aufbau als Liste“);
  Druck-Versatz nur mit motion-full.
- NumberStepper (ganze Schritte) für Winter-Waben, Milbenzahl (neu und
  bearbeiten), Weiselzellen, Entnahme/Zählbestand, Startposition; Entnahme
  nur 1 bis Restmenge, Startposition höchstens 1000.
- Baukasten-Wächter: Radios nur in ChoiceGroup, aria-pressed nur in
  Button und Kalender, auch in Objektform und .ts-Dateien.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:44:31 +02:00
Nick.RosenloecherandClaude Opus 5.5 6ac9eef93b fix: Beute-Editor fängt den Fokus, SVG-Icon im Kopf
- Auf /colonies/[id]/hive bekommen Sprunglink, Seitenleiste, Kopf,
  Offline-Band, untere Navigation und „+ Neu“ das Attribut inert; der
  erste Tab landet auf „Zurück“ im Editor (vorher Stopp 34).
- metadata.icons verlinkt /icon.svg neben favicon.ico und apple-icon.
- Tests: alle THEME_COLORS gleich dem jeweiligen --background,
  ausdrücklich gewähltes Dunkel färbt die theme-color.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:20:28 +02:00
Nick.RosenloecherandClaude Opus 5.5 ce68729233 feat(t5): Fokusring für alle, 44-px-Ziele bei Touch, 16-px-Schrift (T5a A5)
- globals.css: eine Basisregel :where(:focus-visible) mit --focus-width
  und --focus-offset (Sonne 3 px), Firefox-:-moz-focusring gleichgezogen;
  --text-xs 0.8125rem.
- Fokus-Codemod (scripts/codemod-t5-focus.mjs): eigene Ringe und
  outline-none raus, sm:h-/sm:size- zu pointer-fine:, Label-Zeilen mit
  focus-ring-within; Skript-Fokus (Lücken-Liste, „Gespeichert“) bleibt.
- Button/Input 44 px, kompakt nur mit Maus; Icon-Knöpfe mit unsichtbarer
  44-px-Trefferfläche bei Touch, Nachbarn mit genug Abstand.
- 16 px in Feldern, Labels, Auswahl, Leerzuständen, Listentiteln.
- .holo-stage auf allen Beute-Bühnen, .stage-card holt den Seitenring.
- Baukasten-Wächter (src/styles/baukasten-guard.spec.ts) mit den Regeln
  aus A3–A5, Ausnahmen mit Grund; Kalender-Auswahl als Inset-Ring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:06:20 +02:00
Nick.RosenloecherandClaude Opus 5.5 62ab080725 test: daily-evaluation auf 1991/1992, jobs-e2e räumt nur eigene Läufe
Backend E2E / e2e (push) Failing after 10m45s
Backend Image / build-and-push (push) Successful in 7m2s
Frontend Image / build-and-push (push) Successful in 28m17s
Manifests / validate (push) Successful in 27s
- Der Ablauf-Schritt der Vorschläge ist ein globales UPDATE. Auf Tagen in
  2011 lief er über die offenen 2001-Vorschläge von autopilot und
  reminders. Vor 2001 nutzt keine andere Suite ein Datum.
- jobs.e2e löschte im afterAll alle JobRuns seit Start und nahm rekey die
  eigenen Läufe weg; jetzt nur cleanup, daily-evaluation, task-templates.

Refs #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:58:46 +02:00
Nick.RosenloecherandClaude Opus 5.5 2395576c88 feat(t5): App-Icons, Manifest und Offline-Cache v5 (T5a A9)
- Honigzelle auf Tinte als Master-SVG; scripts/generate-icons.mjs erzeugt
  any/maskable 192/512, Monochrom 512, drei Shortcut-Icons, Apple-Icon und
  favicon.ico (16/32/48, ICO-Container von Hand) byte-gleich bei jedem Lauf.
- manifest.ts exakt nach Spec 12.3, Farben gleich dem hellen --background.
- Service Worker: SHELL_CACHE apicore-shell-v5, Icons im Shell-Cache; ein
  fehlendes Icon hält das Vorladen der Seiten nicht auf.
- Proxy-Matcher: nur echte Icon-Dateien (icons/*.png) ohne
  Sicherheits-Header, Punkte maskiert.
- BrandMark-Deckel mittig (linke Kante 18.8).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:51:41 +02:00
Nick.RosenloecherandClaude Opus 5.5 fee84c7a6b chore(deploy): Overlay auf Live-Stand, Rollout-Runbook Schritte 0 und 1 (#28)
- Image-Digests von backend, frontend und postgres sind die live
  laufenden; whisper bekommt seinen ersten CI-Digest. Die neuen CI-Digests
  für Schritt 3 stehen als Kommentar.
- Ingress-Hosts und TLS: apicore.encry.de, apicorebackend.encry.de.
- kubectl diff ohne Components zeigt nur Rollout-Strategie und
  startupProbe am Backend, den Digest-Pin für postgres (ein Neustart) und
  drei neue VOICE_*-Werte in der Backend-ConfigMap.

Refs #28

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:32:21 +02:00
Nick.RosenloecherandClaude Opus 5.5 e71848d996 test: eval:voice-Workflow-Prüfung nur im Monorepo (Backend-Image ohne .gitea)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:32:21 +02:00
Nick.RosenloecherandClaude Opus 5.5 d8238855fc feat(t5): neue Farbwerte, Dunkel für alle, Farb-Codemod (T5a A4)
- globals.css: Hell, Dunkel und Druck mit den Spec-Werten aus 4.2,
  Fokusring = --primary-ink, Verlaufs-Token entfernt.
- Codemod scripts/codemod-t5-colors.mjs: text-primary/-warning auf -ink,
  Vordergrund nur auf voller Fläche, Auswahlrahmen dunkel; --check-Modus
  und Spec halten src sauber.
- Kontrast-Wächter misst die echte globals.css (VALUES_SWITCHED), alle 21
  Zeilen aus 4.4 in allen vier Sätzen; keine dark:-Klassen mehr in src.
- Dunkel ist ohne Entwickler-Schalter wählbar, Standard bleibt Hell.
- Review-Nacharbeit: Auswahl-Umrisse und aktueller Schritt in Vordergrund,
  deaktivierte Felder bg-muted, Avatar ohne Blend, Honig-Check-Rahmen
  über --input.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:23:21 +02:00
Nick.RosenloecherandClaude Opus 5.5 6edae3efec test: Backend-e2e in CI stabil (eigene Jahre, Sperren, ein Server je App)
Backend E2E / e2e (push) Failing after 7m26s
Backend Image / build-and-push (push) Failing after 4m20s
Frontend Image / build-and-push (push) Successful in 15m44s
- daily-evaluation nutzt die Jahre 2011/2012 statt 2001, damit der Job nicht
  die Daten von autopilot und reminders liest und verändert.
- throttler: app.listen(0) einmal je App statt eines Servers je Anfrage
  (ECONNRESET bei 40 parallelen Anfragen).
- outbox-claims: 120 s Zeitlimit für 2000 sequentielle Anweisungen.
- migration-harness: eine `migrate deploy` zur Zeit über eine eigene
  Advisory-Sperre (P1002 bei parallelen Migrations-Suites).
- authority-notices und authority-notices-herd laufen über
  exclusive-suite nacheinander; Herd-Wiederholung räumt eigene Entwürfe.

Refs #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:55:56 +02:00
Nick.RosenloecherandClaude Opus 5.5 258e90828f feat(t5): Bereiche mit eigenem Symbol und Ton, BrandMark, Verläufe raus (T5a A3)
AREAS (37 Bereiche, je Symbol genau einmal; neu compliance, medicines,
voice, hive-systems, system) als einzige Quelle für Kopfzeilen, Menü
und Schnellanlage; PageHeader, DetailHeader und EntityCard nehmen
area statt icon/gradient (Codemod über 72 Dateien). Doppelte Symbole
aufgelöst. BrandMark (Wabe) ersetzt das Verlaufs-Sechseck;
Honig-Banner auf der Startseite durch Überschrift mit Datum ersetzt;
Verläufe aus 33 Verbrauchern entfernt (Token folgen in A4).

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:47:56 +02:00
Nick.RosenloecherandClaude Opus 5.5 cf53cf35ac feat(t5): Darstellung hell/dunkel/Sonne ohne Flackern (T5a A2)
Blockierendes Skript im <head> setzt Darstellung, Kontrast und
Bewegung vor dem ersten Zeichnen (gleiche Logik wie resolveAppearance,
für alle Eingaben getestet), theme-color je Satz, Wahl je Gerät in
localStorage mit Rückfall bei gesperrtem Speicher; Abgleich über Tabs,
Systemeinstellungen und Seitenwechsel an einer Stelle. Profil-Karte
„Darstellung“, Sonnenmodus im Benutzermenü; Dunkel bleibt bis A4 hinter
NEXT_PUBLIC_APPEARANCE_DARK. next-themes entfernt. ARCHITECTURE §9:
'unsafe-inline' der Frontend-CSP als getragenes Risiko mit Begründung.

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:35:28 +02:00
Nick.RosenloecherandClaude Opus 5.5 947a46f435 test: Ende-zu-Ende-Ablauf der Sprachnotizen (T7 L6 Task 23)
Upload → fetch (Arbeitsordner) → Ersatz-Whisper → ingest → Auswertung
mit FakeStructuredModel → genau eine Meldung → Übernahme in die
Durchsicht (Inhalt gelöscht); fremder Nutzer 404, READONLY/API_CLIENT
403; parallele Claims je Aufnahme genau einmal; Text-Weg; der
Log-Mitschnitt enthält nie das Gesagte.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:26:25 +02:00
Nick.RosenloecherandClaude Opus 5.5 6b1b3cfa90 feat(t5): Farb-Token additiv, Sonnen-Farbsätze und Kontrast-Wächter (T5a A1)
Neue Token neben den bestehenden (Schrift auf Hauptfarbe und Warnung,
Zustands-, Bereichs- und Königinnen-Töne, Fokus- und Rahmenmaße,
Wabenstriche), zwei vollständige Sonnen-Sätze (hell/dunkel) mit
größerer Schrift, 3-px-Fokus und ohne Schatten, Holo-Sonne für die
Beute-Bühne und .holo-stage-Fokusring, Druck immer im hellen Satz,
forced-colors. Wächter misst alle 21 Paare der Spec-Tabelle 4.4 in
vier Sätzen (OKLCH → sRGB → WCAG, Tönungen alpha-gemischt), prüft den
sRGB-Farbraum und erzwingt das Umschalten in A4. Sichtbar ändert sich
nur ein minimal entsättigter Holo-Kantenton (vorher außerhalb sRGB).

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:23:33 +02:00
Nick.RosenloecherandClaude Opus 5.5 a7a7599304 docs: ARCHITECTURE §14 Sprache (T7), Annahmen 107–118, TASKS T7-Stand
Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:09:55 +02:00
Nick.RosenloecherandClaude Opus 5.5 f6e1f34baa feat: Ganze Durchsicht aus Diktat auswerten (T7 L4 Task 21)
Knopf „Ganze Durchsicht auswerten“ unter den Freitextfeldern, sobald
diktiert wurde: sendet nur unveränderte Diktat-Teile, die noch in den
Feldern stehen, mit Hinweis auf die Auswertung bei Anthropic. Länge
und Text werden genau wie im Backend normalisiert (NFKC, Steuer- und
Formatzeichen, Codepoints) und so gesendet; Idempotency-Key je Inhalt,
nach endgültigem 4xx neu. Ergebnis in der Sprachnotiz-Box der
Durchsicht, Vorschläge nur für leere Felder, nichts ohne Tipp; offline
und bei ausgeschalteter Auswertung mit Hinweis statt Knopf.

Gates: Frontend lint, typecheck, 3203 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:06:00 +02:00
Nick.RosenloecherandClaude Opus 5.5 8b63c50f7a feat: Gold-Menge und Auswertungs-Werkzeug für die Sprach-Auswertung (T7 L4 Task 16)
30 erfundene Durchsicht-Transkripte mit erwarteten Vorschlägen (Fallen:
Korrekturen, Dezimalzahl, Wetter, Füllwörter, Prompt-Injection, sehr
lange Notiz, ungültige Funde). npm run eval:voice prüft offline mit
FakeStructuredModel (Präzision/Trefferquote je Feld, Schwellen 98/85 %);
--live --yes ruft das echte Modell nur ausdrücklich (≈ 0,21 $ mit
Sonnet). Preistabelle Backend und Frontend per Test gleich.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:50:21 +02:00
Nick.RosenloecherandClaude Opus 5.5 82b0c746d1 build: Schriften lokal statt über Google (next/font/local)
Space Grotesk und Inter als variable Fonts, IBM Plex Mono 400/500, je
latin (enthält ä ö ü ß „ “ – €), unverändert aus @fontsource 5.3.0
(OFL-1.1, Hinweis in src/app/fonts/OFL.txt). Der Build holt nichts mehr
von Google; behebt den sporadischen Turbopack-Fehler beim Laden von
next/font/google im CI und spart einen Drittanbieter-Abruf.

Gates: Frontend lint, typecheck, 3185 Tests, build, 3D-Budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:47:25 +02:00
Nick.RosenloecherandClaude Opus 5.5 6a915aad9c feat: Auswertung der Sprachnotizen mit Claude (T7 L4 Tasks 14, 15)
Backend E2E / e2e (push) Failing after 7m2s
Backend Image / build-and-push (push) Successful in 8m20s
Manifests / validate (push) Successful in 33s
Frontend Image / build-and-push (push) Failing after 21m23s
Task 14: Port StructuredModel mit Anthropic-Adapter (@anthropic-ai/sdk
0.131.0 exakt, Lieferkette geprüft), fester Endpunkt
https://api.anthropic.com (Umgebungs-Header werden entfernt), feste
Systemregeln ohne Datum/Volk/Person, Transkript als Daten in Tags,
strukturierte Ausgabe per JSON-Schema mit effort low, serverseitiger
Fallback per VOICE_SERVER_FALLBACK abschaltbar. Jeder Vorschlag wird
geprüft: bekanntes Feld, Wert in den Formulargrenzen, Zitat steht
wörtlich im Gesagten (Dezimalzahl als ein Wort), Korrektur gewinnt.
Netzwächter: nur die Worker-Verdrahtung erreicht den Adapter
(transitiv geprüft).

Task 15: Worker-Schleife voice-extractions (LISTEN + 15-s-Abfrage, ein
Lauf zugleich, höchstens 20 Notizen je Lauf), Modellaufruf außerhalb
jeder Transaktion mit 240-s-Obergrenze, Ergebnis versiegelt mit
Ereignis nur bei gehaltenem Claim; ohne Schlüssel EXTRACT_DISABLED ohne
Versuch, Tageslimit zählt jeden bezahlten Aufruf über das neue Journal
VoiceExtractionCall (nur IDs, Modell, Tokens, Ergebnis; 90 Tage, nicht
im Backup). Nur der Worker-Container erhält ANTHROPIC_API_KEY aus dem
Secret apicore-anthropic (optional); API- und Job-Rollen warnen, wenn
der Schlüssel bei ihnen gesetzt ist.

Gates: Backend lint, typecheck, 4589 Unit-Tests, build; e2e 104 Suites
(nur der Wetter-Test gegen die echte API schwankt); Manifest-Policy 55.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:38:15 +02:00
Nick SzaunigandClaude Opus 5.5 89a1546f15 perf: Beute-3D ohne Allokationen je Frame, Materialien bleiben beim Stufenwechsel
- Federn und Kamera schreiben in wiederverwendete Objekte (keine
  Garbage je Frame)
- Glas-Materialien hängen nicht mehr an Qualitätsstufe/Umgebung;
  Spiegelung und Farbaufspaltung als Uniforms (keine Neuübersetzung von
  ~70 Shadern beim Stufenwechsel)
- Tiefenunschärfe nur im Fokus, startet ohne Unschärfe bis ein
  Fokuspunkt feststeht
- Spiegelboden als eigener reflector.ts (nach drei MeshReflectorMaterial,
  MIT) gibt Render-Targets und Blur frei
- Form-Cache mit Referenzzählung und höchstens 16 ungenutzten Formen

A/B-Messung gegen den vorherigen Stand: keine fps-Verschlechterung
außerhalb der Streuung; Ziehen im Editor auf beiden Ständen geprüft.
904 Tests (Beute, Volk, Durchsicht), Build und 3D-Budget grün.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:05:13 +02:00
Nick SzaunigandClaude Opus 5.5 291ec1d7ff ci: Backend-e2e als eigener Workflow mit Postgres-Service
backend-e2e.yml läuft bei Änderungen unter ApiCore-Backend/ gegen einen
Wegwerf-Postgres 18.6 (per Digest), mit frisch erzeugten JWT-Secrets
und SKIP_EXTERNAL=1 für den Wetter-Test gegen die echte API. Der
Herdentest prüft globalen Zustand, den parallele Suites verändern; er
wiederholt die herdenabhängigen Fälle bis zu dreimal aus einem
zurückgesetzten Zustand. Lokal 3 volle parallele Läufe grün
(103 Suites, 1073 Tests).

Refs #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:39:08 +02:00
Nick SzaunigandClaude Opus 5.5 279ceec328 fix(deps): Next.js 16.3.8 gegen kritische Sicherheitsmeldungen
Backend Image / build-and-push (push) Successful in 7m42s
Frontend Image / build-and-push (push) Successful in 24m44s
next und eslint-config-next 16.3.1 → 16.3.8 (veröffentlicht
2026-09-30, behebt die kritischen Advisories für 16.0.0–16.3.7), dazu
im Lockfile proxy-addr, js-yaml, fast-uri, source-map-js,
postcss-selector-parser und brace-expansion auf gepatchte Versionen
(alle mindestens 14 Tage alt). npm audit: 21 → 13 Meldungen, keine
kritische mehr; der Rest betrifft Entwicklungswerkzeuge oder hat nur
jüngere oder Major-Downgrade-Fixes.

Gates: Frontend lint, typecheck, 3157 Tests, build, 3D-Budget 299 kB.

Refs #31

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:26:04 +02:00
Nick SzaunigandClaude Opus 5.5 ced65314ba feat: Bearbeiten-Dialoge für Charge, Ernte, Abfüllung und Produkt mit dem Formular-Kit
Felder mit Pflicht-Markierung und Fehler direkt am Feld, Fokus auf das
erste Problemfeld, Serverfehler über dem Speichern-Knopf, Fokus nach
dem Schließen zurück auf „Bearbeiten“; Ernte-Völker als Kacheln;
Bearbeiten offline gesperrt wie bei den Volks-Dialogen (Änderungen
werden nicht vorgemerkt). Gemeinsame Teile DialogSubmitBar und
EditTriggerButton nach components/form.

Refs #9

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:20:09 +02:00
Nick SzaunigandClaude Opus 5.5 1806255211 docs: T5 Bewegungs-Sprache (Spec §17) und Plan-Review 2026-10-16
Spec: Zweck, Stufen Voll/Dezent/Aus über useEffectiveMotionLevel,
Tokens aus dem, was Beute und CSS heute nutzen (eine Quelle in
lib/motion/tokens.ts), Regeln je Muster, was nie animiert,
Performance-Regeln, Abnahme; neue Inhaber-Frage 4.
Plan: 22 Korrekturen gegen den aktuellen Code (u. a. 31 statt 17
Checkbox-Stellen, fehlende AREAS, Lebenslinie unter der Beute-Karte,
Fokusring auf der Holo-Bühne), neue Tasks A11 (Bewegungs-Sprache
durchsetzen) und A12 (Beute-Bühne und Sonne-Modus).

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:17:03 +02:00
Nick SzaunigandClaude Opus 5.5 362ae7466b docs: TASKS und Beute-Spec auf Stand 2026-10-16 (Issue-Verweise, Sprache/KI, ERPNext-Importer)
Refs #40

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:05:58 +02:00
Nick SzaunigandClaude Opus 5.5 9a53fda01c chore: Root-.gitignore für node_modules-Cache und SDD-Arbeitsnotizen
Refs #41

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:05:31 +02:00
Nick SzaunigandClaude Opus 5.5 994c1f4394 test: Skalen-Paritätstest liest das Backend-Fixture erst im Test
Backend Image / build-and-push (push) Successful in 23s
Frontend Image / build-and-push (push) Successful in 21m46s
Im Frontend-Image fehlt der Backend-Ordner; describe.skipIf führt den
Rumpf beim Sammeln trotzdem aus, das readFileSync dort brach den Build
ab (ENOENT). Das Fixture wird jetzt im Test gelesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:54:12 +02:00
Nick SzaunigandClaude Opus 5.5 1763d953b6 fix: Feinschliff nach Browserprüfung (Sprachnotiz-Text, offline Volk, Beute-SVG)
Backend Image / build-and-push (push) Successful in 33s
Frontend Image / build-and-push (push) Failing after 17m8s
- „Gesagt wurde:“ zeigt an, dass der Text aufklappbar ist
- /voice/new nennt offline das Volk aus den Offline-Paketen
- Einstellungen: Überschrift „Sprachnotizen“ ohne „in Vorbereitung“
- Beute-SVG (role=img) ohne fokussierbare Kinder (axe nested-
  interactive); Tastaturweg bleibt die Gliederung. Dabei behoben: der
  Editor fokussierte nach dem Entfernen die SVG-Form statt der
  Listenzeile.

Gates: Frontend lint, typecheck, 3110 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:33:38 +02:00
Nick SzaunigandClaude Opus 5.5 6c33a6de3b docs: TASKS T7-Stand (L2, L3, L5 erledigt, L4 wartet auf Inhaber)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:03:39 +02:00
Nick SzaunigandClaude Opus 5.5 ce922c8569 feat: Sprachnotiz in der Durchsicht, Info-Seite und Zähler (T7 L5 Tasks 20, 22)
Backend Image / build-and-push (push) Successful in 5m31s
Frontend Image / build-and-push (push) Failing after 18m45s
Task 20: /inspections/new?voice=<id> zeigt die Sprachnotiz über dem
Formular (Abgeschriebenes, Unklares, „Alle übernehmen“ mit Rückfrage)
und Vorschläge unter den Feldern mit wörtlichem Zitat; nichts wird ohne
Tipp übernommen, die Volkskarte wird nie angehakt. Bei ausgeschalteter
Auswertung ist der Text offen zum Abtippen. Speichern schickt
voiceCaptureId; bei 409 VOICE_CAPTURE_GONE genau ein neuer Versuch ohne
Notiz mit demselben Idempotency-Key (online und aus der Warteschlange);
VOICE_CAPTURE_COLONY_MISMATCH nie automatisch, sondern „Ohne
Sprachnotiz speichern/senden“. Skalen aus lib/inspections/scales.ts mit
Paritätstest zum Backend.

Task 22: /settings/voice mit Modell und Fristen (bei ausgeschalteter
Auswertung ausdrücklich: nichts geht an einen KI-Dienst), „Alle meine
Sprachnotizen löschen“ auf Server und Gerät mit Anzahl; Systemkarte
„Sprachnotizen“ (nur system:manage) mit Zählern und Kosten der letzten
7 Tage (Ruling 15); Service Worker cacht /api/v1/voice-captures nie,
/voice und /voice/new offline erreichbar.

Gates: Frontend lint, typecheck, 3106 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:03:09 +02:00
Nick SzaunigandClaude Opus 5.5 51206bea4f docs: T7-Plan Task 20 – Wiederholung nach VOICE_CAPTURE_GONE mit demselben Idempotency-Key
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 12:54:21 +02:00
Nick SzaunigandClaude Opus 5.5 fe1626b374 test: Sprachnotiz-Liste wartet auf die Geräteliste (Rennen im CI)
Backend Image / build-and-push (push) Successful in 22s
Frontend Image / build-and-push (push) Successful in 15m48s
Der Test las die Listeneinträge, sobald die Serverliste da war; die
Geräteliste aus IndexedDB kam im CI später. Er wartet jetzt auf
„Wartet auf Netz“, bevor er die Reihenfolge prüft.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 12:43:32 +02:00
Nick SzaunigandClaude Opus 5.5 92323197a7 fix: Sprachnotizen nach Browserprüfung (Wiedergabe, Fokus, Warteschlange)
Backend Image / build-and-push (push) Successful in 6m2s
Frontend Image / build-and-push (push) Failing after 12m25s
Manifests / validate (push) Successful in 16s
- CSP erlaubt media-src 'self' blob:, damit die eigene Aufnahme
  abspielbar ist; Dauer kommt vom Recorder statt aus den Metadaten
- Fokus nach gesperrtem Mikrofon auf der Hilfe, nach Verwerfen in
  /voice auf der Liste
- automatisch beendete Aufnahme läuft über denselben Speicherweg wie
  Senden, kein zweites Speichern; Volk und Datum bleiben bis „Noch eine
  Aufnahme“ gesperrt
- Offline-Band öffnet die Warteschlange auch ohne Ablehnung, wenn
  Sprachnotizen warten; leere Liste offline mit Text
- Abmelde-Dialog und Zurück-Link mit 56 px, Schließen-Knopf der Dialoge
  44 px

Gates: Frontend lint, typecheck, 2991 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 12:22:47 +02:00
Nick SzaunigandClaude Opus 5.5 e0be687b7c feat: Aufnahmeseite und Liste der Sprachnotizen (T7 L5 Task 19)
/voice/new: großer Aufnahmeknopf mit Zeit, Hinweis bei 150 s, Stopp bei
180 s; eine automatisch beendete Aufnahme liegt sofort in der
Warteschlange des Geräts (Ruling 13); Abbrechen und Neu aufnehmen
fragen nach; Hilfe bei gesperrtem Mikrofon; Fokus bleibt bei jedem
Zustandswechsel an einem Bedienelement; Volk und Datum während der
Aufnahme gesperrt, ?date= auf die letzten 7 Tage begrenzt.
/voice: wartende Notizen des Geräts zuerst, dann die des Servers mit
Status, „Prüfen“ nur bei fertigen, Verwerfen mit Rückfrage.
Einstiege über Schnellanlage, Menü (nur mit voice-captures:use und
inspections:create), Volksseite und Durchsicht. Bei ausgeschalteter
Aufnahme verschwinden die Einstiege; jeder HTTP-Fehler der
Konfiguration zählt als aus, nur fehlendes Netz als unbekannt.
Fehlertexte gleich mit dem Backend (Test).

Gates: Frontend lint, typecheck, 2979 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:49:56 +02:00
Nick SzaunigandClaude Opus 5.5 462ac46d1e fix: Sprachnotizen werden bei ausgeschalteter Aufnahme abgelehnt
POST /voice-captures und /voice-captures/text antworten mit 403
VOICE_RECORDING_DISABLED („Sprachnotizen sind gerade ausgeschaltet.“),
solange VOICE_RECORDING_ENABLED nicht true ist; Liste, Abruf,
Wiederholen und Verwerfen bleiben möglich (Ruling 14).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:36:47 +02:00
Nick SzaunigandClaude Opus 5.5 484d799a36 fix: Whisper liest /work/in nur, Restrisiko des Abschreib-Pods dokumentiert
Der Decoder-Container mountet /work/in schreibgeschützt und nur
/work/out beschreibbar (Policy-Test); ingest ignoriert Ergebnisse zu
Aufnahmen außerhalb des Manifests (Test). ARCHITECTURE Annahme 106
beschreibt das getragene Restrisiko (DNS im Pod-Netz, Sicht auf den
eigenen Lauf) und stärkere Optionen für später.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:16:59 +02:00
Nick SzaunigandClaude Opus 5.5 c53810b8a6 feat: Abschreiben im Cluster mit Whisper-Image und Voice-CronJob (T7 L3 Task 13)
Backend Image / build-and-push (push) Successful in 5m49s
Frontend Image / build-and-push (push) Successful in 13m3s
Manifests / validate (push) Successful in 17s
Whisper Image / build-and-push (push) Successful in 1m56s
ApiCore-Whisper: transcribe.py liest das Manifest im RAM-Arbeitsordner,
dekodiert mit festem Demuxer je Endung, misst die Länge vor dem
Abschreiben (TOO_LONG > 240 s, DECODE_FAILED bei Länge 0), sperrt das
Netz vor dem Modell-Laden und schreibt nur Zähler auf stdout. Image:
python:3.12-slim per Digest, Nutzer 1000, alle Pakete mit Hash
(faster-whisper 1.2.1, av 18.1.0 wegen Inkompatibilität mit av 19),
Modell faster-whisper-small auf fester Revision mit SHA-256 je Datei.
CI-Workflow nur bei Änderungen unter ApiCore-Whisper/, Actions per SHA.

deploy/k8s/components/voice: CronJob alle 2 Minuten mit fetch, whisper,
ingest (gleiche UID, getrennte /tmp-Volumes, damit der Decoder dem
Container mit den Secrets nichts unterschieben kann; whisper ohne
Secrets und Umgebungsvariablen außer WHISPER_CPU_THREADS), Overlay mit
Platzhalter-Digest, ConfigMap-Werte für Sprache, Policy-Tests (55).
Die Anbindung des Anthropic-Schlüssels bleibt offen (Ruling 9).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:12:59 +02:00
Nick SzaunigandClaude Opus 5.5 e614ded967 feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18)
Backend Image / build-and-push (push) Successful in 7m6s
Frontend Image / build-and-push (push) Successful in 15m27s
Manifests / validate (push) Successful in 16s
Wartende Sprachnotizen werden nach Einträgen und Fotos hochgeladen, je
Notiz ein fester Idempotency-Key; endgültige 4xx markieren die Notiz
mit festem deutschem Text, 401/408/429/5xx/Netz warten auf den nächsten
Lauf, „wird bearbeitet“ wird übersprungen. Warteschlangen-Sheet mit
Abschnitt „Sprachnotizen“ (Verwerfen je Notiz nur für den Besitzer,
alle verwerfen mit Rückfrage, Hinweis bei zu vielen offenen Notizen),
Offline-Band zählt nur wartende Notizen, Abmelden fragt bei wartenden
Notizen nach. Zählen liest keine Audiodaten.

Gates: Frontend lint, typecheck, 2886 Tests.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:10:14 +02:00
Nick SzaunigandClaude Opus 5.5 0c7147f626 ci: Actions auf Commit-SHAs festnageln
checkout, setup-node, setup-buildx, login, metadata und build-push
laufen jetzt auf dem Commit, auf den der bisherige Major-Tag am
2026-10-16 zeigte (Tag als Kommentar). Ein verschobener Tag kann so
keinen fremden Code mit dem Registry-Token ausführen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:01:26 +02:00
Nick SzaunigandClaude Opus 5.5 560799c723 fix: deutsche Bezeichnungen für Sprachnotiz-Ereignisse im Systembereich
labels.spec prüft, dass jedes Backend-Ereignis einen Text hat; die
neuen Ereignisse VoiceCaptureReady und VoiceCaptureFailed fehlten.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:58:49 +02:00
Nick SzaunigandClaude Opus 5.5 ce472164b5 feat: Aufnahme und lokaler Speicher für Sprachnotizen (T7 L5 Task 17)
Recorder (webm/opus oder mp4, 24 kbit/s, Warnung bei 150 s, Stopp bei
180 s oder im Hintergrund; Mikrofon und Wake-Lock werden in jedem Fall
freigegeben, auch bei Fehlern und ausgestecktem Mikrofon) und
IndexedDB v6 mit Store pending-voice (Index ownerUserId); volle
Speicherquote ergibt VoiceStorageFullError ohne Datenverlust; Abmelden
löscht nur die eigenen Notizen.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:47:45 +02:00
Nick SzaunigandClaude Opus 5.5 1dc39f7658 feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
Backend Image / build-and-push (push) Successful in 6m51s
Frontend Image / build-and-push (push) Successful in 18m9s
Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt
in derselben Transaktion bestätigt (jeder offene Status, Ruling 1),
Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409
VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH;
Wiederholung mit gleichem Schlüssel idempotent.

Task 9: Ereignisse VoiceCaptureReady/VoiceCaptureFailed (nur IDs und
Endcodes), Meldungen „Sprachnotiz für … ist fertig“ bzw. Fehlerhinweis
mit nächstem Schritt, Betreiberhinweis bei abgeschalteter Auswertung;
Handler auch im Worker registriert (sonst gingen die Ereignisse
ungehört durch); keine Weiterleitung an Webhooks.

Task 11: Job voice-transcribe mit Phasen fetch (Audio nur im Speicher
entschlüsselt in ein RAM-Arbeitsverzeichnis 0700/0600) und ingest
(Ergebnis gegen Claim geprüft, Transkript versiegelt, Audio gelöscht,
Auswertung geweckt; Stille/zu lang enden FAILED); Ereignisse genau
einmal in derselben Transaktion. Aufbewahrung: Audio 24 h, Inhalt bis
contentExpiresAt, deaktivierte Besitzer, Zeilen 90 Tage; im täglichen
Cleanup inkl. leerer Job-Läufe (Ruling 10).

Gates: Backend lint, typecheck, 4471 Unit-Tests, build; e2e 103 Suites
(einziger Fehler: Wetter-Test gegen echte API).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:38:14 +02:00
Nick SzaunigandClaude Opus 5.5 e9c6604e3b docs: TASKS 3D-Budget geprüft
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:18:59 +02:00
Nick SzaunigandClaude Opus 5.5 2207bd2e67 ci: Größenbudget des 3D-Chunks im Frontend-Build prüfen
scripts/check-3d-budget.mjs misst den three.js-Chunk (Marker
WebGLRenderer und __THREE__) mit gzip -9 und bricht den Image-Build ab,
wenn er 320 kB übersteigt oder nicht gefunden wird. Heute 299 kB.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:18:58 +02:00
Nick SzaunigandClaude Opus 5.5 92c00b4453 docs: TASKS Beute L4 und Task 22 erledigt, T7-Stand, Rollout-Hinweis
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:16:47 +02:00
Nick SzaunigandClaude Opus 5.5 9f6ab4ea3f refactor: Beute-Frontend vereinfacht (findPart, subVec3, seededRandom)
Gemeinsame Helfer statt vier Kopien der Teilsuche, zwei Vektor-
Subtraktionen und zwei Zufallsgeneratoren; hasPartSheet an einer
Stelle; ungenutzte setOpacity und points-Ref entfernt. Verhalten
unverändert (1050 Tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:16:35 +02:00
Nick SzaunigandClaude Opus 5.5 0b0bc3c6dc feat: Sprachnotizen-API (T7 L2 Task 7)
Upload (Audio nur per Magic Bytes, versiegelt, 2 MiB), Text-Weg,
Liste, Abruf, Wiederholen, Verwerfen, alle verwerfen, Konfiguration und
Zähler unter /voice-captures. Rechte voice-captures:use plus
inspections:create (eigener Guard, API-Schlüssel ausgeschlossen),
Zähler nur mit system:manage; jede Abfrage auf den Besitzer begrenzt.
Grenzen je Benutzer unter Advisory-Lock (10 offen, 30 je Tag, 10 je
60 s), Wiederholen ebenfalls begrenzt und gedrosselt, Audio für QUEUED
nur unter 24 h. Verwerfen löscht Inhalt und Claim sofort. Keine Inhalte
in Log oder Audit. Wächter gegen Sprachinhalte in Suche, Export und
Berichten erweitert.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:10:54 +02:00
Nick SzaunigandClaude Opus 5.5 19550d1394 fix: Beute-Ziehen bricht bei Pinch ab, Rähmchen-Druck zieht keine Zarge
- zweiter Finger während Druck oder Ziehen bricht ab (kein Ablegen)
- Druck auf ein Rähmchen im Editor erreicht die Zarge nicht mehr
- Canvas-Rechteck bei Scroll neu gemessen, Motion-Ziele nur bei
  Abstand oder bewegtem Teil neu berechnet, toter Re-Export entfernt

Refs #21

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:06:46 +02:00
Nick SzaunigandClaude Opus 5.5 45274f4301 feat: Beute-Editor mit Ziehen und Ablegen in 3D (Beute L4, Task 18)
Backend Image / build-and-push (push) Successful in 7m57s
Frontend Image / build-and-push (push) Successful in 18m33s
Manifests / validate (push) Successful in 23s
- Teile aus der Palette in die Szene ziehen: Stapel öffnet sich, erlaubte
  Plätze leuchten, Vorschau rastet ein; erlaubte Plätze per
  applyHiveOps auf einer Kopie (gleiche Regeln wie der Server)
- Vorhandene Teile verschieben, auf „Entfernen“ ziehen; Einsätze aus
  offenen Zargen ziehen, dünne Teile mit Mindest-Trefferhöhe
- Drücken auf ein Teil dreht das Modell nie (Prüfung erst beim Dreh-
  Beginn, passend zur Ereignisfolge von R3F); Abbruch mit Esc, Fenster-
  wechsel oder Maus ohne Taste
- Zoom 0,6–2,5 per Knöpfen (Fokus bleibt an den Grenzen), Rad und Pinch
- Verdunster verschieben fragt nicht mehr nach Behandlungsende;
  Ansage „… verschoben“; gemeinsame Helfer fresh-part und feeder-slots
- Neuzeichnen nur bei Zeigerbewegung, geteilte Materialien

Gates: Frontend lint, typecheck, 2804 Tests, build; 3D-Chunk 292 kB gzip.
Review (Opus) und Browserprüfung Maus/Touch.

Refs #21

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:03:43 +02:00
Nick SzaunigandClaude Opus 5.5 61985afc4b feat: Fachwortliste für Whisper aus einer Quelle (T7 L2 Task 12)
npm run export:voice-vocabulary schreibt ApiCore-Whisper/vocabulary.txt
aus whisperInitialPrompt(); ein Wächter vergleicht Datei und Quelle im
Monorepo (im Backend-Image-Build ohne Nachbarordner übersprungen).
Datei mit LF über .gitattributes; scripts/ aus dem Nest-Build
ausgeschlossen.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:44:58 +02:00
Nick SzaunigandClaude Opus 5.5 9795f98006 feat: Schlüssel und gemeinsamer Kern für Sprachnotizen (T7 L2 Tasks 5+6)
Task 5: Schlüsselbund-Zweck voice (VOICE_ENCRYPTION_KEY), AES-256-GCM-
Hilfen für Text und Bytes mit AAD capture:<id>:<art>, voice-config
(Modell, Tageslimit, Aufnahme, Whisper-Grösse) und anthropic-config
(nie geloggt, nur worker/all), Startprüfung verlangt den Schlüssel in
Produktion in jeder Rolle, rekey versiegelt offene Sprachnotizen neu
(Audio einzeln gelesen, CAS mit Status), Secret-Vorlagen und
ensure-secrets.sh ergänzt.

Task 6: Text-Normalisierung und Bereinigung (Steuer- und Formatzeichen,
Codepoint-Kürzung), Zitatprüfung mit Dezimalzahlen als ein Wort
(Ruling 7), Felderliste passend zu CreateInspectionDto, versiegeltes
Transkript mit Grenzen, Fachwörter und Skalen mit Paritätstests.

Rollout: Ein Image mit diesem Stand startet in Produktion nur mit
VOICE_ENCRYPTION_KEY in apicore-backend-secret (ARCHITECTURE Annahme 22).

Gates: Backend tsc, eslint, 922 Unit-Tests, e2e 100 Suites / 1004 Tests.

Refs #27 #28

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:41:47 +02:00
Nick SzaunigandClaude Opus 5.5 2a4dc868d9 feat: Wiederholregel und Voice-Claims (T7 L2 Task 10)
RetryPolicy mit retryDelayMsFor (bestehende Aufrufer unverändert),
Phasen transcribe/extract mit eigenen Wartezeiten (5/15 min bzw.
30 s/2 min, 3 Versuche) und Stale-Fenstern (30 bzw. 5 min). Claims per
FOR UPDATE SKIP LOCKED mit claimToken als Compare-and-Set, nur
Notizen mit gültigem Inhalt; Erfolg setzt den nächsten Status und nach
dem Abschreiben attempts = 0; Fehler meldet 'lost', wenn die Notiz
inzwischen bestätigt oder verworfen wurde; Aufschub mit Grund;
Stale-Freigabe transaktionsfähig mit den beendeten Notizen als
Rückgabe. Eine Quelle für die 14 Fehlercodes.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:32:16 +02:00
Nick SzaunigandClaude Opus 5.5 af9df0afe4 feat: Datenmodell Sprachnotizen (T7 L2 Task 4)
VoiceCapture und VoiceCaptureAudio nach Spec §3 mit CHECKs
(audioBytes 1..2 MiB, durationSec 1..240, CONFIRMED genau mit
inspectionId, audioMime nur bei AUDIO), Recht voice-captures:use für
ADMIN, BEEKEEPER und STAFF, Sperrreihenfolge mit VoiceCapture am Ende,
Ausschluss aus dem Backup mit Begründung, Wächter gegen Suche und
Export.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:20:04 +02:00
Nick SzaunigandClaude Opus 5.5 257286fbef docs: ARCHITECTURE Beute-Oberfläche (13.1), Offline-Beute, Annahmen 101–105
Refs #22

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:17:19 +02:00
Nick SzaunigandClaude Opus 5.5 af6d142bec docs: TASKS Beute L3–L5 und T7 L1 abgehakt, Folgepunkte
Backend Image / build-and-push (push) Successful in 6m8s
Frontend Image / build-and-push (push) Successful in 18m14s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:17 +02:00
Nick SzaunigandClaude Opus 5.5 8166e7c621 fix: Beute-Editor und Durchsicht nach Browser-Prüfung
- Umbauen: Fokus nach Rückgängig/Wiederholen am Ende auf den anderen
  Verlaufsknopf, nach Entfernen auf die Nachbarzeile (sonst Überschrift)
- Umbauen: Hinweis „Beute wurde inzwischen geändert“ zuverlässig, der
  Stand wird bei Fokus/Sichtbarkeit und alle 30 s neu geladen, solange
  ein Entwurf besteht; Editor-Wurzel ist <main>
- Verlauf: Futtertaschen-Füllstand als „Flüssigfutter 1,5 l“, Entfernen
  als „Futtertasche entfernt“; Gliederung ohne leere Werte („Boden · /“)
- Durchsicht: Rückgängig der letzten Änderung behält den Schalter
  „Liste“ und setzt den Fokus auf die Überschrift der Änderungen

Refs #21 #22

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:17 +02:00
Nick SzaunigandClaude Opus 5.5 7a07e1cac4 docs: Admin-Seed nur bei leerer Benutzer-Tabelle (ARCHITECTURE §8)
Refs #45

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:16 +02:00
Nick SzaunigandClaude Opus 5.5 1a57de8573 feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00
Nick SzaunigandClaude Opus 5.5 ab45d6e24e fix: seed creates the first admin only on an empty user table
The seed checked only SEED_ADMIN_EMAIL. After the real admin changed
their address, every start created a second ADMIN with a random
password and wrote that password to the log. The seed now skips as
soon as any user exists and never prints an operator-supplied
password. seedAdmin() is exported for tests; main() runs only when the
file is executed directly.

Refs #45

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00
Nick SzaunigandClaude Opus 5.5 a654a1a158 docs: T7 Freihand implementation plan (6 deliveries, 25 tasks)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00
Nick SzaunigandClaude Opus 5.5 f7fe1ef094 docs: design spec draft for voice-guided inspection (Beute part 4)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00
Nick SzaunigandClaude Opus 5.5 555dc9e434 docs: owner requirements for voice-guided inspection (Beute part 4)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:03 +02:00
Nick SzaunigandClaude Opus 5.5 6e13236d49 feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)
- 3D dependencies (three, react-three-fiber, drei, postprocessing,
  motion), exact versions, supply-chain checked; scene loaded only on
  demand on the colony page (283 kB gzip), SVG stays as fallback
- glass hologram per owner design 2026-10-16: true Liebig Zander
  shapes (grips, rebate, Wanderboden foot), cool blue edge glow,
  click any part -> it flies to the front while the rest stays as a
  faint ghost; focus shows content (feed levels, Bausperre, pockets);
  frames rise on hover and take their type look (colour + comb pattern)
- full-bleed card with frosted overlays and glass buttons
- Futtertasche (FRAME_FEEDER): new part kind occupying frame slots
  with a fill level, catalog row, validator rules in both ports,
  outline, sheets, follow-ups, settings

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:03 +02:00
Nick SzaunigandClaude Opus 5.5 c608f5d405 feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)
- lib/hive: frontend port of the hive domain (ops, validator, facts,
  layout, follow-ups, describe texts, reason texts) running the backend
  fixtures, API client
- hive card on the colony page (same height as Stammdaten): SVG view,
  accessible outline tree, frame/part sheets with optimistic save,
  conflict sheet, history with past states, init from template or
  another colony, inspection draft mode
- follow-up toasts prefilling feeding and treatment forms (treatment
  end only with an open treatment)
- settings page "Beutensysteme" (hive-systems:manage)
- NumberStepper decimal steps, reactive motion level hook, holo tokens
- spec: owner design decision 2026-10-16 (glass hologram, focus mode)

The SVG view is the fallback; the 3D scene follows in L3.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:03 +02:00
Nick SzaunigandClaude Opus 5.5 0091b3d28b test: restriction page specs use the Berlin day like the page
Between 22:00 and 24:00 UTC the UTC day and the Berlin day differ, so
the specs failed at night.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:03 +02:00
Nick SzaunigandClaude Opus 5.5 ec8c95056d docs: README im Root, Backend/Frontend-READMEs, Statuszeilen (2026-10-16)
Backend Image / build-and-push (push) Successful in 6m43s
Frontend Image / build-and-push (push) Failing after 12m42s
Manifests / validate (push) Successful in 18s
Neues README im Root mit Zweck, Aufbau, Start, Prüfbefehlen, Betrieb und
Projektstand (Issues #44, #43). Backend- und Frontend-README ersetzen die
Framework-Vorlagen. CLAUDE.md: Ordnerstruktur und echte Arbeitsweise
(main, gebündelt pushen, Rollout nur mit Freigabe). ARCHITECTURE §7/§8:
Live-ConfigMap, ensure-secrets.sh, korrektes Admin-Seed-Verhalten.
.env.example um BACKUP_MAX_IMPORT_BYTES und SEED_ADMIN_* ergänzt.
Statuszeilen in Plänen Rückgrat, T3–T6 und Specs Fahrplan, T7.

Refs #42

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 08:12:49 +02:00
Nick SzaunigandClaude Opus 5.5 b48fb157c8 feat: hive setup data model and API (Beute L1)
Backend Image / build-and-push (push) Successful in 5m5s
Frontend Image / build-and-push (push) Successful in 11m2s
Each colony can now hold its hive setup (Ist-Zustand) as validated
JSON with an append-only change log:

- catalog: hive systems and part types with dimensions, placement
  rules and state fields; Liebig-Zander 10 seeded with fixed ids
  (owner's equipment incl. Bausperre, Futterzarge, Nassenheider)
- pure domain module (operations, validator, derived facts) with
  shared JSON fixtures the frontend runs too
- API: catalog (hive-systems:manage), setup create/change/history/
  state at a day (colonies:read/update), 409 HIVE_CONFLICT with the
  current state, idempotent writes, HiveChanged event
- inspections can carry hive ops saved in the same transaction
- backup export/import of the new tables (backup wins per colony)
- docs: ARCHITECTURE section 13, Annahmen 95-100

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 21:23:23 +02:00
Nick SzaunigandClaude Opus 5.5 8982d0796a docs: Beute-3D implementation plan (L1-L5)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 20:00:33 +02:00
Nick SzaunigandClaude Opus 5.5 d03391d2f2 docs: Beute-3D spec (hive data model, 3D view, editor)
Owner wish 2026-10-15: colony page shows the hive as an animated
holographic 3D model of its current state, editable in the card, in a
full-screen editor and through inspections. Covers part 1 (data) and
part 2 (3D view); AI parts follow with their own specs. Reviewed
against the codebase (permissions, locks, offline drain, backup).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 19:51:15 +02:00
Nick SzaunigandClaude Opus 5.5 e712c5d4d1 fix: T6 browser-check findings (compliance, treatments, labels)
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 14m30s
- api-client accepts 2xx answers without body (revoke showed an error)
- authority notices: sentOn in the future is rejected (Berlin day) on
  create, mark-sent and PATCH; dialog defaults to the Berlin day
- service worker never caches /api/v1/site-settings
- treatments/new no longer overflows at 390 px (min-w-0 on form
  wrappers); dose with more than 3 decimals is an error, never rounded
- medicines: expired lots flagged, disposal over the rest warns
- label designer: side panel at lg, sample values instead of
  placeholders, compliance panel reopens on lg, labelled zoom buttons
- skip link to #main-content, headings on no-access pages

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:34:52 +02:00
Nick SzaunigandClaude Opus 5.5 6c390efe7b test: raise Testing Library async timeout to 5 s
Backend Image / build-and-push (push) Successful in 33s
Frontend Image / build-and-push (push) Successful in 15m36s
The frontend image build runs ~240 spec files on a loaded runner; a first
render in apiary-colonies-card.spec missed the default 1 s findByRole
window in CI run 711 while it passes locally. 5 s stays well below the
15 s test timeout, so real hangs still show.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:05:35 +02:00
Nick SzaunigandClaude Opus 5.5 8188e4cab3 feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
Backend Image / build-and-push (push) Successful in 6m52s
Frontend Image / build-and-push (push) Failing after 12m28s
Manifests / validate (push) Successful in 22s
- Treatment form and bulk entry: catalogue medicine or free text, lot with
  oldest preselected on create, lot fields from the lot, dose with at most
  3 decimals, catalogue prefill with explicit take-over of the withdrawal
  period on a medicine switch, ?editTreatment deep link.
- Authority notices: list with year filter, create with draft dedupe link,
  detail steps with PDF download and mark-sent, settings with MM-TT check,
  digit-rule editor and "Annahme – bitte prüfen" notes; COMPLIANCE label
  in notifications and Telegram categories.
- Restricted zone: page with active/planned/ended, create and end dialogs
  (endsOn inclusive), red band at the apiary, guards on move, merge, edit
  and new colony, override only with compliance:manage, harvest warning.
- ARCHITECTURE.md §12.11, Annahmen 91–94; TASKS.md T6 done with open items.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 17:33:55 +02:00
Nick SzaunigandClaude Opus 5.5 13dda0e139 chore: keep LF line endings for shell scripts
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:46:31 +02:00
Nick SzaunigandClaude Opus 5.5 46991a0815 fix(k8s): ensure-secrets keeps secret values off command lines
Values were passed as kubectl -p argument and via environment variables,
both visible to other processes. Node now generates the values and writes
the patch into a private temp file (umask 077, removed on exit); kubectl
reads it with --patch-file. Only key names and modes are on the command
line. Also fixes the first key being dropped (node -e argv offset).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:44:55 +02:00
Nick SzaunigandClaude Opus 5.5 6e3e9aee9b chore(k8s): live config values in overlay, script to fill missing secrets
- overlays/encry configmaps now carry the live cluster values (runbook
  step 1) plus OIDC_CLIENT_ID; kubectl diff against the cluster is empty.
- deploy/k8s/scripts/ensure-secrets.sh adds missing secret keys without
  touching existing ones (random encryption keys, empty Open-Meteo key,
  TELEGRAM_BOT_TOKEN=dummy) and never prints values.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:38:31 +02:00
Nick SzaunigandClaude Opus 5.5 dd1eecdfa0 fix: skip backend fixture comparison when the frontend builds alone
Backend Image / build-and-push (push) Successful in 30s
Frontend Image / build-and-push (push) Successful in 12m37s
The frontend image build only sees ApiCore-Frontend, so reading
../ApiCore-Backend/test/fixtures/placeholder-keys.json failed with ENOENT
in CI. Same pattern as the labels and bulk specs: compare in the monorepo
checkout, skip inside the image build.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:20:40 +02:00
Nick SzaunigandClaude Opus 5.5 b4746a2b17 feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
Backend Image / build-and-push (push) Successful in 6m45s
Frontend Image / build-and-push (push) Failing after 9m30s
- Readiness: all 18 checks (medicine lots, treatment log gaps, labels,
  harvest withdrawal), medicine subjects masked without
  veterinary-medicines:read, report PDF (counts only), summary cached 60 s,
  409 codes READINESS_*.
- Frontend: /compliance page with acknowledge/revoke, dashboard line and
  nav; veterinary medicine catalogue, lots, movement book with inventory
  and disposal; label designer check panel, print card with gap list and
  proof print, food business master data, product legal name.
- Service worker: cacheability is path-based (the backend sends no-store
  on every answer); compliance, medicine, restriction, notice and label
  check/print paths and restricted document kinds are never cached.
- ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 15:59:08 +02:00
Nick SzaunigandClaude Opus 5.5 b912d29166 feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
Backend Image / build-and-push (push) Successful in 4m58s
Frontend Image / build-and-push (push) Successful in 7m57s
- Authority notice PDFs: pure model plus renderer, TSK count in A4
  landscape, FEHLT for missing particulars, stored totals with correction
  note; GET /authority-notices/:id/pdf with compliance:read.
- Compliance notifications in category COMPLIANCE: herd change and TSK
  reference day, at most one unread per topic and user (advisory lock);
  daily rule TskReferenceDayReached with Berlin day and catch-up; handlers
  in API and worker.
- Readiness core: live evaluation of seven pure rules, fingerprinted
  acknowledgements that reopen when revoked or stale, subject masking,
  read-only GET paths; READINESS_RULES injectable; kept out of the worker.
- e2e hygiene: no unscoped deletes, seeded settings rows saved/restored.
- ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:48:57 +02:00
Nick SzaunigandClaude Opus 5.5 edc193f128 feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s
- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:18:41 +02:00
Nick SzaunigandClaude Opus 5.5 c18860c75c feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s
- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:43:44 +02:00
Nick SzaunigandClaude Opus 5.5 8a31c42578 feat: T6 wave 1 – compliance schema, permissions, lock order, backup
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 11m25s
- Migrations (additive): veterinary medicines with lots and movement
  ledger, treatment medicine/lot/dose links, label particulars, authority
  notices, apiary restrictions, compliance settings and acknowledgements;
  notification category COMPLIANCE, added to non-empty Telegram lists.
- Permissions veterinary-medicines:* and compliance:* with role grants.
- LOCKABLE_TABLES: Apiary locks before Colony; T6 tables appended.
- Backup exports and restores the new tables in FK order; unsealed files
  skip them and report dropped medicine links; an open TSK draft in the
  target wins.
- describeNullContracts shared helper; Telegram backlog e2e no longer
  depends on the time of day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:04:13 +02:00
Nick SzaunigandClaude Opus 5.5 7a1480138d fix: T4 browser-check findings, interval on season state, T6 plan refresh
- Honey batch create omits an empty batch number (was 400); product form
  labels the legacy total weight, detail shows jar weight and shelf life;
  bottling hints next to their fields, values cleared on product change,
  post-save water-content wording; withdraw reason placeholder not
  selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
  consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
  dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
  nicht."); backup import gives its disabled reason; missing event labels
  plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
  inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
  2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
  without step-up).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 12:47:08 +02:00
Nick SzaunigandClaude Opus 5.5 9760ce154f feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s
- Bottling form: jar size and best-before from the product with source,
  differs-hints before saving, prefill status, water-content confirmation
  naming the measured value, idempotency key renewed on body change;
  product form gets shelf life (months) and whole-gram jar weight.
- Telegram frontend: link card with step-up dialog, t.me-only deep link,
  status polling, unlink; system Telegram card with receiver/sender state
  and confirmed retry; StepUpDialog shared with the backup import.
- Reauth calls never refresh-and-retry on 401 (no double lockout count);
  German texts for expired session and 429; service worker never caches
  Telegram, notification settings and system responses.
- Telegram backend round 2: shared link removal on every disconnect path,
  lease released on shutdown, connection errors not counted as poison.
- ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:21:08 +02:00
Nick SzaunigandClaude Opus 5.5 fab7f7e12f feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 22:47:17 +02:00
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
Nick SzaunigandClaude Opus 5.5 326b3d9cdd test: raise vitest timeout to 15 s for loaded CI runners
Backend Image / build-and-push (push) Successful in 23s
Frontend Image / build-and-push (push) Successful in 8m1s
A userEvent-heavy colony form spec took 5.1 s inside the Docker build
and hit the 5 s default.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:53:33 +02:00
Nick SzaunigandClaude Opus 5.5 ffaefab834 test: root module spec supplies its own JWT secret
Backend Image / build-and-push (push) Successful in 4m35s
Frontend Image / build-and-push (push) Failing after 7m55s
CI builds have no .env file; the handler discovery test compiled the API
module without JWT_ACCESS_SECRET when no earlier suite in the same jest
worker had set it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:38:22 +02:00
Nick SzaunigandClaude Opus 5.5 c2f71bcb03 fix: T4a browser check findings
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s
- Withdrawal wording from one backend helper everywhere ("bis
  einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
  crosses New Year); withdrawal-status returns lastBlockedDay and the
  ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
  product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
  shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
  toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
  colony codes; collapsible automation rule cards; varroa list newest
  first with chart axes; text fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:26:09 +02:00
Nick SzaunigandClaude Opus 5.5 32c61c8e14 feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI
- In-app reminders: task due (skips rescheduled tasks, catch-up wording)
  and a per-user morning digest step in the daily job
- Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard,
  inspection-due card, season chip, apiary overview fields, cleanup
  sheet for ended colonies
- Frontend: plausibility warnings that stay until dismissed, withdrawal
  spans in the calendar, notification bell with categories and links,
  notification settings in the profile
- ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler
  table and daily job step order

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 17:05:32 +02:00
Nick SzaunigandClaude Opus 5.5 ef33e5549b feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:28:07 +02:00
Nick SzaunigandClaude Opus 5.5 02eb16924e feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:22:36 +02:00
Nick SzaunigandClaude Opus 5.5 64a2b7dad9 docs: T4 plan review and consistency fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:26:23 +02:00
Nick SzaunigandClaude Opus 5.5 1964286459 chore: remove stray test output file
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:26:10 +02:00
Nick SzaunigandClaude Opus 5.5 f468dc5f75 feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:25:24 +02:00
Nick SzaunigandClaude Opus 5.5 d99a697739 feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 13:42:32 +02:00
1797 changed files with 208965 additions and 6637 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Shell scripts must keep LF line endings, also on Windows checkouts with
# core.autocrlf, or bash fails on the carriage returns.
*.sh text eol=lf
# The Whisper word list is generated (npm run export:voice-vocabulary) and
# copied into the image; keep LF so the file stays byte-identical everywhere.
ApiCore-Whisper/vocabulary.txt text eol=lf
+112
View File
@@ -0,0 +1,112 @@
name: Backend E2E
# Fuehrt `npm run test:e2e` (Jest, maxWorkers 4, siehe test/jest-e2e.json)
# gegen einen frischen Postgres-Service-Container aus. Das Backend-Image
# (backend-image.yml) fuehrt nur Lint, Typecheck und Unit-Tests aus, weil der
# Docker-Build keine Datenbank hat; diese Pipeline schliesst die Luecke.
on:
push:
paths:
- "ApiCore-Backend/**"
- ".gitea/workflows/backend-e2e.yml"
workflow_dispatch:
# Ein neuer Push auf denselben Ref ersetzt den laufenden Lauf.
concurrency:
group: backend-e2e-${{ gitea.ref }}
cancel-in-progress: true
jobs:
e2e:
runs-on: docker-builder
timeout-minutes: 30
# Gitea act_runner unterstuetzt `services:`. Annahme (beim ersten Lauf
# pruefen): der Job-Container teilt das Netz der Services und erreicht
# Postgres unter dem Service-Namen `postgres`. Laeuft der Runner
# stattdessen im Host-Modus, ist der Port auf localhost veroeffentlicht;
# der Schritt "Datenbank-Host ermitteln" probiert beide Wege.
services:
postgres:
# Gleiche Major-Version wie Produktion (deploy/k8s/base/postgres).
# Per Digest gepinnt (Index-Digest von 18.6-alpine, nachgeschlagen
# 2026-10-16); die k8s-Manifeste pinnen Postgres bisher nur per Tag.
image: postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
env:
# Wegwerf-Datenbank im Service-Container, nur im Job-Netz erreichbar
# und nach dem Lauf verworfen: kein echtes Secret.
POSTGRES_USER: apicore
POSTGRES_PASSWORD: apicore-ci-only
POSTGRES_DB: apicore_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U apicore -d apicore_test"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
NODE_ENV: test
# Das Wetter-e2e ruft die echte Open-Meteo-API; in CI ueberspringen.
SKIP_EXTERNAL: "1"
JWT_ACCESS_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
PORT: "3001"
FRONTEND_ORIGIN: http://localhost:3000
DOCUMENTS_STORAGE_PATH: ./test-uploads
defaults:
run:
working-directory: ApiCore-Backend
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Node einrichten
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Datenbank-Host ermitteln und Testumgebung erzeugen
# jest-e2e.json laedt .env.test per dotenv, das vorhandene Variablen
# nicht ueberschreibt: alles kommt aus der Job-Umgebung, .env.test
# existiert in CI nicht (gitignored). Die JWT-Secrets sind frisch
# erzeugte Wegwerfwerte (mind. 32 Zeichen, voneinander verschieden),
# nie echte Secrets.
run: |
set -eu
host=""
for candidate in postgres localhost; do
if node -e "const s=require('net').connect(5432,process.argv[1]);s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)" "$candidate"; then
host="$candidate"
break
fi
done
if [ -z "$host" ]; then
echo "Postgres weder unter 'postgres' noch unter 'localhost' erreichbar" >&2
exit 1
fi
echo "Postgres-Host: $host"
access="$(openssl rand -hex 32)"
refresh="$(openssl rand -hex 32)"
echo "::add-mask::$access"
echo "::add-mask::$refresh"
{
echo "DATABASE_URL=postgresql://apicore:apicore-ci-only@${host}:5432/apicore_test?schema=public"
echo "JWT_ACCESS_SECRET=$access"
echo "JWT_REFRESH_SECRET=$refresh"
} >> "$GITEA_ENV"
- name: Abhaengigkeiten installieren
run: npm ci
- name: Prisma-Client erzeugen
run: npx prisma generate
- name: Migrationen anwenden
# Die Rollen (z. B. BEEKEEPER) kommen aus den Migrationen, ein Seed
# ist fuer die e2e-Tests nicht noetig.
run: npx prisma migrate deploy
- name: E2E-Tests
# Auf dem geteilten Runner brauchten App-Start plus bcrypt-Logins in
# beforeAll mehr als die lokalen 20 s (Laeufe 826, 836); hier 60 s.
run: npm run test:e2e -- --ci --testTimeout=60000
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
REGISTRY_PUSH: gitea:80
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Image-Namen in Kleinbuchstaben ermitteln
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
@@ -31,7 +31,7 @@ jobs:
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
@@ -40,7 +40,7 @@ jobs:
http = true
- name: Bei der Gitea-Container-Registry anmelden
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY_PUSH }}
username: ${{ gitea.actor }}
@@ -50,7 +50,7 @@ jobs:
- name: Metadaten (Tags/Labels) ermitteln
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
@@ -58,7 +58,7 @@ jobs:
type=semver,pattern={{version}}
- name: Image bauen und pushen
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: ./ApiCore-Backend
push: true
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
REGISTRY_PUSH: gitea:80
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Image-Namen in Kleinbuchstaben ermitteln
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
@@ -31,7 +31,7 @@ jobs:
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
@@ -40,7 +40,7 @@ jobs:
http = true
- name: Bei der Gitea-Container-Registry anmelden
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY_PUSH }}
username: ${{ gitea.actor }}
@@ -50,7 +50,7 @@ jobs:
- name: Metadaten (Tags/Labels) ermitteln
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
@@ -58,7 +58,7 @@ jobs:
type=semver,pattern={{version}}
- name: Image bauen und pushen
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: ./ApiCore-Frontend
push: true
+2 -2
View File
@@ -32,10 +32,10 @@ jobs:
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Node einrichten
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
+83
View File
@@ -0,0 +1,83 @@
name: Whisper Image
# Nur bei Aenderungen am Abschreib-Image (Spec T7 11.1): das Image ist rund
# 1,2 GB gross (Modell + Python-Abhaengigkeiten) und aendert sich selten.
# Tag sha-<commit>; das Overlay setzt danach den Digest.
on:
push:
branches: [main]
paths: ['ApiCore-Whisper/**', '.gitea/workflows/whisper-image.yml']
workflow_dispatch:
jobs:
build-and-push:
runs-on: docker-builder
env:
# Nur fuer Doku/OCI-Labels, siehe REGISTRY_PUSH fuer den eigentlichen Push.
REGISTRY: git.encry.de
# Cluster-interner Service-Name statt git.encry.de: Registry-Blob-
# Uploads (PUT) vertragen sich nicht mit dem Cloudflare-Proxy vor
# git.encry.de ("413 Payload Too Large" unabhaengig von der Groesse).
# Der Runner-Pod erreicht den Gitea-Service direkt ueber Kubernetes-DNS.
# Push und Pull landen im selben Registry-Pfad (Owner/Repo:Tag), der
# Hostname ist nur die Verbindung, daher bleibt der Pull in den
# k8s-Manifesten unveraendert ueber git.encry.de.
REGISTRY_PUSH: gitea:80
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Image-Namen in Kleinbuchstaben ermitteln
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
# der Gitea-Owner-Name behaelt Gross-/Kleinschreibung bei.
id: image
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-whisper' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
config-inline: |
[registry."gitea:80"]
http = true
- name: Bei der Gitea-Container-Registry anmelden
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY_PUSH }}
username: ${{ gitea.actor }}
# GITEA_TOKEN hat kein Package-Write-Recht — separates Personal
# Access Token (Scope "package") als Secret REGISTRY_TOKEN anlegen.
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Metadaten (Tags/Labels) ermitteln
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
type=sha,format=short
- name: Image bauen und pushen
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: ./ApiCore-Whisper
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=apicore-whisper
cache-to: type=gha,scope=apicore-whisper,mode=max
- name: Schwachstellen-Scan (nur wenn trivy im Runner vorhanden ist)
# Hinweisend (exit-code 0): die Ausgabe gehoert ins Runbook, ein Fund
# blockiert den Push nicht, weil das Image dann schon in der Registry
# liegt. Der Build selbst ist die harte Sperre (Hashes, Modell-SHA,
# pytest in der Test-Stufe).
run: |
if command -v trivy >/dev/null 2>&1; then
first_tag="$(echo '${{ steps.meta.outputs.tags }}' | head -n1)"
trivy image --insecure --severity HIGH,CRITICAL --exit-code 0 "$first_tag"
else
echo "trivy ist im Runner nicht installiert, Scan uebersprungen"
fi
+9
View File
@@ -0,0 +1,9 @@
# Root-level artefacts only; each project has its own .gitignore.
# Tool caches created when tests or scripts run from the repo root.
/node_modules/
# Working notes of the SDD orchestration (ledgers, briefs, review reports)
# and design mockups/screenshots (~90 MB). Decisions that matter are in
# ARCHITECTURE.md, the specs/plans under docs/ and the Gitea issues.
/.superpowers/
+851 -35
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -56,6 +56,16 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
# jedem Lauf geloescht (Standard: 14).
BACKUP_RETENTION_COUNT=14
# Obergrenze fuer eine einzuspielende Backup-Datei in Bytes (Standard 50 MB,
# hoechstens 256 MB, weil der Upload komplett im RAM verarbeitet wird).
# Export und Zeitplan warnen, wenn eine Sicherung groesser wird.
# BACKUP_MAX_IMPORT_BYTES=52428800
# Erster Administrator (prisma/seed.ts): wird angelegt, wenn es noch keinen
# Benutzer mit dieser Adresse gibt. Ohne Passwort erzeugt der Seed ein
# zufaelliges und gibt es einmalig im Log aus.
# [email protected]
# SEED_ADMIN_PASSWORD=
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
@@ -72,9 +82,12 @@ BACKUP_RETENTION_COUNT=14
# MFA_ENCRYPTION_KEY=""
# WEBHOOK_ENCRYPTION_KEY=""
# BACKUP_ENCRYPTION_KEY=""
# Seals voice notes (audio, transcripts, suggestions); required in production.
# VOICE_ENCRYPTION_KEY=""
# MFA_ENCRYPTION_KEY_PREVIOUS=""
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
# VOICE_ENCRYPTION_KEY_PREVIOUS=""
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
@@ -108,3 +121,31 @@ 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.
# Je Umgebung ein eigener Bot. Im Cluster steht der Name in der ConfigMap
# (TELEGRAM_BOT_USERNAME, leer = Telegram in der App nicht eingerichtet); den
# Zustand zeigt Verwaltung > System (Telegram-Karte, nur system:manage).
# TELEGRAM_BOT_USERNAME=
# TELEGRAM_BOT_TOKEN=
+3
View File
@@ -18,3 +18,6 @@ prisma/seed.d.ts
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
/uploads/
/test-uploads/
# T4 migration test harness: scratch copies of the migration folders
/.tmp-migrations/
+32 -86
View File
@@ -1,98 +1,44 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
# ApiCore-Backend
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
NestJS-API von ApiCore (TypeScript, Prisma, PostgreSQL). Alle fachlichen Funktionen liegen unter `/api/v1/...`. Überblick über das ganze Projekt: [`../README.md`](../README.md), Architektur: [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
## Einrichten
```bash
$ npm install
npm install
cp .env.example .env # DATABASE_URL, JWT- und Verschlüsselungs-Schlüssel setzen
npx prisma migrate dev # Schema anwenden (nur lokal; gegen Produktion nur migrate deploy)
npm run start:dev # http://localhost:3001
```
## Compile and run the project
Alle Variablen mit Bedeutung stehen in [`.env.example`](.env.example). Swagger unter `/api/v1/docs` ist nur mit `ENABLE_SWAGGER=true` erreichbar.
## Laufrollen
Ein Build, drei Einstiegspunkte (`ARCHITECTURE.md` Abschnitt 10):
| Befehl | Rolle | Zweck |
|---|---|---|
| `npm run start:dev` / `node dist/main.js` | `api` (lokal ohne `APICORE_ROLE`: `all`) | HTTP-API; mit `all` laufen API und Worker in einem Prozess, nur für Entwicklung und Tests |
| `npm run worker` (`node dist/worker.js`) | `worker` | Zustellung der Outbox `DomainEvent`, Webhooks, Kalender-Sync, Wetter, Telegram; Health auf Port 3002 |
| `npm run job -- <name>` (`node dist/cli.js <name>`) | `job` | `cleanup`, `daily-evaluation`, `task-templates`, `app-backup`, `rekey` (auch `rekey --dry-run`); im Cluster als CronJobs |
In Produktion ist `APICORE_ROLE` Pflicht und `all` verboten. Migrationen laufen im Cluster als initContainer `migrate` (`prisma migrate deploy`), der Seed als initContainer `seed` (`node dist/seed.js`).
## Prüfen
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
npm run lint # eslint mit --fix
npm run typecheck # tsc --noEmit
npm test # Jest-Unit-Tests
npm run test:e2e # Jest-e2e gegen die Datenbank aus .env.test (vorher migrieren)
npm run build # dist/main.js, dist/worker.js, dist/cli.js, dist/seed.js
```
## Run tests
Die e2e-Suites laufen parallel (`maxWorkers: 4` in `test/jest-e2e.json`) und scheitern dabei derzeit sporadisch; mit `npm run test:e2e -- --runInBand` sind sie grün ([Issue #32](https://git.encry.de/Nick.Rosenloecher/ApiCore/issues/32)). Der Image-Build (`Dockerfile`) führt Lint, Typecheck und Unit-Tests aus und bricht bei Fehlern ab; die e2e-Tests laufen dort nicht.
```bash
# unit tests
$ npm run test
## Aufbau
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
- `src/<modul>/`: ein NestJS-Modul je Fachbereich (z. B. `colonies`, `inspections`, `treatments`, `honey-batches`, `compliance`, `hive`), dazu Querschnitt in `common`, `prisma`, `domain-events`, `worker`, `jobs`, `runtime`.
- `prisma/schema.prisma` und `prisma/migrations/` sind die einzige Quelle des Schemas; `prisma db push` wird nirgends verwendet.
- `test/`: e2e-Specs und gemeinsame Fixtures (u. a. `test/fixtures/hive-ops/`, die auch das Frontend nutzt).
+72
View File
@@ -9,6 +9,7 @@
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
"@anthropic-ai/sdk": "0.131.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
@@ -213,6 +214,27 @@
"tslib": "^2.1.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.131.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -674,6 +696,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -3213,6 +3244,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -6925,6 +6962,12 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
@@ -8780,6 +8823,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -11249,6 +11305,16 @@
"node": ">=8"
}
},
"node_modules/standardwebhooks": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11785,6 +11851,12 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+6 -2
View File
@@ -22,9 +22,12 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"migrate:deploy": "prisma migrate deploy",
"seed": "node dist/seed.js"
"seed": "node dist/seed.js",
"export:voice-vocabulary": "ts-node scripts/export-voice-vocabulary.ts",
"eval:voice": "ts-node scripts/eval-voice.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "0.131.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
@@ -102,6 +105,7 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"testTimeout": 20000
}
}
@@ -0,0 +1,19 @@
-- T4 migration 1/2: new enum values only. PostgreSQL cannot use a value in
-- the transaction that adds it, so everything that uses them (the Suggestion
-- table, rule rows) comes in later migrations.
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'INSPECTION_INTERVAL';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_CELLS_SWARM_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_ACCEPTANCE_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SPLIT_SCHEDULE';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_ENDED_CLEANUP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'WITHDRAWAL_ENDED';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_WATER_CONTENT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SEASON_AUTOPILOT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PHENOLOGY';
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'BOTTLING';
-- AlterEnum
ALTER TYPE "CalendarSyncSourceType" ADD VALUE 'WITHDRAWAL';
@@ -0,0 +1,71 @@
-- T4 migration 2/2: suggestions (rule, chain and autopilot proposals the
-- beekeeper decides on). dedupeKey is nullable and unique: Postgres allows many
-- NULLs, so EXPIRED/RESOLVED rows release their key.
-- CreateEnum
CREATE TYPE "SuggestionStatus" AS ENUM ('OPEN', 'ACCEPTED', 'DISMISSED', 'RESOLVED', 'EXPIRED', 'OBSOLETE');
-- CreateEnum
CREATE TYPE "SuggestionMode" AS ENUM ('PLAN', 'DO');
-- CreateEnum
CREATE TYPE "SuggestionSource" AS ENUM ('RULE', 'CHAIN', 'AUTOPILOT');
-- CreateEnum
CREATE TYPE "SuggestionIntent" AS ENUM ('INSPECT', 'SWARM_CHECK', 'QUEEN_CHECK', 'QUEEN_ACCEPTANCE', 'QUEEN_REPLACE', 'SPLIT_PLAN', 'VARROA_MEASURE', 'VARROA_RECHECK', 'VARROA_TREAT', 'SUMMER_TREATMENT', 'FEED', 'HONEY_SUPER', 'WINTER_CHECK', 'HEALTH_CHECK', 'FOLLOW_UP', 'STOCK_REORDER', 'CLEANUP');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceSuggestionId" TEXT;
-- CreateTable
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"ruleKey" "AutomationTriggerType" NOT NULL,
"source" "SuggestionSource" NOT NULL,
"mode" "SuggestionMode" NOT NULL,
"intent" "SuggestionIntent" NOT NULL,
"status" "SuggestionStatus" NOT NULL DEFAULT 'OPEN',
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
"relatedType" "TaskRelatedType" NOT NULL,
"relatedId" TEXT,
"colonyId" TEXT,
"reasonCode" TEXT NOT NULL,
"evidence" JSONB NOT NULL,
"requiredPermission" TEXT,
"dueDate" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"plan" JSONB,
"dedupeKey" TEXT,
"sourceEventId" TEXT,
"decidedAt" TIMESTAMP(3),
"decidedByUserId" TEXT,
"decisionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Suggestion_dedupeKey_key" ON "Suggestion"("dedupeKey");
-- CreateIndex
CREATE INDEX "Suggestion_status_colonyId_idx" ON "Suggestion"("status", "colonyId");
-- CreateIndex
CREATE INDEX "Suggestion_status_dueDate_idx" ON "Suggestion"("status", "dueDate");
-- CreateIndex
CREATE INDEX "Suggestion_ruleKey_relatedType_relatedId_status_idx" ON "Suggestion"("ruleKey", "relatedType", "relatedId", "status");
-- CreateIndex
CREATE INDEX "Task_sourceSuggestionId_idx" ON "Task"("sourceSuggestionId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceSuggestionId_fkey" FOREIGN KEY ("sourceSuggestionId") REFERENCES "Suggestion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_decidedByUserId_fkey" FOREIGN KEY ("decidedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- T4 migration 3/n: one vocabulary for the feed condition. Inspection.feedStatus
-- was free text; it becomes ColonyCondition, like the colony card. Known words
-- (ausreichend, niedrig, kritisch; trimmed of spaces, tabs and line breaks like
-- JS trim, any case) map to the enum, any other text moves to the end of
-- "notes" as 'Futter (alt): <text>'. Nothing is lost.
-- AlterTable
ALTER TABLE "Inspection" ADD COLUMN "feedCondition" "ColonyCondition";
-- Known words map to the enum.
UPDATE "Inspection"
SET "feedCondition" = CASE lower(btrim("feedStatus", E' \t\r\n'))
WHEN 'ausreichend' THEN 'GOOD'::"ColonyCondition"
WHEN 'niedrig' THEN 'WEAK'::"ColonyCondition"
WHEN 'kritisch' THEN 'CRITICAL'::"ColonyCondition"
END
WHERE "feedStatus" IS NOT NULL;
-- Every other non-empty text goes to the notes.
DO $$
DECLARE
moved integer;
BEGIN
UPDATE "Inspection"
SET "notes" = concat_ws(E'\n', NULLIF("notes", ''), 'Futter (alt): ' || btrim("feedStatus", E' \t\r\n'))
WHERE "feedCondition" IS NULL
AND btrim("feedStatus", E' \t\r\n') <> '';
GET DIAGNOSTICS moved = ROW_COUNT;
RAISE NOTICE 't4_feed_vocabulary: % free-text feed values moved into notes', moved;
END $$;
-- AlterTable
ALTER TABLE "Inspection" DROP COLUMN "feedStatus";
ALTER TABLE "Inspection" RENAME COLUMN "feedCondition" TO "feedStatus";
@@ -0,0 +1,8 @@
-- T4 migration 5/n: where the queen of a split colony comes from. Optional:
-- an observation is never filled in for old rows.
-- CreateEnum
CREATE TYPE "SplitQueenSource" AS ENUM ('RAISE_OWN', 'QUEEN_CELL', 'QUEEN');
-- AlterTable
ALTER TABLE "ColonyLineage" ADD COLUMN "queenSource" "SplitQueenSource";
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part sampleBees): number of bees in a powdered-sugar or
-- washing sample, so that the infestation percentage can be computed.
-- AlterTable
ALTER TABLE "VarroaMeasurement" ADD COLUMN "sampleBees" INTEGER;
@@ -0,0 +1,33 @@
-- T4 migration 8/n: every notification belongs to exactly one user. A
-- broadcast (userId IS NULL) is copied once per active user with its read
-- flag and creation time, then the original is deleted. After that userId is
-- NOT NULL; dedupeKey ("<prefix>:<userId>") makes repeated writes harmless.
-- Check afterwards: SELECT count(*) FROM "Notification" WHERE "userId" IS NULL;
-- must be 0 (the SET NOT NULL below fails otherwise).
-- DropForeignKey (was ON DELETE SET NULL, impossible for a NOT NULL column)
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "dedupeKey" TEXT;
-- Fan-out of the broadcasts to ALL active users (isActive only, no role or
-- permission filter): a broadcast was visible to every user before, so the
-- copies keep exactly that visibility. With no active user, broadcasts are
-- dropped. The copies get no dedupeKey and the default requiredPermissions.
INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "relatedId", "read", "createdAt")
SELECT gen_random_uuid()::text, u."id", n."priority", n."message", n."relatedType", n."relatedId", n."read", n."createdAt"
FROM "Notification" n
CROSS JOIN "User" u
WHERE n."userId" IS NULL AND u."isActive" = true;
DELETE FROM "Notification" WHERE "userId" IS NULL;
-- AlterTable
ALTER TABLE "Notification" ALTER COLUMN "userId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Notification_dedupeKey_key" ON "Notification"("dedupeKey");
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- T4 migration 9/n: switch for the withdrawal-period entries of the calendar
-- sync (on by default, like the other sources).
-- AlterTable
ALTER TABLE "CalendarSyncSettings" ADD COLUMN "syncWithdrawal" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,11 @@
-- T4 migration 10/n: Colony.occupiedFrames, the number of occupied frames
-- (0-40, same unit and bounds as Inspection.colonyStrength). Colony.strength
-- (scale 1-10) is left untouched, nothing is converted.
--
-- Check BEFORE the deploy (hits must be clarified with the owner, never cut):
-- SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40;
-- AlterTable
ALTER TABLE "Colony" ADD COLUMN "occupiedFrames" INTEGER;
ALTER TABLE "Colony" ADD CONSTRAINT "Colony_occupiedFrames_range" CHECK ("occupiedFrames" BETWEEN 0 AND 40);
@@ -0,0 +1,39 @@
-- T4 migration 11/n (part category, link, permissions, preferences). Runs
-- after migration 8. category of the old rows: job failures carry
-- relatedType 'JobRun' (and rows without relatedType are system messages) ->
-- SYSTEM, everything else -> SUGGESTION. Only then NOT NULL.
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('TASK_DUE', 'DIGEST', 'SUGGESTION', 'WITHDRAWAL', 'SEASON', 'SYSTEM');
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "category" "NotificationCategory",
ADD COLUMN "link" TEXT,
ADD COLUMN "requiredPermissions" TEXT[] DEFAULT ARRAY[]::TEXT[];
UPDATE "Notification"
SET "category" = CASE
WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM'::"NotificationCategory"
ELSE 'SUGGESTION'::"NotificationCategory"
END;
ALTER TABLE "Notification" ALTER COLUMN "category" SET NOT NULL;
-- CreateTable
CREATE TABLE "NotificationPreference" (
"userId" TEXT NOT NULL,
"taskDueEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestTime" TEXT NOT NULL DEFAULT '06:30',
"quietFrom" TEXT DEFAULT '21:00',
"quietUntil" TEXT DEFAULT '06:30',
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
"telegramCategories" "NotificationCategory"[] DEFAULT ARRAY['TASK_DUE', 'DIGEST']::"NotificationCategory"[],
"telegramDetail" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- T4 follow-up: notifications and their settings go with their user.
-- Users are deactivated, never hard-deleted, in production, so RESTRICT
-- protected nothing; but fan-out writes rows to users of other test files,
-- and deleting such a user must not fail on a foreign notification.
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- DropForeignKey
ALTER TABLE "NotificationPreference" DROP CONSTRAINT "NotificationPreference_userId_fkey";
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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');
@@ -0,0 +1,146 @@
-- T6 migration 1/5: veterinary medicine catalogue, packs and the pack ledger.
-- A treatment may point at a catalogue entry and at the pack it was taken
-- from; the pack ledger is booked, never edited. Rows of the catalogue and
-- the packs are never deleted while something references them (RESTRICT).
-- CHECKs: a pack holds more than nothing; a booking moves something; only a
-- TREATMENT booking belongs to a treatment.
-- CreateEnum
CREATE TYPE "MedicineUnit" AS ENUM ('ML', 'G', 'PIECE');
-- CreateEnum
CREATE TYPE "VeterinaryMedicineMovementType" AS ENUM ('TREATMENT', 'CORRECTION', 'DISPOSAL');
-- AlterEnum
ALTER TYPE "DocumentEntityType" ADD VALUE 'MEDICINE_LOT';
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "doseQuantity" DOUBLE PRECISION,
ADD COLUMN "medicineId" TEXT,
ADD COLUMN "medicineLotId" TEXT;
-- CreateTable
CREATE TABLE "VeterinaryMedicine" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"activeIngredient" TEXT,
"authorisationNumber" TEXT,
"prescriptionOnly" BOOLEAN NOT NULL DEFAULT false,
"withdrawalPeriodDays" INTEGER,
"unit" "MedicineUnit" NOT NULL,
"dosePerColony" DOUBLE PRECISION,
"doseNote" TEXT,
"applicationMethod" TEXT,
"minTemperatureC" DOUBLE PRECISION,
"maxTemperatureC" DOUBLE PRECISION,
"defaultSupplier" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineLot" (
"id" TEXT NOT NULL,
"medicineId" TEXT NOT NULL,
"batchNumber" TEXT,
"purchasedOn" TIMESTAMP(3) NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"expiresOn" TIMESTAMP(3),
"supplier" TEXT,
"purchaseProof" TEXT,
"prescribingVet" TEXT,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicineLot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineMovement" (
"id" TEXT NOT NULL,
"lotId" TEXT NOT NULL,
"type" "VeterinaryMedicineMovementType" NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"treatmentId" TEXT,
"reason" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VeterinaryMedicineMovement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VeterinaryMedicine_active_idx" ON "VeterinaryMedicine"("active");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineLot_medicineId_expiresOn_idx" ON "VeterinaryMedicineLot"("medicineId", "expiresOn");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_lotId_idx" ON "VeterinaryMedicineMovement"("lotId");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_treatmentId_idx" ON "VeterinaryMedicineMovement"("treatmentId");
-- CreateIndex
CREATE INDEX "Treatment_medicineId_idx" ON "Treatment"("medicineId");
-- CreateIndex
CREATE INDEX "Treatment_medicineLotId_idx" ON "Treatment"("medicineLotId");
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineLotId_fkey" FOREIGN KEY ("medicineLotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "Treatment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "VeterinaryMedicineLot"
ADD CONSTRAINT "VeterinaryMedicineLot_quantity_positive" CHECK ("quantity" > 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_quantity_nonzero" CHECK ("quantity" <> 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_treatment_matches_type"
CHECK (("type" = 'TREATMENT') = ("treatmentId" IS NOT NULL));
-- Permissions: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN), so the grants are made here (same as winter_checks).
-- API_CLIENT gets nothing: receipts and invoice numbers are not for the shop.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'veterinary-medicines:read', 'veterinary-medicines', 'read', 'Ansehen', 'Tierarzneimittel', 3700),
(gen_random_uuid()::text, 'veterinary-medicines:create', 'veterinary-medicines', 'create', 'Anlegen', 'Tierarzneimittel', 3701),
(gen_random_uuid()::text, 'veterinary-medicines:update', 'veterinary-medicines', 'update', 'Bearbeiten', 'Tierarzneimittel', 3702)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'veterinary-medicines'
AND (
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
OR (r.key = 'READONLY' AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,21 @@
-- T6 migration 2/5: mandatory label particulars. The food business operator
-- (name and address) is separate from the bee keeper of the Bestandsbuch; a
-- product carries the legal name of the food; a label template carries the
-- minimum x-height (CHECK 0.5 to 5 mm) the print check warns about. Printing
-- a label is audited as PRINT.
-- AlterEnum
ALTER TYPE "AuditAction" ADD VALUE 'PRINT';
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "foodBusinessAddress" TEXT,
ADD COLUMN "foodBusinessName" TEXT;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "legalName" TEXT;
-- AlterTable
ALTER TABLE "LabelTemplate" ADD COLUMN "minXHeightMm" DOUBLE PRECISION NOT NULL DEFAULT 1.2;
ALTER TABLE "LabelTemplate"
ADD CONSTRAINT "LabelTemplate_minXHeightMm_range" CHECK ("minXHeightMm" BETWEEN 0.5 AND 5);
@@ -0,0 +1,140 @@
-- T6 migration 3/5: authority package and readiness. ComplianceSettings is a
-- singleton like SiteSettings; the default net-quantity digit rules are an
-- assumption the owner has to check (ARCHITECTURE). AuthorityNotice.openKey
-- is a nullable unique key: at most one open draft per kind and year, any
-- number of rows without a key. ApiaryRestriction ends on or after its start.
-- CreateEnum
CREATE TYPE "AuthorityNoticeKind" AS ENUM ('REGISTRATION', 'HERD_CHANGE', 'TSK_REPORT');
-- CreateEnum
CREATE TYPE "AuthorityNoticeStatus" AS ENUM ('DRAFT', 'SENT', 'CANCELLED');
-- AlterEnum
-- This migration adds more than one value to an enum. They are not used in
-- this migration (PostgreSQL forbids it inside the same transaction).
ALTER TYPE "DocumentEntityType" ADD VALUE 'AUTHORITY_NOTICE';
ALTER TYPE "DocumentEntityType" ADD VALUE 'APIARY_RESTRICTION';
-- CreateTable
CREATE TABLE "ComplianceSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"veterinaryOfficeName" TEXT,
"veterinaryOfficeAddress" TEXT,
"registrationNumber" TEXT,
"noticeOnColonyCountChange" BOOLEAN NOT NULL DEFAULT true,
"noticeOnApiaryChange" BOOLEAN NOT NULL DEFAULT true,
"tskReferenceDay" TEXT NOT NULL DEFAULT '01-01',
"tskReportDueDay" TEXT,
"tskMemberNumber" TEXT,
"tskExcludedColonyTypes" "ColonyType"[] DEFAULT ARRAY[]::"ColonyType"[],
"netQuantityDigitRules" JSONB NOT NULL DEFAULT '[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ComplianceSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthorityNotice" (
"id" TEXT NOT NULL,
"kind" "AuthorityNoticeKind" NOT NULL,
"status" "AuthorityNoticeStatus" NOT NULL DEFAULT 'DRAFT',
"year" INTEGER,
"referenceDate" TIMESTAMP(3) NOT NULL,
"countedAt" TIMESTAMP(3) NOT NULL,
"snapshot" JSONB NOT NULL,
"totalColonies" INTEGER NOT NULL,
"sentOn" TIMESTAMP(3),
"sentVia" TEXT,
"reference" TEXT,
"notes" TEXT,
"openKey" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AuthorityNotice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiaryRestriction" (
"id" TEXT NOT NULL,
"apiaryId" TEXT NOT NULL,
"disease" TEXT NOT NULL,
"authority" TEXT,
"reference" TEXT,
"startsOn" TIMESTAMP(3) NOT NULL,
"endsOn" TIMESTAMP(3),
"notes" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiaryRestriction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ComplianceAcknowledgement" (
"id" TEXT NOT NULL,
"itemKey" TEXT NOT NULL,
"subjectId" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"note" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"revokedByUserId" TEXT,
CONSTRAINT "ComplianceAcknowledgement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthorityNotice_openKey_key" ON "AuthorityNotice"("openKey");
-- CreateIndex
CREATE INDEX "AuthorityNotice_kind_status_idx" ON "AuthorityNotice"("kind", "status");
-- CreateIndex
CREATE INDEX "AuthorityNotice_year_idx" ON "AuthorityNotice"("year");
-- CreateIndex
CREATE INDEX "ApiaryRestriction_apiaryId_startsOn_idx" ON "ApiaryRestriction"("apiaryId", "startsOn");
-- CreateIndex
CREATE INDEX "ComplianceAcknowledgement_itemKey_subjectId_idx" ON "ComplianceAcknowledgement"("itemKey", "subjectId");
-- AddForeignKey
ALTER TABLE "AuthorityNotice" ADD CONSTRAINT "AuthorityNotice_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ComplianceAcknowledgement" ADD CONSTRAINT "ComplianceAcknowledgement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "ApiaryRestriction"
ADD CONSTRAINT "ApiaryRestriction_endsOn_after_startsOn"
CHECK ("endsOn" IS NULL OR "endsOn" >= "startsOn");
-- Permissions (see the winter_checks migration): read for everyone who
-- reads the records, manage (settings, notices, restrictions, readiness
-- sign-off) only for ADMIN and BEEKEEPER. API_CLIENT gets nothing.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'compliance:read', 'compliance', 'read', 'Ansehen', 'Prüfbereitschaft & Behörden', 3800),
(gen_random_uuid()::text, 'compliance:manage', 'compliance', 'manage', 'Verwalten', 'Prüfbereitschaft & Behörden', 3801)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'compliance'
AND (
r.key IN ('ADMIN', 'BEEKEEPER')
OR (r.key IN ('STAFF', 'READONLY') AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,7 @@
-- T6 migration 4/5: notification category for the authority hints ("Bestand
-- geaendert", "TSK-Stichtag"). The value is only added here; migration 5
-- uses it (PostgreSQL forbids using a new enum value in the same
-- transaction that adds it).
-- AlterEnum
ALTER TYPE "NotificationCategory" ADD VALUE 'COMPLIANCE';
@@ -0,0 +1,13 @@
-- T6 migration 5/5: COMPLIANCE is a Telegram category chosen by default. New
-- preferences start with it; every existing preference gets it appended
-- (the app shows all categories, the list only steers Telegram). A user can
-- still remove it in the profile: an empty list is a deliberate opt-out and
-- stays empty (only lists with at least one entry get the new category).
-- AlterTable
ALTER TABLE "NotificationPreference" ALTER COLUMN "telegramCategories" SET DEFAULT ARRAY['TASK_DUE', 'DIGEST', 'COMPLIANCE']::"NotificationCategory"[];
UPDATE "NotificationPreference"
SET "telegramCategories" = array_append("telegramCategories", 'COMPLIANCE'::"NotificationCategory")
WHERE cardinality("telegramCategories") > 0
AND NOT ('COMPLIANCE'::"NotificationCategory" = ANY("telegramCategories"));
@@ -0,0 +1,9 @@
-- T6 migration 6/6: the ComplianceSettings singleton row exists from the
-- start. The label check reads the net-quantity digit rules from it and fails
-- closed when the row is missing, so no legal value lives in the code. The
-- column defaults (digit rules, notice switches) fill the row; the owner has
-- to check them (ARCHITECTURE).
INSERT INTO "ComplianceSettings" ("id", "updatedAt")
VALUES ('singleton', now())
ON CONFLICT ("id") DO NOTHING;
@@ -0,0 +1,109 @@
-- Beute in 3D: hive systems, part types, per-colony setup and its change
-- history (spec 2026-10-15, section 5.1).
-- CreateEnum
CREATE TYPE "HivePartKind" AS ENUM ('FLOOR', 'BOX', 'DIVIDER_BOARD', 'QUEEN_EXCLUDER', 'FEEDER', 'COVER_SHEET', 'PROPOLIS_SCREEN', 'INNER_LID', 'INSULATION', 'ROOF', 'FLOOR_INSERT', 'EVAPORATOR');
-- CreateEnum
CREATE TYPE "HiveBoxDepth" AS ENUM ('FULL', 'SHALLOW');
-- CreateEnum
CREATE TYPE "HiveChangeSource" AS ENUM ('INIT', 'CARD', 'EDITOR', 'INSPECTION', 'AI_SUGGESTION');
-- CreateTable
CREATE TABLE "HiveSystem" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"frameWidthMm" INTEGER NOT NULL,
"frameHeightMm" INTEGER NOT NULL,
"footprintLMm" INTEGER NOT NULL,
"footprintWMm" INTEGER NOT NULL,
"template" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HiveSystem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HivePartType" (
"id" TEXT NOT NULL,
"systemId" TEXT NOT NULL,
"kind" "HivePartKind" NOT NULL,
"name" TEXT NOT NULL,
"articleRef" TEXT,
"lengthMm" INTEGER NOT NULL,
"widthMm" INTEGER NOT NULL,
"heightMm" INTEGER NOT NULL,
"boxDepth" "HiveBoxDepth",
"frameSlots" INTEGER,
"stateSchema" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HivePartType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HiveSetup" (
"colonyId" TEXT NOT NULL,
"systemId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"state" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HiveSetup_pkey" PRIMARY KEY ("colonyId")
);
-- CreateTable
CREATE TABLE "HiveChange" (
"id" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"occurredOn" TIMESTAMP(3) NOT NULL,
"source" "HiveChangeSource" NOT NULL,
"inspectionId" TEXT,
"userId" TEXT NOT NULL,
"ops" JSONB NOT NULL,
"stateAfter" JSONB NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HiveChange_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HiveSystem_name_key" ON "HiveSystem"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HivePartType_systemId_name_key" ON "HivePartType"("systemId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "HiveChange_inspectionId_key" ON "HiveChange"("inspectionId");
-- CreateIndex
CREATE INDEX "HiveChange_colonyId_occurredOn_idx" ON "HiveChange"("colonyId", "occurredOn");
-- CreateIndex
CREATE UNIQUE INDEX "HiveChange_colonyId_version_key" ON "HiveChange"("colonyId", "version");
-- AddForeignKey
ALTER TABLE "HivePartType" ADD CONSTRAINT "HivePartType_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "HiveSetup"("colonyId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "Inspection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- Beute in 3D: start catalog for the Liebig-Zander equipment (spec 2026-10-15,
-- sections 4.3 and 4.4) under fixed ids, so the system template has stable
-- references and a restore into a fresh install creates no duplicate names.
-- Dimensions are catalog data and editable in the settings.
-- Hive system
INSERT INTO "HiveSystem" ("id", "name", "frameWidthMm", "frameHeightMm", "footprintLMm", "footprintWMm", "template", "active", "updatedAt")
VALUES (
'hive-system-zander-liebig-10',
'Zander nach Dr. Liebig, 10 Rähmchen',
477,
220,
525,
430,
'{"schemaVersion":1,"stack":[{"partTypeId":"hive-part-zl10-floor","state":{}},{"partTypeId":"hive-part-zl10-box-10","state":{},"frames":[{"slot":1,"type":"FOUNDATION"},{"slot":2,"type":"FOUNDATION"},{"slot":3,"type":"FOUNDATION"},{"slot":4,"type":"FOUNDATION"},{"slot":5,"type":"FOUNDATION"},{"slot":6,"type":"FOUNDATION"},{"slot":7,"type":"FOUNDATION"},{"slot":8,"type":"FOUNDATION"},{"slot":9,"type":"FOUNDATION"},{"slot":10,"type":"FOUNDATION"}]},{"partTypeId":"hive-part-zl10-cover-abdeckfix","state":{}},{"partTypeId":"hive-part-zl10-inner-lid","state":{}},{"partTypeId":"hive-part-zl10-insulation","state":{}},{"partTypeId":"hive-part-zl10-roof","state":{}}]}'::jsonb,
true,
CURRENT_TIMESTAMP
);
-- Part types
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "stateSchema", "active", "updatedAt")
VALUES
('hive-part-zl10-floor', 'hive-system-zander-liebig-10', 'FLOOR', 'Liebig Wanderboden', 525, 430, 120, NULL, NULL, '[{"key":"entrance","label":"Flugloch","kind":"choice","options":["OPEN","NARROW","CLOSED"],"optionLabels":["offen","eng","zu"]},{"key":"varroaTray","label":"Varroa-Schublade","kind":"choice","options":["IN","OUT"],"optionLabels":["drin","draußen"]}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-floor-insert', 'hive-system-zander-liebig-10', 'FLOOR_INSERT', 'Liebig Bausperre', 475, 375, 5, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-box-10', 'hive-system-zander-liebig-10', 'BOX', 'Liebig Zander Ganzzarge, 10 Rähmchen', 525, 430, 230, 'FULL', 10, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-box-empty', 'hive-system-zander-liebig-10', 'BOX', 'Leerzarge (Liebig Zander Ganzzarge ohne Rähmchen)', 525, 430, 230, 'FULL', 0, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-divider-board', 'hive-system-zander-liebig-10', 'DIVIDER_BOARD', 'Liebig Zwischenboden', 525, 430, 50, NULL, NULL, '[{"key":"bungHoles","label":"Spundlöcher","kind":"choice","options":["OPEN","CLOSED","BEE_ESCAPE"],"optionLabels":["offen","zu","Bienenflucht"]}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-queen-excluder', 'hive-system-zander-liebig-10', 'QUEEN_EXCLUDER', 'Niro-Absperrgitter Zander Liebig', 478, 376, 2, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-feeder', 'hive-system-zander-liebig-10', 'FEEDER', 'Liebig Zweikammer-Futterzarge', 520, 420, 70, NULL, NULL, '[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":3,"step":0.5,"feedType":"SUGAR_WATER"},{"key":"fondantKg","label":"Futterteig","kind":"level","unit":"kg","max":2.5,"step":0.5,"feedType":"FEED_PASTE"}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-cover-abdeckfix', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Abdeck-Fix (Folie, klar, 0,4 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-cover-lotus', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Imgut Lotus-Abdeckung (0,5 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-propolis-screen', 'hive-system-zander-liebig-10', 'PROPOLIS_SCREEN', 'Imgut Propolisgitter', 478, 378, 3, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-inner-lid', 'hive-system-zander-liebig-10', 'INNER_LID', 'Liebig Holzdeckel', 525, 430, 35, NULL, NULL, '[{"key":"feedHole","label":"Futterloch","kind":"choice","options":["OPEN","CLOSED"],"optionLabels":["offen","zu"]}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-insulation', 'hive-system-zander-liebig-10', 'INSULATION', 'Styropor-Einlage (selbst gebaut)', 465, 365, 20, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-roof', 'hive-system-zander-liebig-10', 'ROOF', 'Edelstahldeckel konisch Liebig', 525, 430, 60, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-evaporator', 'hive-system-zander-liebig-10', 'EVAPORATOR', 'Nassenheider Verdunster Professional', 200, 140, 120, NULL, NULL, '[{"key":"acidMl","label":"Ameisensäure","kind":"level","unit":"ml","max":290,"step":10}]'::jsonb, true, CURRENT_TIMESTAMP);
@@ -0,0 +1,10 @@
-- Beute in 3D: Futtertasche (FRAME_FEEDER, spec 2026-10-15 sections 4.1, 4.3,
-- 5.1). A frame feeder hangs in a box in place of frames and covers
-- `slotWidth` frame slots. The enum value cannot be used in the migration that
-- adds it, so the catalog row comes in 20261018100100.
-- AlterEnum
ALTER TYPE "HivePartKind" ADD VALUE 'FRAME_FEEDER';
-- AlterTable
ALTER TABLE "HivePartType" ADD COLUMN "slotWidth" INTEGER;
@@ -0,0 +1,20 @@
-- Beute in 3D: start catalog row for the Futtertasche (owner wish 2026-10-16,
-- spec sections 4.3 and 4.1). Fixed id like the other start data; dimensions and
-- the 2 l capacity are assumptions the owner confirms (catalog data, editable).
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "slotWidth", "stateSchema", "active", "updatedAt")
VALUES (
'hive-part-zl10-frame-feeder',
'hive-system-zander-liebig-10',
'FRAME_FEEDER',
'Holz-Futtertasche Zander, doppelte Wabenbreite',
477,
70,
220,
NULL,
NULL,
2,
'[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":2,"step":0.25,"feedType":"SUGAR_WATER"}]'::jsonb,
true,
CURRENT_TIMESTAMP
);
@@ -0,0 +1,96 @@
-- CreateEnum
CREATE TYPE "VoiceCaptureSource" AS ENUM ('AUDIO', 'TEXT');
-- CreateEnum
CREATE TYPE "VoiceCaptureStatus" AS ENUM ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY', 'CONFIRMED', 'DISCARDED', 'FAILED', 'EXPIRED');
-- CreateTable
CREATE TABLE "VoiceCapture" (
"id" TEXT NOT NULL,
"ownerUserId" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"inspectionDate" DATE NOT NULL,
"source" "VoiceCaptureSource" NOT NULL,
"status" "VoiceCaptureStatus" NOT NULL DEFAULT 'QUEUED',
"audioMime" TEXT,
"audioBytes" INTEGER,
"durationSec" INTEGER,
"transcriptEnc" TEXT,
"suggestionEnc" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"claimToken" TEXT,
"lastErrorCode" TEXT,
"model" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"inspectionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"readyAt" TIMESTAMP(3),
"contentExpiresAt" TIMESTAMP(3) NOT NULL,
"contentDeletedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoiceCapture_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoiceCaptureAudio" (
"captureId" TEXT NOT NULL,
"dataEnc" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VoiceCaptureAudio_pkey" PRIMARY KEY ("captureId")
);
-- CreateIndex
CREATE INDEX "VoiceCapture_status_availableAt_idx" ON "VoiceCapture"("status", "availableAt");
-- CreateIndex
CREATE INDEX "VoiceCapture_ownerUserId_status_idx" ON "VoiceCapture"("ownerUserId", "status");
-- CreateIndex
CREATE INDEX "VoiceCapture_contentExpiresAt_idx" ON "VoiceCapture"("contentExpiresAt");
-- AddForeignKey
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceCaptureAudio" ADD CONSTRAINT "VoiceCaptureAudio_captureId_fkey" FOREIGN KEY ("captureId") REFERENCES "VoiceCapture"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_audioBytes_range"
CHECK ("audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152);
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_durationSec_range"
CHECK ("durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240);
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_confirmed_matches_inspection"
CHECK (("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL));
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_audioMime_only_for_audio"
CHECK ("source" = 'AUDIO' OR "audioMime" IS NULL);
-- Permission: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN), so the grants are made here (same as T6). READONLY
-- and API_CLIENT get nothing: voice notes are for people who inspect.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'voice-captures:use', 'voice-captures', 'use', 'Benutzen', 'Sprache', 4000)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.key = 'voice-captures:use'
AND r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
ON CONFLICT DO NOTHING;
@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "VoiceExtractionOutcome" AS ENUM ('OK', 'REFUSED', 'TRUNCATED', 'INVALID', 'RETRY', 'BAD_REQUEST', 'AUTH', 'ABORTED');
-- CreateTable
CREATE TABLE "VoiceExtractionCall" (
"id" TEXT NOT NULL,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"model" TEXT NOT NULL,
"inputTokens" INTEGER NOT NULL DEFAULT 0,
"outputTokens" INTEGER NOT NULL DEFAULT 0,
"outcome" "VoiceExtractionOutcome" NOT NULL,
"captureId" TEXT,
CONSTRAINT "VoiceExtractionCall_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VoiceExtractionCall_at_idx" ON "VoiceExtractionCall"("at");
+743 -3
View File
@@ -104,6 +104,8 @@ model User {
refreshTokens RefreshToken[]
inspections Inspection[]
voiceCaptures VoiceCapture[]
hiveChanges HiveChange[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
@@ -111,10 +113,12 @@ model User {
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
phenologyReports PhenologyReport[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
@@ -124,12 +128,22 @@ model User {
batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[]
notificationPreference NotificationPreference?
telegramLink TelegramLink?
telegramLinkTokens TelegramLinkToken[]
notificationDeliveries NotificationDelivery[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
veterinaryMedicineLots VeterinaryMedicineLot[]
veterinaryMedicineMovements VeterinaryMedicineMovement[]
authorityNotices AuthorityNotice[]
apiaryRestrictions ApiaryRestriction[]
complianceAcknowledgements ComplianceAcknowledgement[]
@@index([roleId])
}
@@ -196,6 +210,7 @@ enum AuditAction {
MFA_ENABLED
MFA_DISABLED
ACCOUNT_LOCKED
PRINT
}
model AuditLog {
@@ -243,6 +258,10 @@ model Apiary {
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
phenologyReports PhenologyReport[]
phenologyStates ApiaryPhenology[]
weatherDays ApiaryWeatherDay[]
restrictions ApiaryRestriction[]
@@index([status])
}
@@ -290,6 +309,7 @@ model Colony {
colonyType ColonyType
status ColonyStatus @default(ACTIVE)
strength Int?
occupiedFrames Int?
broodStatus ColonyCondition?
feedStatus ColonyCondition?
healthStatus ColonyCondition?
@@ -312,6 +332,9 @@ model Colony {
feedings Feeding[]
harvests HarvestColony[]
queenEvaluations QueenEvaluation[]
suggestions Suggestion[]
hiveSetup HiveSetup?
voiceCaptures VoiceCapture[]
@@index([apiaryId])
@@index([status])
@@ -340,6 +363,12 @@ enum LineageType {
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
enum SplitQueenSource {
RAISE_OWN
QUEEN_CELL
QUEEN
}
model ColonyLineage {
id String @id @default(cuid())
type LineageType
@@ -348,6 +377,7 @@ model ColonyLineage {
toColonyId String
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
date DateTime
queenSource SplitQueenSource?
notes String?
createdAt DateTime @default(now())
@@ -467,7 +497,7 @@ model Inspection {
swarmMood Int?
gentleness Int?
combBehavior Int?
feedStatus String?
feedStatus ColonyCondition?
honeySuperStatus String?
healthIssues String?
varroaIssues String?
@@ -476,6 +506,7 @@ model Inspection {
createdAt DateTime @default(now())
tasks Task[]
hiveChange HiveChange?
@@index([colonyId, date])
}
@@ -529,12 +560,99 @@ model Task {
// rule see that it already left an open task for the same object instead
// of piling up a duplicate on every further trigger.
sourceAutomationTrigger AutomationTriggerType?
// Set when the task was created by accepting a suggestion.
sourceSuggestionId String?
sourceSuggestion Suggestion? @relation(fields: [sourceSuggestionId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
completedAt DateTime?
@@index([status, dueDate])
@@index([relatedType, relatedId])
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
@@index([sourceSuggestionId])
}
// ---------------------------------------------------------------------------
// Vorschlaege (T4): Regeln, Ketten und der Saison-Autopilot schlagen vor, der
// Imker entscheidet. Uebernehmen legt eine Aufgabe an (Task.sourceSuggestionId).
// ---------------------------------------------------------------------------
enum SuggestionStatus {
OPEN
ACCEPTED
DISMISSED
RESOLVED
EXPIRED
OBSOLETE
}
enum SuggestionMode {
PLAN
DO
}
enum SuggestionSource {
RULE
CHAIN
AUTOPILOT
}
enum SuggestionIntent {
INSPECT
SWARM_CHECK
QUEEN_CHECK
QUEEN_ACCEPTANCE
QUEEN_REPLACE
SPLIT_PLAN
VARROA_MEASURE
VARROA_RECHECK
VARROA_TREAT
SUMMER_TREATMENT
FEED
HONEY_SUPER
WINTER_CHECK
HEALTH_CHECK
FOLLOW_UP
STOCK_REORDER
CLEANUP
}
model Suggestion {
id String @id @default(cuid())
ruleKey AutomationTriggerType
source SuggestionSource
mode SuggestionMode
intent SuggestionIntent
status SuggestionStatus @default(OPEN)
priority TaskPriority @default(NORMAL)
relatedType TaskRelatedType
relatedId String?
colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id])
reasonCode String
// Only IDs, numbers and YYYY-MM-DD.
evidence Json
// e.g. 'varroa:read'; without that permission the suggestion is invisible.
requiredPermission String?
dueDate DateTime?
expiresAt DateTime?
// PLAN with several dates: [{ step, offsetDays, windowDays? }]
plan Json?
// '<ruleKey>:<relatedId|none>:<anchor>'; EXPIRED/RESOLVED clear it.
dedupeKey String? @unique
sourceEventId String?
decidedAt DateTime?
decidedByUserId String?
decidedByUser User? @relation("SuggestionDecider", fields: [decidedByUserId], references: [id])
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
@@index([status, colonyId])
@@index([status, dueDate])
@@index([ruleKey, relatedType, relatedId, status])
}
enum TaskTemplateRecurrenceType {
@@ -589,6 +707,7 @@ model VarroaMeasurement {
method VarroaMethod
durationMinutes Int?
miteCount Int?
sampleBees Int?
calculatedValue Float?
assessment String?
notes String?
@@ -711,9 +830,21 @@ model Treatment {
supplier String?
purchaseProof String?
prescribingVet String?
// T6: catalogue entry, the pack the dose was taken from and the dose per
// colony in the medicine's unit. With a pack, supplier / proof / batch
// number / vet are read from the pack, never copied.
medicineId String?
medicine VeterinaryMedicine? @relation(fields: [medicineId], references: [id], onDelete: Restrict)
medicineLotId String?
medicineLot VeterinaryMedicineLot? @relation(fields: [medicineLotId], references: [id], onDelete: Restrict)
doseQuantity Float?
createdAt DateTime @default(now())
medicineMovements VeterinaryMedicineMovement[]
@@index([colonyId, date])
@@index([medicineId])
@@index([medicineLotId])
}
// ---------------------------------------------------------------------------
@@ -829,6 +960,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])
@@ -840,6 +974,7 @@ model HoneyBatch {
bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[]
movements BatchMovement[]
@@index([status])
@@index([extractionId])
@@ -1013,6 +1148,10 @@ model Product {
packagingSize Float?
weight Float?
salesUnit String?
// Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung).
shelfLifeMonths Int?
// Bezeichnung des Lebensmittels auf dem Etikett, z. B. "Bluetenhonig" (T6).
legalName String?
status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
@@ -1051,6 +1190,8 @@ model LabelTemplate {
// Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten
// Etikettenbögen, die nicht geschnitten werden.
cropMarks Boolean @default(true)
// Mindest-x-Hoehe der Pflichtangaben in mm (T6), CHECK 0,5 bis 5.
minXHeightMm Float @default(1.2)
isDefault Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
@@ -1076,6 +1217,7 @@ model BottlingBatch {
createdAt DateTime @default(now())
movements BatchMovement[]
causedMovements BatchMovement[] @relation("BatchMovementSourceBottling")
@@index([honeyBatchId])
@@index([productId])
@@ -1172,6 +1314,8 @@ enum BatchMovementType {
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
CONSUMPTION
// Abfuellung entnimmt Honig aus der Charge (Task 23: Bewegungsbuch)
BOTTLING
}
enum WithdrawalReason {
@@ -1194,6 +1338,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
@@ -1209,6 +1359,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])
@@ -1275,21 +1427,126 @@ enum NotificationPriority {
KRITISCH
}
enum NotificationCategory {
TASK_DUE
DIGEST
SUGGESTION
WITHDRAWAL
SEASON
SYSTEM
COMPLIANCE
}
// Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
// des Inhalts; dedupeKey = "<praefix>:<userId>".
model Notification {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id])
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
priority NotificationPriority
category NotificationCategory
message String
relatedType String?
relatedId String?
link String?
requiredPermissions String[] @default([])
dedupeKey String? @unique
read Boolean @default(false)
createdAt DateTime @default(now())
deliveries NotificationDelivery[]
@@index([userId, read])
@@index([priority])
}
// Einstellungen je Nutzer; Standard beim ersten Lesen angelegt.
model NotificationPreference {
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
taskDueEnabled Boolean @default(true)
digestEnabled Boolean @default(true)
digestTime String @default("06:30")
quietFrom String? @default("21:00")
quietUntil String? @default("06:30")
telegramEnabled Boolean @default(false)
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST, COMPLIANCE])
telegramDetail Boolean @default(true)
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
// ---------------------------------------------------------------------------
@@ -1320,6 +1577,9 @@ enum DocumentEntityType {
QUEEN
INSPECTION
HONEY_BATCH
MEDICINE_LOT
AUTHORITY_NOTICE
APIARY_RESTRICTION
}
model Document {
@@ -1358,6 +1618,15 @@ enum AutomationTriggerType {
RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW
INSPECTION_INTERVAL
QUEEN_CELLS_SWARM_CHECK
QUEEN_ACCEPTANCE_CHECK
SPLIT_SCHEDULE
COLONY_ENDED_CLEANUP
WITHDRAWAL_ENDED
HONEY_WATER_CONTENT
SEASON_AUTOPILOT
PHENOLOGY
}
model AutomationRule {
@@ -1410,6 +1679,10 @@ model SiteSettings {
// part of any public page.
holderName String?
holderAddress String?
// Name/firm and postal address of the food business operator printed on
// the label (T6) - the brand, not the bee keeper of the Bestandsbuch.
foodBusinessName String?
foodBusinessAddress String?
// Country of origin printed on labels and shop output.
originCountry String @default("Deutschland")
updatedAt DateTime @updatedAt
@@ -1446,6 +1719,7 @@ enum CalendarSyncSourceType {
FEEDING
HARVEST
QUEEN_ASSIGNMENT
WITHDRAWAL
}
enum CalendarSyncStatus {
@@ -1465,6 +1739,7 @@ model CalendarSyncSettings {
syncFeeding Boolean @default(true)
syncHarvest Boolean @default(true)
syncQueenAssignment Boolean @default(true)
syncWithdrawal Boolean @default(true)
updatedAt DateTime @updatedAt
}
@@ -1646,3 +1921,468 @@ 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])
}
// ---------------------------------------------------------------------------
// T6 Pruefbereitschaft (docs/superpowers/specs/2026-10-13-t6-compliance-design.md)
// ---------------------------------------------------------------------------
enum MedicineUnit {
ML
G
PIECE
}
// Katalog des Imkers. Jeder Wert kommt aus Packungsbeilage oder Rezept und
// wird vom Imker eingetragen; der Code liefert keine Arzneimitteldaten
// (ARCHITECTURE Annahme 10).
model VeterinaryMedicine {
id String @id @default(cuid())
name String
activeIngredient String?
authorisationNumber String?
prescriptionOnly Boolean @default(false)
// Wartezeit Honig in Tagen, auch 0.
withdrawalPeriodDays Int?
// Nur aenderbar, solange keine Packung existiert.
unit MedicineUnit
dosePerColony Float?
doseNote String?
applicationMethod String?
minTemperatureC Float?
maxTemperatureC Float?
defaultSupplier String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lots VeterinaryMedicineLot[]
treatments Treatment[]
@@index([active])
}
// Eine gekaufte Packung. Ch.-B., Lieferant, Beleg und Tierarzt stehen hier
// einmal und gelten fuer jede Behandlung aus dieser Packung. CHECK quantity > 0.
model VeterinaryMedicineLot {
id String @id @default(cuid())
// Nur aenderbar, solange keine Bewegung existiert.
medicineId String
medicine VeterinaryMedicine @relation(fields: [medicineId], references: [id], onDelete: Restrict)
batchNumber String?
purchasedOn DateTime
quantity Float
expiresOn DateTime?
supplier String?
purchaseProof String?
prescribingVet String?
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements VeterinaryMedicineMovement[]
treatments Treatment[]
@@index([medicineId, expiresOn])
}
enum VeterinaryMedicineMovementType {
TREATMENT
CORRECTION
DISPOSAL
}
// Bewegungsbuch der Packungen: nie geaendert, nie geloescht; Fehler werden
// gegengebucht. Rest = quantity der Packung + Summe aller quantity.
// CHECKs: quantity <> 0; (type = 'TREATMENT') = (treatmentId IS NOT NULL).
model VeterinaryMedicineMovement {
id String @id @default(cuid())
lotId String
lot VeterinaryMedicineLot @relation(fields: [lotId], references: [id], onDelete: Restrict)
type VeterinaryMedicineMovementType
quantity Float
treatmentId String?
treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Restrict)
reason String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([lotId])
@@index([treatmentId])
}
// Singleton wie SiteSettings. Werte mit "Annahme" sind vom Inhaber zu pruefen.
model ComplianceSettings {
id String @id @default("singleton")
veterinaryOfficeName String?
veterinaryOfficeAddress String?
registrationNumber String?
noticeOnColonyCountChange Boolean @default(true)
noticeOnApiaryChange Boolean @default(true)
// MM-TT.
tskReferenceDay String @default("01-01")
tskReportDueDay String?
tskMemberNumber String?
tskExcludedColonyTypes ColonyType[] @default([])
// Vorgabe in der Migration (Spec 5.4), Annahme - vom Inhaber zu pruefen.
netQuantityDigitRules Json @default("[{\"maxGrams\":50,\"minMm\":2},{\"maxGrams\":200,\"minMm\":3},{\"maxGrams\":1000,\"minMm\":4},{\"maxGrams\":null,\"minMm\":6}]")
updatedAt DateTime @updatedAt
}
enum AuthorityNoticeKind {
REGISTRATION
HERD_CHANGE
TSK_REPORT
}
enum AuthorityNoticeStatus {
DRAFT
SENT
CANCELLED
}
model AuthorityNotice {
id String @id @default(cuid())
kind AuthorityNoticeKind
status AuthorityNoticeStatus @default(DRAFT)
year Int?
referenceDate DateTime
countedAt DateTime
snapshot Json
totalColonies Int
sentOn DateTime?
sentVia String?
reference String?
notes String?
// Nur bei DRAFT gesetzt ("REGISTRATION", "HERD_CHANGE", "TSK_REPORT:2027"),
// sonst null: hoechstens ein offener Entwurf je Art und Jahr. Nullbares
// @unique statt partiellem Index, wie dedupeKey im Rueckgrat.
openKey String? @unique
createdByUserId String?
createdBy User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([kind, status])
@@index([year])
}
// CHECK "endsOn" IS NULL OR "endsOn" >= "startsOn".
model ApiaryRestriction {
id String @id @default(cuid())
apiaryId String
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Restrict)
disease String
authority String?
reference String?
startsOn DateTime
endsOn DateTime?
notes String?
createdByUserId String?
createdBy User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([apiaryId, startsOn])
}
// "Geprueft, in Ordnung" fuer einen Pruefen-Punkt. Gilt nur, solange der
// Fingerabdruck zum aktuellen Befund passt.
model ComplianceAcknowledgement {
id String @id @default(cuid())
itemKey String
subjectId String
fingerprint String
note String
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
revokedAt DateTime?
revokedByUserId String?
@@index([itemKey, subjectId])
}
// ---------------------------------------------------------------------------
// Beute in 3D: Beutensysteme, Teile-Arten, Aufbau je Volk (Spec 2026-10-15)
// ---------------------------------------------------------------------------
model HiveSystem {
id String @id @default(cuid())
name String @unique
frameWidthMm Int // Oberträger
frameHeightMm Int
footprintLMm Int
footprintWMm Int
template Json // Aufbau-JSON ohne Ids, Schema 5.2
active Boolean @default(true)
partTypes HivePartType[]
setups HiveSetup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HivePartType {
id String @id @default(cuid())
systemId String
system HiveSystem @relation(fields: [systemId], references: [id])
kind HivePartKind
name String
articleRef String? // z. B. Holtermann-Art.-Nr.
lengthMm Int
widthMm Int
heightMm Int
boxDepth HiveBoxDepth?
frameSlots Int? // nur BOX
slotWidth Int? // nur FRAME_FEEDER: belegte Rähmchen-Plätze
stateSchema Json // Abschnitt 4.2
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([systemId, name])
}
model HiveSetup {
colonyId String @id
colony Colony @relation(fields: [colonyId], references: [id])
systemId String
system HiveSystem @relation(fields: [systemId], references: [id])
version Int @default(1)
state Json // Abschnitt 5.2, immer vollständig
updatedAt DateTime @updatedAt
changes HiveChange[]
}
model HiveChange {
id String @id @default(cuid())
colonyId String
setup HiveSetup @relation(fields: [colonyId], references: [colonyId])
version Int // Version nach der Änderung
occurredOn DateTime // fachlicher Tag (Durchsicht oder Eingabe)
source HiveChangeSource
inspectionId String? @unique
inspection Inspection? @relation(fields: [inspectionId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
ops Json // angewandte Operationen, Abschnitt 6
stateAfter Json
note String?
createdAt DateTime @default(now())
@@unique([colonyId, version])
@@index([colonyId, occurredOn])
}
enum HivePartKind {
FLOOR
BOX
DIVIDER_BOARD
QUEEN_EXCLUDER
FEEDER
COVER_SHEET
PROPOLIS_SCREEN
INNER_LID
INSULATION
ROOF
FLOOR_INSERT
EVAPORATOR
FRAME_FEEDER
}
enum HiveBoxDepth {
FULL
SHALLOW
}
enum HiveChangeSource {
INIT
CARD
EDITOR
INSPECTION
AI_SUGGESTION
}
enum VoiceCaptureSource {
AUDIO
TEXT
}
enum VoiceCaptureStatus {
QUEUED // audio uploaded, waits for Whisper
TRANSCRIBING // claimed by the CronJob
TRANSCRIBED // text present, waits for Claude (also source TEXT)
EXTRACTING // claimed by the worker
READY // suggestions present, waits for the user
CONFIRMED // inspection saved, content deleted
DISCARDED // discarded by the user, content deleted
FAILED // finally failed, code in lastErrorCode
EXPIRED // deadline passed, content deleted
}
// A voice note. Audio and text are personal data: content lives here only
// encrypted and only until it is taken over, at most 7 days. The row itself
// holds no content and stays 90 days for the error and cost overview.
model VoiceCapture {
id String @id @default(cuid())
ownerUserId String
owner User @relation(fields: [ownerUserId], references: [id])
colonyId String
colony Colony @relation(fields: [colonyId], references: [id])
inspectionDate DateTime @db.Date // date of the inspection per the form
source VoiceCaptureSource
status VoiceCaptureStatus @default(QUEUED)
audioMime String? // audio/webm, audio/ogg, audio/mp4
audioBytes Int?
durationSec Int? // reported by the device; Whisper measures again
transcriptEnc String? // key-ring ciphertext
suggestionEnc String? // key-ring ciphertext of the checked suggestion
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
claimToken String? // guards against completion by a process that lost the claim
lastErrorCode String? // fixed code, never content
model String? // e.g. "claude-sonnet-5-5"
inputTokens Int?
outputTokens Int?
inspectionId String? // set when CONFIRMED
createdAt DateTime @default(now())
readyAt DateTime?
contentExpiresAt DateTime // createdAt + 7 days
contentDeletedAt DateTime? // when audio/text/suggestion were deleted
updatedAt DateTime @updatedAt
audio VoiceCaptureAudio?
@@index([status, availableAt])
@@index([ownerUserId, status])
@@index([contentExpiresAt])
}
// The audio lives in its own table: deleting is a DELETE of the row (not
// nulling a big column), and the row is never loaded into lists.
model VoiceCaptureAudio {
captureId String @id
capture VoiceCapture @relation(fields: [captureId], references: [id], onDelete: Cascade)
dataEnc Bytes // key-ring ciphertext of the audio, at most 2 MB plaintext
createdAt DateTime @default(now())
}
// What a call to the language model counts as. The daily budget counts every
// outcome except AUTH and ABORTED (those never reached the model).
enum VoiceExtractionOutcome {
OK
REFUSED
TRUNCATED
INVALID
RETRY
BAD_REQUEST
AUTH
ABORTED
}
// Ledger of paid model calls for voice extraction: the daily budget and the
// cost card read it, not the notes (a retry or a refused call costs money
// too). Ids and numbers only - no content, no foreign key (a note may be
// deleted while the call stays counted). Rows go after 90 days (cleanup).
model VoiceExtractionCall {
id String @id @default(cuid())
at DateTime @default(now())
model String
inputTokens Int @default(0)
outputTokens Int @default(0)
outcome VoiceExtractionOutcome
captureId String?
@@index([at])
}
+58 -29
View File
@@ -16,18 +16,28 @@ function generateRandomPassword(): string {
}
// Only printed the moment the account is actually created (first deploy on
// an empty database), never on later restarts: by then the real admin may
// an empty user table), never on later restarts: by then the real admin may
// have changed the password via /profile, so repeating it would just be
// stale and would leak credentials into logs indefinitely for no reason.
function printLoginBanner(email: string, password: string): void {
// An operator-supplied password is never printed: the operator already has
// it, and the log would only be one more place it leaks to.
function printLoginBanner(
email: string,
password: string | null,
log: (line: string) => void,
): void {
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const line = '='.repeat(60);
console.log(line);
console.log('Seed: Admin-Zugang eingerichtet.');
console.log(` URL: ${url}`);
console.log(` Benutzer: ${email}`);
console.log(` Passwort: ${password}`);
console.log(line);
log(line);
log('Seed: Admin-Zugang eingerichtet.');
log(` URL: ${url}`);
log(` Benutzer: ${email}`);
log(
password === null
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
: ` Passwort: ${password}`,
);
log(line);
}
// Duplicated from src/task-templates/recurrence.ts rather than imported:
@@ -189,22 +199,26 @@ async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
);
}
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
await seedDefaultTaskTemplates(prisma);
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
console.log(`Seed: admin user ${email} already exists, skipping.`);
await prisma.$disconnect();
return;
/**
* Creates the first administrator, but only while the user table is still
* empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
* its password) on every start after the real admin changed their address.
*/
export async function seedAdmin(
prisma: AdminSeedClient,
env: NodeJS.ProcessEnv,
log: (line: string) => void,
): Promise<'created' | 'skipped'> {
if ((await prisma.user.count()) > 0) {
log('Seed: Es gibt bereits Benutzer, kein Admin angelegt.');
return 'skipped';
}
const email = env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const suppliedPassword = env.SEED_ADMIN_PASSWORD;
const password = suppliedPassword ?? generateRandomPassword();
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
@@ -218,27 +232,42 @@ async function main(): Promise<void> {
roleId: adminRole.id,
},
});
printLoginBanner(email, password);
} catch (error: unknown) {
// P2002 = unique constraint violation on email: another replica's
// startup won this race between the existence check above and this
// create() call. The user now exists either way, so this is a no-op,
// not a failure - container startup must not crash on it.
// startup won this race between the count above and this create()
// call. The user now exists either way, so this is a no-op, not a
// failure - container startup must not crash on it.
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
console.log(
`Seed: admin user ${email} was created concurrently, skipping.`,
);
} else {
log(`Seed: admin user ${email} was created concurrently, skipping.`);
return 'skipped';
}
throw error;
}
printLoginBanner(
email,
suppliedPassword === undefined ? password : null,
log,
);
return 'created';
}
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
await seedDefaultTaskTemplates(prisma);
await seedAdmin(prisma, process.env, (line) => console.log(line));
await prisma.$disconnect();
}
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
if (require.main === module) {
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Evaluates voice extraction against the gold set (test/fixtures/voice/gold).
*
* npm run eval:voice offline, replays the expected answers, no network, free
* npm run eval:voice -- --live --yes real Claude calls, costs money
* npm run eval:voice -- --live --yes --model=claude-haiku-5-5
*
* Live mode needs ANTHROPIC_API_KEY in the environment AND both --live and
* --yes; it is never the default and never runs in CI (no workflow calls
* this script). This is an operator tool, not a request path: it lives
* outside src/, so the network guard (src/common/no-network-outside-loops)
* does not scan it, and it talks to the adapter only through the
* StructuredModel port.
*
* Exit code: 0 pass, 1 below the thresholds (precision < 98 % or recall <
* 85 %, see EVAL_THRESHOLDS), 2 usage or setup error.
*/
import { join } from 'node:path';
import { anthropicApiKey } from '../src/voice/anthropic-config';
import { formatReport } from '../src/voice/eval/eval-report';
import { loadGoldCases } from '../src/voice/eval/gold-case';
import { estimateCostUsd } from '../src/voice/eval/voice-prices';
import {
offlineModelFor,
runEval,
summarize,
} from '../src/voice/eval/voice-eval';
import { AnthropicStructuredModel } from '../src/voice/extraction/anthropic-structured-model';
import { FAKE_USAGE } from '../src/voice/extraction/fake-structured-model';
import { DEFAULT_VOICE_MODEL, VOICE_MODELS } from '../src/voice/voice-config';
const GOLD_DIR = join(__dirname, '..', 'test', 'fixtures', 'voice', 'gold');
const EXIT_PASS = 0;
const EXIT_BELOW_THRESHOLD = 1;
const EXIT_USAGE = 2;
interface Options {
live: boolean;
yes: boolean;
model: string;
}
function parseArgs(args: readonly string[]): Options | string {
let live = false;
let yes = false;
let model: string = DEFAULT_VOICE_MODEL;
for (const arg of args) {
if (arg === '--live') {
live = true;
} else if (arg === '--yes') {
yes = true;
} else if (arg.startsWith('--model=')) {
model = arg.slice('--model='.length);
} else {
return `Unknown argument: ${arg}`;
}
}
if (!(VOICE_MODELS as readonly string[]).includes(model)) {
return `--model must be one of ${VOICE_MODELS.join(', ')}`;
}
return { live, yes, model };
}
function fail(message: string): never {
console.error(message);
process.exit(EXIT_USAGE);
}
async function main(): Promise<number> {
const options = parseArgs(process.argv.slice(2));
if (typeof options === 'string') {
return fail(options);
}
const cases = loadGoldCases(GOLD_DIR);
if (!options.live) {
const results = await runEval(
cases,
offlineModelFor(options.model),
options.model,
);
console.log(
formatReport(results, summarize(results), {
mode: 'offline',
model: options.model,
}),
);
return summarizeExit(results);
}
const key = anthropicApiKey(process.env);
if (key === null) {
return fail('--live needs a valid ANTHROPIC_API_KEY in the environment.');
}
const estimate = estimateCostUsd(options.model, {
input: FAKE_USAGE.input * cases.length,
output: FAKE_USAGE.output * cases.length,
cacheRead: 0,
cacheWrite: 0,
});
console.warn(
`WARNING: ${cases.length} real Claude calls on ${options.model}, roughly ${
estimate === null ? 'an unknown amount' : `$${estimate.toFixed(2)}`
} of API cost.`,
);
if (!options.yes) {
return fail('Add --yes to confirm and run the live evaluation.');
}
const live = new AnthropicStructuredModel(key);
const results = await runEval(cases, () => live, options.model);
console.log(
formatReport(results, summarize(results), {
mode: 'live',
model: options.model,
}),
);
return summarizeExit(results);
}
function summarizeExit(results: Parameters<typeof summarize>[0]): number {
return summarize(results).failures.length === 0
? EXIT_PASS
: EXIT_BELOW_THRESHOLD;
}
main().then(
(code) => process.exit(code),
(error: unknown) => {
console.error(error instanceof Error ? error.message : 'eval failed');
process.exit(EXIT_USAGE);
},
);
@@ -0,0 +1,15 @@
/**
* Writes the Whisper initial prompt to ApiCore-Whisper/vocabulary.txt, so the
* Python image uses the same word list as the backend (one source:
* src/voice/core/voice-vocabulary.ts). Run `npm run export:voice-vocabulary`
* after changing the list; voice-vocabulary-export.spec.ts fails until you do.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { whisperInitialPrompt } from '../src/voice/core/voice-vocabulary';
const target = join(__dirname, '..', '..', 'ApiCore-Whisper', 'vocabulary.txt');
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, `${whisperInitialPrompt()}\n`, 'utf8');
console.log(`Wrote ${target}`);
@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { HiveModule } from '../hive/hive.module';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@Module({
imports: [AutopilotModule, DomainEventsModule, HiveModule],
controllers: [ApiariesController],
providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService],
@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApiariesService } from './apiaries.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
describe('ApiariesService', () => {
let service: ApiariesService;
let prisma: {
apiary: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
let order: string[];
let tx: {
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
colony: { count: jest.Mock };
};
let prisma: {
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
const existingApiary = {
id: 'apiary-1',
name: 'Heimstand',
status: 'ACTIVE',
deactivatedAt: null,
address: 'Weg 1',
location: 'Garten',
latitude: 48.1,
longitude: 11.5,
notes: null,
};
const changes = (): string[] =>
(
events.record.mock.calls as [unknown, { payload: { change: string } }][]
).map(([, event]) => event.payload.change);
beforeEach(() => {
prisma = {
order = [];
tx = {
$queryRaw: jest.fn().mockImplementation(() => {
order.push('lock');
return Promise.resolve([{ id: 'apiary-1' }]);
}),
apiary: {
findMany: jest.fn(),
findUnique: jest.fn(),
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(existingApiary);
}),
create: jest.fn(),
update: jest.fn(),
},
colony: { count: jest.fn().mockResolvedValue(0) },
};
prisma = {
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
);
});
// Whatever a test recorded, the herd payloads carry only ids.
afterEach(() => {
expectOnlyIdPayloads(events.record.mock.calls);
});
it('findById throws NotFoundException when missing', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
);
});
it('create persists the apiary and records an audit entry', async () => {
prisma.apiary.create.mockResolvedValue(existingApiary);
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
const result = await service.create({ name: 'Heimstand' }, 'user-1');
expect(result).toEqual(existingApiary);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Apiary',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: 'apiary-1',
actorUserId: 'user-1',
payload: { change: 'CREATED' },
});
});
it('a failing audit write rolls back the apiary change', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
auditLog.record.mockRejectedValue(new Error('audit down'));
await expect(
service.create({ name: 'Heimstand' }, 'user-1'),
).rejects.toThrow('audit down');
// Record, audit and event share one transaction callback: its rejection
// is what rolls the record back.
expect(events.record).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it('update reads before after locking the Apiary row', async () => {
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
expect(order).toEqual(['lock', 'read']);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: existingApiary,
}),
tx,
);
});
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue({
it('update of a missing apiary answers 404', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.apiary.update).not.toHaveBeenCalled();
});
it.each([
[{ address: 'Neuer Weg 2' }],
[{ location: 'Wiese' }],
[{ latitude: 50 }],
[{ longitude: 12 }],
])('update records LOCATION_CHANGED for %j', async (dto) => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
await service.update('apiary-1', dto, 'user-1');
expect(changes()).toEqual(['LOCATION_CHANGED']);
});
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
await service.update(
'apiary-1',
{ name: 'Anders', notes: 'neu' },
'user-1',
);
// The same value sent again is no change either.
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
expect(events.record).not.toHaveBeenCalled();
});
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
tx.apiary.update.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
const result = await service.deactivate('apiary-1', 'user-1');
expect(result.status).toBe('INACTIVE');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
);
});
it('deactivate counts every living colony, weak and queenless ones included', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue(existingApiary);
await service.deactivate('apiary-1', 'user-1');
expect(prisma.colony.count).toHaveBeenCalledWith({
expect(order).toEqual(['lock', 'read']);
expect(tx.colony.count).toHaveBeenCalledWith({
where: {
apiaryId: 'apiary-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
tx,
);
expect(changes()).toEqual(['DEACTIVATED']);
});
it('deactivate throws ConflictException when active colonies remain', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.colony.count.mockResolvedValue(2);
tx.colony.count.mockResolvedValue(2);
await expect(
service.deactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.apiary.update).not.toHaveBeenCalled();
expect(tx.apiary.update).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect(
service.reactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
prisma.apiary.findUnique.mockResolvedValue({
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
tx.apiary.findUnique.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
prisma.apiary.update.mockResolvedValue({
...existingApiary,
status: 'ACTIVE',
deactivatedAt: null,
});
tx.apiary.update.mockResolvedValue(existingApiary);
const result = await service.reactivate('apiary-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.apiary.update).toHaveBeenCalledWith({
expect(tx.apiary.update).toHaveBeenCalledWith({
where: { id: 'apiary-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
tx,
);
expect(changes()).toEqual(['REACTIVATED']);
});
});
@@ -5,8 +5,11 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { DomainEventPayloads } from '../domain-events/domain-event-types';
import { lockRowForUpdate } from '../prisma/row-lock';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { Apiary, ApiaryStatus } from '../../generated/prisma';
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
@@ -15,11 +18,22 @@ export interface ApiaryFilter {
location?: string;
}
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
// The fields a change of which counts as a new location of the apiary.
const LOCATION_FIELDS = [
'address',
'location',
'latitude',
'longitude',
] as const;
@Injectable()
export class ApiariesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
@@ -43,15 +57,21 @@ export class ApiariesService {
}
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
await this.auditLog.record({
return this.prisma.$transaction(async (tx) => {
const apiary = await tx.apiary.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: apiary.id,
action: 'CREATE',
userId: actingUserId,
afterData: apiary,
});
},
tx,
);
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
return apiary;
});
}
async update(
@@ -59,30 +79,41 @@ export class ApiariesService {
dto: UpdateApiaryDto,
actingUserId: string,
): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
const apiary = await tx.apiary.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record({
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
},
tx,
);
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
}
return apiary;
});
}
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
// Colony.apiaryId is a plain FK, not enforced against apiary status -
// without this guard a colony can silently keep pointing at an
// inactive apiary (still shows ACTIVE, still countable, but its
// "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead.
const activeColonyCount = await this.prisma.colony.count({
// colonies elsewhere or dissolve them first instead. Counted under the
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
// into it that is still running finishes first.
const activeColonyCount = await tx.colony.count({
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
});
if (activeColonyCount > 0) {
@@ -90,38 +121,78 @@ export class ApiariesService {
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
);
}
const apiary = await this.prisma.apiary.update({
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
});
await this.auditLog.record({
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
},
tx,
);
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
return apiary;
});
}
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Apiary is already active');
}
const apiary = await this.prisma.apiary.update({
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.auditLog.record({
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
);
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
return apiary;
});
}
// Locks the apiary row and reads it afterwards: the result - not an earlier
// read - is "before" for the comparison, the audit and the event.
private async lockApiary(
tx: Prisma.TransactionClient,
id: string,
): Promise<Apiary> {
await lockRowForUpdate(tx, 'Apiary', id);
const apiary = await tx.apiary.findUnique({ where: { id } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
return apiary;
}
private recordChange(
tx: Prisma.TransactionClient,
apiaryId: string,
change: ApiaryChange,
actorUserId: string,
): Promise<void> {
return this.events.record(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: apiaryId,
actorUserId,
payload: { change },
});
}
}
@@ -2,6 +2,10 @@ import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveService } from '../hive/hive.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
describe('ApiaryFieldService', () => {
let service: ApiaryFieldService;
@@ -9,11 +13,28 @@ describe('ApiaryFieldService', () => {
apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
notification: { groupBy: jest.Mock };
$queryRaw: jest.Mock;
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
let hives: { getMany: jest.Mock };
let hiveCatalogs: { listSystems: jest.Mock };
const stepsOf = (
colonyId: string,
overrides: Partial<ColonyNextSteps> = {},
): ColonyNextSteps => ({
colonyId,
segment: 'SCHWARMZEIT',
phase: null,
phaseSource: null,
inspectionDueOn: null,
withdrawalUntil: null,
steps: [],
...overrides,
});
const colonyRow = (id: string, code: string, queenId: string | null) => ({
id,
@@ -23,6 +44,7 @@ describe('ApiaryFieldService', () => {
colonyType: 'PRODUCTION_COLONY',
hiveType: null,
strength: 5,
occupiedFrames: 12,
broodStatus: null,
feedStatus: null,
healthStatus: null,
@@ -55,14 +77,28 @@ describe('ApiaryFieldService', () => {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]),
},
notification: { groupBy: jest.fn().mockResolvedValue([]) },
$queryRaw: jest.fn().mockResolvedValue([]),
queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
nextSteps = {
forColonies: jest.fn().mockResolvedValue(
new Map([
['c10', stepsOf('c10')],
['c2', stepsOf('c2')],
]),
),
warningCounts: jest.fn().mockResolvedValue(new Map()),
};
hives = { getMany: jest.fn().mockResolvedValue({}) };
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService,
hives as unknown as HiveService,
hiveCatalogs as unknown as HiveCatalogService,
);
});
@@ -101,6 +137,40 @@ describe('ApiaryFieldService', () => {
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('carries the hives of the apiary colonies and the hive catalog', async () => {
grant('colonies:read');
const view = { colonyId: 'c2', systemId: 's1', version: 3 };
hives.getMany.mockResolvedValue({ c2: view });
const systems = [{ id: 's1', name: 'Zander', active: true, partTypes: [] }];
hiveCatalogs.listSystems.mockResolvedValue(systems);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(hives.getMany).toHaveBeenCalledWith(['c2', 'c10']);
expect(pack.hives).toEqual({ c2: view });
expect(pack.hiveCatalog).toEqual(systems);
});
it('limits the hive catalog to active systems and those the hives use', async () => {
grant('colonies:read');
hives.getMany.mockResolvedValue({ c2: { colonyId: 'c2', systemId: 's2' } });
hiveCatalogs.listSystems.mockResolvedValue([
{ id: 's1', active: true },
{ id: 's2', active: false },
{ id: 's3', active: false },
]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hiveCatalog?.map((system) => system.id)).toEqual(['s1', 's2']);
});
it('keeps colonies without a hive setup out of the hives', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hives).toEqual({});
});
it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z');
@@ -172,26 +242,18 @@ describe('ApiaryFieldService', () => {
expect(inspectionArgs.omit).toEqual({ userId: true });
});
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
grant('colonies:read');
prisma.notification.groupBy.mockResolvedValue([
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
]);
it('takes the warning counts from the open suggestions the caller may see', async () => {
grant('colonies:read', 'tasks:read');
nextSteps.warningCounts.mockResolvedValue(
new Map([['c2', { warnings: 2, critical: 1 }]]),
);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: ['c2', 'c10'] },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId: 'u1' }, { userId: null }],
},
}),
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
['c2', 'c10'],
'u1',
new Set(['colonies:read', 'tasks:read']),
);
expect(overview.rows[0]).toMatchObject({
code: 'V-2',
@@ -202,5 +264,69 @@ describe('ApiaryFieldService', () => {
openWarnings: 0,
openCritical: 0,
});
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
grant('colonies:read', 'tasks:read');
nextSteps.forColonies.mockResolvedValue(
new Map([
[
'c2',
stepsOf('c2', {
segment: 'SOMMER',
inspectionDueOn: '2026-05-10',
steps: [
{
key: 'suggestion:s1',
kind: 'SUGGESTION',
intent: 'INSPECT',
title: 'Durchsicht mit Schwarmkontrolle',
reasons: [],
suggestionIds: ['s1'],
dueOn: '2026-05-10',
urgency: 'OVERDUE',
action: { type: 'ACCEPT', suggestionId: 's1' },
dismissible: false,
},
],
}),
],
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
]),
);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows[0]).toMatchObject({
inspectionDueOn: '2026-05-10',
segment: 'SOMMER',
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
});
expect(overview.rows[1]).toMatchObject({
inspectionDueOn: null,
segment: 'WINTERRUHE',
nextStepTitle: null,
});
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
});
it('leaves the segment null when the colony has no season state', async () => {
grant('colonies:read');
nextSteps.forColonies.mockResolvedValue(new Map());
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
});
it('adds the next steps per colony to the offline pack', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.version).toBe(1);
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
});
});
@@ -1,5 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
import { HiveService } from '../hive/hive.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import type {
@@ -10,6 +14,15 @@ import type {
const INSPECTIONS_PER_COLONY = 3;
/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */
function activeOrUsed<S extends { id: string; active: boolean }>(
systems: S[],
hives: Record<string, { systemId: string }>,
): S[] {
const used = new Set(Object.values(hives).map((hive) => hive.systemId));
return systems.filter((system) => system.active || used.has(system.id));
}
type PackColony = OfflinePackV1['colonies'][number];
interface FieldData {
@@ -17,6 +30,7 @@ interface FieldData {
colonies: PackColony[];
permissions: ReadonlySet<string>;
overview: ColonyOverview;
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
}
/**
@@ -29,6 +43,9 @@ export class ApiaryFieldService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
private readonly hives: HiveService,
private readonly hiveCatalogs: HiveCatalogService,
) {}
async colonyOverview(
@@ -39,16 +56,16 @@ export class ApiaryFieldService {
}
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview } = await this.load(
apiaryId,
userId,
);
const [queens, inspections, tasks] = await Promise.all([
const { apiary, colonies, permissions, overview, nextSteps } =
await this.load(apiaryId, userId);
const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read')
? this.loadInspections(colonies)
: null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
this.hives.getMany(colonies.map((colony) => colony.id)),
this.hiveCatalogs.listSystems(),
]);
return {
version: 1,
@@ -59,6 +76,14 @@ export class ApiaryFieldService {
queens,
inspections,
tasks,
hives,
hiveCatalog: activeOrUsed(hiveCatalog, hives),
nextSteps: Object.fromEntries(
colonies.map((colony) => [
colony.id,
nextSteps.get(colony.id)?.steps ?? [],
]),
),
};
}
@@ -83,6 +108,7 @@ export class ApiaryFieldService {
colonyType: true,
hiveType: true,
strength: true,
occupiedFrames: true,
broodStatus: true,
feedStatus: true,
healthStatus: true,
@@ -94,27 +120,34 @@ export class ApiaryFieldService {
a.code.localeCompare(b.code, 'de', { numeric: true }),
);
const inspectionsVisible = permissions.has('inspections:read');
const [lastInspections, warnings] = await Promise.all([
const colonyIds = colonies.map((colony) => colony.id);
const [lastInspections, warnings, nextSteps] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.warningCounts(colonies, userId),
this.nextSteps.warningCounts(colonyIds, userId, permissions),
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
code: colony.code,
status: colony.status,
strength: colony.strength,
occupiedFrames: colony.occupiedFrames,
broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0,
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0,
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
segment: nextSteps.get(colony.id)?.segment ?? null,
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
openCritical: warnings.get(colony.id)?.critical ?? 0,
}));
return {
apiary,
colonies,
permissions,
overview: { apiaryId, inspectionsVisible, rows },
nextSteps,
};
}
@@ -135,36 +168,6 @@ export class ApiaryFieldService {
return dates;
}
private async warningCounts(
colonies: PackColony[],
userId: string,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const groups = await this.prisma.notification.groupBy({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId }, { userId: null }],
},
_count: { _all: true },
});
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
for (const group of groups) {
if (
group.relatedId &&
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
) {
counts.set(group.relatedId, {
...counts.get(group.relatedId),
[group.priority]: group._count._all,
});
}
}
return counts;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],
@@ -7,6 +7,10 @@ import type {
Queen,
Task,
} from '../../generated/prisma';
import type { HiveSystemDto } from '../hive/hive-catalog.service';
import type { HiveSetupView } from '../hive/hive.service';
import type { NextStep } from '../autopilot/next-step-ranking';
import type { SeasonSegmentKey } from '../season/season-types';
/** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow {
@@ -14,14 +18,24 @@ export interface ColonyOverviewRow {
code: string;
status: ColonyStatus;
strength: number | null;
occupiedFrames: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null;
/** Unread WARNUNG notifications visible to the caller. */
/**
* The day the next inspection is due (YYYY-MM-DD); null in the winter
* rest, when the interval rule is off and without `inspections:read`.
*/
inspectionDueOn: string | null;
/** The season segment of the apiary today; null when it is unknown. */
segment: SeasonSegmentKey | null;
/** Title of the first next step; null without one or without `tasks:read`. */
nextStepTitle: string | null;
/** Open suggestions of priority HIGH the caller may see. */
openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */
/** Open suggestions of priority URGENT the caller may see. */
openCritical: number;
}
@@ -56,6 +70,7 @@ export interface OfflinePackV1 {
| 'colonyType'
| 'hiveType'
| 'strength'
| 'occupiedFrames'
| 'broodStatus'
| 'feedStatus'
| 'healthStatus'
@@ -88,4 +103,16 @@ export interface OfflinePackV1 {
| 'relatedId'
>[]
| null;
/**
* The next steps per colony id (what `GET /colonies/:id/next-steps`
* returns as `steps`). Absent in packs cached before T4.
*/
nextSteps?: Record<string, NextStep[]>;
/**
* The hive setup per colony id, only for colonies of this apiary that have
* one. Absent in packs cached before the hive card.
*/
hives?: Record<string, HiveSetupView>;
/** The hive systems with their part types. Absent in older packs. */
hiveCatalog?: HiveSystemDto[];
}
@@ -0,0 +1,22 @@
import {
describeNullContracts,
NullContract,
} from '../common/validation/null-contract.spec-helper';
import { UpdateColonyDto } from '../colonies/dto/update-colony.dto';
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
const contracts: Record<string, NullContract> = {
UpdateApiaryRestrictionDto: {
dto: UpdateApiaryRestrictionDto,
notNull: ['disease', 'startsOn'],
clearable: ['endsOn', 'authority', 'reference', 'notes'],
},
// The reason is only read, never stored: null means "no override".
UpdateColonyDto: {
dto: UpdateColonyDto,
notNull: ['status'],
clearable: ['restrictionOverrideReason'],
},
};
describeNullContracts('Apiary restriction DTOs', contracts);
@@ -0,0 +1,82 @@
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 { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
import { CreateApiaryRestrictionDto } from './dto/create-apiary-restriction.dto';
import { RestrictionListQuery } from './dto/restriction-list-query.dto';
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
// Full paths on an empty prefix: the list lives under /apiary-restrictions,
// the per-apiary read under /apiaries/:id/restrictions.
@ApiTags('apiary-restrictions')
@ApiBearerAuth()
@Controller()
export class ApiaryRestrictionsController {
constructor(
private readonly restrictions: ApiaryRestrictionsService,
private readonly permissions: PermissionResolverService,
) {}
// Authority, file reference and notes are for compliance:read only.
private async mayReadDetails(actor: AuthenticatedUser): Promise<boolean> {
return (await this.permissions.getPermissions(actor.id)).has(
'compliance:read',
);
}
@RequirePermission('apiaries:read')
@Get('apiary-restrictions')
async findAll(
@Query() query: RestrictionListQuery,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.restrictions.list(
query.active,
await this.mayReadDetails(actor),
);
}
@RequirePermission('apiaries:read')
@Get('apiaries/:id/restrictions')
async findForApiary(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.restrictions.findForApiary(
id,
await this.mayReadDetails(actor),
);
}
@RequirePermission('compliance:manage')
@Idempotent()
@Post('apiary-restrictions')
create(
@Body() dto: CreateApiaryRestrictionDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.restrictions.create(dto, actor.id);
}
@RequirePermission('compliance:manage')
@Patch('apiary-restrictions/:id')
update(
@Param('id') id: string,
@Body() dto: UpdateApiaryRestrictionDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.restrictions.update(id, dto, actor.id);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ApiaryRestrictionsController } from './apiary-restrictions.controller';
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
/**
* Not needed in WorkerModule: the service injects no permission resolver and
* the guard helper (compliance/restricted-zone.ts) is a plain function.
*/
@Module({
controllers: [ApiaryRestrictionsController],
providers: [ApiaryRestrictionsService],
exports: [ApiaryRestrictionsService],
})
export class ApiaryRestrictionsModule {}
@@ -0,0 +1,295 @@
import { Prisma } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
describe('ApiaryRestrictionsService', () => {
let service: ApiaryRestrictionsService;
let tx: {
$queryRaw: jest.Mock;
apiary: { findMany: jest.Mock };
apiaryRestriction: {
create: jest.Mock;
update: jest.Mock;
findUnique: jest.Mock;
};
};
let prisma: {
apiary: { findUnique: jest.Mock };
apiaryRestriction: { findMany: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
apiary: { findMany: jest.fn() },
apiaryRestriction: {
create: jest.fn(),
update: jest.fn(),
findUnique: jest.fn(),
},
};
prisma = {
apiary: { findUnique: jest.fn() },
apiaryRestriction: { findMany: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiaryRestrictionsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
function lockStatements(): string[] {
return (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => query.sql,
);
}
describe('create', () => {
const dto = {
apiaryIds: ['a2', 'a1'],
disease: 'Faulbrut',
startsOn: '2026-10-01',
};
it('creates one row per apiary in one transaction, audits each, locks the apiaries first', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
tx.apiaryRestriction.create.mockImplementation(
({ data }: { data: { apiaryId: string } }) =>
Promise.resolve({ id: `r-${data.apiaryId}`, ...data }),
);
const created = await service.create(dto, 'user-1');
expect(created.map((row) => row.apiaryId)).toEqual(['a1', 'a2']);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(lockStatements()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
]);
const [[firstRow]] = tx.apiaryRestriction.create.mock.calls as [
[{ data: Record<string, unknown> }],
];
expect(firstRow.data).toMatchObject({
apiaryId: 'a1',
disease: 'Faulbrut',
startsOn: day('2026-10-01'),
createdByUserId: 'user-1',
});
expect(auditLog.record).toHaveBeenCalledTimes(2);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ApiaryRestriction',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.apiaryRestriction.create.mock.invocationCallOrder[0],
);
});
it('rolls all back when one apiary is unknown (404 after the lock, nothing written)', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
status: 404,
});
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.apiaryRestriction.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('rejects an endsOn before startsOn with 400 before any lock', async () => {
await expect(
service.create({ ...dto, endsOn: '2026-09-30' }, 'user-1'),
).rejects.toMatchObject({ status: 400 });
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('rejects no apiary and more than 50 apiaries with 400', async () => {
const ids = Array.from({ length: 51 }, (_, index) => `a${index}`);
await expect(
service.create({ ...dto, apiaryIds: ids }, 'user-1'),
).rejects.toMatchObject({ status: 400 });
await expect(
service.create({ ...dto, apiaryIds: [] }, 'user-1'),
).rejects.toMatchObject({ status: 400 });
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
describe('update', () => {
const stored = {
id: 'r1',
apiaryId: 'a1',
disease: 'Faulbrut',
startsOn: day('2026-10-01'),
endsOn: null as Date | null,
};
beforeEach(() => {
prisma.apiaryRestriction.findUnique.mockResolvedValue(stored);
tx.apiaryRestriction.findUnique.mockResolvedValue(stored);
tx.apiaryRestriction.update.mockImplementation(
({ data }: { data: object }) => Promise.resolve({ ...stored, ...data }),
);
});
it('locks the apiary FOR UPDATE, then the restriction, then reads before', async () => {
await service.update('r1', { endsOn: '2026-10-20' }, 'user-1');
expect(lockStatements()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
'SELECT id FROM "ApiaryRestriction" WHERE id = ? FOR UPDATE',
]);
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
tx.apiaryRestriction.findUnique.mock.invocationCallOrder[0],
);
});
it('ending a restriction is an update of endsOn, nothing is deleted', async () => {
const result = await service.update(
'r1',
{ endsOn: '2026-10-20' },
'user-1',
);
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: { endsOn: day('2026-10-20') },
});
expect(result.endsOn).toEqual(day('2026-10-20'));
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ApiaryRestriction',
entityId: 'r1',
action: 'UPDATE',
beforeData: stored,
}),
tx,
);
});
it('null clears endsOn', async () => {
tx.apiaryRestriction.findUnique.mockResolvedValue({
...stored,
endsOn: day('2026-10-20'),
});
await service.update('r1', { endsOn: null }, 'user-1');
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: { endsOn: null },
});
});
it('checks endsOn >= startsOn on the merged state (400)', async () => {
await expect(
service.update('r1', { endsOn: '2026-09-30' }, 'user-1'),
).rejects.toMatchObject({ status: 400 });
await expect(
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
).resolves.toBeDefined();
tx.apiaryRestriction.findUnique.mockResolvedValue({
...stored,
endsOn: day('2026-10-20'),
});
await expect(
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
).rejects.toMatchObject({ status: 400 });
expect(tx.apiaryRestriction.update).toHaveBeenCalledTimes(1);
});
it('answers 404 for an unknown restriction without locking', async () => {
prisma.apiaryRestriction.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toMatchObject({ status: 404 });
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('reads', () => {
it('findActive lists only restrictions active today, with the apiary name', async () => {
prisma.apiaryRestriction.findMany.mockResolvedValue([
{
id: 'r1',
apiaryId: 'a1',
apiary: { name: 'Nord' },
disease: 'Faulbrut',
startsOn: day('2000-01-01'),
endsOn: null,
},
{
id: 'r2',
apiaryId: 'a2',
apiary: { name: 'Sued' },
disease: 'Faulbrut',
startsOn: day('2000-01-01'),
endsOn: day('2000-01-02'),
},
]);
await expect(service.findActive()).resolves.toEqual([
{
id: 'r1',
apiaryId: 'a1',
apiaryName: 'Nord',
disease: 'Faulbrut',
startsOn: '2000-01-01',
},
]);
});
it('without compliance:read the lists carry no authority, reference, notes or creator', async () => {
const row = {
id: 'r1',
apiaryId: 'a1',
disease: 'Faulbrut',
authority: 'Veterinaeramt',
reference: 'AZ 12/3',
notes: 'intern',
createdByUserId: 'u1',
startsOn: day('2026-10-01'),
endsOn: null,
};
prisma.apiaryRestriction.findMany.mockResolvedValue([row]);
prisma.apiary.findUnique.mockResolvedValue({ id: 'a1' });
const expected = [
{
id: 'r1',
apiaryId: 'a1',
disease: 'Faulbrut',
startsOn: '2026-10-01',
endsOn: null,
},
];
await expect(service.list(undefined, false)).resolves.toEqual(expected);
await expect(service.findForApiary('a1', false)).resolves.toEqual(
expected,
);
await expect(service.list(undefined, true)).resolves.toEqual([row]);
});
it('findForApiary answers 404 for an unknown apiary', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findForApiary('missing')).rejects.toMatchObject({
status: 404,
});
});
});
});
@@ -0,0 +1,224 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ApiaryRestriction } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { berlinToday, dayToDate } from '../common/dates';
import { optionalDate } from '../common/patch-values';
import {
ActiveRestrictionInfo,
isRestrictionActive,
startedRestrictions,
toActiveRestrictionInfo,
} from '../compliance/restricted-zone';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import {
CreateApiaryRestrictionDto,
MAX_RESTRICTION_APIARIES,
} from './dto/create-apiary-restriction.dto';
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
const AUDIT_ENTITY = 'ApiaryRestriction';
/** What a reader without compliance:read sees: no authority, reference, notes or creator. */
export interface RestrictionSummary {
id: string;
apiaryId: string;
disease: string;
startsOn: string;
endsOn: string | null;
}
function toSummary(row: ApiaryRestriction): RestrictionSummary {
return {
id: row.id,
apiaryId: row.apiaryId,
disease: row.disease,
startsOn: row.startsOn.toISOString().slice(0, 10),
endsOn: row.endsOn ? row.endsOn.toISOString().slice(0, 10) : null,
};
}
function assertEndNotBeforeStart(startsOn: Date, endsOn: Date | null): void {
if (endsOn !== null && endsOn < startsOn) {
throw new BadRequestException(
'Das Ende der Sperre darf nicht vor ihrem Beginn liegen',
);
}
}
/**
* Restricted zones (Sperrbezirke) of apiaries. A restriction is never deleted:
* it ends by an endsOn date, so the colony history stays explainable.
*
* Lock order (Task 1): the apiary rows FOR UPDATE first, then the restriction
* row. A colony path holds its apiaries FOR SHARE until it commits, so a
* restriction and a move into that apiary queue up behind each other.
*/
@Injectable()
export class ApiaryRestrictionsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
/** One row per apiary, all in one transaction; an unknown apiary rolls all back. */
async create(
dto: CreateApiaryRestrictionDto,
actingUserId: string,
): Promise<ApiaryRestriction[]> {
const apiaryIds = [...new Set(dto.apiaryIds)].sort();
if (apiaryIds.length < 1 || apiaryIds.length > MAX_RESTRICTION_APIARIES) {
throw new BadRequestException(
`Es sind 1 bis ${MAX_RESTRICTION_APIARIES} Stände möglich`,
);
}
const startsOn = dayToDate(dto.startsOn);
const endsOn = dto.endsOn ? dayToDate(dto.endsOn) : null;
assertEndNotBeforeStart(startsOn, endsOn);
return this.prisma.$transaction(async (tx) => {
await lockRowsForUpdate(tx, 'Apiary', apiaryIds);
const known = await tx.apiary.findMany({
where: { id: { in: apiaryIds } },
select: { id: true },
});
if (known.length !== apiaryIds.length) {
throw new NotFoundException('Bienenstand nicht gefunden');
}
const created: ApiaryRestriction[] = [];
for (const apiaryId of apiaryIds) {
const restriction = await tx.apiaryRestriction.create({
data: {
apiaryId,
disease: dto.disease,
authority: dto.authority,
reference: dto.reference,
startsOn,
endsOn,
notes: dto.notes,
createdByUserId: actingUserId,
},
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: restriction.id,
action: 'CREATE',
userId: actingUserId,
afterData: restriction,
},
tx,
);
created.push(restriction);
}
return created;
});
}
async update(
id: string,
dto: UpdateApiaryRestrictionDto,
actingUserId: string,
): Promise<ApiaryRestriction> {
const seen = await this.prisma.apiaryRestriction.findUnique({
where: { id },
select: { apiaryId: true },
});
if (!seen) {
throw new NotFoundException('Sperrbezirk nicht gefunden');
}
return this.prisma.$transaction(async (tx) => {
await lockRowsForUpdate(tx, 'Apiary', [seen.apiaryId]);
await lockRowForUpdate(tx, 'ApiaryRestriction', id);
const before = await tx.apiaryRestriction.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('Sperrbezirk nicht gefunden');
}
const startsOnPatch =
dto.startsOn === undefined ? undefined : dayToDate(dto.startsOn);
const startsOn = startsOnPatch ?? before.startsOn;
const endsOn = optionalDate(dto.endsOn);
assertEndNotBeforeStart(
startsOn,
endsOn === undefined ? before.endsOn : endsOn,
);
const updated = await tx.apiaryRestriction.update({
where: { id },
data: {
disease: dto.disease,
authority: dto.authority,
reference: dto.reference,
startsOn: startsOnPatch,
endsOn,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
}
/** The restrictions in force today (Berlin day), with the apiary name. */
async findActive(): Promise<ActiveRestrictionInfo[]> {
const today = berlinToday();
const started = await startedRestrictions(this.prisma, undefined, today);
return started
.filter((row) => isRestrictionActive(row, today))
.map(toActiveRestrictionInfo);
}
/**
* Every restriction (newest first); `active` narrows to in force / ended.
* Authority, file reference, notes and the creator are for callers with
* compliance:read (`detailed`); everybody else gets the summary.
*/
async list(
active?: boolean,
detailed = true,
): Promise<
ApiaryRestriction[] | ActiveRestrictionInfo[] | RestrictionSummary[]
> {
if (active === true) {
return this.findActive();
}
const rows = await this.prisma.apiaryRestriction.findMany({
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
});
const wanted =
active === false
? rows.filter((row) => !isRestrictionActive(row, berlinToday()))
: rows;
return detailed ? wanted : wanted.map(toSummary);
}
async findForApiary(
apiaryId: string,
detailed = true,
): Promise<ApiaryRestriction[] | RestrictionSummary[]> {
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
select: { id: true },
});
if (!apiary) {
throw new NotFoundException('Bienenstand nicht gefunden');
}
const rows = await this.prisma.apiaryRestriction.findMany({
where: { apiaryId },
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
});
return detailed ? rows : rows.map(toSummary);
}
}
@@ -0,0 +1,77 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
export const MAX_RESTRICTION_APIARIES = 50;
const MAX_ID_LENGTH = 64;
const MAX_TEXT_LENGTH = 200;
const MAX_NOTE_LENGTH = 2000;
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
/** A restricted zone declared by the authority, for one or several apiaries. */
export class CreateApiaryRestrictionDto {
@ApiProperty({
type: [String],
description: 'Ein Datensatz je Stand, 1 bis 50 Stände',
})
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(MAX_RESTRICTION_APIARIES)
@ArrayUnique()
@IsString({ each: true })
@Matches(ID_PATTERN, { each: true })
@MaxLength(MAX_ID_LENGTH, { each: true })
apiaryIds!: string[];
@ApiProperty({ description: 'Seuche, z. B. Amerikanische Faulbrut' })
@IsString()
@IsNotEmpty()
@Matches(/\S/, { message: 'disease darf nicht nur aus Leerzeichen bestehen' })
@MaxLength(MAX_TEXT_LENGTH)
disease!: string;
@ApiPropertyOptional({ nullable: true, description: 'Anordnende Behörde' })
@IsOptional()
@IsString()
@MaxLength(MAX_TEXT_LENGTH)
authority?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
@IsOptional()
@IsString()
@MaxLength(MAX_TEXT_LENGTH)
reference?: string | null;
@ApiProperty({ example: '2026-10-01', description: 'Erster Tag der Sperre' })
@Matches(DAY_PATTERN, { message: `startsOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
startsOn!: string;
@ApiPropertyOptional({
nullable: true,
example: '2026-12-01',
description: 'Letzter Tag der Sperre; leer = bis auf Weiteres',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `endsOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
endsOn?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTE_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
import { booleanQuery } from '../../veterinary-medicines/dto/medicine-list-query.dto';
export class RestrictionListQuery {
@ApiPropertyOptional({
description: 'true: nur heute geltende Sperren; false: nur beendete',
})
@IsOptional()
@Transform(booleanQuery)
@IsBoolean()
active?: boolean;
}
@@ -0,0 +1,12 @@
import { OmitType, PartialType } from '@nestjs/swagger';
import { CreateApiaryRestrictionDto } from './create-apiary-restriction.dto';
/**
* The apiary of a restriction is fixed: a restriction for another stand is a
* new restriction. Omitted stays, null clears a nullable field (endsOn ends
* the restriction as open again), null on disease or startsOn is a 400.
*/
export class UpdateApiaryRestrictionDto extends PartialType(
OmitType(CreateApiaryRestrictionDto, ['apiaryIds'] as const),
{ skipNullProperties: false },
) {}
+22
View File
@@ -28,6 +28,8 @@ 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';
import { ProductionBatchesModule } from './production-batches/production-batches.module';
@@ -53,7 +55,16 @@ import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { CleanupModule } from './suggestions/cleanup.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { HiveModule } from './hive/hive.module';
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
import { ComplianceModule } from './compliance/compliance.module';
import { ReadinessModule } from './compliance/readiness/readiness.module';
import { VoiceModule } from './voice/voice.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -106,6 +117,12 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
WebhooksModule,
DocumentsModule,
AutomationModule,
SuggestionsModule,
CleanupModule,
AutopilotModule,
PhenologyModule,
WithdrawalModule,
RemindersModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
@@ -120,6 +137,11 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
ErpExportModule,
ApiKeysModule,
SystemModule,
VeterinaryMedicinesModule,
HiveModule,
ComplianceModule,
ReadinessModule,
VoiceModule,
],
controllers: [],
providers: [
@@ -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',
@@ -379,6 +391,32 @@ export const PERMISSION_CATALOG = [
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3600,
),
// T6: the veterinary medicine catalogue and its packs. Receipts, invoice
// numbers and supplier addresses live here, so the shop account gets none.
...group(
'Tierarzneimittel',
'veterinary-medicines',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
3700,
),
...group(
'Prüfbereitschaft & Behörden',
'compliance',
[
['read', 'Ansehen'],
['manage', 'Verwalten'],
],
3800,
),
// Beute in 3D: hive systems and their part-type catalog. Reading the
// catalog and writing a colony's setup use the colonies permissions.
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
// T7 Freihand: voice notes. Every endpoint also needs inspections:create.
...group('Sprache', 'voice-captures', [['use', 'Benutzen']], 4000),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { PermissionResolverService } from './permission-resolver.service';
/**
* PermissionResolverService for the job and worker context (BackupService
* clears its cache after a restore, which no job does). Not
* PermissionsModule: its PermissionCatalogSyncService would rewrite the
* permission catalog on every job or worker start. Global like
* PermissionsModule, so modules that inject the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
export class JobPermissionsModule {}
@@ -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
@@ -0,0 +1,61 @@
import { UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { ReauthService } from './reauth.service';
describe('ReauthService', () => {
const baseUser = {
id: 'u1',
email: '[email protected]',
passwordHash: bcrypt.hashSync('richtig', 4),
mfaEnabled: false,
mfaSecret: null,
mfaLastTimeStep: null,
authLockedUntil: null,
isActive: true,
};
const accountGuard = {
isLocked: jest.fn(),
recordFailure: jest.fn(),
recordSuccess: jest.fn(),
consumeTotp: jest.fn(),
};
const prisma = { user: { findUnique: jest.fn() } };
const service = new ReauthService(prisma as never, accountGuard as never);
beforeEach(() => {
jest.resetAllMocks();
prisma.user.findUnique.mockResolvedValue(baseUser);
accountGuard.isLocked.mockReturnValue(false);
});
it('sagt bei falschem Passwort, dass es nicht stimmt', async () => {
await expect(service.verify('u1', { password: 'falsch' })).rejects.toThrow(
new UnauthorizedException('Das Passwort stimmt nicht.'),
);
expect(accountGuard.recordFailure).toHaveBeenCalledWith('u1');
});
it('sagt bei falschem Code, dass er nicht stimmt', async () => {
prisma.user.findUnique.mockResolvedValue({ ...baseUser, mfaEnabled: true });
accountGuard.consumeTotp.mockResolvedValue(false);
await expect(
service.verify('u1', { password: 'richtig', mfaCode: '000000' }),
).rejects.toThrow(new UnauthorizedException('Der Code stimmt nicht.'));
});
it('nennt bei gesperrtem Konto die Sperre', async () => {
accountGuard.isLocked.mockReturnValue(true);
await expect(service.verify('u1', { password: 'richtig' })).rejects.toThrow(
new UnauthorizedException(
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
),
);
});
it('lässt richtige Angaben durch', async () => {
await expect(
service.verify('u1', { password: 'richtig' }),
).resolves.toBeUndefined();
expect(accountGuard.recordSuccess).toHaveBeenCalledWith('u1');
});
});
@@ -37,7 +37,7 @@ export class ReauthService {
});
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
throw new UnauthorizedException(
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
);
}
const passwordMatches =
@@ -45,9 +45,7 @@ export class ReauthService {
(await bcrypt.compare(proof.password, user.passwordHash));
if (!passwordMatches) {
await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException(
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
);
throw new UnauthorizedException('Das Passwort stimmt nicht.');
}
if (user.mfaEnabled) {
const codeMatches =
@@ -55,9 +53,7 @@ export class ReauthService {
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
if (!codeMatches) {
await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException(
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
);
throw new UnauthorizedException('Der Code stimmt nicht.');
}
}
await this.accountGuard.recordSuccess(user.id);
@@ -42,6 +42,7 @@ describe('AutomationEventHandlers', () => {
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
let automation: Record<
| 'evaluateInspectionIn'
| 'evaluateInspectionCorrectionIn'
| 'evaluateVarroaMeasurementIn'
| 'evaluateTreatmentIn'
| 'evaluateFeedingIn'
@@ -63,6 +64,7 @@ describe('AutomationEventHandlers', () => {
) as Record<RecordModel, { findUnique: jest.Mock }>;
automation = {
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined),
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
@@ -84,7 +86,7 @@ describe('AutomationEventHandlers', () => {
expect(registered('inspectionRules')).toEqual([
{
types: ['InspectionRecorded'],
types: ['InspectionRecorded', 'InspectionCorrected'],
handlerId: 'automation.inspection-rules',
},
]);
@@ -154,12 +156,17 @@ describe('AutomationEventHandlers', () => {
async (method, type, model, core) => {
const record = { id: 'record-1', colonyId: 'colony-1' };
tx[model].findUnique.mockResolvedValueOnce(record);
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await run(method, type);
expect(tx[model].findUnique).toHaveBeenCalledWith({
where: { id: 'record-1' },
});
expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' },
select: { status: true },
});
expect(automation[core]).toHaveBeenCalledWith(tx, record);
},
);
@@ -174,8 +181,56 @@ describe('AutomationEventHandlers', () => {
},
);
it.each(cases)(
'rule handlers write nothing for an ended colony: %s on %s',
async (method, type, model, core) => {
tx[model].findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' });
await run(method, type);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it.each(cases)(
'rule handlers write nothing for a colony that is gone: %s on %s',
async (method, type, model, core) => {
tx[model].findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
await run(method, type);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it('a corrected inspection only follows open suggestions', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await run('inspectionRules', 'InspectionCorrected' as never);
expect(automation.evaluateInspectionCorrectionIn).toHaveBeenCalledTimes(
1,
);
expect(automation.evaluateInspectionIn).not.toHaveBeenCalled();
});
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
automation.evaluateInspectionIn.mockRejectedValue(
new Error('notifications down'),
);
@@ -192,22 +247,24 @@ describe('AutomationEventHandlers', () => {
const event = () =>
envelope('ColonyStatusChanged', 'colony-1', { from, to });
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await handlers.colonyHealth(event(), asTx());
expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' },
select: { id: true },
select: { status: true },
});
// The rule decides on the snapshots of the event, not on the colony
// as it is now: a later change must not hide the transition.
// as it is now: a later change must not hide the transition. The
// event is the anchor of the suggestion.
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
tx,
'colony-1',
from,
to,
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
);
});
@@ -216,6 +273,14 @@ describe('AutomationEventHandlers', () => {
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
});
it('skips a colony that ended', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' });
await handlers.colonyHealth(event(), asTx());
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
});
});
describe('raw-material-low', () => {
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { Prisma } from '../../generated/prisma';
import { StockService } from '../stock/stock.service';
import { AutomationService } from './automation.service';
@@ -10,9 +11,10 @@ type Tx = Prisma.TransactionClient;
/**
* Evaluates the automation rules on domain events. Pure DB writer: it reads
* the record the event is about through the delivery transaction and lets
* AutomationService write task, warning and audit entries in it. A record
* deleted before the event was dispatched is skipped. Errors propagate, so
* the delivery is rolled back as a whole and retried.
* AutomationService write suggestion, notification and audit entries in it.
* A record deleted before the event was dispatched, or of a colony that
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
* whole and retried.
*/
@Injectable()
export class AutomationEventHandlers {
@@ -21,22 +23,46 @@ export class AutomationEventHandlers {
private readonly stock: StockService,
) {}
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
/**
* Fast path only: proposeForRuleIn re-checks the colony under its lock.
* Whether the colony still lives (a colony that ended takes no proposals).
*/
private async isLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true },
});
return colony !== null && isLivingColonyStatus(colony.status);
}
// A correction proposes nothing new; it only lets an open suggestion
// follow the corrected values.
@OnDomainEvent(
['InspectionRecorded', 'InspectionCorrected'],
'automation.inspection-rules',
)
async inspectionRules(
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
tx: Tx,
): Promise<void> {
const inspection = await tx.inspection.findUnique({
where: { id: event.aggregateId },
});
if (inspection === null) {
if (
inspection === null ||
!(await this.isLivingColonyIn(tx, inspection.colonyId))
) {
return;
}
if (event.type === 'InspectionRecorded') {
await this.automation.evaluateInspectionIn(tx, inspection);
} else {
await this.automation.evaluateInspectionCorrectionIn(tx, inspection);
}
}
// A correction can lift a count over the threshold, so it is evaluated
// like a new measurement; the open-task check keeps it to one task.
// like a new measurement; the open-suggestion check keeps it to one.
@OnDomainEvent(
['VarroaMeasured', 'VarroaCorrected'],
'automation.varroa-threshold',
@@ -48,7 +74,10 @@ export class AutomationEventHandlers {
const measurement = await tx.varroaMeasurement.findUnique({
where: { id: event.aggregateId },
});
if (measurement === null) {
if (
measurement === null ||
!(await this.isLivingColonyIn(tx, measurement.colonyId))
) {
return;
}
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
@@ -62,7 +91,10 @@ export class AutomationEventHandlers {
const treatment = await tx.treatment.findUnique({
where: { id: event.aggregateId },
});
if (treatment === null) {
if (
treatment === null ||
!(await this.isLivingColonyIn(tx, treatment.colonyId))
) {
return;
}
await this.automation.evaluateTreatmentIn(tx, treatment);
@@ -76,7 +108,10 @@ export class AutomationEventHandlers {
const feeding = await tx.feeding.findUnique({
where: { id: event.aggregateId },
});
if (feeding === null) {
if (
feeding === null ||
!(await this.isLivingColonyIn(tx, feeding.colonyId))
) {
return;
}
await this.automation.evaluateFeedingIn(tx, feeding);
@@ -89,18 +124,15 @@ export class AutomationEventHandlers {
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
tx: Tx,
): Promise<void> {
const colony = await tx.colony.findUnique({
where: { id: event.aggregateId },
select: { id: true },
});
if (colony === null) {
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
return;
}
await this.automation.evaluateColonyHealthIn(
tx,
colony.id,
event.aggregateId,
event.payload.from,
event.payload.to,
{ id: event.id, occurredAt: event.occurredAt },
);
}
@@ -1,18 +1,18 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { StockModule } from '../stock/stock.module';
import { TasksModule } from '../tasks/tasks.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutomationController } from './automation.controller';
import { AutomationEventHandlers } from './automation.handlers';
import { AutomationService } from './automation.service';
/**
* Automation rules and the handlers that evaluate them on domain events.
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
* it calendar entry and task.created webhook), so no WebhooksService here.
* The rules propose through SuggestionsService; the task follows when the
* beekeeper accepts (TasksService, with calendar entry and task.created
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
*/
@Module({
imports: [TasksModule, NotificationsModule, StockModule],
imports: [SuggestionsModule, StockModule],
controllers: [AutomationController],
providers: [AutomationService, AutomationEventHandlers],
exports: [AutomationService],
File diff suppressed because it is too large. Load diff
@@ -1,11 +1,24 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { TasksService } from '../tasks/tasks.service';
import { addDays, berlinDayOf, berlinToday } from '../common/dates';
import { isLivingColonyStatus } from '../colonies/colony-status';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
import { SuggestionsService } from '../suggestions/suggestions.service';
import {
suggestionDedupeKey,
type SuggestionEvidence,
} from '../suggestions/suggestion.types';
import { treatmentWindowsIn } from '../treatments/treatment-windows';
import {
AutomationRule,
AutomationTriggerType,
@@ -14,6 +27,8 @@ import {
Prisma,
RawMaterialKind,
StockItem,
Suggestion,
SuggestionIntent,
SwarmControlCheck,
TaskRelatedType,
Treatment,
@@ -21,7 +36,13 @@ import {
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
import { varroaThresholdOf } from './varroa-threshold';
import {
DEFAULT_RULES,
lockRuleIn,
readRule,
validateRuleConfig,
} from './rule-config';
import { rateMeasurement } from './varroa-bands';
type Tx = Prisma.TransactionClient;
@@ -32,11 +53,6 @@ export type NumberFormatTrigger =
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
BEESWAX: 'Bienenwachs',
PROPOLIS: 'Propolis',
};
/** What {ART} turns into in the raw material number format. */
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
BEESWAX: 'WA',
@@ -56,115 +72,55 @@ function isUniqueViolation(error: unknown): boolean {
);
}
interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
/** The rules that propose instead of creating anything themselves. */
export type RuleSuggestionTrigger =
| 'QUEENLESS_NO_EGGS'
| 'FEED_STATUS_CRITICAL'
| 'VARROA_THRESHOLD'
| 'SWARM_MOOD_HIGH'
| 'COLONY_HEALTH_CRITICAL'
| 'STOCK_ITEM_LOW'
| 'RAW_MATERIAL_LOW'
| 'TREATMENT_FOLLOW_UP'
| 'FEEDING_FOLLOW_UP';
export interface RuleSuggestionTarget {
relatedType: TaskRelatedType;
relatedId: string;
colonyId: string | null;
intent: SuggestionIntent;
reasonCode: string;
evidence: SuggestionEvidence;
anchor: string;
requiredPermission: PermissionKey;
dueDate?: string | null;
sourceEventId?: string | null;
}
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
QUEENLESS_NO_EGGS: {
config: {},
actionTemplate:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
},
VARROA_THRESHOLD: {
config: { thresholdMitesPerDay: 10 },
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
},
FEED_STATUS_CRITICAL: {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
SWARM_MOOD_HIGH: {
config: {},
actionTemplate:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
},
COLONY_HEALTH_CRITICAL: {
config: {},
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
},
STOCK_ITEM_LOW: {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
BOTTLING_LOT_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
PRODUCTION_BATCH_NUMBER_FORMAT: {
config: { format: 'HS-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
// no minimum of their own, so the minimum per kind (config.minGrams) lives
// here - and without one there is nothing to compare, hence off by default.
RAW_MATERIAL_LOW: {
config: {},
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
active: false,
},
// Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
TREATMENT_FOLLOW_UP: {
config: {},
actionTemplate: 'Behandlungserfolg kontrollieren',
active: false,
},
FEEDING_FOLLOW_UP: {
config: {},
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
active: false,
},
};
/** The anchor of a finding that has no record to hang on. */
const NO_ANCHOR = 'none';
/**
* Optional per-rule follow-up interval (config.dueInDays). There is no
* built-in default: how soon a queenless colony or a high mite fall needs a
* look depends on season and practice, so the rule stays date-less unless the
* beekeeper configures one.
* Optional per-rule follow-up interval (config.dueInDays) counted from the
* business day. There is no built-in default: how soon a queenless colony or
* a high mite fall needs a look depends on season and practice, so the
* suggestion stays date-less unless the beekeeper configures one (accepting
* it then creates a task due today).
*/
function formatDay(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
function dueDayOf(config: object, baseDay: string): string | null {
const { dueInDays } = config as { dueInDays?: unknown };
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
}
function dueDateFromConfig(
dueInDays: unknown,
baseDate: Date = new Date(),
): Date | undefined {
if (
typeof dueInDays !== 'number' ||
!Number.isInteger(dueInDays) ||
dueInDays < 0 ||
dueInDays > 365
) {
return undefined;
}
const due = new Date(baseDate);
due.setUTCHours(0, 0, 0, 0);
due.setUTCDate(due.getUTCDate() + dueInDays);
return due;
function inspectionEvidence(
inspection: Inspection,
inspectedOn: string,
): SuggestionEvidence {
return {
colonyId: inspection.colonyId,
inspectionId: inspection.id,
inspectedOn,
};
}
@Injectable()
@@ -174,8 +130,7 @@ export class AutomationService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly notifications: NotificationsService,
private readonly tasks: TasksService,
private readonly suggestions: SuggestionsService,
) {}
/**
@@ -223,13 +178,34 @@ export class AutomationService {
return rule;
}
/**
* The row is locked before it is read, so "before" (and the audit entry)
* is the state this write replaces. A config with an invalid known value
* is refused with the fields named; unknown keys pass unchanged.
*/
async update(
id: string,
dto: UpdateAutomationRuleDto,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
const rule = await this.prisma.automationRule.update({
return this.prisma.$transaction(async (tx) => {
const locked = await lockRowForUpdate(tx, 'AutomationRule', id);
const before = locked
? await tx.automationRule.findUnique({ where: { id } })
: null;
if (!before) {
throw new NotFoundException('Automation rule not found');
}
if (typeof dto.config === 'object' && dto.config !== null) {
const issues = validateRuleConfig(before.triggerType, dto.config);
if (issues.length > 0) {
throw new BadRequestException({
message: 'Die Einstellung ist ungültig.',
issues,
});
}
}
const rule = await tx.automationRule.update({
where: { id },
data: {
...dto,
@@ -241,15 +217,19 @@ export class AutomationService {
: (dto.config as Prisma.InputJsonValue | undefined),
},
});
await this.auditLog.record({
await this.auditLog.record(
{
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
},
tx,
);
return rule;
});
}
// -------------------------------------------------------------------------
@@ -260,44 +240,151 @@ export class AutomationService {
// transaction, the legacy wrappers below with a transaction of their own.
// -------------------------------------------------------------------------
/** A new inspection: proposes new findings and follows the open ones. */
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
await this.ensureDefaults(tx);
const rules = await tx.automationRule.findMany({
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
active: true,
},
await this.syncInspectionRulesIn(tx, inspection, true);
}
/**
* A corrected inspection proposes nothing new, but an open suggestion it
* supersedes follows the corrected values (resolved or refreshed).
*/
async evaluateInspectionCorrectionIn(
tx: Tx,
inspection: Inspection,
): Promise<void> {
assertTransactionClient(
tx,
'AutomationService.evaluateInspectionCorrectionIn',
);
await this.syncInspectionRulesIn(tx, inspection, false);
}
/**
* Colony lock first, then everything that decides is read under it: the
* colony's actual latest inspection (date, createdAt, id) is the only one
* that counts. An event of any other inspection - a late or retried
* delivery, a backdated entry, the correction of an older one - proposes
* nothing and changes nothing; the latest inspection has its own event.
* (An inspection moved to another colony raises no event for the colony
* it left, so a suggestion there stays until that colony's next
* inspection.)
*/
private async syncInspectionRulesIn(
tx: Tx,
inspection: Inspection,
proposeNew: boolean,
): Promise<void> {
if (!(await lockRowForUpdate(tx, 'Colony', inspection.colonyId))) {
return;
}
const latest = await tx.inspection.findFirst({
where: { colonyId: inspection.colonyId },
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
});
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
if (
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerActionIn(
if (latest === null || latest.id !== inspection.id) {
return;
}
await this.syncInspectionRuleIn(
tx,
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
'QUEENLESS_NO_EGGS',
'QUEEN_CHECK',
latest,
latest.queenSeen === false && latest.eggsPresent === false,
proposeNew,
);
await this.syncInspectionRuleIn(
tx,
'FEED_STATUS_CRITICAL',
'FEED',
latest,
latest.feedStatus === 'CRITICAL',
proposeNew,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerActionIn(
tx,
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
/**
* One inspection rule against the colony's latest inspection (colony
* locked). No open suggestion: a met condition proposes - new inspections
* only, a correction never proposes (a corrected value is not a new
* finding; the beekeeper was told about the original). An open one follows
* the latest inspection: condition gone, it is resolved; still met, its
* evidence is refreshed. The refresh keeps the original dedupeKey/anchor,
* so evidence.inspectionId may differ from the anchor. An inactive rule
* leaves open suggestions untouched (as the varroa rule does).
*/
private async syncInspectionRuleIn(
tx: Tx,
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
intent: SuggestionIntent,
latest: Inspection,
met: boolean,
proposeNew: boolean,
): Promise<void> {
const open = await tx.suggestion.findFirst({
where: {
ruleKey: trigger,
colonyId: latest.colonyId,
status: 'OPEN',
},
select: { id: true },
});
if (open === null) {
if (met && proposeNew) {
await this.proposeOnInspectionIn(tx, trigger, intent, latest);
}
return;
}
const rule = await readRule(tx, trigger);
if (!rule.active) {
return;
}
// Colony (held), then suggestion.
await lockRowForUpdate(tx, 'Suggestion', open.id);
if (!met) {
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
return;
}
const inspectedOn = berlinDayOf(latest.date);
await this.suggestions.refreshIn(tx, open.id, {
evidence: inspectionEvidence(latest, inspectedOn),
dueDate: dueDayOf(rule.config, inspectedOn),
priority: 'HIGH',
expiresAt: null,
});
}
/** The reason code of an inspection rule is the rule's own name. */
private async proposeOnInspectionIn(
tx: Tx,
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
intent: SuggestionIntent,
inspection: Inspection,
): Promise<void> {
const rule = await readRule(tx, trigger);
if (!rule.active) {
return;
}
const inspectedOn = berlinDayOf(inspection.date);
await this.proposeForRuleIn(tx, trigger, {
relatedType: 'COLONY',
relatedId: inspection.colonyId,
colonyId: inspection.colonyId,
intent,
reasonCode: trigger,
evidence: inspectionEvidence(inspection, inspectedOn),
anchor: inspection.id,
requiredPermission: 'inspections:read',
dueDate: dueDayOf(rule.config, inspectedOn),
});
}
/**
* A red measurement outside the effect of a treatment proposes a
* treatment check. The light comes from the same bands as the colony view
* (season, young colony, method), so rule and screen never disagree.
*/
async evaluateVarroaMeasurementIn(
tx: Tx,
measurement: VarroaMeasurement,
@@ -306,26 +393,78 @@ export class AutomationService {
tx,
'AutomationService.evaluateVarroaMeasurementIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
const rule = await readRule(tx, 'VARROA_THRESHOLD');
if (!rule.active) {
return;
}
const colony = await tx.colony.findUnique({
where: { id: measurement.colonyId },
select: { colonyType: true },
});
// null for an inactive rule; the one source of the default value.
const threshold = varroaThresholdOf(rule);
if (!rule || threshold === null) {
if (colony === null) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only that
// method yields a value in this unit - a mite count from a sugar roll or
// wash is per bee sample and would be compared against the wrong scale,
// as would a raw board count without the number of days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
const rating = rateMeasurement(
measurement,
colony.colonyType,
rule.config,
await treatmentWindowsIn(tx, measurement.colonyId),
);
// A correction can move a measurement out of red (or into it): an open
// suggestion of this measurement follows the corrected value.
const open = await tx.suggestion.findFirst({
where: {
ruleKey: 'VARROA_THRESHOLD',
status: 'OPEN',
dedupeKey: suggestionDedupeKey(
'VARROA_THRESHOLD',
measurement.colonyId,
measurement.id,
),
},
select: { id: true },
});
if (
rating.light !== 'red' ||
rating.band === null ||
measurement.calculatedValue === null
) {
if (open !== null) {
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
}
return;
}
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
const measuredOn = berlinDayOf(measurement.date);
const evidence = {
colonyId: measurement.colonyId,
measurementId: measurement.id,
measuredOn,
value: measurement.calculatedValue,
redAbove: rating.band.redAbove,
unit: rating.band.unit,
};
const dueDate = dueDayOf(rule.config, measuredOn);
if (open !== null) {
await this.suggestions.refreshIn(tx, open.id, {
reasonCode: 'VARROA_RED',
evidence,
dueDate,
priority: 'HIGH',
expiresAt: null,
});
return;
}
await this.proposeForRuleIn(tx, 'VARROA_THRESHOLD', {
relatedType: 'COLONY',
relatedId: measurement.colonyId,
colonyId: measurement.colonyId,
intent: 'VARROA_TREAT',
reasonCode: 'VARROA_RED',
evidence,
anchor: measurement.id,
requiredPermission: 'varroa:read',
dueDate,
});
}
async evaluateSwarmControlCheckIn(
@@ -336,24 +475,38 @@ export class AutomationService {
tx,
'AutomationService.evaluateSwarmControlCheckIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
if (check.swarmMood !== 'HIGH') {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
const rule = await readRule(tx, 'SWARM_MOOD_HIGH');
if (!rule.active) {
return;
}
const checkedOn = berlinDayOf(check.date);
await this.proposeForRuleIn(tx, 'SWARM_MOOD_HIGH', {
relatedType: 'COLONY',
relatedId: check.colonyId,
colonyId: check.colonyId,
intent: 'SWARM_CHECK',
reasonCode: 'SWARM_MOOD_HIGH',
evidence: { colonyId: check.colonyId, checkId: check.id, checkedOn },
anchor: check.id,
requiredPermission: 'swarm-control:read',
dueDate: dueDayOf(rule.config, checkedOn),
});
}
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
/**
* Fires only on the transition into CRITICAL, not on every edit while
* already critical. The event is the anchor: a later transition into
* CRITICAL is a new finding.
*/
async evaluateColonyHealthIn(
tx: Tx,
colonyId: string,
from: ColonyStateSnapshot,
to: ColonyStateSnapshot,
event: { id: string; occurredAt: Date },
): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
// ColonyStatusChanged comes with every status or health edit: decide on
@@ -361,16 +514,29 @@ export class AutomationService {
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL');
if (!rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
const changedOn = berlinDayOf(event.occurredAt);
await this.proposeForRuleIn(tx, 'COLONY_HEALTH_CRITICAL', {
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
intent: 'HEALTH_CHECK',
reasonCode: 'COLONY_HEALTH_CRITICAL',
evidence: { colonyId, changedOn },
anchor: event.id,
requiredPermission: 'colonies:read',
dueDate: dueDayOf(rule.config, changedOn),
sourceEventId: event.id,
});
}
/**
* Anchored on the newest incoming movement: a dismissed reorder stays
* dismissed until the item was restocked and ran low again.
*/
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
// Every movement of the item comes here: only one below its minimum
@@ -378,22 +544,39 @@ export class AutomationService {
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'STOCK_ITEM_LOW');
if (!rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
const restock = await tx.stockMovement.findFirst({
where: { stockItemId: item.id, type: 'IN' },
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
select: { id: true },
});
await this.proposeForRuleIn(tx, 'STOCK_ITEM_LOW', {
relatedType: 'STORAGE',
relatedId: item.id,
colonyId: null,
intent: 'STOCK_REORDER',
reasonCode: 'STOCK_ITEM_LOW',
evidence: {
stockItemId: item.id,
quantity: item.quantity,
minThreshold: item.minThreshold,
},
anchor: restock?.id ?? NO_ANCHOR,
requiredPermission: 'storage:read',
dueDate: dueDayOf(rule.config, berlinToday()),
});
}
/**
* After raw material left its lots (consumption, withdrawal, inventory):
* compares what is left of the kind across all lots with config.minGrams.
* The task is about the kind, not one lot - a new lot of the same kind is
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
* keeps one open task per kind.
* The suggestion is about the kind, not one lot - a new lot of the same
* kind is the remedy - so it hangs on GENERAL with the kind as relatedId,
* which keeps one open suggestion per kind, and is anchored on the newest
* lot of the kind.
*/
async evaluateRawMaterialLowIn(
tx: Tx,
@@ -401,69 +584,73 @@ export class AutomationService {
remainingGrams: number,
): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'RAW_MATERIAL_LOW');
if (!rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
const minGrams = rule.config.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
const newestLot = await tx.rawMaterialLot.findFirst({
where: { kind },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { id: true },
});
await this.proposeForRuleIn(tx, 'RAW_MATERIAL_LOW', {
relatedType: 'GENERAL',
relatedId: kind,
colonyId: null,
intent: 'STOCK_REORDER',
reasonCode: 'RAW_MATERIAL_LOW',
evidence: { kind, remainingGrams, minGrams },
anchor: newestLot?.id ?? NO_ANCHOR,
requiredPermission: 'raw-material:read',
dueDate: dueDayOf(rule.config, berlinToday()),
});
}
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
const measureDate = treatment.endDate ?? treatment.date;
await this.evaluateFollowUpIn(
tx,
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
measureDate,
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
);
const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP');
if (!rule.active) {
return;
}
const endedOn = berlinDayOf(treatment.endDate ?? treatment.date);
await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', {
relatedType: 'COLONY',
relatedId: treatment.colonyId,
colonyId: treatment.colonyId,
intent: 'FOLLOW_UP',
reasonCode: 'TREATMENT_FOLLOW_UP',
evidence: {
colonyId: treatment.colonyId,
treatmentId: treatment.id,
endedOn,
},
anchor: treatment.id,
requiredPermission: 'treatments:read',
dueDate: dueDayOf(rule.config, endedOn),
});
}
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
await this.evaluateFollowUpIn(
tx,
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
);
}
/**
* A check counted from the measure's own date, not from today. Its public
* callers check the transaction client, so an error names them.
*/
private async evaluateFollowUpIn(
tx: Tx,
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'FEEDING_FOLLOW_UP');
if (!rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
baseDate: measureDate,
description,
const fedOn = berlinDayOf(feeding.date);
await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', {
relatedType: 'COLONY',
relatedId: feeding.colonyId,
colonyId: feeding.colonyId,
intent: 'FOLLOW_UP',
reasonCode: 'FEEDING_FOLLOW_UP',
evidence: { colonyId: feeding.colonyId, feedingId: feeding.id, fedOn },
anchor: feeding.id,
requiredPermission: 'feedings:read',
dueDate: dueDayOf(rule.config, fedOn),
});
}
@@ -593,76 +780,80 @@ export class AutomationService {
}
/**
* Leaves a suggestion task (never a record of work done) plus a warning,
* all on the caller's transaction: task, its audit entry and TaskCreated
* (TasksService.createInTx - the task.created webhook and the calendar
* entry follow from that event), the warning and the rule's audit entry.
* A failing write rolls back all of it; a handler delivery is retried.
* Proposes what a rule found, on the caller's transaction: the suggestion
* (SuggestionsService.proposeIn: colony then rule lock, one open thing per
* intent and object, an anchor is proposed once, the notification when the
* rule says `notify`) and the rule's audit entry. A failing write rolls
* back all of it; a handler delivery is retried. Resolves to null when
* nothing was proposed (rule off, colony ended, anchor or intent taken).
*
* At most one open task per rule and object: while the reorder task for a
* stock item or the check task for a colony is still open, further triggers
* (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters.
*
* The check runs under a lock on the rule's row, held until the caller
* commits: two evaluations at the same moment (parallel requests, a
* handler and a legacy path, several pods) would otherwise both see
* "no open task" and both create one.
* The colony is locked and read before the rule is locked - the order
* every proposal uses - so a colony that ends in between is not missed.
*/
private async triggerActionIn(
async proposeForRuleIn(
tx: Tx,
rule: AutomationRule,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> {
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
const alreadyOpen = await tx.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
trigger: RuleSuggestionTrigger,
target: RuleSuggestionTarget,
): Promise<Suggestion | null> {
assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
if (
target.colonyId !== null &&
!(await this.isLivingColonyLockedIn(tx, target.colonyId))
) {
return null;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.tasks.createInTx(
tx,
{
title,
const rule = await lockRuleIn(tx, trigger);
if (!rule.active) {
return null;
}
const suggestion = await this.suggestions.proposeIn(tx, {
ruleKey: trigger,
source: 'RULE',
mode: 'PLAN',
intent: target.intent,
priority: 'HIGH',
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
null,
);
await this.notifications.record(
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
tx,
);
relatedType: target.relatedType,
relatedId: target.relatedId,
colonyId: target.colonyId,
reasonCode: target.reasonCode,
evidence: target.evidence,
requiredPermission: target.requiredPermission,
dueDate: target.dueDate ?? null,
anchor: target.anchor,
sourceEventId: target.sourceEventId ?? null,
notify: rule.config.notify,
});
if (suggestion === null) {
return null;
}
await this.auditLog.record(
{
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: {
taskId: task.id,
relatedType,
relatedId,
triggerType: rule.triggerType,
suggestionId: suggestion.id,
relatedType: target.relatedType,
relatedId: target.relatedId,
triggerType: trigger,
},
},
tx,
);
return suggestion;
}
private async isLivingColonyLockedIn(
tx: Tx,
colonyId: string,
): Promise<boolean> {
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
return false;
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true },
});
return colony !== null && isLivingColonyStatus(colony.status);
}
}
@@ -0,0 +1,40 @@
import { bool, intIn, makeParser } from './parse-helpers';
import type { ChainConfig } from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
export function chainParser(trigger: string, defaults: ChainConfig) {
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
dueInDays: intIn(
scope,
'dueInDays',
defaults.dueInDays,
0,
MAX_DUE_IN_DAYS,
),
notify: bool(scope, 'notify', defaults.notify),
}));
}
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
dueInDays: 7,
notify: false,
};
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
'WITHDRAWAL_ENDED',
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
(scope) => ({
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
}),
);
/** The cleanup rule has no setting; it is only switched on or off. */
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
'COLONY_ENDED_CLEANUP',
{},
() => ({}),
);
@@ -0,0 +1,72 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
export interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
/** Texts of the rules that create a task; the new rules have none yet. */
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
SWARM_MOOD_HIGH:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
STOCK_ITEM_LOW: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: '',
BOTTLING_LOT_NUMBER_FORMAT: '',
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
PRODUCTION_BATCH_NUMBER_FORMAT: '',
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
INSPECTION_INTERVAL: '',
QUEEN_CELLS_SWARM_CHECK: '',
QUEEN_ACCEPTANCE_CHECK: '',
SPLIT_SCHEDULE: '',
COLONY_ENDED_CLEANUP: '',
WITHDRAWAL_ENDED: '',
HONEY_WATER_CONTENT: '',
SEASON_AUTOPILOT: '',
PHENOLOGY: '',
};
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
// the minimum per kind (config.minGrams) lives on the rule - and without
// one there is nothing to compare, hence off by default.
// - Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
'FEEDING_FOLLOW_UP',
]);
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
return {
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
string,
unknown
>,
actionTemplate: ACTION_TEMPLATES[trigger],
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
};
}
/** The rule rows created on first use; the config is the parser's default. */
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
Object.fromEntries(
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
(trigger) => [trigger, defaultsOf(trigger)],
),
) as Record<AutomationTriggerType, RuleDefaults>;
@@ -0,0 +1,8 @@
export * from './rule-config.types';
export {
RULE_CONFIG_PARSERS,
ruleConfigOf,
validateRuleConfig,
} from './rule-config.registry';
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
@@ -0,0 +1,44 @@
import type { SeasonSegmentKey } from '../../season/season-types';
import { childScope, intIn, makeParser } from './parse-helpers';
import type { InspectionIntervalConfig } from './rule-config.types';
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
const MIN_INTERVAL_DAYS = 7;
const MAX_INTERVAL_DAYS = 35;
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
const MAX_SWARM_INTERVAL_DAYS = 9;
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 14,
SPAETSOMMER: 21,
HERBST: 21,
},
};
const SEGMENTS = Object.keys(
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
) as IntervalSegment[];
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
'INSPECTION_INTERVAL',
DEFAULT_INSPECTION_INTERVAL_CONFIG,
(scope) => {
const days = childScope(scope, 'intervalDays');
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
const intervalDays = { ...defaults };
for (const segment of SEGMENTS) {
intervalDays[segment] = intIn(
days,
segment,
defaults[segment],
MIN_INTERVAL_DAYS,
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
);
}
return { intervalDays };
},
);
@@ -0,0 +1,349 @@
import { Logger } from '@nestjs/common';
import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types';
/**
* Where a value is read from: the object, its field path (for messages) and
* the list the issues go to. Helpers never throw - an invalid or missing key
* gives the fallback, and an invalid one that is present is reported.
*/
export interface Scope {
readonly raw: Readonly<Record<string, unknown>>;
readonly path: string;
readonly issues: RuleConfigIssue[];
}
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope {
return { raw: asRecord(json) ?? NO_VALUES, path: '', issues };
}
export function fieldPath(scope: Scope, key: string): string {
return scope.path === '' ? key : `${scope.path}.${key}`;
}
export function report(scope: Scope, key: string, message: string): void {
scope.issues.push({ field: fieldPath(scope, key), message });
}
/**
* A key counts as given when it is present - an explicit null too, which the
* readers then report as invalid instead of silently using the default.
*/
export function has(scope: Scope, key: string): boolean {
if (!Object.hasOwn(scope.raw, key)) {
return false;
}
return scope.raw[key] !== undefined;
}
/** An explicit null: on an optional key it clears the value. */
export function isCleared(scope: Scope, key: string): boolean {
return Object.hasOwn(scope.raw, key) && scope.raw[key] === null;
}
/** The nested object under `key`; a non-object is reported and read as empty. */
export function childScope(scope: Scope, key: string): Scope {
const path = fieldPath(scope, key);
if (!has(scope, key)) {
return { raw: NO_VALUES, path, issues: scope.issues };
}
const nested = asRecord(scope.raw[key]);
if (nested === null) {
report(scope, key, 'Bitte die Angaben als Gruppe senden.');
return { raw: NO_VALUES, path, issues: scope.issues };
}
return { raw: nested, path, issues: scope.issues };
}
/** Runs `read`; if it reported anything, `fallback()` replaces its result. */
export function orFallback<T>(
scope: Scope,
read: () => T,
fallback: () => T,
): T {
const before = scope.issues.length;
const value = read();
return scope.issues.length > before ? fallback() : value;
}
const numberFormat = new Intl.NumberFormat('de-DE', {
maximumFractionDigits: 3,
});
export function formatDe(value: number): string {
return numberFormat.format(value);
}
export function intIn(
scope: Scope,
key: string,
fallback: number,
min: number,
max: number,
): number {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= min &&
value <= max
) {
return value;
}
report(
scope,
key,
`Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`,
);
return fallback;
}
/** Like intIn, but an absent key stays undefined. */
export function optionalIntIn(
scope: Scope,
key: string,
min: number,
max: number,
): number | undefined {
if (!has(scope, key) || isCleared(scope, key)) {
return undefined;
}
const value = intIn(scope, key, Number.NaN, min, max);
return Number.isNaN(value) ? undefined : value;
}
export interface NumberOptions {
/** The lower limit itself is not allowed (min < x). */
exclusiveMin?: boolean;
}
export function numberIn(
scope: Scope,
key: string,
fallback: number,
min: number,
max: number,
options: NumberOptions = {},
): number {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'number' &&
Number.isFinite(value) &&
(options.exclusiveMin ? value > min : value >= min) &&
value <= max
) {
return value;
}
const lower = options.exclusiveMin
? `größer als ${formatDe(min)}`
: `mindestens ${formatDe(min)}`;
report(
scope,
key,
Number.isFinite(max)
? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.`
: `Bitte eine Zahl ${lower} angeben.`,
);
return fallback;
}
/** Like numberIn, but an absent key stays undefined. */
export function optionalNumberIn(
scope: Scope,
key: string,
min: number,
max: number,
options: NumberOptions = {},
): number | undefined {
if (!has(scope, key) || isCleared(scope, key)) {
return undefined;
}
const value = numberIn(scope, key, Number.NaN, min, max, options);
return Number.isNaN(value) ? undefined : value;
}
export function bool(scope: Scope, key: string, fallback: boolean): boolean {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (typeof value === 'boolean') {
return value;
}
report(scope, key, 'Bitte Ja oder Nein wählen.');
return fallback;
}
export function stringIn(
scope: Scope,
key: string,
fallback: string,
minLength: number,
maxLength: number,
pattern?: RegExp,
): string {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'string' &&
value.length >= minLength &&
value.length <= maxLength &&
(pattern === undefined || pattern.test(value))
) {
return value;
}
report(
scope,
key,
`Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`,
);
return fallback;
}
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
/** 'MM-DD' that exists in a leap year (02-29 is allowed). */
export function isMonthDay(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
const match = MONTH_DAY.exec(value);
if (!match) {
return false;
}
const month = Number(match[1]);
const day = Number(match[2]);
const date = new Date(Date.UTC(2024, month - 1, day));
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
export function monthDay(scope: Scope, key: string, fallback: string): string {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (isMonthDay(value)) {
return value;
}
report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.');
return fallback;
}
export function months(
scope: Scope,
key: string,
fallback: readonly number[],
): number[] {
if (!has(scope, key)) {
return [...fallback];
}
const value = scope.raw[key];
if (
Array.isArray(value) &&
value.every(
(month): month is number =>
typeof month === 'number' &&
Number.isInteger(month) &&
month >= 1 &&
month <= 12,
) &&
new Set(value).size === value.length
) {
return [...value];
}
report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.');
return [...fallback];
}
export function enumValue<T extends string>(
scope: Scope,
key: string,
fallback: T,
allowed: readonly T[],
): T {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'string' &&
(allowed as readonly string[]).includes(value)
) {
return value as T;
}
report(scope, key, 'Bitte einen gültigen Wert wählen.');
return fallback;
}
export function enumList<T extends string>(
scope: Scope,
key: string,
fallback: readonly T[],
allowed: readonly T[],
): T[] {
if (!has(scope, key)) {
return [...fallback];
}
const value = scope.raw[key];
if (
Array.isArray(value) &&
value.every(
(entry): entry is T =>
typeof entry === 'string' &&
(allowed as readonly string[]).includes(entry),
) &&
new Set(value).size === value.length
) {
return [...value];
}
report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.');
return [...fallback];
}
const logger = new Logger('RuleConfig');
/**
* Builds the parser of one rule from its reader. The reader turns the scope
* into a complete config and reports every invalid key; `parse` logs those
* (trigger and field, never the value) and returns the config, `validate`
* returns them.
*/
export function makeParser<T>(
trigger: string,
defaults: T,
read: (scope: Scope) => T,
): RuleConfigParser<T> {
return {
defaults,
parse(json) {
const issues: RuleConfigIssue[] = [];
const value = read(rootScope(json, issues));
for (const issue of issues) {
logger.warn(
`Invalid setting ${trigger}.${issue.field}; using the default`,
);
}
return value;
},
validate(json) {
const issues: RuleConfigIssue[] = [];
read(rootScope(json, issues));
return issues;
},
};
}
@@ -0,0 +1,158 @@
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
} from '../../season/season-types';
import {
bool,
childScope,
enumList,
enumValue,
has,
isCleared,
makeParser,
monthDay,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { PhenologyConfig } from './rule-config.types';
/** Upper limit of a growing-degree-day threshold; far above any phase. */
const MAX_GTS_THRESHOLD = 5000;
const MAX_GAP_SHARE = 0.5;
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
useGts: true,
gtsThresholds: { SALWEIDE: 200 },
calibrateFromReports: true,
calibratePhases: ['KIRSCHE', 'APFEL'],
calendarFallback: {
SALWEIDE: '03-20',
KIRSCHE: '04-15',
APFEL: '04-28',
HOLUNDER: '05-30',
LINDE: '06-20',
TRACHTENDE: '08-01',
HERBST: '09-15',
WINTER: '11-15',
},
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
maxGapShare: 0.1,
};
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
// Without the group the defaults apply; with it, only the phases given
// have a threshold - leaving one out removes it.
if (!has(scope, 'gtsThresholds')) {
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
}
const thresholds = childScope(scope, 'gtsThresholds');
const result: PhenologyConfig['gtsThresholds'] = {};
for (const phase of PHENOLOGY_PHASES) {
if (isCleared(thresholds, phase)) {
continue;
}
const value = numberIn(
thresholds,
phase,
Number.NaN,
0,
MAX_GTS_THRESHOLD,
{ exclusiveMin: true },
);
if (!Number.isNaN(value)) {
result[phase] = value;
}
}
return result;
}
/**
* The calendar dates of the phases must not run backwards in phase order.
* One violation sends the whole map back to the defaults, so it stays
* consistent; the issue names a key the caller actually sent.
*/
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
const calendar = childScope(scope, 'calendarFallback');
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
const before = scope.issues.length;
const result = { ...defaults };
for (const phase of PHENOLOGY_PHASES) {
result[phase] = monthDay(calendar, phase, defaults[phase]);
}
if (scope.issues.length > before) {
return { ...defaults };
}
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
const previous = PHENOLOGY_PHASES[index - 1];
const phase = PHENOLOGY_PHASES[index];
if (result[phase] < result[previous]) {
const culprit = has(calendar, phase) ? phase : previous;
report(
calendar,
culprit,
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
);
return { ...defaults };
}
}
return result;
}
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
const season = childScope(scope, 'swarmSeason');
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
const from: PhenologyPhaseKey = enumValue(
season,
'from',
defaults.from,
PHENOLOGY_PHASES,
);
const until: PhenologyPhaseKey = enumValue(
season,
'until',
defaults.until,
PHENOLOGY_PHASES,
);
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
report(
season,
has(season, 'from') ? 'from' : 'until',
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
);
return { ...defaults };
}
return { from, until };
}
export const phenologyParser = makeParser<PhenologyConfig>(
'PHENOLOGY',
DEFAULT_PHENOLOGY_CONFIG,
(scope) => {
const d = DEFAULT_PHENOLOGY_CONFIG;
return {
useGts: bool(scope, 'useGts', d.useGts),
gtsThresholds: readThresholds(scope),
calibrateFromReports: bool(
scope,
'calibrateFromReports',
d.calibrateFromReports,
),
calibratePhases: enumList(
scope,
'calibratePhases',
d.calibratePhases,
PHENOLOGY_PHASES,
),
calendarFallback: readCalendar(scope),
swarmSeason: readSwarmSeason(scope),
maxGapShare: numberIn(
scope,
'maxGapShare',
d.maxGapShare,
0,
MAX_GAP_SHARE,
),
};
},
);
@@ -0,0 +1,100 @@
import { Prisma } from '../../../generated/prisma';
import type { AutomationTriggerType } from '../../../generated/prisma';
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
import { lockRowForUpdate } from '../../prisma/row-lock';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf } from './rule-config.registry';
import type { RuleConfigMap } from './rule-config.types';
export interface ActiveRule<K extends AutomationTriggerType> {
/** null while no row exists yet: the built-in setting applies. */
id: string | null;
active: boolean;
config: RuleConfigMap[K];
actionTemplate: string | null;
}
interface RuleRow {
id: string;
active: boolean;
config: Prisma.JsonValue;
actionTemplate: string | null;
}
const RULE_COLUMNS = {
id: true,
active: true,
config: true,
actionTemplate: true,
} as const;
function toActiveRule<K extends AutomationTriggerType>(
trigger: K,
row: RuleRow | null,
): ActiveRule<K> {
if (row === null) {
const defaults = DEFAULT_RULES[trigger];
return {
id: null,
active: defaults.active ?? true,
config: ruleConfigOf(trigger, defaults.config),
actionTemplate: defaults.actionTemplate,
};
}
return {
id: row.id,
active: row.active,
config: ruleConfigOf(trigger, row.config),
actionTemplate: row.actionTemplate,
};
}
/**
* The rule as it is in force: its row, or the built-in setting while there is
* none. The config is parsed, so every key is valid.
*/
export async function readRule<K extends AutomationTriggerType>(
db: DbClient,
trigger: K,
): Promise<ActiveRule<K>> {
const row = await db.automationRule.findUnique({
where: { triggerType: trigger },
select: RULE_COLUMNS,
});
return toActiveRule(trigger, row);
}
/**
* Creates the rule row if it is missing (without touching an existing one),
* locks it until the transaction ends and reads it. Serialises everything
* that decides "is there already an open task/suggestion" under the rule.
*/
export async function lockRuleIn<K extends AutomationTriggerType>(
tx: Prisma.TransactionClient,
trigger: K,
): Promise<ActiveRule<K> & { id: string }> {
assertTransactionClient(tx, 'lockRuleIn');
const defaults = DEFAULT_RULES[trigger];
await tx.automationRule.createMany({
data: [
{
triggerType: trigger,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
],
skipDuplicates: true,
});
const { id } = await tx.automationRule.findUniqueOrThrow({
where: { triggerType: trigger },
select: { id: true },
});
await lockRowForUpdate(tx, 'AutomationRule', id);
// Read after the lock: a concurrent edit of the rule is visible now.
const row = await tx.automationRule.findUniqueOrThrow({
where: { id },
select: RULE_COLUMNS,
});
return { ...toActiveRule(trigger, row), id };
}
@@ -0,0 +1,91 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import {
chainParser,
colonyEndedCleanupParser,
DEFAULT_CHAIN_CONFIG,
withdrawalEndedParser,
} from './chain.config';
import { inspectionIntervalParser } from './inspection-interval.config';
import { phenologyParser } from './phenology.config';
import { seasonAutopilotParser } from './season-autopilot.config';
import { numberFormatParser, simpleParser } from './simple.config';
import { splitScheduleParser } from './split-schedule.config';
import { varroaThresholdParser } from './varroa-threshold.config';
import { waterContentParser } from './water-content.config';
import type {
RuleConfigIssue,
RuleConfigMap,
RuleConfigParser,
} from './rule-config.types';
/** The number-format rules keep the defaults they always had. */
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
export const RULE_CONFIG_PARSERS: {
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
} = {
VARROA_THRESHOLD: varroaThresholdParser,
INSPECTION_INTERVAL: inspectionIntervalParser,
QUEEN_CELLS_SWARM_CHECK: chainParser(
'QUEEN_CELLS_SWARM_CHECK',
DEFAULT_CHAIN_CONFIG,
),
QUEEN_ACCEPTANCE_CHECK: chainParser(
'QUEEN_ACCEPTANCE_CHECK',
DEFAULT_CHAIN_CONFIG,
),
SPLIT_SCHEDULE: splitScheduleParser,
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
WITHDRAWAL_ENDED: withdrawalEndedParser,
HONEY_WATER_CONTENT: waterContentParser,
SEASON_AUTOPILOT: seasonAutopilotParser,
PHENOLOGY: phenologyParser,
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
'HONEY_BATCH_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
'BOTTLING_LOT_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
'{ART}-{YYYY}-{SEQ:4}',
),
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
'PRODUCTION_BATCH_NUMBER_FORMAT',
'HS-{YYYY}-{SEQ:4}',
),
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
notify: false,
}),
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
notify: true,
}),
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
notify: false,
withMinGrams: true,
}),
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
};
/** The effective setting of a rule: valid keys as stored, the rest default. */
export function ruleConfigOf<K extends AutomationTriggerType>(
trigger: K,
json: unknown,
): RuleConfigMap[K] {
return RULE_CONFIG_PARSERS[trigger].parse(json);
}
/** The invalid known keys of a config a client wants to save. */
export function validateRuleConfig(
trigger: AutomationTriggerType,
json: unknown,
): RuleConfigIssue[] {
return RULE_CONFIG_PARSERS[trigger].validate(json);
}
@@ -0,0 +1,440 @@
import { Logger } from '@nestjs/common';
import { AutomationTriggerType } from '../../../generated/prisma';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
return validateRuleConfig(trigger, config).map((issue) => issue.field);
}
describe('rule config', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('every trigger has defaults and a parser', () => {
for (const t of ALL_TRIGGERS) {
expect(DEFAULT_RULES[t]).toBeDefined();
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
}
});
it.each(ALL_TRIGGERS)(
'the default config of %s parses to itself and validates without issues',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
expect(validateRuleConfig(trigger, config)).toEqual([]);
},
);
it.each(ALL_TRIGGERS)(
'%s falls back to its defaults for null, arrays and scalars',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
for (const junk of [null, undefined, [], 'x', 5]) {
expect(ruleConfigOf(trigger, junk)).toEqual(config);
expect(validateRuleConfig(trigger, junk)).toEqual([]);
}
},
);
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
).toEqual({
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 7,
winterRedAbove: 0.5,
}));
it('lets the band format win over the legacy key', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 7,
naturalFall: { redAbove: 9 },
}).naturalFall.redAbove,
).toBe(9));
it('ignores an invalid legacy threshold', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
.redAbove,
).toBe(10));
it('reports an invalid legacy threshold with field and message', () => {
const issues = validateRuleConfig('VARROA_THRESHOLD', {
thresholdMitesPerDay: 0,
});
expect(issues).toEqual([
{
field: 'thresholdMitesPerDay',
message: expect.any(String) as string,
},
]);
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
[],
);
});
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
'naturalFall.redAbove',
]);
expect(
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
).toEqual(['infestationPercent.summer.redAbove']);
expect(
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
).toEqual([]);
});
it('reports an explicit null on a known key', () => {
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
'queenMaxAgeYears',
]);
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
'swarmSeason',
]);
});
it('accepts null on optional keys as clearing them', () => {
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: null, PROPOLIS: 50 },
}),
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('SPLIT_SCHEDULE', {
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
}).QUEEN,
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
expect(
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
).toEqual([]);
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
.gtsThresholds,
).toEqual({});
});
it('lets a client remove a gts threshold by leaving it out', () => {
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
.gtsThresholds,
).toEqual({ KIRSCHE: 400 });
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
).toEqual({});
});
it('keeps yellowFrom at or below a legacy threshold under five', () => {
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 3,
});
expect(naturalFall.redAbove).toBe(3);
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
});
it('falls back per invalid key and keeps the valid ones', () =>
expect(
ruleConfigOf('SEASON_AUTOPILOT', {
queenMaxAgeYears: 'x',
honeySuperMinFrames: 10,
}),
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
it('logs a warning with trigger and field but without the value', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
expect(logged).toContain('SEASON_AUTOPILOT');
expect(logged).toContain('queenMaxAgeYears');
expect(logged).not.toContain('secret-value');
});
it('rejects SCHWARMZEIT 10 days with the field name', () =>
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SCHWARMZEIT: 10 },
}),
).toEqual([
{
field: 'intervalDays.SCHWARMZEIT',
message: expect.stringContaining('7') as string,
},
]));
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
}),
).toEqual([]);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
).toEqual(['intervalDays.SOMMER']);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
).toEqual(['intervalDays.HERBST']);
});
it('merges a partial interval config over the defaults', () =>
expect(
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
).toEqual({
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 10,
SPAETSOMMER: 21,
HERBST: 21,
},
}));
it('rejects yellowFrom above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
naturalFall: { yellowFrom: 12, redAbove: 8 },
}),
).toEqual(['naturalFall.yellowFrom']));
it('rejects percent bands where yellowFrom is above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
}),
).toEqual(['infestationPercent.summer.yellowFrom']));
it('rejects duplicate or out-of-range months', () => {
expect(
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
).toEqual(['naturalFall.seasonMonths']);
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summerMonths: [0, 13] },
}),
).toEqual(['infestationPercent.summerMonths']);
});
it('checks the varroa limits', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
'youngColonyTypes',
]);
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ notify: 'yes' })).toEqual(['notify']);
});
it('reports no issue for keys it does not know', () =>
expect(
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
).toEqual([]));
it('rejects a calendar fallback that runs backwards', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
).toEqual(['calendarFallback.KIRSCHE']);
// The whole map falls back so the phases stay in order.
expect(
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
.calendarFallback,
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
});
it('rejects an invalid month-day', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
).toEqual([]);
});
it('checks the phenology limits', () => {
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
'gtsThresholds.SALWEIDE',
]);
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
'calibratePhases',
]);
expect(
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
).toEqual(['swarmSeason.from']);
});
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
SALWEIDE: 200,
}));
it('checks the season autopilot limits', () => {
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
'honeySuperMinFrames',
]);
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
'varroaMeasureIntervalDays',
]);
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
expect(
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
).toEqual(['winterVarroaWindow.from']);
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
expect(fields({ rules: { A3: false } })).toEqual([]);
expect(
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
});
it('checks the water content limits', () => {
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
expect(fields({ confirmAbovePercent: 31 })).toEqual([
'confirmAbovePercent',
]);
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
).toEqual([]);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
});
it('checks the split schedule steps', () => {
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
expect(
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
).toEqual(['QUEEN[0].offsetDays']);
expect(
fields({
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
}),
).toEqual(['QUEEN_CELL[0].windowDays']);
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
'RAISE_OWN[0].step',
]);
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
expect(
fields({
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
}),
).toEqual([]);
});
it('checks dueInDays of chains and simple rules', () => {
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
'dueInDays',
]);
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
'dueInDays',
]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
notify: false,
dueInDays: 14,
});
});
it('reads minGrams per raw material kind', () => {
expect(
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
expect(
fieldsOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
}),
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
});
it('keeps a number format and rejects an empty or oversized one', () => {
expect(
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
).toEqual({ format: 'HB-{SEQ:3}' });
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
'format',
]);
expect(
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
).toEqual(['format']);
});
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
const withNotify = ALL_TRIGGERS.filter(
(trigger) =>
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
);
expect([...withNotify].sort()).toEqual(
[
'COLONY_HEALTH_CRITICAL',
'QUEENLESS_NO_EGGS',
'VARROA_THRESHOLD',
'WITHDRAWAL_ENDED',
].sort(),
);
});
it('starts the optional rules switched off', () => {
const off = ALL_TRIGGERS.filter(
(trigger) => DEFAULT_RULES[trigger].active === false,
).sort();
expect(off).toEqual([
'FEEDING_FOLLOW_UP',
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
]);
});
it('gives every new rule an empty action template', () => {
for (const trigger of [
'INSPECTION_INTERVAL',
'QUEEN_CELLS_SWARM_CHECK',
'QUEEN_ACCEPTANCE_CHECK',
'SPLIT_SCHEDULE',
'COLONY_ENDED_CLEANUP',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'SEASON_AUTOPILOT',
'PHENOLOGY',
] as const) {
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
}
});
});
@@ -0,0 +1,140 @@
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
import type {
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../../season/season-types';
/** One invalid setting of a rule: the field path and a German message. */
export interface RuleConfigIssue {
field: string;
message: string;
}
export interface RuleConfigParser<T> {
/** The built-in setting; also what an empty or unusable config reads as. */
readonly defaults: T;
/** Never throws: each invalid key falls back to its default (and is logged). */
parse(json: unknown): T;
/** Reports the known keys that are present and invalid; nothing else. */
validate(json: unknown): RuleConfigIssue[];
}
export interface SimpleRuleConfig {
notify: boolean;
dueInDays?: number;
minGrams?: Partial<Record<RawMaterialKind, number>>;
}
export interface NumberFormatConfig {
format: string;
}
export interface VarroaThresholdConfig {
naturalFall: {
seasonMonths: number[];
yellowFrom: number;
redAbove: number;
winterRedAbove: number;
};
infestationPercent: {
summerMonths: number[];
summer: { yellowFrom: number; redAbove: number };
autumnMonths: number[];
autumn: { yellowFrom: number; redAbove: number };
};
youngColonyFactor: number;
youngColonyTypes: ColonyType[];
recheckAfterDays: number;
notify: boolean;
}
export interface InspectionIntervalConfig {
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
}
export interface ChainConfig {
dueInDays: number;
notify: boolean;
}
export interface PlanStepConfig {
step: string;
offsetDays: number;
windowDays?: number;
}
export interface SplitScheduleConfig {
RAISE_OWN: PlanStepConfig[];
QUEEN_CELL: PlanStepConfig[];
QUEEN: PlanStepConfig[];
notify: boolean;
}
export interface WaterContentConfig {
warnAbovePercent: number;
confirmAbovePercent: number;
byHoneyType: Record<
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number }
>;
}
export const AUTOPILOT_RULE_IDS = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
] as const;
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
export interface SeasonAutopilotConfig {
honeySuperMinFrames: number;
varroaMeasureIntervalDays: number;
winterVarroaWindow: { from: string; to: string };
queenMaxAgeYears: number;
suggestionTtlDays: number;
maxStepsPerColony: number;
rules: Record<AutopilotRuleId, boolean>;
}
export interface PhenologyConfig {
useGts: boolean;
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
calibrateFromReports: boolean;
calibratePhases: PhenologyPhaseKey[];
calendarFallback: Record<PhenologyPhaseKey, string>;
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
maxGapShare: number;
}
/** One entry per AutomationTriggerType. */
export interface RuleConfigMap {
VARROA_THRESHOLD: VarroaThresholdConfig;
INSPECTION_INTERVAL: InspectionIntervalConfig;
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
SPLIT_SCHEDULE: SplitScheduleConfig;
COLONY_ENDED_CLEANUP: Record<string, never>;
WITHDRAWAL_ENDED: { notify: boolean };
HONEY_WATER_CONTENT: WaterContentConfig;
SEASON_AUTOPILOT: SeasonAutopilotConfig;
PHENOLOGY: PhenologyConfig;
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
QUEENLESS_NO_EGGS: SimpleRuleConfig;
FEED_STATUS_CRITICAL: SimpleRuleConfig;
SWARM_MOOD_HIGH: SimpleRuleConfig;
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
STOCK_ITEM_LOW: SimpleRuleConfig;
RAW_MATERIAL_LOW: SimpleRuleConfig;
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
FEEDING_FOLLOW_UP: SimpleRuleConfig;
}
@@ -0,0 +1,102 @@
import {
bool,
childScope,
has,
intIn,
makeParser,
monthDay,
report,
} from './parse-helpers';
import {
AUTOPILOT_RULE_IDS,
type SeasonAutopilotConfig,
} from './rule-config.types';
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
honeySuperMinFrames: 8,
varroaMeasureIntervalDays: 28,
winterVarroaWindow: { from: '11-15', to: '12-20' },
queenMaxAgeYears: 2,
suggestionTtlDays: 30,
maxStepsPerColony: 3,
rules: {
A1: true,
A2: true,
A3: true,
A4: true,
A5: true,
A6: true,
A7: true,
A8: true,
A9: true,
A10: true,
},
};
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
'SEASON_AUTOPILOT',
DEFAULT_SEASON_AUTOPILOT_CONFIG,
(scope) => {
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
const windowScope = childScope(scope, 'winterVarroaWindow');
let winterVarroaWindow = {
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
};
if (winterVarroaWindow.from > winterVarroaWindow.to) {
report(
windowScope,
has(windowScope, 'from') ? 'from' : 'to',
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
);
winterVarroaWindow = { ...d.winterVarroaWindow };
}
const ruleScope = childScope(scope, 'rules');
const rules = { ...d.rules };
for (const id of AUTOPILOT_RULE_IDS) {
rules[id] = bool(ruleScope, id, d.rules[id]);
}
return {
honeySuperMinFrames: intIn(
scope,
'honeySuperMinFrames',
d.honeySuperMinFrames,
1,
40,
),
varroaMeasureIntervalDays: intIn(
scope,
'varroaMeasureIntervalDays',
d.varroaMeasureIntervalDays,
7,
90,
),
winterVarroaWindow,
queenMaxAgeYears: intIn(
scope,
'queenMaxAgeYears',
d.queenMaxAgeYears,
1,
10,
),
suggestionTtlDays: intIn(
scope,
'suggestionTtlDays',
d.suggestionTtlDays,
1,
365,
),
maxStepsPerColony: intIn(
scope,
'maxStepsPerColony',
d.maxStepsPerColony,
1,
3,
),
rules,
};
},
);
@@ -0,0 +1,93 @@
import { RawMaterialKind } from '../../../generated/prisma';
import {
bool,
childScope,
has,
isCleared,
makeParser,
numberIn,
optionalIntIn,
stringIn,
} from './parse-helpers';
import type {
NumberFormatConfig,
RuleConfigParser,
SimpleRuleConfig,
} from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Far above any minimum stock of wax or propolis. */
const MAX_MIN_GRAMS = 1_000_000_000;
const MAX_NUMBER_FORMAT_LENGTH = 100;
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
export interface SimpleRuleOptions {
/** Whether the rule notifies by default. */
notify: boolean;
/** RAW_MATERIAL_LOW reads a minimum per kind. */
withMinGrams?: boolean;
}
/**
* Rules that propose one suggestion: notify, an optional follow-up interval
* (dueInDays) and, for the raw material rule, minGrams per kind.
*/
export function simpleParser(
trigger: string,
options: SimpleRuleOptions,
): RuleConfigParser<SimpleRuleConfig> {
return makeParser<SimpleRuleConfig>(
trigger,
{ notify: options.notify },
(scope) => {
const config: SimpleRuleConfig = {
notify: bool(scope, 'notify', options.notify),
};
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
if (dueInDays !== undefined) {
config.dueInDays = dueInDays;
}
if (
options.withMinGrams &&
has(scope, 'minGrams') &&
!isCleared(scope, 'minGrams')
) {
const minScope = childScope(scope, 'minGrams');
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
for (const kind of RAW_MATERIAL_KINDS) {
if (isCleared(minScope, kind)) {
continue;
}
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
if (!Number.isNaN(grams)) {
minGrams[kind] = grams;
}
}
if (Object.keys(minGrams).length > 0) {
config.minGrams = minGrams;
}
}
return config;
},
);
}
/** The batch/lot number rules: one format string with placeholders. */
export function numberFormatParser(
trigger: string,
defaultFormat: string,
): RuleConfigParser<NumberFormatConfig> {
return makeParser<NumberFormatConfig>(
trigger,
{ format: defaultFormat },
(scope) => ({
format: stringIn(
scope,
'format',
defaultFormat,
1,
MAX_NUMBER_FORMAT_LENGTH,
),
}),
);
}
@@ -0,0 +1,106 @@
import {
asRecord,
bool,
fieldPath,
has,
intIn,
makeParser,
optionalIntIn,
report,
stringIn,
type Scope,
} from './parse-helpers';
import type { PlanStepConfig, SplitScheduleConfig } from './rule-config.types';
type SplitVariant = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';
const MAX_STEPS_PER_VARIANT = 10;
const MAX_OFFSET_DAYS = 120;
const MAX_WINDOW_DAYS = 30;
const STEP_NAME = /^[A-Z][A-Z_]{1,39}$/;
export const DEFAULT_SPLIT_SCHEDULE_CONFIG: SplitScheduleConfig = {
RAISE_OWN: [
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
{ step: 'EGGS_CHECK', offsetDays: 28 },
],
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }],
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }],
notify: false,
};
function clonedSteps(variant: SplitVariant): PlanStepConfig[] {
return DEFAULT_SPLIT_SCHEDULE_CONFIG[variant].map((step) => ({ ...step }));
}
/** Scope of one list entry: its path is `VARIANT[index]`. */
function stepScope(
scope: Scope,
variant: SplitVariant,
index: number,
raw: unknown,
): Scope | null {
const record = asRecord(raw);
const path = `${fieldPath(scope, variant)}[${index}]`;
if (record === null) {
scope.issues.push({
field: path,
message: 'Bitte jeden Schritt als Gruppe senden.',
});
return null;
}
return { raw: record, path, issues: scope.issues };
}
function readSteps(scope: Scope, variant: SplitVariant): PlanStepConfig[] {
if (!has(scope, variant)) {
return clonedSteps(variant);
}
const list = scope.raw[variant];
if (!Array.isArray(list) || list.length > MAX_STEPS_PER_VARIANT) {
report(
scope,
variant,
`Bitte eine Liste mit höchstens ${MAX_STEPS_PER_VARIANT} Schritten senden.`,
);
return clonedSteps(variant);
}
const before = scope.issues.length;
const steps: PlanStepConfig[] = [];
list.forEach((entry: unknown, index) => {
const item = stepScope(scope, variant, index, entry);
if (item === null) {
return;
}
if (!has(item, 'step')) {
scope.issues.push({
field: `${item.path}.step`,
message: 'Bitte den Schritt angeben.',
});
return;
}
const step: PlanStepConfig = {
step: stringIn(item, 'step', '', 2, 40, STEP_NAME),
offsetDays: intIn(item, 'offsetDays', 0, 0, MAX_OFFSET_DAYS),
};
const windowDays = optionalIntIn(item, 'windowDays', 0, MAX_WINDOW_DAYS);
if (windowDays !== undefined) {
step.windowDays = windowDays;
}
steps.push(step);
});
// One bad entry makes the whole list unreliable: back to the default plan.
return scope.issues.length > before ? clonedSteps(variant) : steps;
}
export const splitScheduleParser = makeParser<SplitScheduleConfig>(
'SPLIT_SCHEDULE',
DEFAULT_SPLIT_SCHEDULE_CONFIG,
(scope) => ({
RAISE_OWN: readSteps(scope, 'RAISE_OWN'),
QUEEN_CELL: readSteps(scope, 'QUEEN_CELL'),
QUEEN: readSteps(scope, 'QUEEN'),
notify: bool(scope, 'notify', DEFAULT_SPLIT_SCHEDULE_CONFIG.notify),
}),
);
@@ -0,0 +1,188 @@
import { ColonyType } from '../../../generated/prisma';
import {
bool,
childScope,
enumList,
has,
intIn,
makeParser,
months,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { VarroaThresholdConfig } from './rule-config.types';
const MAX_RECHECK_DAYS = 60;
export const DEFAULT_VARROA_THRESHOLD_CONFIG: VarroaThresholdConfig = {
naturalFall: {
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 10,
winterRedAbove: 0.5,
},
infestationPercent: {
summerMonths: [5, 6, 7],
summer: { yellowFrom: 1, redAbove: 5 },
autumnMonths: [8, 9, 10],
autumn: { yellowFrom: 2, redAbove: 5 },
},
youngColonyFactor: 0.5,
youngColonyTypes: ['YOUNG_COLONY', 'NUCLEUS_COLONY', 'MATING_NUCLEUS'],
recheckAfterDays: 14,
notify: true,
};
/** Upper limits: mites per day cannot get near 1000; a percentage stops at 100. */
const MAX_MITES_PER_DAY = 1000;
const MAX_PERCENT = 100;
/**
* The key before the bands existed: natural mite fall that counts as red.
* A present but unusable value is reported.
*/
function legacyThreshold(scope: Scope): number | undefined {
if (!has(scope, 'thresholdMitesPerDay')) {
return undefined;
}
const value = scope.raw.thresholdMitesPerDay;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
report(
scope,
'thresholdMitesPerDay',
'Bitte eine Zahl größer als 0 angeben.',
);
return undefined;
}
interface Band {
yellowFrom: number;
redAbove: number;
}
interface BandLimits {
max: number;
/** The red limit itself must be above zero. */
redAboveZero?: boolean;
}
/**
* yellowFrom and redAbove of one band. An absent yellowFrom never ends above
* redAbove; a given one that does is reported and replaced.
*/
function readBand(
scope: Scope,
defaults: Band,
limits: BandLimits,
legacyRed?: number,
): Band {
const redAbove = numberIn(
scope,
'redAbove',
legacyRed ?? defaults.redAbove,
0,
limits.max,
{ exclusiveMin: limits.redAboveZero },
);
const yellowDefault = Math.min(defaults.yellowFrom, redAbove);
const yellowFrom = numberIn(
scope,
'yellowFrom',
yellowDefault,
0,
limits.max,
);
if (yellowFrom > redAbove) {
report(
scope,
'yellowFrom',
'Der gelbe Wert darf nicht über dem roten Wert liegen.',
);
return { yellowFrom: yellowDefault, redAbove };
}
return { yellowFrom, redAbove };
}
const COLONY_TYPES = Object.values(ColonyType);
export const varroaThresholdParser = makeParser<VarroaThresholdConfig>(
'VARROA_THRESHOLD',
DEFAULT_VARROA_THRESHOLD_CONFIG,
(scope) => {
const d = DEFAULT_VARROA_THRESHOLD_CONFIG;
const legacy = legacyThreshold(scope);
const fall = childScope(scope, 'naturalFall');
const fallBand = readBand(
fall,
d.naturalFall,
{ max: MAX_MITES_PER_DAY, redAboveZero: true },
legacy,
);
const naturalFall = {
seasonMonths: months(fall, 'seasonMonths', d.naturalFall.seasonMonths),
...fallBand,
winterRedAbove: numberIn(
fall,
'winterRedAbove',
d.naturalFall.winterRedAbove,
0,
MAX_MITES_PER_DAY,
),
};
const percent = childScope(scope, 'infestationPercent');
const infestationPercent = {
summerMonths: months(
percent,
'summerMonths',
d.infestationPercent.summerMonths,
),
summer: readBand(
childScope(percent, 'summer'),
d.infestationPercent.summer,
{ max: MAX_PERCENT },
),
autumnMonths: months(
percent,
'autumnMonths',
d.infestationPercent.autumnMonths,
),
autumn: readBand(
childScope(percent, 'autumn'),
d.infestationPercent.autumn,
{ max: MAX_PERCENT },
),
};
return {
naturalFall,
infestationPercent,
youngColonyFactor: numberIn(
scope,
'youngColonyFactor',
d.youngColonyFactor,
0,
1,
{ exclusiveMin: true },
),
youngColonyTypes: enumList(
scope,
'youngColonyTypes',
d.youngColonyTypes,
COLONY_TYPES,
),
recheckAfterDays: intIn(
scope,
'recheckAfterDays',
d.recheckAfterDays,
0,
MAX_RECHECK_DAYS,
),
notify: bool(scope, 'notify', d.notify),
};
},
);
@@ -0,0 +1,112 @@
import {
childScope,
has,
makeParser,
numberIn,
optionalNumberIn,
report,
type Scope,
} from './parse-helpers';
import type { WaterContentConfig } from './rule-config.types';
const MIN_WATER_PERCENT = 10;
const MAX_WATER_PERCENT = 30;
const MAX_HONEY_TYPES = 50;
const MAX_HONEY_TYPE_LENGTH = 100;
export const DEFAULT_WATER_CONTENT_CONFIG: WaterContentConfig = {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: {},
};
function percent(scope: Scope, key: string, fallback: number): number {
return numberIn(scope, key, fallback, MIN_WATER_PERCENT, MAX_WATER_PERCENT);
}
const ORDER_MESSAGE =
'Die Warnschwelle darf nicht über der Bestätigungsschwelle liegen.';
function readHoneyTypes(scope: Scope): WaterContentConfig['byHoneyType'] {
const types = childScope(scope, 'byHoneyType');
const names = Object.keys(types.raw);
if (names.length > MAX_HONEY_TYPES) {
report(scope, 'byHoneyType', `Höchstens ${MAX_HONEY_TYPES} Sorten.`);
return {};
}
const entries: [
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number },
][] = [];
for (const name of names) {
if (name.length === 0 || name.length > MAX_HONEY_TYPE_LENGTH) {
report(types, name.slice(0, 30), 'Der Sortenname ist ungültig.');
continue;
}
const entry = childScope(types, name);
const before = entry.issues.length;
const value: { warnAbovePercent?: number; confirmAbovePercent?: number } =
{};
const warn = optionalNumberIn(
entry,
'warnAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
const confirm = optionalNumberIn(
entry,
'confirmAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
if (warn !== undefined && confirm !== undefined && warn > confirm) {
report(entry, 'warnAbovePercent', ORDER_MESSAGE);
}
if (entry.issues.length > before) {
continue;
}
if (warn !== undefined) {
value.warnAbovePercent = warn;
}
if (confirm !== undefined) {
value.confirmAbovePercent = confirm;
}
entries.push([name, value]);
}
// fromEntries defines own properties, so a name like "__proto__" is harmless.
return Object.fromEntries(entries);
}
export const waterContentParser = makeParser<WaterContentConfig>(
'HONEY_WATER_CONTENT',
DEFAULT_WATER_CONTENT_CONFIG,
(scope) => {
const d = DEFAULT_WATER_CONTENT_CONFIG;
let warnAbovePercent = percent(
scope,
'warnAbovePercent',
d.warnAbovePercent,
);
let confirmAbovePercent = percent(
scope,
'confirmAbovePercent',
d.confirmAbovePercent,
);
if (warnAbovePercent > confirmAbovePercent) {
report(
scope,
has(scope, 'warnAbovePercent')
? 'warnAbovePercent'
: 'confirmAbovePercent',
ORDER_MESSAGE,
);
warnAbovePercent = d.warnAbovePercent;
confirmAbovePercent = d.confirmAbovePercent;
}
return {
warnAbovePercent,
confirmAbovePercent,
byHoneyType: readHoneyTypes(scope),
};
},
);
@@ -0,0 +1,271 @@
import type { ColonyType, VarroaMethod } from '../../generated/prisma';
import { bandsFor, classify, rateMeasurement } from './varroa-bands';
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
const DEFAULTS = DEFAULT_VARROA_THRESHOLD_CONFIG;
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('varroa bands', () => {
it.each<
[
string,
ColonyType,
VarroaMethod,
number,
'green' | 'yellow' | 'red' | null,
]
>([
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'],
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'],
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 4.9, 'green'],
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 5, 'yellow'],
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'],
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.5, 'green'],
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'],
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 2.5, 'yellow'],
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.3, 'red'],
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.25, 'green'],
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'],
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 1, 'yellow'],
['2026-07-10', 'YOUNG_COLONY', 'POWDERED_SUGAR', 4, 'yellow'],
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 1.5, 'green'],
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 2, 'yellow'],
['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null],
['2026-11-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 9, null],
['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null],
])('%s %s %s %p -> %p', (day, type, method, value, light) =>
expect(
classify(value, bandsFor(day, type, DEFAULTS, []).bands[method]),
).toBe(light),
);
it('classifies a missing value or band as no light', () => {
const band = bandsFor('2026-08-10', null, DEFAULTS, []).bands
.NATURAL_MITE_FALL;
expect(classify(null, band)).toBeNull();
expect(classify(3, undefined)).toBeNull();
expect(classify(3, null)).toBeNull();
});
it('describes the band with its reason, unit and colony kind', () => {
const autumn = bandsFor('2026-08-10', 'YOUNG_COLONY', DEFAULTS, []);
expect(autumn.day).toBe('2026-08-10');
expect(autumn.bands.NATURAL_MITE_FALL).toEqual({
method: 'NATURAL_MITE_FALL',
unit: 'MITES_PER_DAY',
yellowFrom: 2.5,
redAbove: 5,
reason: 'NATURAL_FALL_SEASON',
youngColony: true,
});
expect(autumn.bands.POWDERED_SUGAR).toMatchObject({
unit: 'PERCENT',
yellowFrom: 2,
redAbove: 5,
reason: 'INFESTATION_AUTUMN',
});
const winter = bandsFor('2026-01-10', 'PRODUCTION_COLONY', DEFAULTS, []);
expect(winter.bands.NATURAL_MITE_FALL).toMatchObject({
yellowFrom: null,
redAbove: 0.5,
reason: 'NATURAL_FALL_WINTER',
youngColony: false,
});
expect(winter.bands.POWDERED_SUGAR).toBeUndefined();
});
it('treats a colony without a type as a full colony', () =>
expect(
bandsFor('2026-08-10', null, DEFAULTS, []).bands.NATURAL_MITE_FALL,
).toMatchObject({ redAbove: 10, youngColony: false }));
it('names the recheck day while a treatment effect is in force', () => {
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
const until = (day: string) =>
bandsFor(day, null, DEFAULTS, treatments).postTreatmentUntil;
expect(until('2026-07-25')).toBe('2026-08-16');
expect(until('2026-08-15')).toBe('2026-08-16');
expect(until('2026-08-16')).toBeNull();
expect(until('2026-07-19')).toBeNull();
});
it('uses the latest recheck day of overlapping treatments', () =>
expect(
bandsFor('2026-08-01', null, DEFAULTS, [
{ startDay: '2026-07-01', endDay: '2026-07-20' },
{ startDay: '2026-07-25', endDay: '2026-08-10' },
]).postTreatmentUntil,
).toBe('2026-08-24'));
describe('month edges', () => {
const lightOn = (
day: string,
method: VarroaMethod,
value: number,
): string | null =>
classify(
value,
bandsFor(day, 'PRODUCTION_COLONY', DEFAULTS, []).bands[method],
);
it.each<[string, VarroaMethod, number, string | null]>([
['2026-02-28', 'NATURAL_MITE_FALL', 3, 'red'],
['2026-03-01', 'NATURAL_MITE_FALL', 3, 'green'],
['2026-10-31', 'NATURAL_MITE_FALL', 3, 'green'],
['2026-11-01', 'NATURAL_MITE_FALL', 3, 'red'],
['2026-04-30', 'POWDERED_SUGAR', 3, null],
['2026-05-01', 'POWDERED_SUGAR', 3, 'yellow'],
['2026-07-31', 'POWDERED_SUGAR', 1.5, 'yellow'],
['2026-08-01', 'POWDERED_SUGAR', 1.5, 'green'],
['2026-10-31', 'POWDERED_SUGAR', 3, 'yellow'],
['2026-11-01', 'POWDERED_SUGAR', 3, null],
['2026-04-30', 'WASHING_METHOD', 3, null],
['2026-05-01', 'WASHING_METHOD', 3, 'yellow'],
['2026-07-31', 'WASHING_METHOD', 1.5, 'yellow'],
['2026-08-01', 'WASHING_METHOD', 1.5, 'green'],
['2026-10-31', 'WASHING_METHOD', 3, 'yellow'],
['2026-11-01', 'WASHING_METHOD', 3, null],
])('%s %s %p -> %p', (day, method, value, light) =>
expect(lightOn(day, method, value)).toBe(light),
);
});
it('keeps the young factor off the percent bands', () => {
const { bands } = bandsFor('2026-07-10', 'YOUNG_COLONY', DEFAULTS, []);
expect(bands.POWDERED_SUGAR).toMatchObject({
youngColony: false,
yellowFrom: 1,
redAbove: 5,
});
expect(bands.NATURAL_MITE_FALL?.youngColony).toBe(true);
});
describe('treatment effect edges', () => {
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
const ratingOn = (day: string, method: VarroaMethod) =>
rateMeasurement(
{ date: d(day), method, calculatedValue: 12 },
null,
DEFAULTS,
treatments,
);
it('starts on the start day and ends the day before the recheck day', () => {
expect(ratingOn('2026-07-19', 'NATURAL_MITE_FALL').light).toBe('red');
expect(ratingOn('2026-07-20', 'NATURAL_MITE_FALL').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-08-15', 'NATURAL_MITE_FALL').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-08-16', 'NATURAL_MITE_FALL').light).toBe('red');
});
it('covers a single-day treatment', () => {
const single = [{ startDay: '2026-08-01', endDay: '2026-08-01' }];
const until = (day: string) =>
bandsFor(day, null, DEFAULTS, single).postTreatmentUntil;
expect(until('2026-08-01')).toBe('2026-08-15');
expect(until('2026-08-14')).toBe('2026-08-15');
expect(until('2026-08-15')).toBeNull();
});
it('applies to the percent methods and never to the drone brood check', () => {
expect(ratingOn('2026-07-25', 'POWDERED_SUGAR').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-08-05', 'WASHING_METHOD').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-07-25', 'DRONE_BROOD_CONTROL').light).toBeNull();
});
});
it('rates by the Berlin calendar day of the measurement', () => {
// 2026-10-31T23:30Z is 2026-11-01 00:30 in Berlin: winter band.
const rating = rateMeasurement(
{
date: new Date('2026-11-01T00:30:00+01:00'),
method: 'NATURAL_MITE_FALL',
calculatedValue: 3,
},
null,
DEFAULTS,
[],
);
expect(rating.reasonCode).toBe('NATURAL_FALL_WINTER');
expect(rating.light).toBe('red');
});
describe('rateMeasurement', () => {
it('a measurement 5 days after treatment end has no light and names the recheck day', () =>
expect(
rateMeasurement(
{
date: d('2026-08-07'),
method: 'NATURAL_MITE_FALL',
calculatedValue: 12,
},
'PRODUCTION_COLONY',
DEFAULTS,
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
),
).toEqual({
light: 'POST_TREATMENT',
reasonCode: 'POST_TREATMENT',
band: null,
postTreatmentUntil: '2026-08-16',
}));
it('rates outside a treatment effect with the band and reason', () => {
const rating = rateMeasurement(
{
date: d('2026-08-20'),
method: 'NATURAL_MITE_FALL',
calculatedValue: 12,
},
'PRODUCTION_COLONY',
DEFAULTS,
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
);
expect(rating).toMatchObject({
light: 'red',
reasonCode: 'NATURAL_FALL_SEASON',
postTreatmentUntil: null,
});
expect(rating.band).toMatchObject({ redAbove: 10 });
});
it('has no light for a method without a band or a missing value', () => {
expect(
rateMeasurement(
{
date: d('2026-04-10'),
method: 'POWDERED_SUGAR',
calculatedValue: 9,
},
null,
DEFAULTS,
[],
),
).toEqual({
light: null,
reasonCode: null,
band: null,
postTreatmentUntil: null,
});
expect(
rateMeasurement(
{
date: d('2026-08-10'),
method: 'NATURAL_MITE_FALL',
calculatedValue: null,
},
null,
DEFAULTS,
[],
).light,
).toBeNull();
});
});
});
@@ -0,0 +1,198 @@
import type {
ColonyType,
VarroaMeasurement,
VarroaMethod,
} from '../../generated/prisma';
import { addDays, berlinDayOf } from '../common/dates';
import type { TreatmentWindowLite } from '../treatments/withdrawal-window';
import type { VarroaThresholdConfig } from './rule-config/rule-config.types';
export type VarroaLightKey = 'green' | 'yellow' | 'red';
export type VarroaBandReason =
| 'NATURAL_FALL_SEASON'
| 'NATURAL_FALL_WINTER'
| 'INFESTATION_SUMMER'
| 'INFESTATION_AUTUMN';
export interface VarroaBand {
method: VarroaMethod;
unit: 'MITES_PER_DAY' | 'PERCENT';
/** null: no yellow stage (winter). */
yellowFrom: number | null;
redAbove: number;
reason: VarroaBandReason;
youngColony: boolean;
}
export interface VarroaBands {
day: string;
bands: Partial<Record<VarroaMethod, VarroaBand>>;
/** Recheck day while a treatment effect is in force, otherwise null. */
postTreatmentUntil: string | null;
}
export interface VarroaRating {
light: VarroaLightKey | 'POST_TREATMENT' | null;
reasonCode: VarroaBandReason | 'POST_TREATMENT' | null;
band: VarroaBand | null;
postTreatmentUntil: string | null;
}
const PERCENT_METHODS: readonly VarroaMethod[] = [
'POWDERED_SUGAR',
'WASHING_METHOD',
];
function naturalFallBand(
month: number,
factor: number,
young: boolean,
config: VarroaThresholdConfig['naturalFall'],
): VarroaBand {
const inSeason = config.seasonMonths.includes(month);
return {
method: 'NATURAL_MITE_FALL',
unit: 'MITES_PER_DAY',
yellowFrom: inSeason ? config.yellowFrom * factor : null,
redAbove: (inSeason ? config.redAbove : config.winterRedAbove) * factor,
reason: inSeason ? 'NATURAL_FALL_SEASON' : 'NATURAL_FALL_WINTER',
youngColony: young,
};
}
/** The percentage band of the month, absent outside summer and autumn. */
function percentBand(
method: VarroaMethod,
month: number,
config: VarroaThresholdConfig['infestationPercent'],
): VarroaBand | undefined {
if (config.summerMonths.includes(month)) {
return {
method,
unit: 'PERCENT',
...config.summer,
reason: 'INFESTATION_SUMMER',
youngColony: false,
};
}
if (config.autumnMonths.includes(month)) {
return {
method,
unit: 'PERCENT',
...config.autumn,
reason: 'INFESTATION_AUTUMN',
youngColony: false,
};
}
return undefined;
}
/** Latest recheck day of the treatments whose effect covers `day`. */
function postTreatmentUntil(
day: string,
recheckAfterDays: number,
treatments: readonly TreatmentWindowLite[],
): string | null {
let until: string | null = null;
for (const { startDay, endDay } of treatments) {
const recheckDay = addDays(endDay, recheckAfterDays);
const covers = startDay <= day && day < recheckDay;
if (covers && (until === null || recheckDay > until)) {
until = recheckDay;
}
}
return until;
}
/**
* The bands in force on `day` for a colony of `colonyType`: natural mite fall
* by season (young colonies at the reduced factor), the percentage methods in
* summer and autumn only, no band for the drone brood check.
*/
export function bandsFor(
day: string,
colonyType: ColonyType | null,
config: VarroaThresholdConfig,
treatments: readonly TreatmentWindowLite[],
): VarroaBands {
const month = Number(day.slice(5, 7));
const young =
colonyType !== null && config.youngColonyTypes.includes(colonyType);
const factor = young ? config.youngColonyFactor : 1;
const bands: VarroaBands['bands'] = {
NATURAL_MITE_FALL: naturalFallBand(
month,
factor,
young,
config.naturalFall,
),
};
for (const method of PERCENT_METHODS) {
const band = percentBand(method, month, config.infestationPercent);
if (band) {
bands[method] = band;
}
}
return {
day,
bands,
postTreatmentUntil: postTreatmentUntil(
day,
config.recheckAfterDays,
treatments,
),
};
}
/** Red above redAbove, yellow from yellowFrom, otherwise green. */
export function classify(
value: number | null,
band: VarroaBand | null | undefined,
): VarroaLightKey | null {
if (value === null || !band) {
return null;
}
if (value > band.redAbove) {
return 'red';
}
if (band.yellowFrom !== null && value >= band.yellowFrom) {
return 'yellow';
}
return 'green';
}
/**
* The light of one measurement. A measurement taken from the start of a
* treatment until the recheck day has no light (treatment effect), except the
* drone brood check, which never has one.
*/
export function rateMeasurement(
m: Pick<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>,
colonyType: ColonyType | null,
config: VarroaThresholdConfig,
treatments: readonly TreatmentWindowLite[],
): VarroaRating {
const day = berlinDayOf(m.date);
const { bands, postTreatmentUntil: recheckDay } = bandsFor(
day,
colonyType,
config,
treatments,
);
const band = bands[m.method] ?? null;
if (recheckDay !== null && m.method !== 'DRONE_BROOD_CONTROL') {
return {
light: 'POST_TREATMENT',
reasonCode: 'POST_TREATMENT',
band: null,
postTreatmentUntil: recheckDay,
};
}
return {
light: classify(m.calculatedValue, band),
reasonCode: band?.reason ?? null,
band,
postTreatmentUntil: null,
};
}
@@ -1,41 +0,0 @@
import {
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
varroaThresholdOf,
} from './varroa-threshold';
describe('varroaThresholdOf', () => {
it('uses the default when no rule exists yet (default rule is active)', () => {
expect(varroaThresholdOf(null)).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
});
it('returns null for an inactive rule', () => {
expect(
varroaThresholdOf({
active: false,
config: { thresholdMitesPerDay: 7 },
}),
).toBeNull();
});
it('returns the configured threshold of an active rule', () => {
expect(
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
).toBe(7);
});
it.each([
['a string', { thresholdMitesPerDay: 'x' }],
['zero', { thresholdMitesPerDay: 0 }],
['a negative number', { thresholdMitesPerDay: -1 }],
['NaN', { thresholdMitesPerDay: Number.NaN }],
['a missing key', {}],
['a null config', null],
])('falls back to the default for %s', (_label, config) => {
expect(varroaThresholdOf({ active: true, config })).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
});
});
@@ -1,30 +0,0 @@
import type { AutomationRule } from '../../generated/prisma';
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule.
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
* - inactive rule: null (no threshold in force)
* - active rule: the configured value, or the default if it is not a
* positive finite number (a hand-edited config must never disable the check).
*/
export function varroaThresholdOf(
rule: Pick<AutomationRule, 'active' | 'config'> | null,
): number | null {
if (rule === null) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
if (!rule.active) {
return null;
}
const config = rule.config;
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
@@ -0,0 +1,212 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { lateSummerStart, loadAutopilotInputIn } from './autopilot-input';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const season = calendarSeasonState(
'api-1',
'2026-08-20',
ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
);
function tx(treatments: object[], varroaCount = 0) {
return {
colony: {
findUnique: jest.fn().mockResolvedValue({
id: 'c1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdAt: d('2026-04-01'),
feedStatus: null,
currentQueen: null,
}),
},
inspection: { findFirst: jest.fn().mockResolvedValue(null) },
varroaMeasurement: {
findFirst: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(varroaCount),
},
treatment: {
findMany: jest.fn().mockResolvedValue(treatments),
count: jest.fn().mockResolvedValue(0),
},
feeding: { count: jest.fn().mockResolvedValue(0) },
winterCheck: { findMany: jest.fn().mockResolvedValue([]) },
};
}
describe('loadAutopilotInputIn', () => {
it('takes the treatment with the latest end day, not the latest date', async () => {
const db = tx([
{
id: 'old',
date: d('2026-07-01'),
startDate: null,
endDate: d('2026-08-10'),
},
{
id: 'backdated',
date: d('2026-07-20'),
startDate: null,
endDate: null,
},
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
]);
const input = await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(input?.lastTreatment).toEqual({
id: 'old',
startDay: '2026-07-01',
endDay: '2026-08-10',
});
});
it('counts only measurements after the end day', async () => {
const db = tx([
{
id: 't',
date: d('2026-08-01'),
startDate: null,
endDate: d('2026-08-10'),
},
]);
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') } },
});
});
it('has no last treatment without treatments', async () => {
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();
});
});
@@ -0,0 +1,161 @@
import type { Prisma } from '../../generated/prisma';
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types';
import type { AutopilotInput } from './autopilot.types';
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
/** 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;
}
/**
* What the autopilot rules need of one colony, in a fixed sequence of
* queries (no query per rule, none per record). Resolves to null for a
* colony that does not exist. The caller holds the colony row lock, so the
* picture stays consistent while the rules run.
*/
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 },
select: {
id: true,
code: true,
status: true,
colonyType: true,
apiaryId: true,
createdAt: true,
feedStatus: true,
currentQueen: { select: { id: true, hatchYear: true } },
},
});
if (colony === null) {
return null;
}
const inspection = await tx.inspection.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: {
id: true,
date: true,
colonyStrength: true,
feedStatus: true,
queenCellsPresent: true,
},
});
const varroa = await tx.varroaMeasurement.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: { id: true, date: true },
});
// The latest END day wins, not the latest entry: a backdated or
// overlapping treatment must not hide one that ends later.
const treatments = await tx.treatment.findMany({
where: { colonyId },
orderBy: [{ date: 'desc' }, { id: 'asc' }],
select: { id: true, date: true, startDate: true, endDate: true },
});
const lastTreatment = treatments
.map((treatment) => ({
id: treatment.id,
startDay: berlinDayOf(treatment.startDate ?? treatment.date),
endDay: berlinDayOf(treatment.endDate ?? treatment.date),
}))
.reduce<{ id: string; startDay: string; endDay: string } | null>(
(latest, candidate) =>
latest === null || candidate.endDay > latest.endDay
? candidate
: latest,
null,
);
const varroaSinceLastTreatmentEnd =
lastTreatment !== null &&
(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, date: { gte: feedingsSince } },
});
const lateSummer = lateSummerStart(season, calendarTrachtende);
const sinceLateSummer =
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
const feedingsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.feeding.count({ where: { colonyId, date: sinceLateSummer } });
const treatmentsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.treatment.count({
where: { colonyId, date: sinceLateSummer },
});
const winterChecks = await tx.winterCheck.findMany({
where: { colonyId, winterYear: { gte: season.year - 1 } },
select: { winterYear: true, season: true },
orderBy: [{ winterYear: 'asc' }, { season: 'asc' }],
});
return {
colony: {
id: colony.id,
code: colony.code,
status: colony.status,
colonyType: colony.colonyType,
apiaryId: colony.apiaryId,
createdOn: berlinDayOf(colony.createdAt),
feedStatus: colony.feedStatus,
},
lastInspection:
inspection === null
? null
: { ...inspection, date: berlinDayOf(inspection.date) },
lastVarroa:
varroa === null
? null
: { id: varroa.id, date: berlinDayOf(varroa.date) },
lastTreatment,
varroaSinceLastTreatmentEnd,
feedingsSinceInspection,
feedingsSinceLateSummer,
treatmentsSinceLateSummer,
queen: colony.currentQueen,
winterChecks,
season,
};
}
@@ -0,0 +1,824 @@
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';
type RuleName = 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL' | 'VARROA_THRESHOLD';
function activeRule(trigger: RuleName, override?: object) {
return {
id: null,
active: true,
config: ruleConfigOf(trigger, {
...DEFAULT_RULES[trigger].config,
...override,
}),
actionTemplate: null,
};
}
function configs(autopilotOverride?: object): AutopilotConfigs {
return {
autopilot: activeRule('SEASON_AUTOPILOT', autopilotOverride),
interval: activeRule('INSPECTION_INTERVAL'),
varroa: activeRule('VARROA_THRESHOLD'),
phenology: ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
} as AutopilotConfigs;
}
function input(
today: string,
patch: Partial<AutopilotInput> = {},
): AutopilotInput {
return {
colony: {
id: 'col_1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api_1',
createdOn: '2025-04-01',
feedStatus: null,
},
lastInspection: null,
lastVarroa: { id: 'var_1', date: today },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api_1', today, configs().phenology),
...patch,
};
}
const inspectionOn = (date: string) => ({
id: 'insp_9',
date,
colonyStrength: null,
feedStatus: null,
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', () => {
it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', () => {
const today = '2026-05-12';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-04') }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-11',
priority: 'HIGH',
anchor: 'insp_9',
requiredPermission: 'inspections:read',
});
expect(result[0].evidence).toMatchObject({
colonyId: 'col_1',
inspectionId: 'insp_9',
lastInspectedOn: '2026-05-04',
intervalDays: 7,
segment: 'SCHWARMZEIT',
});
});
it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', () => {
const today = '2026-05-12';
const inTwo = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-07') }),
configs(),
today,
);
expect(inTwo[0]).toMatchObject({
dueDate: '2026-05-14',
priority: 'NORMAL',
});
const inThree = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-08') }),
configs(),
today,
);
expect(inThree).toEqual([]);
const winter = '2026-12-05';
expect(
evaluateAutopilot(
input(winter, {
lastInspection: inspectionOn('2026-09-01'),
lastVarroa: { id: 'var_1', date: '2026-12-01' },
}),
configs(),
winter,
),
).toEqual([]);
});
it('A1: due today is not overdue; no inspection ever uses the anchor none', () => {
const today = '2026-05-12';
const dueToday = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-05') }),
configs(),
today,
);
expect(dueToday[0].priority).toBe('NORMAL');
const base = input(today);
const never = evaluateAutopilot(
input(today, { colony: { ...base.colony, createdOn: '2026-05-01' } }),
configs(),
today,
);
expect(never[0]).toMatchObject({ anchor: 'none', dueDate: '2026-05-08' });
expect(never[0].evidence).toMatchObject({
inspectionId: null,
lastInspectedOn: null,
});
});
it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', () => {
const today = '2026-04-01';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2025-09-20') }),
configs(),
today,
);
expect(result[0]).toMatchObject({
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
dueDate: '2026-03-20',
priority: 'HIGH',
anchor: 'insp_9',
});
});
it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-06' },
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode: 'VARROA_MEASURE_DUE',
dueDate: '2026-06-03',
priority: 'NORMAL',
anchor: 'var_7',
requiredPermission: 'varroa:read',
});
});
it('A2: a measurement 27 days ago yields nothing', () => {
const today = '2026-06-10';
expect(
evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-14' },
}),
configs(),
today,
),
).toEqual([]);
});
it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', () => {
const today = '2026-11-20';
const result = evaluateAutopilot(
input(today, { lastVarroa: { id: 'var_2', date: '2026-09-01' } }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
intent: 'VARROA_MEASURE',
reasonCode: 'VARROA_WINTER_MEASURE',
dueDate: '2026-11-15',
anchor: 'winter-2026',
});
});
it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', () => {
const today = '2026-08-16';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-10'),
lastVarroa: { id: 'var_3', date: '2026-08-01' },
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A3',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
dueDate: '2026-08-16',
priority: 'NORMAL',
anchor: 'tre_1',
requiredPermission: 'varroa:read',
});
expect(result[0].evidence).toMatchObject({
treatmentId: 'tre_1',
endedOn: '2026-08-02',
});
});
it('A3: nothing 13 days after the end, nothing once a measurement exists since', () => {
const early = '2026-08-15';
expect(
evaluateAutopilot(
input(early, {
lastInspection: inspectionOn('2026-08-10'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
}),
configs(),
early,
),
).toEqual([]);
const late = '2026-08-20';
expect(
evaluateAutopilot(
input(late, {
lastInspection: inspectionOn('2026-08-15'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
varroaSinceLastTreatmentEnd: true,
}),
configs(),
late,
),
).toEqual([]);
});
it('A3: honours recheckAfterDays of the varroa rule', () => {
const today = '2026-08-09';
const cfg = configs();
cfg.varroa = activeRule('VARROA_THRESHOLD', {
recheckAfterDays: 7,
}) as AutopilotConfigs['varroa'];
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-05'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}),
cfg,
today,
);
expect(result.map((c) => c.rule)).toEqual(['A3']);
expect(result[0].dueDate).toBe('2026-08-09');
});
it('returns the candidates in the order A1, A2', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2']);
});
it('A2 is not proposed while A3 applies: the recheck is the measurement', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
lastTreatment: {
id: 'tre_1',
startDay: '2026-05-01',
endDay: '2026-05-10',
},
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A3']);
});
it('A3 only looks at treatments that ended at most 60 days ago', () => {
const today = '2026-08-16';
const base = { lastInspection: inspectionOn('2026-08-10') };
const at = (endDay: string) =>
evaluateAutopilot(
input(today, {
...base,
lastTreatment: { id: 'tre_1', startDay: '2026-05-01', endDay },
}),
configs(),
today,
).filter((c) => c.rule === 'A3');
expect(at('2026-06-17')).toHaveLength(1); // 60 days ago
expect(at('2026-06-16')).toHaveLength(0); // 61 days ago
});
it('a rule switched off in config.rules yields nothing', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
expect(
evaluateAutopilot(base, configs(), today).map((c) => c.rule),
).toEqual(['A1']);
const a1Off = configs({
rules: { ...configs().autopilot.config.rules, A1: false },
});
expect(evaluateAutopilot(base, a1Off, today)).toEqual([]);
});
it('SEASON_AUTOPILOT switched off yields nothing; INSPECTION_INTERVAL off only silences A1', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
const off = configs();
off.autopilot = { ...off.autopilot, active: false };
expect(evaluateAutopilot(base, off, today)).toEqual([]);
const noInterval = configs();
noInterval.interval = { ...noInterval.interval, active: false };
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']);
});
});
@@ -0,0 +1,413 @@
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,
AutopilotConfigs,
AutopilotInput,
AutopilotRule,
} from './autopilot.types';
/**
* A3 only follows treatments that ended at most this many days ago, so old
* history without a later measurement does not produce suggestions.
*/
const RECHECK_MAX_AGE_DAYS = 60;
/** A1: an inspection is due (or nearly) and the colony is not in winter rest. */
const INSPECTION_LEAD_DAYS = 2;
const a1Inspection: AutopilotRule = (input, configs, today) => {
if (!configs.interval.active) {
return null;
}
const due = inspectionDueOn(
{
createdOn: input.colony.createdOn,
lastInspectedOn: input.lastInspection?.date ?? null,
},
input.season,
configs.interval.config,
);
if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) {
return null;
}
return {
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE',
evidence: {
colonyId: input.colony.id,
inspectionId: input.lastInspection?.id ?? null,
lastInspectedOn: input.lastInspection?.date ?? null,
dueOn: due.dueOn,
intervalDays: due.intervalDays,
segment: input.season.segment,
},
dueDate: due.dueOn,
priority: due.dueOn < today ? 'HIGH' : 'NORMAL',
anchor: input.lastInspection?.id ?? 'none',
requiredPermission: 'inspections:read',
};
};
/** A2: the varroa measurement interval ran out, or the winter window is open. */
const a2VarroaMeasure: AutopilotRule = (input, configs, today) => {
const due = varroaMeasurementDue(
{
createdOn: input.colony.createdOn,
lastMeasuredOn: input.lastVarroa?.date ?? null,
lastMeasurementId: input.lastVarroa?.id ?? null,
},
input.season,
configs.autopilot.config,
today,
);
if (due === null || due.dueOn > today) {
return null;
}
return {
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode:
due.kind === 'WINTER_WINDOW'
? 'VARROA_WINTER_MEASURE'
: 'VARROA_MEASURE_DUE',
evidence: {
colonyId: input.colony.id,
measurementId: input.lastVarroa?.id ?? null,
lastMeasuredOn: input.lastVarroa?.date ?? null,
dueOn: due.dueOn,
intervalDays: configs.autopilot.config.varroaMeasureIntervalDays,
},
dueDate: due.dueOn,
priority: 'NORMAL',
anchor: due.anchor,
requiredPermission: 'varroa:read',
};
};
/** A3: a treatment ended long enough ago and nothing was measured since. */
const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
const treatment = input.lastTreatment;
if (
treatment === null ||
input.varroaSinceLastTreatmentEnd ||
daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS
) {
return null;
}
const recheckAfterDays = configs.varroa.config.recheckAfterDays;
const dueOn = addDays(treatment.endDay, recheckAfterDays);
if (dueOn > today) {
return null;
}
return {
rule: 'A3',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
evidence: {
colonyId: input.colony.id,
treatmentId: treatment.id,
endedOn: treatment.endDay,
recheckOn: dueOn,
},
dueDate: dueOn,
priority: 'NORMAL',
anchor: treatment.id,
// The action is a varroa measurement.
requiredPermission: 'varroa:read',
};
};
/** 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[] = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
];
/**
* The candidates for one colony, always in the order A1...A10 so concurrent
* syncs take the proposal locks in the same order. A switched-off autopilot
* or rule yields nothing.
*/
export function evaluateAutopilot(
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
): AutopilotCandidate[] {
if (!configs.autopilot.active) {
return [];
}
const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) {
if (!configs.autopilot.config.rules[id]) {
continue;
}
const candidate = AUTOPILOT_RULES[id](input, configs, today);
if (candidate !== null) {
candidates.push(candidate);
}
}
// The recheck after a treatment is the measurement: A2 would ask twice.
return candidates.some((candidate) => candidate.rule === 'A3')
? candidates.filter((candidate) => candidate.rule !== 'A2')
: candidates;
}
@@ -0,0 +1,26 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, 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 { AutopilotTodayDto } from './dto/next-steps-response.dto';
import { NextStepsService, type AutopilotToday } from './next-steps.service';
@ApiTags('autopilot')
@ApiBearerAuth()
@Controller('autopilot')
export class AutopilotTodayController {
constructor(private readonly nextSteps: NextStepsService) {}
/**
* "Today": the most urgent step over all colonies, the due inspections
* per apiary, the number of open suggestions and the season per apiary.
* Tasks and suggestions only with `tasks:read`.
*/
@ApiOkResponse({ type: AutopilotTodayDto })
@RequirePermission('colonies:read')
@Get('today')
today(@CurrentUser() actor: AuthenticatedUser): Promise<AutopilotToday> {
return this.nextSteps.today(actor.id);
}
}
@@ -0,0 +1,261 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { 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',
'VarroaMeasured',
'VarroaCorrected',
'TreatmentRecorded',
'TreatmentCorrected',
'FeedingRecorded',
'FeedingCorrected',
'HarvestRecorded',
'HarvestCorrected',
'ColonyStatusChanged',
'ColonySplit',
'QueenAssigned',
'TaskCompleted',
'TaskUpdated',
'WithdrawalEnded',
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
'WinterCheckRecorded',
];
function event(
type: DomainEventType,
aggregateId: string,
payload: object,
): AnyDomainEventEnvelope {
return {
id: 'evt-1',
type,
aggregateType: 'Colony',
aggregateId,
payload,
actorUserId: null,
correlationId: 'cor-1',
occurredAt: new Date(),
attempts: 0,
} as AnyDomainEventEnvelope;
}
describe('AutopilotEventHandlers', () => {
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({}),
syncApiaryIn: jest.fn().mockResolvedValue({}),
};
tx = { task: { findUnique: jest.fn() } };
handlers = new AutopilotEventHandlers(
autopilot as unknown as AutopilotService,
);
});
afterEach(() => {
jest.useRealTimers();
});
const run = (e: AnyDomainEventEnvelope) =>
handlers.colonySync(
e as Parameters<AutopilotEventHandlers['colonySync']>[0],
tx as unknown as Prisma.TransactionClient,
);
const synced = () =>
autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]);
it('is registered as autopilot.colony-sync for the listed events', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
AutopilotEventHandlers.prototype.colonySync,
);
expect(entries).toHaveLength(1);
expect(entries[0].handlerId).toBe('autopilot.colony-sync');
expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort());
});
it.each([
['InspectionRecorded', { colonyId: 'c1' }],
['InspectionCorrected', { colonyId: 'c1' }],
['VarroaMeasured', { colonyId: 'c1' }],
['VarroaCorrected', { colonyId: 'c1' }],
['TreatmentRecorded', { colonyId: 'c1' }],
['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][])(
'%s syncs the colony of its payload for the Berlin day',
async (type, payload) => {
await run(event(type, 'aggregate-1', payload));
expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1);
expect(autopilot.syncColonyIn).toHaveBeenCalledWith(
tx,
'c1',
'2026-05-12',
);
},
);
it.each([
'ColonyStatusChanged',
'InspectionOverdue',
'VarroaMeasurementDue',
] as const)('%s syncs the aggregate colony', async (type) => {
await run(event(type, 'c9', {}));
expect(synced()).toEqual(['c9']);
});
it('syncs every colony of a harvest, in id order', async () => {
await run(
event('HarvestRecorded', 'h1', {
apiaryId: 'a',
colonyIds: ['c3', 'c1', 'c2', 'c1'],
}),
);
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' }));
expect(synced()).toEqual(['mother', 'new']);
});
it('syncs the colony and the previous colony of a queen assignment', async () => {
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }),
);
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }),
);
expect(synced()).toEqual(['c1', 'c2', 'c2']);
});
it('syncs a colony task on completion and update, nothing for other tasks', async () => {
await run(
event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }),
);
await run(
event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }),
);
tx.task.findUnique
.mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' })
.mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' })
.mockResolvedValueOnce(null);
await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null }));
expect(synced()).toEqual(['c1', 'c4']);
});
it('does nothing for a queen event without a colony', async () => {
await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 }));
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
});
it('lets errors propagate so the delivery is retried', async () => {
autopilot.syncColonyIn.mockRejectedValue(new Error('db down'));
await expect(
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');
});
});
});
@@ -0,0 +1,139 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { berlinToday } from '../common/dates';
import type {
AnyDomainEventEnvelope,
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 = [
'InspectionRecorded',
'InspectionCorrected',
'VarroaMeasured',
'VarroaCorrected',
'TreatmentRecorded',
'TreatmentCorrected',
'FeedingRecorded',
'FeedingCorrected',
'HarvestRecorded',
'HarvestCorrected',
'ColonyStatusChanged',
'ColonySplit',
'QueenAssigned',
'TaskCompleted',
'TaskUpdated',
'WithdrawalEnded',
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
'WinterCheckRecorded',
] as const satisfies readonly DomainEventType[];
type ColonySyncEvent = AnyDomainEventEnvelope<
(typeof COLONY_SYNC_EVENTS)[number]
>;
/** The colony of a task, if it is about one. */
async function taskColonyIdIn(
tx: Prisma.TransactionClient,
taskId: string,
): Promise<string | null> {
const task = await tx.task.findUnique({
where: { id: taskId },
select: { relatedType: true, relatedId: true },
});
return task?.relatedType === 'COLONY' ? task.relatedId : null;
}
/** The colonies an event is about; none if it is about no colony. */
async function colonyIdsOf(
event: ColonySyncEvent,
tx: Prisma.TransactionClient,
): Promise<(string | null)[]> {
switch (event.type) {
case 'InspectionRecorded':
case 'InspectionCorrected':
case 'VarroaMeasured':
case 'VarroaCorrected':
case 'TreatmentRecorded':
case 'TreatmentCorrected':
case 'FeedingRecorded':
case 'FeedingCorrected':
case 'WithdrawalEnded':
case 'WinterCheckRecorded':
case 'QueenAged':
return [event.payload.colonyId];
case 'HarvestRecorded':
case 'HarvestCorrected':
return event.payload.colonyIds;
case 'ColonyStatusChanged':
case 'InspectionOverdue':
case 'VarroaMeasurementDue':
return [event.aggregateId];
case 'ColonySplit':
// The aggregate is the new colony.
return [event.aggregateId, event.payload.parentColonyId];
case 'QueenAssigned':
return [event.payload.colonyId, event.payload.previousColonyId];
case 'TaskCompleted':
return [
event.payload.relatedType === 'COLONY' ? event.payload.relatedId : null,
];
case 'TaskUpdated':
return [await taskColonyIdIn(tx, event.aggregateId)];
}
}
/**
* 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.
*/
@Injectable()
export class AutopilotEventHandlers {
constructor(private readonly autopilot: AutopilotService) {}
@OnDomainEvent([...COLONY_SYNC_EVENTS], 'autopilot.colony-sync')
async colonySync(
event: ColonySyncEvent,
tx: Prisma.TransactionClient,
): Promise<void> {
const colonyIds = [
...new Set((await colonyIdsOf(event, tx)).filter((id) => id !== null)),
].sort();
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(),
);
}
}
@@ -0,0 +1,35 @@
import { Module } from '@nestjs/common';
import { SeasonModule } from '../season/season.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutopilotEventHandlers } from './autopilot.handlers';
import { AutopilotTodayController } from './autopilot-today.controller';
import { AutopilotService } from './autopilot.service';
import { AutopilotStep } from './autopilot.step';
import { ColonyNextStepsController } from './colony-next-steps.controller';
import { NextStepsService } from './next-steps.service';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
/**
* The season autopilot: the event handler and the daily steps that keep the
* autopilot suggestions of each colony in line with the rules, and the read
* side "next steps" / "today". Imported by AppModule and WorkerModule (the
* handler), JobsModule (the steps) and ApiariesModule (the colony overview).
*/
@Module({
imports: [SeasonModule, SuggestionsModule],
controllers: [ColonyNextStepsController, AutopilotTodayController],
providers: [
AutopilotService,
NextStepsService,
AutopilotEventHandlers,
AutopilotStep,
SuggestionsExpiryStep,
],
exports: [
AutopilotService,
NextStepsService,
AutopilotStep,
SuggestionsExpiryStep,
],
})
export class AutopilotModule {}
@@ -0,0 +1,397 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_RULES,
readRule,
ruleConfigOf,
} from '../automation/rule-config';
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';
import { AutopilotService } from './autopilot.service';
import type { AutopilotInput } from './autopilot.types';
jest.mock('../prisma/row-lock');
jest.mock('../automation/rule-config', () => ({
...jest.requireActual<object>('../automation/rule-config'),
readRule: jest.fn(),
}));
jest.mock('./autopilot-input');
const lockRow = jest.mocked(lockRowForUpdate);
const lockRows = jest.mocked(lockRowsForUpdate);
const readRuleMock = jest.mocked(readRule);
const loadInput = jest.mocked(loadAutopilotInputIn);
const TODAY = '2026-05-12';
const PHENOLOGY = ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config);
function rule(trigger: string, override: object = {}, active = true) {
return {
id: null,
active,
config: ruleConfigOf(trigger as never, {
...DEFAULT_RULES[trigger as 'SEASON_AUTOPILOT'].config,
...override,
}),
actionTemplate: null,
};
}
function input(lastInspectedOn: string | null): AutopilotInput {
return {
colony: {
id: 'col-1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdOn: '2026-04-20',
feedStatus: null,
},
lastInspection:
lastInspectedOn === null
? null
: {
id: 'insp-9',
date: lastInspectedOn,
colonyStrength: null,
feedStatus: null,
queenCellsPresent: null,
},
lastVarroa: { id: 'var-1', date: '2026-05-10' },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api-1', TODAY, PHENOLOGY),
};
}
describe('AutopilotService', () => {
let tx: {
$connect?: undefined;
colony: { findUnique: jest.Mock; findMany: jest.Mock };
suggestion: { findMany: jest.Mock };
};
let suggestions: {
proposeIn: jest.Mock;
refreshIn: jest.Mock;
resolveIn: jest.Mock;
};
let seasons: { statesFor: jest.Mock };
let service: AutopilotService;
let rules: Record<string, ReturnType<typeof rule>>;
beforeEach(() => {
jest.resetAllMocks();
tx = {
colony: {
findUnique: jest
.fn()
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
};
suggestions = {
proposeIn: jest.fn().mockResolvedValue({ id: 'new' }),
refreshIn: jest.fn().mockResolvedValue(undefined),
resolveIn: jest
.fn()
.mockImplementation((_tx, ids: string[]) => ids.length),
};
seasons = {
statesFor: jest
.fn()
.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
),
};
rules = {
SEASON_AUTOPILOT: rule('SEASON_AUTOPILOT'),
INSPECTION_INTERVAL: rule('INSPECTION_INTERVAL'),
VARROA_THRESHOLD: rule('VARROA_THRESHOLD'),
PHENOLOGY: rule('PHENOLOGY'),
};
readRuleMock.mockImplementation((_db, trigger) =>
Promise.resolve(rules[trigger] as never),
);
lockRow.mockResolvedValue(true);
loadInput.mockResolvedValue(input('2026-05-03'));
service = new AutopilotService(
suggestions as unknown as SuggestionsService,
seasons,
);
});
const sync = () => service.syncColonyIn(tx as never, 'col-1', TODAY);
it('locks the colony row first and does nothing for an ended colony', async () => {
tx.colony.findUnique.mockResolvedValue({
status: 'DECEASED',
apiaryId: 'api-1',
});
const result = await sync();
expect(lockRow).toHaveBeenCalledWith(tx, 'Colony', 'col-1');
expect(lockRow.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findUnique.mock.invocationCallOrder[0],
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('does nothing when the colony row is gone', async () => {
lockRow.mockResolvedValue(false);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(tx.colony.findUnique).not.toHaveBeenCalled();
});
it('proposes a new candidate with the autopilot source, dedupe inputs and expiry', async () => {
const result = await sync();
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: 'col-1',
colonyId: 'col-1',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-10',
anchor: 'insp-9',
requiredPermission: 'inspections:read',
expiresAt: '2026-06-11',
}),
);
});
it('keeps an overdue suggestion alive for the full ttl from today', async () => {
loadInput.mockResolvedValue(input('2026-03-25'));
await sync();
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
dueDate: '2026-04-01',
expiresAt: '2026-06-11',
}),
);
});
it('refreshes an open suggestion with the same key instead of creating one', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-1',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-9',
expiresAt: new Date('2026-06-01T00:00:00.000Z'),
},
]);
const result = await sync();
expect(result).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'sug-1', {
reasonCode: 'INSPECTION_DUE',
evidence: expect.objectContaining({ inspectionId: 'insp-9' }) as object,
dueDate: '2026-05-10',
priority: 'HIGH',
expiresAt: '2026-06-01',
});
});
it('drops a candidate when proposeIn finds an open task of the same intent', async () => {
suggestions.proposeIn.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
});
it('resolves open autopilot suggestions whose key vanished, before proposing, never rule or chain ones', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-old',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-1',
expiresAt: null,
},
]);
const result = await sync();
expect(tx.suggestion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { colonyId: 'col-1', source: 'AUTOPILOT', status: 'OPEN' },
}),
);
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['sug-old'],
'Bedingung entfällt',
);
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 1 });
expect(suggestions.resolveIn.mock.invocationCallOrder[0]).toBeLessThan(
suggestions.proposeIn.mock.invocationCallOrder[0],
);
});
it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', async () => {
rules.SEASON_AUTOPILOT = rule('SEASON_AUTOPILOT', {}, false);
tx.suggestion.findMany.mockResolvedValue([
{ id: 'a', dedupeKey: 'k1', expiresAt: null },
{ id: 'b', dedupeKey: 'k2', expiresAt: null },
]);
const result = await sync();
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['a', 'b'],
'Autopilot aus',
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 2 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('proposes the candidates in the order A1, A2', async () => {
loadInput.mockResolvedValue({
...input('2026-04-20'),
lastVarroa: { id: 'var-1', date: '2026-04-01' },
});
await sync();
expect(
suggestions.proposeIn.mock.calls.map(
(call: [unknown, { intent: string }]) => call[1].intent,
),
).toEqual(['INSPECT', 'VARROA_MEASURE']);
});
it('uses preloaded configs and seasons without reading rules or seasons again', async () => {
const season = calendarSeasonState('api-1', TODAY, PHENOLOGY);
const preload = {
configs: {
autopilot: rules.SEASON_AUTOPILOT,
interval: rules.INSPECTION_INTERVAL,
varroa: rules.VARROA_THRESHOLD,
phenology: PHENOLOGY,
},
seasons: new Map([['api-1', season]]),
};
readRuleMock.mockClear();
const result = await service.syncColonyIn(
tx as never,
'col-1',
TODAY,
preload,
);
expect(result.opened).toBe(1);
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season, '08-01', TODAY);
});
it('preloadFor reads the four rules and the seasons once', async () => {
const preload = await service.preloadFor(tx as never, ['api-1'], TODAY);
expect(readRuleMock).toHaveBeenCalledTimes(4);
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
expect(preload.seasons.has('api-1')).toBe(true);
expect(preload.configs.phenology).toEqual(PHENOLOGY);
});
it('warns and does nothing when the apiary has no season state or the input is gone', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
seasons.statesFor.mockResolvedValue(new Map());
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('season'));
seasons.statesFor.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
);
loadInput.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
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');
});
});
});
@@ -0,0 +1,238 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
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, lockRowsForUpdate } from '../prisma/row-lock';
import type { SeasonState } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SuggestionsService } from '../suggestions/suggestions.service';
import { suggestionDedupeKey } from '../suggestions/suggestion.types';
import { loadAutopilotInputIn } from './autopilot-input';
import { evaluateAutopilot } from './autopilot-rules';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotSyncResult,
} from './autopilot.types';
/** Rules and seasons a daily step reads once for all its colonies. */
export interface AutopilotPreload {
configs: AutopilotConfigs;
seasons: ReadonlyMap<string, SeasonState>;
}
const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 };
/**
* A suggestion lives suggestionTtlDays from its due day, from today if it is
* already overdue: a condition that has lasted longer than the ttl would
* otherwise expire the day it is proposed and never be seen.
*/
function expiresOn(dueDate: string, today: string, ttlDays: number): string {
return addDays(dueDate > today ? dueDate : today, ttlDays);
}
/**
* Reconciles the open autopilot suggestions of one colony with what the
* rules say today. It writes suggestions only, never tasks or colony data,
* and leaves rule and chain suggestions alone.
*/
@Injectable()
export class AutopilotService {
private readonly logger = new Logger(AutopilotService.name);
constructor(
private readonly suggestions: SuggestionsService,
private readonly seasons: SeasonService,
) {}
/** The rules and the season of the given apiaries, read once for many colonies. */
async preloadFor(
db: DbClient,
apiaryIds: readonly string[],
today: string,
): Promise<AutopilotPreload> {
return {
configs: await this.readConfigs(db),
seasons: await this.seasons.statesFor(db, apiaryIds, today),
};
}
/**
* Locks the colony row, then: ended colony - nothing; autopilot off - all
* open autopilot suggestions RESOLVED; otherwise suggestions whose key is
* gone are RESOLVED, those with the same key refreshed, new candidates
* proposed (in the order A1...A10, so concurrent syncs take the proposal
* locks in the same order). A candidate is dropped by `proposeIn` when an
* open suggestion or task of the same intent exists. A colony that has
* ended is skipped here; its open suggestions become OBSOLETE through the
* handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied:
* it limits the next-steps view, not what is proposed. `preload` spares a
* daily step the rule and season reads per colony.
*/
async syncColonyIn(
tx: Prisma.TransactionClient,
colonyId: string,
today: string,
preload?: AutopilotPreload,
): Promise<AutopilotSyncResult> {
assertTransactionClient(tx, 'AutopilotService.syncColonyIn');
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
return NOTHING;
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true, apiaryId: true },
});
if (colony === null || !isLivingColonyStatus(colony.status)) {
return NOTHING;
}
const configs = preload?.configs ?? (await this.readConfigs(tx));
const autopilot = configs.autopilot;
const open = await tx.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
select: { id: true, dedupeKey: true, expiresAt: true },
orderBy: { id: 'asc' },
});
if (!autopilot.active) {
const resolved = await this.suggestions.resolveIn(
tx,
open.map((suggestion) => suggestion.id),
'Autopilot aus',
);
return { ...NOTHING, resolved };
}
const season = (
preload?.seasons ??
(await this.seasons.statesFor(tx, [colony.apiaryId], today))
).get(colony.apiaryId);
if (season === undefined) {
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
return NOTHING;
}
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;
}
const candidates = evaluateAutopilot(input, configs, today);
const keyOf = (candidate: AutopilotCandidate): string =>
suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor);
const currentKeys = new Set(candidates.map(keyOf));
const resolved = await this.suggestions.resolveIn(
tx,
open
.filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey))
.map((s) => s.id),
'Bedingung entfällt',
);
const openByKey = new Map(
open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])),
);
const ttlDays = autopilot.config.suggestionTtlDays;
let opened = 0;
let refreshed = 0;
for (const candidate of candidates) {
const existing = openByKey.get(keyOf(candidate));
if (existing !== undefined) {
await this.suggestions.refreshIn(tx, existing.id, {
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
dueDate: candidate.dueDate,
priority: candidate.priority,
expiresAt:
existing.expiresAt === null ? null : utcDay(existing.expiresAt),
});
refreshed += 1;
continue;
}
const created = await this.suggestions.proposeIn(tx, {
ruleKey: candidate.ruleKey,
source: 'AUTOPILOT',
mode: candidate.mode,
intent: candidate.intent,
priority: candidate.priority,
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
requiredPermission: candidate.requiredPermission,
dueDate: candidate.dueDate,
expiresAt: expiresOn(candidate.dueDate, today, ttlDays),
anchor: candidate.anchor,
notify: false,
});
if (created !== null) {
opened += 1;
}
}
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'),
interval: await readRule(db, 'INSPECTION_INTERVAL'),
varroa: await readRule(db, 'VARROA_THRESHOLD'),
phenology: (await readRule(db, 'PHENOLOGY')).config,
};
}
}
@@ -0,0 +1,95 @@
import { Logger } from '@nestjs/common';
import type { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotStep } from './autopilot.step';
import type { AutopilotService } from './autopilot.service';
describe('AutopilotStep', () => {
const tx = { tx: true };
let findMany: jest.Mock;
let transaction: jest.Mock;
let syncColonyIn: jest.Mock;
let preloadFor: jest.Mock;
const PRELOAD = { configs: {}, seasons: new Map() };
let step: AutopilotStep;
beforeEach(() => {
findMany = jest.fn().mockResolvedValue([
{ id: 'c1', apiaryId: 'a1' },
{ id: 'c2', apiaryId: 'a1' },
{ id: 'c3', apiaryId: 'a2' },
]);
transaction = jest.fn((work: (client: unknown) => unknown) => work(tx));
syncColonyIn = jest
.fn()
.mockResolvedValue({ opened: 1, refreshed: 0, resolved: 2 });
preloadFor = jest.fn().mockResolvedValue(PRELOAD);
step = new AutopilotStep(
{
colony: { findMany },
$transaction: transaction,
} as unknown as PrismaService,
{ syncColonyIn, preloadFor } as unknown as AutopilotService,
);
});
afterEach(() => jest.restoreAllMocks());
it('is the autopilot step', () => {
expect(step.name).toBe('autopilot');
});
it('syncs every living colony in a transaction of its own and sums up', async () => {
const result = await step.run('2026-05-12');
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
orderBy: { id: 'asc' },
}),
);
expect(transaction).toHaveBeenCalledTimes(3);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c1', '2026-05-12', PRELOAD);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c3', '2026-05-12', PRELOAD);
expect(result).toEqual({
autopilotColonies: 3,
suggestionsOpened: 3,
suggestionsResolved: 6,
});
});
it('reads rules and seasons once for all colonies', async () => {
await step.run('2026-05-12');
expect(preloadFor).toHaveBeenCalledTimes(1);
expect(preloadFor).toHaveBeenCalledWith(
expect.anything(),
['a1', 'a2'],
'2026-05-12',
);
});
it('a failing colony does not stop the others; the step fails with the counters', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
syncColonyIn
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ opened: 1, refreshed: 0, resolved: 0 });
const failure = await step.run('2026-05-12').then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
result: {
autopilotColonies: 2,
suggestionsOpened: 2,
suggestionsResolved: 2,
},
failedSteps: ['1 colony'],
});
});
});
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { loggableError } from '../common/loggable-error';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotService } from './autopilot.service';
/**
* Step "autopilot" of the daily evaluation: reconciles the suggestions of
* every living colony, one transaction (and one colony row lock) per colony.
* A failing colony is logged and does not stop the others; the step then
* fails with the counters of the colonies that went through, and the next
* run tries again.
*/
@Injectable()
export class AutopilotStep implements DailyEvaluationStep {
readonly name = 'autopilot';
private readonly logger = new Logger(AutopilotStep.name);
constructor(
private readonly prisma: PrismaService,
private readonly autopilot: AutopilotService,
) {}
async run(today: string): Promise<JobResult> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, apiaryId: true },
orderBy: { id: 'asc' },
});
// Rules and seasons are the same for every colony: read them once.
const preload = await this.autopilot.preloadFor(
this.prisma,
[...new Set(colonies.map((colony) => colony.apiaryId))],
today,
);
let synced = 0;
let failed = 0;
let opened = 0;
let resolved = 0;
for (const { id } of colonies) {
try {
const result = await this.prisma.$transaction((tx) =>
this.autopilot.syncColonyIn(tx, id, today, preload),
);
synced += 1;
opened += result.opened;
resolved += result.resolved;
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(
`Autopilot of colony ${id} failed: ${message}`,
stack,
);
failed += 1;
}
}
const counts: JobResult = {
autopilotColonies: synced,
suggestionsOpened: opened,
suggestionsResolved: resolved,
};
if (failed > 0) {
throw new JobPartialFailure(counts, [
`${failed} ${failed === 1 ? 'colony' : 'colonies'}`,
]);
}
return counts;
}
}
Loaded 100 of 1797 files, more files were not shown because too many files have changed in this diff. Show more