Compare commits

...
96 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
1505 changed files with 163912 additions and 5287 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 REGISTRY_PUSH: gitea:80
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Image-Namen in Kleinbuchstaben ermitteln - name: Image-Namen in Kleinbuchstaben ermitteln
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad; # 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" run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
- name: Docker Buildx einrichten - name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with: with:
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS) # REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab. # statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
@@ -40,7 +40,7 @@ jobs:
http = true http = true
- name: Bei der Gitea-Container-Registry anmelden - name: Bei der Gitea-Container-Registry anmelden
uses: docker/login-action@v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with: with:
registry: ${{ env.REGISTRY_PUSH }} registry: ${{ env.REGISTRY_PUSH }}
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
@@ -50,7 +50,7 @@ jobs:
- name: Metadaten (Tags/Labels) ermitteln - name: Metadaten (Tags/Labels) ermitteln
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }} images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: | tags: |
@@ -58,7 +58,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Image bauen und pushen - name: Image bauen und pushen
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: ./ApiCore-Backend context: ./ApiCore-Backend
push: true push: true
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
REGISTRY_PUSH: gitea:80 REGISTRY_PUSH: gitea:80
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Image-Namen in Kleinbuchstaben ermitteln - name: Image-Namen in Kleinbuchstaben ermitteln
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad; # 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" run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
- name: Docker Buildx einrichten - name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with: with:
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS) # REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab. # statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
@@ -40,7 +40,7 @@ jobs:
http = true http = true
- name: Bei der Gitea-Container-Registry anmelden - name: Bei der Gitea-Container-Registry anmelden
uses: docker/login-action@v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with: with:
registry: ${{ env.REGISTRY_PUSH }} registry: ${{ env.REGISTRY_PUSH }}
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
@@ -50,7 +50,7 @@ jobs:
- name: Metadaten (Tags/Labels) ermitteln - name: Metadaten (Tags/Labels) ermitteln
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }} images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: | tags: |
@@ -58,7 +58,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Image bauen und pushen - name: Image bauen und pushen
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: ./ApiCore-Frontend context: ./ApiCore-Frontend
push: true push: true
+2 -2
View File
@@ -32,10 +32,10 @@ jobs:
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385 YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Node einrichten - name: Node einrichten
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 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/
+670 -36
View File
@@ -23,13 +23,19 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `ExtractionsModule` | Schleuderungen | Harvests | | `ExtractionsModule` | Schleuderungen | Harvests |
| `HoneyBatchesModule` | Honigchargen, Honigmessungen | Extractions | | `HoneyBatchesModule` | Honigchargen, Honigmessungen | Extractions |
| `StorageModule` | Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) | HoneyBatches | | `StorageModule` | Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) | HoneyBatches |
| `VeterinaryMedicinesModule` | Tierarzneimittel-Katalog, Packungen, Bewegungsbuch (T6, Abschnitt 12.2); exportiert `VeterinaryMedicinesService` und `VeterinaryMedicineLotsService`; die Behandlung bucht direkt über ihr `tx` und importiert das Modul nicht (12.2) | – |
| `ComplianceModule` | Compliance-Einstellungen, Bestandsbild und Behördenanzeigen (T6, Abschnitt 12.8); `restricted-zone.ts` für den Sperrbezirk (12.7); ohne Provider an `PermissionResolverService` | – |
| `ApiaryRestrictionsModule` | Sperrbezirke je Stand (T6, Abschnitt 12.7); von `ColoniesModule` importiert | – |
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. | | `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. | | `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. | | `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests | | `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
| `SuggestionsModule` | Vorschläge (Modell, API, Aufräumen) und Ketten-Handler, Abschnitt 11 | Tasks, Notifications | | `SuggestionsModule` | Vorschläge (Modell, API, Aufräumen) und Ketten-Handler, Abschnitt 11 | Tasks, Notifications |
| `AutopilotModule` | Saison-Autopilot (A1–A3), „Die nächsten Schritte“, „Heute“ | Season, Suggestions | | `AutopilotModule` | Saison-Autopilot (A1–A10), „Die nächsten Schritte“, „Heute“ | Season, Suggestions |
| `SeasonModule` | Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin | – | | `SeasonModule` | Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin | – |
| `PhenologyModule` | Saisonzustand je Stand (GTS, Phase, Kalibrierung), Job-Schritt `phenology`, Meldungen (API, Neuberechnung, Handler), Abschnitt 11.11 | Season, Weather, Notifications |
| `WeatherHistoryModule` | Wetterquelle (`WeatherHistorySource`) und Rückholdienst für die Worker-Schleife `phenology-weather`, Abschnitt 11.11 | – |
| `TelegramModule` | `TelegramClient`, `TelegramSender` (Zustellung, nur Worker), Abschnitt 11.13 | Notifications |
| `WithdrawalModule` | Wartezeit-Prüfung bei Ernte, Abfüllung und Behandlung, Meldungen | Notifications | | `WithdrawalModule` | Wartezeit-Prüfung bei Ernte, Abfüllung und Behandlung, Meldungen | Notifications |
| `RemindersModule` | Erinnerung je Aufgabe, Morgenübersicht | Notifications, Season, Autopilot | | `RemindersModule` | Erinnerung je Aufgabe, Morgenübersicht | Notifications, Season, Autopilot |
@@ -70,6 +76,10 @@ Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-But
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`). **Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
**Beute offline (Abschnitt 13.1):** Das Paket (`offline-pack`) trägt optional `hives` (Aufbau je Volk) und `hiveCatalog` (aktive Systeme plus die von den Aufbauten genutzten); ein älteres Paket ohne diese Felder sagt „unbekannt“, nicht „kein Aufbau“ (`hiveFromPacks`, `lib/hive/pack.ts`). Änderungen ohne Netz landen im IndexedDB-Speicher `pending-hive-changes` (DB-Version 5, `queueHiveChange`). `drainTimeline` (`lib/offline/sync.ts`) sendet diese Einträge und die Durchsichten, die eine `hiveChange` tragen, gemeinsam nach `queuedAt`, nacheinander. Netzfehler und 5xx stoppen den Lauf; jede andere Ablehnung (`HIVE_CONFLICT`, 403, 400) wird am Eintrag gespeichert, der Eintrag bleibt in der Warteschlange und hält die späteren Einträge desselben Volks zurück, andere Völker laufen weiter. **Überholschutz:** Solange für ein Volk eine Beute-Änderung wartet (auch eine abgelehnte), reiht `useHiveMutation` jede neue Kartenänderung hinter sie ein, statt online zu senden (`hasQueuedHiveChange`). Die Karte zeigt die wartenden Operationen auf dem geladenen Stand; eine abgelehnte Änderung zeigt `HivePendingNote` als Hinweis mit Weg in die Warteschlange (Annahme 102).
**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`, aktuell der Hash `215afb9f84e9`; ein Spec prüft, dass die Version zum Inhalt von `sw-routes.js` passt, jede Änderung an der Datei braucht also eine neue Version). Seit T6 kommen dazu: `/api/v1/compliance` (Prüfbereitschaft, Anzeigen-Einstellungen), `authority-notices`, `apiary-restrictions`, `apiaries/:id/restrictions`, `veterinary-medicines`, `veterinary-medicine-lots`, die Etikett-Prüfung und der Druck (`label-templates/check`, `label-templates/:id/check…`, `label-templates/:id/print…`; sie tragen die Anschrift des Lebensmittelunternehmers), das Einzeldokument `documents/:id` (die Art steht nicht im Pfad) und die Dokumentlisten der drei eingeschränkten Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` (die Art steht in der Query `/documents?entityType=…`, daher prüft `isCacheableApiUrl` die URL samt Query; Listen der Durchsicht-Fotos und `documents/:id/download` bleiben offline verfügbar, das Präfix `/api/v1/documents` wird nicht als Ganzes gesperrt). **Der globale Header `Cache-Control: no-store` des Backends ist bewusst kein Signal für den Worker:** Jede API-Antwort trägt ihn (Abschnitt 9, `noStoreMiddleware`), ein Header-Check würde das gesamte Offline-Verhalten abschalten. `isKeepableApiAnswer` prüft nur Erfolg und JSON; ob etwas behalten wird, entscheidet allein der Pfad (Annahme 87). Ebenfalls nie gespeichert wird `/api/v1/colonies/:id/hive` (`/\/api\/v1\/colonies\/[^/]+\/hive\/?$/i`): das Paket antwortet dort, eine alte Kopie würde einen neueren Stand verdecken. `…/hive/changes` und der Katalog (`/hive-systems`) bleiben cachebar.
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht. **Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
**Als Nächstes und Heute:** Die Volksseite zeigt oben „Als Nächstes“ (höchstens 3 Schritte, `GET /colonies/:id/next-steps`), die Startseite „Heute“ den dringlichsten Schritt und die fälligen Durchsichten je Stand (`GET /autopilot/today`; bei Fehler, offline oder ohne Recht gilt die bisherige Aufgabenlogik). Die Stand-Seite nennt je Volk „Durchsicht fällig in n Tagen“ und „Als Nächstes: …“. Offline zeigt die Volksseite die Schritte aus dem Paket nur lesend. Einzelheiten: Abschnitt 11.5. **Als Nächstes und Heute:** Die Volksseite zeigt oben „Als Nächstes“ (höchstens 3 Schritte, `GET /colonies/:id/next-steps`), die Startseite „Heute“ den dringlichsten Schritt und die fälligen Durchsichten je Stand (`GET /autopilot/today`; bei Fehler, offline oder ohne Recht gilt die bisherige Aufgabenlogik). Die Stand-Seite nennt je Volk „Durchsicht fällig in n Tagen“ und „Als Nächstes: …“. Offline zeigt die Volksseite die Schritte aus dem Paket nur lesend. Einzelheiten: Abschnitt 11.5.
@@ -114,24 +124,24 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN. 4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung. 5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung.
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. Seit T6 gilt für die Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes (Abschnitt 12.3, Annahmen 69 und 70).
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Auch die Königin-Zuordnung (`QueenAssigned` → `calendar-sync.queen-assignment`) und die Wartezeit (`calendar-sync.withdrawal`, Abschnitt 11.8) laufen so; `CalendarSyncService.enqueue` und das Einreihen nach dem Commit gibt es nicht mehr, nur `enqueueIn`. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung/Wartezeit) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. 9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Auch die Königin-Zuordnung (`QueenAssigned` → `calendar-sync.queen-assignment`) und die Wartezeit (`calendar-sync.withdrawal`, Abschnitt 11.8) laufen so; `CalendarSyncService.enqueue` und das Einreihen nach dem Commit gibt es nicht mehr, nur `enqueueIn`. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung/Wartezeit) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender und `getWithdrawalStatus` rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. 10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender und `getWithdrawalStatus` rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. Auch bei der Schleuderung warnt die App (`WITHDRAWAL_ACTIVE`, Abschnitt 11.12). Diese Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`: Lebensmittelsicherheit geht vor der Lese-Trennung.
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker, Königin-Zuordnung sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`) und den Webhook `colony.created`. Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`; `queen.replaced` kommt seit T4 aus `QueenAssigned`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`. 11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker, Königin-Zuordnung sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`) und den Webhook `colony.created`. Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`; `queen.replaced` kommt seit T4 aus `QueenAssigned`) und Lager (`product.stock_changed`); `batch.created` kommt seit T4b aus `HoneyBatchRecorded` (Abschnitt 10, Webhook-Zustellung), Schleuderungen und Honigchargen haben keine Post-Commit-Seiteneffekte mehr. Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`.
12. **Automatische Hinweise sind Vorschläge, keine Aufgaben**: Automatisierungsregeln legen keine Aufgaben mehr an, sondern schreiben Vorschläge (`Suggestion`, Abschnitt 11.1). Erst „Übernehmen“ erzeugt die Aufgabe (Quelle „Aus Vorschlag“: `Task.sourceSuggestionId`, `sourceAutomationTrigger` = Regel); bestehende Automatik-Aufgaben bleiben Aufgaben. Je Regel und Objekt existiert höchstens eine offene Sache (Vorschlag oder Aufgabe). Geprüft wird unter der Volkssperre und der Zeilensperre der Regel (`SELECT … FOR UPDATE`, Reihenfolge Volk, dann Regel), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel, gerechnet ab dem Berliner Tag. Ohne sie ist die Aufgabe beim Übernehmen heute fällig. Anker je Regel (ein verworfener Anker kommt nie wieder): `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` die Durchsicht-ID; `VARROA_THRESHOLD` die Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4); `SWARM_MOOD_HIGH` die Kontroll-ID; `COLONY_HEALTH_CRITICAL` die Ereignis-ID; `STOCK_ITEM_LOW` die jüngste IN-Bewegung (sonst `none`); `RAW_MATERIAL_LOW` die jüngste Charge der Sorte (sonst `none`); `TREATMENT_FOLLOW_UP` und `FEEDING_FOLLOW_UP` die Behandlungs- bzw. Fütterungs-ID. Die Tagesrate des natürlichen Milbenfalls wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Vorschlag, Meldung und der Audit-Eintrag der Regel (`AutomationRule CREATE`, mit `suggestionId`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (Kategorie `SUGGESTION`, WARNUNG, bei `URGENT` KRITISCH) schreibt ein Vorschlag nur, wenn die Regel `config.notify` hat. Empfänger sind Inhaber von `tasks:read` und des Leserechts des Bereichs; der Text lautet „<Volkscode>: <Titel>“ (bewusste Abweichung vom Spec-Wortlaut, bei dem das Präfix der Regelcode war; ohne Volk nur der Titel). Der Kalendereintrag (`calendar-sync.task`) und der Webhook `task.created` (`webhooks.forward`) folgen erst aus `TaskCreated` beim Übernehmen; die Regel ruft `WebhooksService` nicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge von `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` nach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der Vorschlag `RESOLVED` („Bedingung entfällt“); besteht der Befund, bleibt es bei einem Vorschlag mit aktuellem Beleg (der Anker bleibt der alte); eine rückdatierte Durchsicht ändert nichts, eine Korrektur schlägt nie neu vor, führt aber Offenes nach. Dasselbe gilt für `VARROA_THRESHOLD` bei `VarroaCorrected`. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`, überspringen beendete Völker und prüfen unter der Volkssperre erneut. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie. 12. **Automatische Hinweise sind Vorschläge, keine Aufgaben**: Automatisierungsregeln legen keine Aufgaben mehr an, sondern schreiben Vorschläge (`Suggestion`, Abschnitt 11.1). Erst „Übernehmen“ erzeugt die Aufgabe (Quelle „Aus Vorschlag“: `Task.sourceSuggestionId`, `sourceAutomationTrigger` = Regel); bestehende Automatik-Aufgaben bleiben Aufgaben. Je Regel und Objekt existiert höchstens eine offene Sache (Vorschlag oder Aufgabe). Geprüft wird unter der Volkssperre und der Zeilensperre der Regel (`SELECT … FOR UPDATE`, Reihenfolge Volk, dann Regel), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel, gerechnet ab dem Berliner Tag. Ohne sie ist die Aufgabe beim Übernehmen heute fällig. Anker je Regel (ein verworfener Anker kommt nie wieder): `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` die Durchsicht-ID; `VARROA_THRESHOLD` die Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4); `SWARM_MOOD_HIGH` die Kontroll-ID; `COLONY_HEALTH_CRITICAL` die Ereignis-ID; `STOCK_ITEM_LOW` die jüngste IN-Bewegung (sonst `none`); `RAW_MATERIAL_LOW` die jüngste Charge der Sorte (sonst `none`); `TREATMENT_FOLLOW_UP` und `FEEDING_FOLLOW_UP` die Behandlungs- bzw. Fütterungs-ID. Die Tagesrate des natürlichen Milbenfalls wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Vorschlag, Meldung und der Audit-Eintrag der Regel (`AutomationRule CREATE`, mit `suggestionId`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (Kategorie `SUGGESTION`, WARNUNG, bei `URGENT` KRITISCH) schreibt ein Vorschlag nur, wenn die Regel `config.notify` hat. Empfänger sind Inhaber von `tasks:read` und des Leserechts des Bereichs; der Text lautet „<Volkscode>: <Titel>“ (bewusste Abweichung vom Spec-Wortlaut, bei dem das Präfix der Regelcode war; ohne Volk nur der Titel). Der Kalendereintrag (`calendar-sync.task`) und der Webhook `task.created` (`webhooks.forward`) folgen erst aus `TaskCreated` beim Übernehmen; die Regel ruft `WebhooksService` nicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge von `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` nach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der Vorschlag `RESOLVED` („Bedingung entfällt“); besteht der Befund, bleibt es bei einem Vorschlag mit aktuellem Beleg (der Anker bleibt der alte); eine rückdatierte Durchsicht ändert nichts, eine Korrektur schlägt nie neu vor, führt aber Offenes nach. Dasselbe gilt für `VARROA_THRESHOLD` bei `VarroaCorrected`. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`, überspringen beendete Völker und prüfen unter der Volkssperre erneut. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30. 14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30.
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). Ergänzung T6: Wird eine Behandlung aus einer Packung erfasst, kommen Lieferant, Beleg, Ch.-B. und Tierarzt live aus der Packung und stehen nie als Kopie auf der Behandlung (Abschnitt 12.2).
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. Ein Verlust mit `markColonyDeceased` beendet auch die Königin-Zuordnung und schreibt `ColonyEnded` mit `via: WINTER_CHECK` (Abschnitt 11.7). 16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. Ein Verlust mit `markColonyDeceased` beendet auch die Königin-Zuordnung und schreibt `ColonyEnded` mit `via: WINTER_CHECK` (Abschnitt 11.7).
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung. 17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen. 18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden. 19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`. 20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`.
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist. 21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. 22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien), `VOICE_ENCRYPTION_KEY` (Sprachnotizen: Transkript, Vorschlag, Aufnahme; seit T7, AAD `capture:<id>:<art>`). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die vier Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. Der Job `rekey` versiegelt beim Schlüsseltausch nur offene Sprachnotizen neu (bestätigte, verworfene und abgelaufene haben keinen Inhalt mehr). **Rollout:** Ein Image mit T7 startet in Produktion in keiner Rolle ohne `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret`; vorher `deploy/k8s/scripts/ensure-secrets.sh` ausführen und den Schlüssel wie die anderen sichern.
23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden. 23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden.
**Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. **Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen.
**Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar). **Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar).
@@ -148,12 +158,89 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
33. **Futter-Vokabular**: `Inspection.feedStatus` ist `ColonyCondition` wie auf der Volkskarte (`EXCELLENT` Reichlich, `GOOD` Ausreichend, `AVERAGE` Mittel, `WEAK` Niedrig, `CRITICAL` Kritisch). Die Migration bildet den alten Freitext (getrimmt auf Leerzeichen, Tab, CR, LF; ohne Groß-/Kleinschreibung) ab: „ausreichend“ → `GOOD`, „niedrig“ → `WEAK`, „kritisch“ → `CRITICAL`; leer → `NULL`; alles andere → `NULL` und `Futter (alt): <Text>` wird nach einem Zeilenumbruch an `notes` gehängt (der Lauf meldet die Zahl der verschobenen Texte per `RAISE NOTICE`). Dieselbe Abbildung gilt für den CSV-Völkerimport (unbekannter Text landet in den Notizen, die Zeile trägt die Meldung „Futterangabe … nicht erkannt, in die Notizen übernommen.“) und für alte Backup-Bundles (ein Wert, der kein Text ist, wird `NULL` mit Notiz `Futter (alt): <JSON>`). Die API akzeptiert bei `POST/PATCH /inspections` die Enum-Namen in jeder Schreibweise und die drei alten Wörter (so bleiben Einträge der Offline-Warteschlange alter Clients sendbar); leerer Text wird `NULL`, anderer Freitext 400. Die Regel `FEED_STATUS_CRITICAL` feuert bei `feedStatus = CRITICAL`; der Webhook `inspection.created` trägt `feedStatus` als Enum-Wert (der Shop nutzt das Ereignis nicht). 33. **Futter-Vokabular**: `Inspection.feedStatus` ist `ColonyCondition` wie auf der Volkskarte (`EXCELLENT` Reichlich, `GOOD` Ausreichend, `AVERAGE` Mittel, `WEAK` Niedrig, `CRITICAL` Kritisch). Die Migration bildet den alten Freitext (getrimmt auf Leerzeichen, Tab, CR, LF; ohne Groß-/Kleinschreibung) ab: „ausreichend“ → `GOOD`, „niedrig“ → `WEAK`, „kritisch“ → `CRITICAL`; leer → `NULL`; alles andere → `NULL` und `Futter (alt): <Text>` wird nach einem Zeilenumbruch an `notes` gehängt (der Lauf meldet die Zahl der verschobenen Texte per `RAISE NOTICE`). Dieselbe Abbildung gilt für den CSV-Völkerimport (unbekannter Text landet in den Notizen, die Zeile trägt die Meldung „Futterangabe … nicht erkannt, in die Notizen übernommen.“) und für alte Backup-Bundles (ein Wert, der kein Text ist, wird `NULL` mit Notiz `Futter (alt): <JSON>`). Die API akzeptiert bei `POST/PATCH /inspections` die Enum-Namen in jeder Schreibweise und die drei alten Wörter (so bleiben Einträge der Offline-Warteschlange alter Clients sendbar); leerer Text wird `NULL`, anderer Freitext 400. Die Regel `FEED_STATUS_CRITICAL` feuert bei `feedStatus = CRITICAL`; der Webhook `inspection.created` trägt `feedStatus` als Enum-Wert (der Shop nutzt das Ereignis nicht).
34. **Volksstärke in besetzten Waben**: `Colony.occupiedFrames` und `Inspection.colonyStrength` zählen besetzte Waben, 0 bis 40 (CHECK in der Datenbank; 400 „Höchstens 40 besetzte Waben.“). Das alte `Colony.strength` (1–10) bleibt unverändert, wird in Swagger als veraltet markiert und nie umgerechnet; die Oberfläche zeigt es nur, wenn es gespeichert ist, als „Volksstärke (alt)“. Vor dem Ausrollen listet der Kopf der Migration `20261014100700_t4_occupied_frames` die Abfrage nach `Inspection.colonyStrength > 40`. 34. **Volksstärke in besetzten Waben**: `Colony.occupiedFrames` und `Inspection.colonyStrength` zählen besetzte Waben, 0 bis 40 (CHECK in der Datenbank; 400 „Höchstens 40 besetzte Waben.“). Das alte `Colony.strength` (1–10) bleibt unverändert, wird in Swagger als veraltet markiert und nie umgerechnet; die Oberfläche zeigt es nur, wenn es gespeichert ist, als „Volksstärke (alt)“. Vor dem Ausrollen listet der Kopf der Migration `20261014100700_t4_occupied_frames` die Abfrage nach `Inspection.colonyStrength > 40`.
35. **Übernahme Durchsicht → Volkskarte**: Siehe Abschnitt 11.10. Annahme: Das Brutbild wird zum Brutzustand der Volkskarte (5 `EXCELLENT`, 4 `GOOD`, 3 `AVERAGE`, 2 `WEAK`, 1 `CRITICAL`); diese Zuordnung ist nicht aus dem Lastenheft abgeleitet. 35. **Übernahme Durchsicht → Volkskarte**: Siehe Abschnitt 11.10. Annahme: Das Brutbild wird zum Brutzustand der Volkskarte (5 `EXCELLENT`, 4 `GOOD`, 3 `AVERAGE`, 2 `WEAK`, 1 `CRITICAL`); diese Zuordnung ist nicht aus dem Lastenheft abgeleitet.
36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). T4a kennt nur den Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Vor `SALWEIDE` gilt noch `WINTER` des Vorjahres. Gemeldete Phasen und Wetter (Gradtagszahl) folgen später über `SeasonService.statesFor`; die Aufrufer bleiben gleich. 36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). Der Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor `SALWEIDE` noch `WINTER` des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über `SeasonService.statesFor`, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in `ApiaryPhenology (apiaryId, year)`; der Job-Schritt `phenology` und jede Meldung oder Rücknahme berechnen ihn neu (`PhenologyStateService.recomputeIn`, unter Zeilensperre auf `Apiary`). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer Korrektur oder Rücknahme einer Meldung (`allowBackward`, gesetzt nur vom Handler bei `PhenologyCorrected`; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).
37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis). 37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis).
38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft). 38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft).
39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`. 39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`. `NotificationsService` liest die Empfänger und schreibt die Meldungen in einer Transaktion und sperrt die Nutzerzeilen dabei mit `FOR KEY SHARE`; ein gleichzeitiges Löschen eines Empfängers wartet, statt einen Fremdschlüsselfehler (P2003) im Handler auszulösen.
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. Die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit steuern in dieser Lieferung nichts in der App; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken erst auf die Telegram-Zustellung (T4c). 40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).
41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen. 41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen.
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
43. **Pflanzenentwicklung: Tabellen und Fremdschlüssel**: Abweichend vom Entwurf (Spec 4.2) verweisen `ApiaryPhenology.apiaryId` und `ApiaryWeatherDay.apiaryId` mit `ON DELETE CASCADE` auf `Apiary` (abgeleitete Daten und Cache, keine Waisen, ein Restore von Ständen wird nie blockiert). `PhenologyReport.apiaryId` verweist mit `ON DELETE RESTRICT` auf `Apiary`: Der Prisma-Standard für eine optionale Relation, `SetNull`, machte aus einer Meldung still eine Meldung „für alle Stände“. Die Migration `20261015100000_t4_phenology` vergibt `phenology:read`, `phenology:create` und `phenology:update` (Gruppe „Pflanzenentwicklung“) an alle Systemrollen; `read` geht an Rollen, die `colonies:read` halten, `create` und `update` an Rollen mit `inspections:create`, jeweils nur Nicht-Systemrollen. Die Migration legt die Rechte selbst an, weil der Katalog-Abgleich beim Start neue Rechte nur für Zeilen vergibt, die er selbst anlegt (`PermissionCatalogSyncService`).
44. **Bewegungsbuch der Honigcharge**: `HoneyBatch.amount` bleibt der laufende Rest in kg, `HoneyBatch.initialAmount` (kg) ist die Anfangsmenge. `BatchMovement.quantity` ist bei Honig eine vorzeichenbehaftete Menge in Gramm mit `honeyBatchId`. Invariante: `amount = initialAmount + Summe(quantity) / 1000`, auf das Gramm gerundet, nie unter 0, Toleranz 0,5 g. Eine Charge ohne `amount` und `initialAmount` wird nicht gebucht. Jede `BatchMovement` gehört zu genau einer Quelle aus Abfüllung, Herstellungscharge, Rohstoff-Charge und Honigcharge (CHECK `BatchMovement_exactly_one_batch`, `num_nonnulls(…) = 1`). Rückrechnung für Altbestand (Migration `20261015100100_t4_honey_ledger`, Import alter Bundles): `initialAmount = amount + Summe(Gramm der Abfüllungen) / 1000`, je Abfüllung eine Buchung `BOTTLING` mit `sourceBottlingBatchId`, Datum und Benutzer der Abfüllung (Grammwerte halb aufgerundet, `floor(x·1000 + 0,5)`). Frühere Handkorrekturen von `amount` stecken damit in `initialAmount`. Die Migration endet mit einer Prüfung (`RAISE NOTICE` mit Zählern, `RAISE EXCEPTION` bei verletzter Invariante, dann Rollback). Laufzeit: `HoneyBatchLedgerService` (`src/honey-batches/honey-batch-ledger.service.ts`) ist der einzige Schreiber von `HoneyBatch.amount` (Wächter `honey-batch-amount-writes.spec.ts`). Buchungen laufen in der Transaktion des Aufrufers unter `lockRowForUpdate('HoneyBatch')`: `bookIn` (vorzeichenbehaftete Gramm; `amount` wird aus `initialAmount + Summe(quantity) / 1000` auf das Gramm neu berechnet, nie unter 0), `setInitialIn` (erste Menge: `amount` und `initialAmount` zugleich, keine Buchung) und `clearIn` (Menge entfernen, nur ohne Buchungen, sonst 400 „Die Menge wird ueber das Bewegungsbuch gefuehrt. Bitte eine Korrektur buchen.“). Eine Abbuchung braucht genug Rest (Toleranz 0,5 g), sonst 400 (Abfüllung: „Abfuellmenge uebersteigt die verfuegbare Menge der Honigcharge“, sonst „Die Menge kann nicht unter 0 sinken.“). Gerundet wird überall gleich, `floor(x·1000 + 0,5)` Gramm, auch bei Mengen, die Nutzer eingeben (12,3456 kg werden 12,346 kg). Eine Abfüllung anlegen bucht `BOTTLING` negativ mit `sourceBottlingBatchId`, Ändern die Differenz (bei Chargenwechsel zurück auf die alte, von der neuen), Entfernen `BOTTLING` positiv mit der Notiz „Abfuellung entfernt“ (ohne Quelle: die Abfüllung wird danach gelöscht). `PATCH amount` bucht `CORRECTION` (neu minus aktuell; `initialAmount` bleibt, Startmenge und Summe der Buchungen weichen danach ab); der erste Wert einer Charge ohne Menge (`null` → Wert) setzt `initialAmount`. Eine Charge ohne `initialAmount` bucht nichts, die Abfüllung setzt trotzdem den Status `BOTTLED`. Eine Abfüllung, die dem Buch nie etwas entnommen hat (Heuristik `drewFromLedger`: keine `BOTTLING`-Buchung mit `honeyBatchId` und ihrer `sourceBottlingBatchId`, etwa weil die Charge beim Abfüllen noch keine Menge hatte), bucht bei Änderung und Entfernung nichts zurück; wird sie in eine andere Charge verschoben, entnimmt sie erst dort. Das folgt aus den Buchungen und ist kein gespeichertes Merkmal. Sperrreihenfolge je Pfad: Abfüllung → Honigcharge (zwei Chargen in einer Anweisung, aufsteigend; die Abfüllung sperrt die Charge schon vor dem Anlegen), Schleuderung → Honigcharge. API des Buchs: Abschnitt 11.12.
45. **Honig-Kette: Vorschläge, Warnungen, nie sperren**: Vorbelegungen (Menge, Honigsorte, Wassergehalt, Glasgröße, MHD) sind Vorschläge mit Übernehmen-Knopf, nie stillschweigend eingetragen. Plausibilitätswarnungen (`EXTRACTION_EXCEEDS_HARVEST`, `BATCHES_EXCEED_EXTRACTION`, Hinweis `HARVEST_NET_MISSING`) und die Wartezeitwarnung sperren nie (kein 4xx). Die Wartezeitwarnung `WITHDRAWAL_ACTIVE` bekommt jeder, der eine Schleuderung erfasst oder vorbelegt, auch ohne `treatments:read` (Lebensmittelsicherheit vor der Lese-Trennung). Die Vorbelegungs-Endpunkte hängen an den Anlege-Rechten (`extractions:create`, `honey-batches:create`): Sie liefern dieselben Daten wie der Anlege-Ablauf. Die Honigsorte kommt aus den Ernten, weil `Extraction` kein Sortenfeld hat. Abschnitt 11.12.
46. **Telegram: Token nur im Worker, Zustellung über Outbox**: Der Bot-Token (`TELEGRAM_BOT_TOKEN`) liegt nur in der Umgebung des Workers (optionales Secret `apicore-telegram`); der API-Pod kennt nur `TELEGRAM_BOT_USERNAME` für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über `NotificationDelivery`, nie aus Anfragen, Handlern oder Jobs; die Zustellung läuft in der Schleife `telegram-send`, der Empfang in `telegram-updates` (Abschnitt 11.13). Der Sender nimmt genau ein Worker-Replikat an (`replicas: 1`): Chat-Abstand, Senderate und die Pausen nach 401 und 429 liegen im Speicher des Pods, mit mehreren Pods hätte jeder seine eigenen. Die SQL-Regel „eine Zustellung je Nutzer und Batch“ und Telegrams 429-Antworten schützen den Bot auch dann. Ohne `FRONTEND_ORIGIN` geht die Nachricht außerhalb der Entwicklung (`NODE_ENV` leer oder `development`) ohne Link hinaus (ein Warn-Log je Pod), nie mit einem localhost-Link. Abschnitt 11.13.
47. **Pflanzen-Meldungen, Neuberechnung und Saisonwechsel**: Rechte: `phenology:read` (Liste), `phenology:create` (Melden), `phenology:update` (Bearbeiten und Zurückziehen); `GET /phenology/state` braucht `apiaries:read` und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); `apiaryId` leer (`""`) ergibt 400, `null` heißt „alle Stände“. Wer `phenology:update` hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. `PhenologyReported` und `PhenologyCorrected` (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. `PhenologyCorrected` trägt `previousApiaryId` (beim Zurückziehen gleich `apiaryId`): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite `null` (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler `phenology.recompute` rechnet für den späteren von Berliner Tag des Ereignisses (`occurredAt`) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (kein Schlüssel), und `phenology.recompute-apiary` rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. **Bewusste Abweichung von der Spec:** Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. `SeasonPhaseChanged` bleibt für den Autopiloten.
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), `Apiary` steht seit T6 an erster Stelle der Karte, also vor `Colony` (Entscheidung zu Task 27, in T6 Task 1 in die Karte übernommen). Hinter `NotificationDelivery` stehen die T6-Tabellen `LabelTemplate`, `SiteSettings`, `VeterinaryMedicine`, `VeterinaryMedicineLot`, `ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement` (Schlüssel jeweils `id`, `Product` stand schon in der Karte). Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände (aufsteigend), dann das Volk; `row-lock.spec.ts` hält die Reihenfolge und die Einmaligkeit jeder Tabelle fest. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Aufrufer (`ColoniesService.create/update/move/merge`, `ImportService`, `ApiariesService`, `ApiaryRestrictionsService`) sind umgebaut (Abschnitt 12.7); ein Umsetzen, das zwischen Lesen und Sperren den Stand wechselt, endet mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“ ohne automatischen Neuversuch (Abschnitt 12.1).
51. **Telegram: fehlerhaftes Update wird übersprungen (Annahme der Umsetzung)**: Ein Update, dessen Verarbeitung scheitert, wird bis zu 5 Mal oder höchstens 10 Minuten wiederholt (Zähler und erste Fehlzeit je `update_id` im Speicher des Pods; `MAX_UPDATE_ATTEMPTS`, `MAX_UPDATE_RETRY_MS`). Danach überspringt es eine eigene Transaktion: Offset weiter, `lastError` = „Telegram-Nachricht übersprungen“, eine Warnung ohne IDs und Text im Log, der Rest des Batches läuft weiter. Ohne diese Grenze würde ein dauerhaft scheiternder Befehl alle späteren Befehle (etwa jedes `/start`) sperren. Kosten bei falscher Annahme: ein verlorenes `/start`, der Nutzer erzeugt den Link neu. Eine verlorene Lease zählt nie als Fehlversuch, Verbindungsfehler ebenso wenig; die Zähler werden nach jedem Batch bereinigt. Abschnitt 11.13.
52. **Wassergehalt: Bestätigung gilt für den gemessenen Wert**: Die Bestätigung über der gesetzlichen Grenze (`acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']`) trägt zusätzlich `acknowledgedWaterContent` mit dem Wert aus der 409-Antwort. Sie gilt nur, wenn der unter der Sperre geprüfte Wert genau gleich ist; sonst folgt eine frische 409. Wer bestätigt hat, hat also diesen Wert gesehen, und eine zwischenzeitlich neue Messung entwertet die Bestätigung (Lebensmittelsicherheit; Kosten bei falscher Annahme: ein zusätzliches Feld). Streng größer als die Grenze löst die Bestätigung aus, genau 20,0 % warnt nur. Abschnitt 11.12.
53. **Vorbelegungs-Hinweise: Speichern bleibt erlaubt**: Laden die Hinweise einer Vorbelegung (Warnungen, etwa die Wartezeit) nicht, sperrt die Oberfläche das Speichern nicht; eine dauerhafte Zeile weist darauf hin, das Backend prüft beim Speichern. Kosten bei falscher Annahme: Ein Nutzer speichert, ohne die Wartezeitwarnung gesehen zu haben; die Antwort des Speicherns trägt die Warnung trotzdem. Abschnitt 11.12.
54. **Telegram: `LINKED` ohne Antwortgrenze**: Die Grenze von 3 Antworten je Chat in 10 Minuten gilt für `HELP`, `LINK_INVALID` und `STOPPED`, nicht für `LINKED`. `LINKED` braucht ein gültiges Einmal-Token des angemeldeten Nutzers, ein Fremder kann es nicht auslösen. Abschnitt 11.13.
55. **Pflanzenmeldung: doppelte Meldung beim erneuten Öffnen des Blatts**: Jede Meldung des Blatts „Entwicklung melden“ bekommt einen eigenen Schlüssel. Öffnet jemand das Blatt erneut und meldet dieselbe Phase noch einmal, entsteht eine zweite Meldung (auch aus der Offline-Warteschlange). Das ist bewusst hingenommen: Die Auswertung nimmt den frühesten Meldetag der Phase, die Zeile lässt sich zurückziehen (Kosten bei falscher Annahme: eine Zeile mehr). Abschnitt 11.11.
56. **Step-up: ein 401 wird nie mit Refresh wiederholt**: Bei Aufrufen mit Nachweis (Telegram verbinden, Backup-Import, Passwort ändern, Konto deaktivieren, MFA bestätigen und abschalten) bedeutet ein 401 „Nachweis falsch“. Der Client erneuert dort das Token nicht und wiederholt nicht (`refreshOn401: false`), weil ein zweiter Versuch den Sperrzähler verdoppeln würde. Ist die Sitzung wirklich abgelaufen, erscheint statt Stille der deutsche Hinweis „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“ (der Client kennt den Ablauf des Tokens nicht und erneuert nicht vorab). Kosten bei falscher Annahme: Eine abgelaufene Sitzung zeigt bei einem Step-up einmal einen Fehler statt einer stillen Erneuerung. Abschnitt 11.13.
57. **Abfüllung: Produktwechsel setzt Artikelwerte zurück**: Wechselt das Produkt, verwirft die Maske von Hand geänderte Glasgröße und MHD und nimmt die Werte des neuen Artikels; ein Datumswechsel lässt eigene Eingaben stehen. Sonst bliebe die Glasgröße eines anderen Artikels unbemerkt im Formular. Kosten bei falscher Annahme: Wer erst tippt und dann das Produkt wählt, muss neu eintragen. Abschnitt 11.12.
58. **„Alle fehlgeschlagenen erneut versuchen“: die Zahl kann zu niedrig sein**: Die API liefert keine Gesamtzahl der `FAILED`-Zeilen, die Rückfrage nennt daher nur „Mindestens N“ (größerer Wert aus `failed24h` und den `FAILED`-Zeilen der Liste) und immer die Grenze „höchstens 100, neueste zuerst“. Kosten bei falscher Annahme: Es gehen mehr (bis 100) Nachrichten erneut hinaus als angezeigt. Abschnitt 10, System-Seite.
59. **T6-Schema: Prüfungen in der Datenbank, Rechtswerte als Einstellung**: CHECK-Constraints sichern `VeterinaryMedicineLot.quantity > 0`, `VeterinaryMedicineMovement.quantity <> 0` und `(type = 'TREATMENT') = ("treatmentId" IS NOT NULL)`, `ApiaryRestriction.endsOn >= startsOn` sowie `LabelTemplate.minXHeightMm` zwischen 0,5 und 5. Katalog, Packung, Behandlung und Stand hängen mit `ON DELETE RESTRICT` aneinander. `AuthorityNotice.openKey` ist nullbar-eindeutig (höchstens ein offener Entwurf je Art und Jahr). Die Vorgabe `ComplianceSettings.netQuantityDigitRules` (bis 50 g: 2 mm, bis 200 g: 3 mm, bis 1000 g: 4 mm, darüber 6 mm) und `minXHeightMm` 1,2 mm sind Annahme – vom Inhaber zu prüfen; im Code stehen keine Rechtswerte. Abschnitt 12.1.
60. **Tierarzneimittel: eigener Katalog und eigenes Bewegungsbuch**: Ergänzung zu Annahme 10. Mittel (`VeterinaryMedicine`), Packungen (`VeterinaryMedicineLot`) und Bewegungen (`VeterinaryMedicineMovement`) sind nicht `StockItem`/`BatchMovement`. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht (es gibt keine Route dafür). Der Rest einer Packung ist `quantity` plus Summe der Bewegungen, auf Tausendstel gerundet, immer berechnet und nie gespeichert; ein negativer Rest ist erlaubt (die Behandlung hat stattgefunden) und erscheint in der Prüfbereitschaft. Der Katalog startet leer, der Seed legt nichts an. Kosten bei falscher Annahme: Wer einen Buchungsfehler erwartet zu löschen, muss gegenbuchen. Abschnitt 12.2.
61. **Ein inaktives Mittel nimmt keinen neuen Bestand**: Eine Packung lässt sich weder zu einem inaktiven Mittel anlegen noch auf ein solches umhängen (400 „Das Mittel ist deaktiviert.“, `active` wird unter der Mittelsperre gelesen). Kosten bei falscher Annahme: Wer eine Lieferung eines stillgelegten Mittels buchen will, muss es erst wieder aktivieren.
62. **Packung und Mittel müssen dieselbe Einheit haben**: Beim Anlegen und beim Umhängen einer Packung auf ein anderes Mittel gilt 400 „Die Einheit des Mittels passt nicht zur Packung.“; die Einheit eines Mittels ist nur änderbar, solange keine Packung existiert, das Mittel einer Packung nur, solange keine Bewegung existiert (jeweils 409).
63. **Wiederherstellung ersetzt das Packungsbuch**: Eine versiegelte Datei überschreibt Mittel, Packungen und Bewegungen nach Id; sie führt das Buch nicht mit dem vorhandenen zusammen. Aus unversiegelten Dateien entsteht kein Packungsbezug einer Behandlung (Abschnitt 8). Kosten bei falscher Annahme: Buchungen, die nach der Sicherung entstanden sind, fehlen nach dem Einspielen und müssen nachgetragen werden.
64. **Beim Wiederherstellen gewinnt der offene Entwurf im Zielsystem**: Hält dort ein Entwurf (`openKey`, etwa der vom Tagesjob angelegte TSK-Entwurf) den Schlüssel unter anderer Id, entfällt die Anzeige der Datei mit ihren Dokumenten; der Restore scheitert nicht am Unique-Constraint. Kosten bei falscher Annahme: Der Entwurf aus der Datei fehlt und muss neu erfasst werden (die Ids stehen in `summary.skippedNoticeIds`).
65. **Etikett-Pflichtangaben prüft eine reine Funktion am aufgelösten Inhalt**: `checkLabel` (`src/label-templates/label-particulars.ts`) bewertet alle Text-Elemente mit eingesetzten Platzhaltern, normalisiert mit NFKC, Kleinschreibung und „alles außer Buchstaben und Ziffern wird zum Leerzeichen“. Ein Text, der nicht in sein Feld passt (gemessene Höhe größer als Feldhöhe plus 0,5 pt), zählt nicht und löst `TEXT_CLIPPED` aus. Nicht geprüft werden die „gleiches Sichtfeld“-Regel und die Nährwertdeklaration (für Honig befreit – Annahme, vom Inhaber zu prüfen). Das Datum auf dem Etikett und das erwartete MHD der Prüfung kommen beide aus `labelDayOf` (Berliner Kalendertag, unabhängig von der Prozess-Zeitzone; ein Quelltext-Wächter sichert das). Eine Zahl direkt vor der Menge („1,250 g“) gilt nicht als Treffer für 250 g; der Ziffern-Wächter gilt je Element, nie im verbundenen Text. Eine Anschrift wird auch erkannt, wenn sie auf mehrere Elemente verteilt ist (Annahme 75), weil eine unerkannte Anschrift den Druck sperrt. Abschnitt 12.4.
66. **Füllmengen-Höhe: jede Nennung zählt**: `NET_QUANTITY_HEIGHT` ist erfüllt, wenn irgendeine Nennung der Füllmenge hoch genug ist (größte Versalhöhe aller Träger); eine zusätzliche kleine Nennung warnt nicht. Die Warnung sperrt nie. Kosten bei falscher Annahme: Eine große Nennung neben einer zu kleinen wird nicht bemängelt.
67. **Verkehrsbezeichnung: „honig“ in einem beliebigen Wort genügt**: Für `LEGAL_NAME` reicht ein Wort mit „honig“ („Blütenhonig“, auch „Honigtau“); ob die Bezeichnung inhaltlich stimmt, prüft die Software nicht. Kosten bei falscher Annahme: Ein falsch benannter Honig sperrt den Druck nicht.
68. **Losnummer mit „L“ am Anfang erfüllt die Präfix-Regel**: Beginnt die Losnummer schon mit „L“ („L-001“), stellt der Platzhalter `lotLine` kein weiteres „L“ voran und `LOT_PREFIX` warnt nicht. Die Warnung sperrt nie.
69. **`compliance:manage` schließt `compliance:read` nicht ein**: Die Rechte sind getrennt; die Rollenvergabe koppelt sie (nur ADMIN und BEEKEEPER haben `manage`, und diese haben auch `read`). Ein Nutzer mit nur `manage` darf bei Dokumenten hochladen, aber die Liste nicht lesen (403). Kosten bei falscher Annahme: Eine handgebaute Rolle mit nur `manage` sieht nichts, was sie angelegt hat.
70. **Dokumente gelöschter Datensätze bleiben bestehen**: Sie bleiben durch das Recht der Art geschützt und werden nicht mitgelöscht; bisher löscht kein Pfad einen Datensatz mit Dokumenten (Behandlungen, Sperrbezirke und Behördenanzeigen haben keine Löschroute; Sperrbezirke enden über das Enddatum, Entwürfe werden verworfen, Annahme 76). Ein künftiger Löschpfad entfernt die Dokumente seines Datensatzes selbst. Kosten bei falscher Annahme: Dateien ohne Datensatz bleiben im Speicher.
71. **Der CSV-Import befolgt den Sperrbezirk ohne Ausnahme**: Der Import legt in einem Stand mit aktivem Sperrbezirk kein Volk an; die Zeile wird als Fehlerzeile gemeldet (Text des Sperrbezirks), nichts wird für sie geschrieben. Der Import kennt keine Ausnahmebegründung. Kosten bei falscher Annahme: Wer Altbestand in einen gesperrten Stand importieren muss, legt die Völker einzeln mit Begründung an. Abschnitt 12.7.
72. **Eine Ausnahmebegründung ohne `compliance:manage` ist immer 403**: Wer `restrictionOverrideReason` sendet, ohne `compliance:manage` zu halten, bekommt 403, bevor gelesen oder gesperrt wird, auch ohne aktive Sperre. Mit dem Recht wird eine Begründung ohne aktive Sperre ignoriert. Kosten bei falscher Annahme: Ein Client, der die Begründung immer mitsendet, braucht das Recht. Abschnitt 12.7.
73. **Der Bestandsbuch-Export braucht zusätzlich `veterinary-medicines:read`**: `GET /export/treatment-log.pdf` verlangt `export:read` und `veterinary-medicines:read`, weil es Lieferant, Beleg und Tierarzt aus der Packung zeigt (die Prüfung steckt im `ExportController`, nur für diesen Bericht). Kosten bei falscher Annahme: Ein Nutzer mit nur `export:read` verliert das Bestandsbuch; das Frontend (Task 15) blendet den Export ohne das Recht aus. Abschnitt 12.2.
74. **„500“ und „g“ in zwei Elementen zählen nur als anliegendes Paar**: Die Füllmenge gilt in zwei Elementen nur, wenn sich die Boxen senkrecht überlappen, die Einheit rechts der Zahl liegt (`x` nicht kleiner) und der waagerechte Abstand höchstens 10 mm beträgt; vor der Zahl darf keine Ziffer stehen. Sonst ist es eine Lücke (sperrt den Druck, der Probedruck bleibt möglich). Abschnitt 12.4.
75. **Eine verteilte Anschrift zählt nur mit höchstens 10 mm Abstand**: Name und Anschrift des Lebensmittelunternehmers dürfen über mehrere Elemente verteilt sein, wenn der senkrechte Abstand jedes Elements zur bisherigen Gruppe höchstens 10 mm beträgt (symmetrisch, gegen die laufende Ober- und Unterkante der Gruppe; die Lesereihenfolge kommt allein aus den Positionen). Der waagerechte Abstand zählt dort nicht. Kosten bei falscher Annahme: Eine sehr weit auseinandergezogene Anschrift gilt als Lücke; zwei nahe, fremde Elemente mit der passenden Wortfolge gelten als Anschrift. Abschnitt 12.4.
76. **Verwerfen einer Behördenanzeige behält die Dokumente**: `cancel` setzt den Entwurf auf `CANCELLED` und gibt den `openKey` frei; es gibt keine Löschroute (Spec 7), angehängte Dokumente (`AUTHORITY_NOTICE`) bleiben bestehen und durch das Recht der Art geschützt (Annahme 70). Kosten bei falscher Annahme: Wer einen Entwurf „löschen“ erwartet, findet ihn als verworfen in der Liste. Abschnitt 12.8.
77. **Etikett-Rechte: derzeit nur ADMIN (Frage an den Inhaber offen)**: `label-templates:read|create|update|deactivate|print` vergibt keine Migration, nur die Katalog-Synchronisation an die damaligen Systemrollen; in der Test-Datenbank halten sie nur ADMIN. Prüfen und Drucken brauchen zusätzlich `bottling:read`. Offen ist, ob BEEKEEPER (und für `read` STAFF und READONLY) Etiketten ansehen und drucken dürfen; vor dem Rollout die tatsächliche Rollenvergabe in Produktion prüfen. Abschnitt 12.6.
78. **Eine Bestandsänderung meldet nur, sie legt keine Anzeige an**: `compliance.herd-change` schreibt eine Meldung (`HINWEIS`, `COMPLIANCE`) an jeden aktiven Nutzer mit `compliance:manage`, nie einen Entwurf (Spec 5.5). Kosten bei falscher Annahme: Wer einen Entwurf erwartet, legt die Änderungsanzeige selbst an. Abschnitt 12.9.
79. **Die TSK-Erinnerung geht auch bei einem offenen Entwurf**: Existiert für das Jahr nur ein Entwurf, geht die Meldung weiter (Link auf den Entwurf, höchstens eine ungelesene je Nutzer); nur eine gesendete Anzeige hält sie still. Kosten bei falscher Annahme: Eine Erinnerung zu viel, solange der Entwurf nicht gesendet ist. Abschnitt 12.9.
80. **Ein Umschalter der Benachrichtigung wirkt erst beim nächsten Ereignis**: Wer `noticeOnColonyCountChange` oder `noticeOnApiaryChange` einschaltet, bekommt eine bereits bestehende Abweichung erst beim nächsten Herd-Ereignis gemeldet. Kosten bei falscher Annahme: Die Meldung kommt später; die Prüfbereitschaft (12.10) zeigt die Abweichung sofort. Abschnitt 12.9.
81. **Maskierte Aufrufer der Prüfbereitschaft sehen die Zählungen**: Ohne `subjectPermission` bleiben `subjects` leer, `more` und Sätze wie „N Stände liegen im Sperrbezirk“ bleiben sichtbar; Namen, Anschriften und Aktenzeichen stehen nie in Titel und Detail. Kosten bei falscher Annahme: Die Anzahl ist für alle mit `compliance:read` sichtbar. Abschnitt 12.10.
82. **Das TSK-PDF ist A4 quer**: Der Zählnachweis hat viele Spalten (eine je Völkerart plus Summe); im Hochformat blieben der Lage-Spalte etwa 49 pt. Erst- und Änderungsanzeige bleiben Hochformat. Kosten bei falscher Annahme: Das Blatt muss gedreht abgelegt werden. Abschnitt 12.8.
83. **Der Ort im Briefkopf kommt aus der Postleitzahl-Zeile**: Aus „…, 34117 Kassel“ wird „Kassel“; ohne Treffer steht nur das Datum, der Ort bleibt leer (kein `FEHLT`). Kosten bei falscher Annahme: Der Ort fehlt neben dem Datum. Abschnitt 12.8.
84. **Die Summe im Anzeigen-PDF ist das gespeicherte `totalColonies`**: Weicht es von der Summe der Stände ab, steht „Gesamtzahl berichtigt (Summe der Stände: n)“; es wird nicht live neu gezählt. Kosten bei falscher Annahme: Ein veraltetes `totalColonies` erscheint im Ausdruck, bis `recount` im Entwurf läuft. Abschnitt 12.8.
85. **Keine der ersten sieben Prüfregeln ist quittierbar**: Weder Pflicht- noch Prüfen-Punkte dieser Stufe lassen sich wegquittieren (Spec 5.7); die Quittung ist Mechanik für spätere weiche Regeln (Task 13). Kosten bei falscher Annahme: Der Inhaber muss die Ursache beheben statt zu quittieren. Abschnitt 12.10.
86. **Die Kurzfassung der Prüfbereitschaft kann bis zu 60 s veraltet sein**: `GET /compliance/readiness/summary` (Dashboard-Zeile) wird je Prozess 60 s aus dem Speicher bedient und nach Quittieren oder Zurücknehmen im selben Prozess sofort verworfen. Änderungen an den Daten und Quittungen eines anderen Prozesses erscheinen dort bis zu 60 s später; Liste, PDF-Bericht und Quittierpfad rechnen immer live. Kosten bei falscher Annahme: Die Dashboard-Zeile zeigt kurz einen alten Stand. Abschnitt 12.10.
87. **Der Service Worker entscheidet über das Zwischenspeichern nach dem Pfad, nicht nach `Cache-Control`**: Das Backend sendet `Cache-Control: no-store` an jede API-Antwort (Abschnitt 9); würde der Worker den Header als Signal lesen, verwürfe er das gesamte Offline-Verhalten. Nie gespeichert wird, was `NEVER_CACHED_API_PATHS` und die Dokumentarten-Regel benennen; ein neuer schutzwürdiger Pfad muss dort eingetragen werden und hebt `SW_ROUTES_VERSION`. Kosten bei falscher Annahme: Ein vergessener Pfad kann offline gespeichert werden, obwohl der Server `no-store` sendet. Abschnitt 2 und 12.3.
88. **„1.000“ wird als mehrdeutig abgelehnt**: Die Mengenfelder der Tierarzneimittel-Oberfläche lesen das Komma als Dezimalzeichen und lehnen einen einzelnen Punkt mit genau drei Nachkommastellen („1.000“, „12.345“) sowie jeden Text mit mehreren Trennzeichen ab, weil er Tausender oder Dezimalstelle meinen könnte. Kosten bei falscher Annahme: Wer „1.000“ als eins meint, muss „1“ oder „1,0“ schreiben. Abschnitt 12.11.
89. **Eine neue, ungespeicherte Etikettvorlage prüft nichts, bis sie einen Wert hat**: Der Check-Endpunkt verlangt die Mindest-x-Höhe; das Frontend kennt keinen eigenen Vorgabewert (kein Rechtswert im Code). Eine neue Vorlage startet mit leerem Feld, sendet beim Anlegen keinen Wert (der Server setzt seinen Standard) und zeigt das Panel erst nach Eingabe oder nach dem ersten Speichern. Kosten bei falscher Annahme: Im Designer fehlt bei einer ganz neuen Vorlage die Prüfung, bis der Wert gesetzt ist. Abschnitt 12.11.
90. **Das Prüfbericht-PDF druckt Zählungen, keine Namen**: Es nennt je Punkt nur die Zahl der betroffenen Objekte, nie Stände, Völker, Mittel, Packungen oder Aktenzeichen, damit der Bericht ohne Rechteprüfung der Namen weitergegeben werden kann. Kosten bei falscher Annahme: Wer die Objekte braucht, öffnet die Seite `/compliance` mit den Rechten. Abschnitt 12.10.
91. **Eine Sperre, die heute endet, gilt bis Tagesende; ein Aufheben am selben Tag gibt es nicht**: `endsOn` ist inklusive (aktiv bei `startsOn` ≤ Berliner Tag ≤ `endsOn`), das Backend lehnt `endsOn` vor `startsOn` ab. Die Oberfläche schlägt „gestern“ als Endtag vor; wer heute oder später wählt, bekommt „Die Sperre gilt noch bis einschließlich …“. Kosten bei falscher Annahme: Wer eine Sperre heute am Vormittag aufheben will, hat sie bis Mitternacht Berliner Zeit weiter am Stand. Abschnitt 12.11.
92. **Beim Bearbeiten einer Behandlung wählt die Oberfläche nie selbst eine Packung vor**: Nur das Anlegen schlägt die Packung mit dem frühesten Verfall vor; im Bearbeiten-Dialog steht nach einem Mittelwechsel „Ohne Packung“, bis der Nutzer wählt. Kosten bei falscher Annahme: Wer eine Abbuchung erwartet, muss die Packung ausdrücklich wählen. Abschnitt 12.11.
93. **Die Glocke zeigt für `COMPLIANCE` das Kurz-Etikett „Behörden“**: Die Badges der Glocke sind Einwort-Etiketten („Aufgabe“, „Vorschlag“); „Behörden & Prüfung“ würde die Zeile umbrechen und steht darum nur in `lib/notifications/labels.ts` und der Telegram-Kategorienauswahl. Kosten bei falscher Annahme: Zwei Bezeichnungen für dieselbe Kategorie. Abschnitt 12.11.
94. **Die Dosis ist auf drei Nachkommastellen begrenzt**: Das Eingabefeld lehnt mehr als drei Nachkommastellen mit „Höchstens 3 Nachkommastellen.“ ab (Packungsmengen rechnen auf Tausendstel, `roundToThousandths`). Kosten bei falscher Annahme: Eine feinere Dosis lässt sich nicht erfassen. Abschnitt 12.11.
95. **Die Liebig-Zander-Zarge misst außen 525 × 430 mm**: Der Wert stammt aus dem Innenmaß des Edelstahldeckels und steht im Katalog (Migration `20261017100100_hive_catalog_zander_liebig`, `test/fixtures/hive-ops/catalog.json`). Kosten bei falscher Annahme: Die 3D-Maße stimmen nicht; der Inhaber misst nach, der Katalogwert wird mit `hive-systems:manage` geändert. Abschnitt 13.
96. **Die Styropor-Einlage misst 465 × 365 × 20 mm**: Der Inhaber misst nach. Kosten bei falscher Annahme: Die Dämmung sitzt im Modell zu groß oder zu klein; Katalogwert ändern, kein Code. Abschnitt 13.
97. **Der Nassenheider Professional liegt in einer Leerzarge auf den Rähmchen der Zarge darunter**: Im Modell ist er ein Einsatz der Leerzarge (`EVAPORATOR` in einer `BOX` mit 0 Plätzen). Kosten bei falscher Annahme: Die Lage im Aufbau weicht vom Stock ab; der Inhaber bestätigt mit Foto, die Regel steht in `hive-validate.ts`. Abschnitt 13.
98. **Rähmchen-Plätze zählen von links, mit Blick auf das Flugloch**: Platz 1 ist links, Platz `frameSlots` rechts. Kosten bei falscher Annahme: Alle gespeicherten Platznummern sind gespiegelt; das lässt sich nur mit einer Datenmigration über alle Aufbauten beheben. Abschnitt 13.
99. **Den Aufbau ändert nur, wer `colonies:update` hat; den Katalog pflegt nur `hive-systems:manage` (ADMIN)**: STAFF hat `colonies:update` nicht und kann die Beute daher nicht ändern, auch nicht in der Durchsicht (403 `HIVE_FORBIDDEN`). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung zur Rolle ADMIN, ohne Rechte-Migration. Kosten bei falscher Annahme: Mitarbeiter müssen den Aufbau über einen Administrator ändern lassen; der Inhaber vergibt `colonies:update` in der Rollenverwaltung, das ist kein Code. Abschnitt 13.
100. **Die Bausperre ist eine Einlage ohne Zustand; „Winter“ ist kein eigener Modus**: Die Bausperre (`FLOOR_INSERT`) hat kein Zustandsfeld, Einlegen und Entfernen sind die Operationen `addInsert` und `removePart`. Kosten bei falscher Annahme: Wer einen Winter-Modus erwartet, muss Einlagen einzeln setzen; ein Modus wäre eine spätere Erweiterung des Katalogs. Abschnitt 13.
101. **Die Futtertasche misst 477 × 70 × 220 mm und fasst 2 l**: Der Hersteller (Holtermann) nennt Anschlussmaße, kein Volumen; Maße und `max: 2` im Zustandsfeld `syrupLiters` sind Annahmen der Startdaten (Migration `20261018100100_hive_catalog_frame_feeder`) und im Katalog editierbar (Einstellungen > Beutensysteme). Kosten bei falscher Annahme: Die Tasche sitzt im 3D-Modell zu groß oder zu klein und die Füllstandsgrenze stimmt nicht; der Inhaber misst nach, der Katalogwert wird mit `hive-systems:manage` geändert, kein Code. Abschnitt 13.
102. **Eine wartende Beute-Änderung wird nie überholt, auch eine abgelehnte nicht**: Ohne Netz oder mit offener Warteschlange reiht die Karte jede weitere Änderung desselben Volks hinten ein, statt sie online zu senden; ein abgelehnter Eintrag hält die späteren desselben Volks zurück, bis der Nutzer ihn in der Warteschlange klärt (Abschnitt 2). Kosten bei falscher Annahme: Nach einer abgelehnten Änderung sind neue Änderungen am Volk vorerst blockiert, bis der Nutzer entscheidet; die Alternative (überholen) könnte einen Aufbau erzeugen, den die Reihenfolge nie hatte. Abschnitt 13.1.
103. **Die Historienzeilen haben keine Folgeknöpfe**: Eine Zeile der Beute-Historie zeigt die Änderung und den Stand an diesem Tag, bietet aber keine Vorschläge wie „Futter nachlegen“ an, weil ein Eintrag den Zustand davor nicht trägt; die Vorschläge erscheinen nur als Hinweis (Toast) direkt nach dem Speichern. Kosten bei falscher Annahme: Ein weggewischter Hinweis ist aus der Historie nicht wiederzufinden; Abhilfe wäre, `stateBefore` im Protokoll zu speichern (Migration). Abschnitt 13.1.
104. **Der Editor hält den Stand fest, auf dem der Entwurf begann**: Beim ersten Schritt merkt sich `useHiveDraft` den geladenen Aufbau (`snapshot`) und zeigt die Vorschau darauf; ändert sich der Aufbau auf dem Server inzwischen, erscheint ein Hinweis, und beim Speichern wendet der Server die Operationen auf den aktuellen Stand an. Passen sie nicht mehr, öffnet sich das Konflikt-Sheet, es wird nichts gespeichert. Kosten bei falscher Annahme: Die Vorschau im Editor weicht nach einer gleichzeitigen Änderung vom gespeicherten Ergebnis ab; der Konflikt-Weg fängt Unverträgliches ab. Abschnitt 13.1.
105. **Der Zoom der 3D-Szene (0,6 bis 2,5) kommt mit dem Ziehen und Ablegen**: Die Szene hat Drehen und Fokus auf ein Teil, aber noch keinen frei wählbaren Zoom; der Bereich ist der Aufgabe für Ziehen und Ablegen zugeordnet (Task 18). Kosten bei falscher Annahme: Auf kleinen Bildschirmen fehlt zunächst ein Zoom; der Browser-Zoom und die Listenansicht (Gliederung) bleiben als Weg. Abschnitt 13.1.
106. **Restrisiko im Abschreib-Pod wird getragen**: Der Container `whisper` (CronJob `voice-transcribe`, Komponente `deploy/k8s/components/voice`) dekodiert nicht vertrauenswürdige Audiodateien mit libav/PyAV und CTranslate2. Er teilt das Pod-Netz; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Ein kompromittierter Decoder könnte über DNS-Anfragen langsam Daten nach außen tragen und Postgres erreichen, hat aber keine Zugangsdaten (kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, eigenes `/tmp`, damit er `ingest` mit den Secrets nichts unterschieben kann). Er sieht Ton und Abschriften seines Laufs (höchstens 5 Aufnahmen, 600 s) und könnte deren Ergebnisse verfälschen; `/work/in` ist für ihn nur lesbar, und `ingest` übernimmt nur Ergebnisse zu Aufnahmen des eigenen Manifests mit passendem `claimToken`, prüft jedes Ergebnis streng, und nichts geht ohne Klick in ein Formular. Akzeptiert nach Spec 9.2: ein Betrieb mit eigenen Nutzern, keine Geheimnisse im Decoder, kein Internet, Schaden auf einen Lauf begrenzt. Das CronJob-Objekt trägt bewusst `app: apicore-voice-job` (nur das Pod-Template `app: apicore-job`), damit der gemeinsame Job-Patch keinen weiteren Container einsetzt; `av` ist wegen faster-whisper 1.2.1 auf 18.1.0 festgelegt. Kosten bei falscher Annahme: Abschriften eines Laufs könnten bei einer Lücke im Decoder abfließen oder vertauscht werden. Stärkere Optionen später: eigener Pod für den Decoder mit deny-all-NetworkPolicy (auch ohne DNS) und Übergabe-Volume, oder ein Subprozess je Aufnahme mit eigener Sandbox.
107. **Speichern der Durchsicht bestätigt eine Sprachnotiz in jedem offenen Zustand**: Auch eine Notiz, die noch abgeschrieben oder ausgewertet wird, wird mit dem Speichern `CONFIRMED` und ihr Inhalt in derselben Transaktion gelöscht; ein laufender Job verliert seinen Anspruch. Grund: Wer speichert, hat entschieden, und Inhalte sollen nie liegen bleiben. Kosten bei falscher Annahme: Eine noch laufende Abschrift oder Auswertung geht verloren, wenn der Nutzer vorher speichert; die Notiz lässt sich nicht mehr nutzen und muss neu aufgenommen werden. Abschnitt 14.2.
108. **Eine Dezimalzahl bleibt beim Zitatabgleich ein Wort**: `4,5` und `4.5` gelten als dieselbe Zahl und werden nicht an Komma oder Punkt zerlegt, sonst ließe sich ein Zitat aus Zahlenresten zusammensetzen, das nie gesagt wurde. Kosten bei falscher Annahme: Ein Zitat mit anderer Schreibweise der Dezimalzahl trifft die Abschrift nicht, ein gültiger Vorschlag wird verworfen (der Nutzer trägt von Hand ein). Abschnitt 14.4.
109. **Wiederholen wird über Grenzen gebremst, nicht über eine Zahl je Notiz**: Jedes Wiederholen reiht die Notiz neu ein und kann einen bezahlten Aufruf auslösen. Gebremst wird es durch die Sperre je Nutzer, die Grenze von 10 offenen Notizen und höchstens 5 Anfragen je Minute und Netz; eine Obergrenze je Notiz gibt es nicht (sie bräuchte eine Schemaänderung). Kosten bei falscher Annahme: Ein Nutzer mit einer dauerhaft kaputten Notiz kann sie wiederholt neu starten und Tagesbudget verbrauchen; Abhilfe wäre ein Zähler für manuelle Wiederholungen mit Migration. Abschnitt 14.1.
110. **Das Tagesbudget zählt bezahlte Aufrufe im Protokoll, nicht Notizen**: `VoiceExtractionCall` hält jeden Aufruf mit Ausgang; `AUTH` und `ABORTED` zählen nicht, abgelehnte, abgeschnittene, ungültige und wiederholte Aufrufe schon, weil sie Geld kosten. Die Tabelle ist nicht im Backup und wird nach 90 Tagen gelöscht. Kosten bei falscher Annahme: Bei vielen Fehlversuchen ist das Budget früher erschöpft, als die Zahl der Notizen vermuten lässt, und Notizen warten bis zum nächsten Tag; eine weitere Tabelle ist zu pflegen. Abschnitt 14.4.
111. **Das Tagesbudget wechselt um 00:00 UTC**: Die Grenze ist der UTC-Tag der Datenbankuhr (wie die 30 neuen Notizen je Tag), also 01:00 oder 02:00 Uhr in Berlin. Kosten bei falscher Annahme: Wer in der Nacht arbeitet, sieht das Limit nicht zum Berliner Tageswechsel zurückgesetzt; für einen Betrieb mit Tagesarbeit ohne Folgen, sonst ein Wechsel der Grenze auf den Berliner Tag. Abschnitt 14.4.
112. **Eine von selbst beendete Aufnahme wird sofort gespeichert**: Erreicht die Aufnahme 180 s, geht sie ohne Rückfrage in die Geräte-Warteschlange; ein Verlassen der Seite verwirft sie nicht. Kosten bei falscher Annahme: Eine Aufnahme, die der Nutzer nicht wollte, liegt in der Warteschlange und wird hochgeladen, bis er sie verwirft; sie kostet Speicher und Abschreibzeit, aber keine Auswertung, solange er sie nicht öffnet. Abschnitt 14.6.
113. **Der Schalter wirkt auch im Server**: Bei `VOICE_RECORDING_ENABLED=false` lehnen Upload und Text-Weg mit 403 `VOICE_RECORDING_DISABLED` ab, nicht nur die Oberfläche. Lesen, Wiederholen und Verwerfen bleiben. Kosten bei falscher Annahme: Notizen, die offline vor dem Ausschalten aufgenommen wurden, werden beim Sync abgelehnt und bleiben mit Fehler in der Warteschlange (der Nutzer verwirft sie); der Rückweg ist damit sauber, aber nicht verlustfrei. Abschnitt 14.1.
114. **Kosten werden für die letzten 7 Tage gezeigt, zum Preis des eingestellten Modells**: Die Zähler der Systemseite kennen nur ein 7-Tage-Fenster, und die Preistabelle liegt in zwei Dateien (`lib/voice/prices.ts`, `src/voice/eval/voice-prices.ts`, ein Paritätstest). Kosten bei falscher Annahme: Der Inhaber liest eine Wochen-, keine Monatssumme; die echte Abrechnung steht in der Anthropic-Konsole. Abschnitt 14.5.
115. **Eine abgelehnte Anfrage darf serverseitig auf einem anderen Modell laufen**: Mit `VOICE_SERVER_FALLBACK=default` (Voreinstellung) setzt der Adapter für Sonnet und Opus den Ausweichweg von Anthropic; gebucht wird das tatsächlich antwortende Modell. Kosten bei falscher Annahme: Eine Notiz wird auf einem anderen, womöglich teureren Modell beantwortet als eingestellt, und das Tagesbudget zählt Aufrufe, nicht Euro; abschaltbar mit `off`, dann endet die Notiz bei einer Ablehnung `FAILED/REFUSED`. Abschnitt 14.4.
116. **Der Text einer Sprachnotiz darf zu Anthropic (Drittland)**: Der Inhaber hat den API-Schlüssel angelegt (2026-10-16); das gilt als Freigabe von Spec-Frage 3. Modell `claude-sonnet-5-5` mit `effort: low` (Frage 1); die Cloud-Erkennung des Browsers bleibt ohne Einwilligung aus (Frage 2). Eine Region ist nicht gesetzt (`inference_geo` bleibt aus), es gilt die Voreinstellung des Anthropic-Arbeitsbereichs. Kosten bei falscher Annahme: Fehlt die Freigabe, bleibt der Worker ohne das Secret `apicore-anthropic`, die Notizen bleiben `TRANSCRIBED` und sind als Text nutzbar; Datenschutzerklärung und Verzeichnis müssen die Übermittlung vor dem Einschalten nennen. Abschnitt 14.7.
117. **Die Grenzen der Vorschläge folgen dem Formular, nicht der Spec**: Volksstärke bis 40 und fünf Futterstufen, wie `CreateInspectionDto` und das Formular; für `broodFrames` ohne Obergrenze im Formular gilt im Sprachweg 40. Kosten bei falscher Annahme: Die Auswertung schlägt Werte vor, die der Autor der Spec nicht erwartet hat, oder verwirft zulässige (sie landen als Zahl in `dropped`); die Grenzen stehen an einer Stelle (`core/inspection-field-specs.ts`). Abschnitt 14.4.
118. **Meldungen zu Sprachnotizen gehören in die Kategorie „Vorschläge“**: „Sprachnotiz ist fertig“ und „konnte nicht ausgewertet werden“ haben die Kategorie `SUGGESTION`, weil sie zu Vorschlägen führen. Kosten bei falscher Annahme: Wer die Kategorie „Vorschläge“ in Telegram oder der App abschaltet, bekommt auch diese Meldungen nicht mehr; die Notiz bleibt unter `/voice` sichtbar. Abschnitt 14.5.
## 7. Kubernetes ## 7. Kubernetes
@@ -166,8 +253,10 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
- `pod-hardening`: Frontend mit `readOnlyRootFilesystem`, `emptyDir` auf `/tmp` und `/app/.next/cache`, ohne Service-Account-Token, Ressourcen laut Spec §7. - `pod-hardening`: Frontend mit `readOnlyRootFilesystem`, `emptyDir` auf `/tmp` und `/app/.next/cache`, ohne Service-Account-Token, Ressourcen laut Spec §7.
- `backend-roles`: `APICORE_ROLE=api` samt initContainern `migrate` und `seed` im Deployment `apicore-backend`, Deployment `apicore-worker`, CronJobs `apicore-job-<name>`. Das neue Backend-Image (CMD nur `node dist/main.js`) geht nur zusammen mit dieser Komponente live. Ohne `APICORE_ROLE` bricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration. - `backend-roles`: `APICORE_ROLE=api` samt initContainern `migrate` und `seed` im Deployment `apicore-backend`, Deployment `apicore-worker`, CronJobs `apicore-job-<name>`. Das neue Backend-Image (CMD nur `node dist/main.js`) geht nur zusammen mit dieser Komponente live. Ohne `APICORE_ROLE` bricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration.
- `api-hpa`: HPA `apicore-backend` (`autoscaling/v2`, 1–2 Pods bei 70 % CPU); entfernt `replicas` aus dem Deployment. - `api-hpa`: HPA `apicore-backend` (`autoscaling/v2`, 1–2 Pods bei 70 % CPU); entfernt `replicas` aus dem Deployment.
- `overlays/encry/`: Namespace `apicore`, ConfigMaps, Ingresses (Traefik, Middleware `apicore-cloudflare-only@kubernetescrd`, cert-manager `letsencrypt-prod` mit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (`example.com`, `sha256:000…`). - `overlays/encry/`: Namespace `apicore`, ConfigMaps, Ingresses (Traefik, Middleware `apicore-cloudflare-only@kubernetescrd`, cert-manager `letsencrypt-prod` mit HTTP-01), Image-Digests. Die ConfigMap-Werte sind seit 2026-10-15 die Live-Werte aus dem Cluster (Runbook Schritt 1, Commit `6e3e9ae`). Hosts und TLS in `ingress-*.yaml` und die Image-Digests sind bis zum Rollout noch Platzhalter (`example.com`, `sha256:000…`).
- `scripts/ensure-secrets.sh`: ergänzt fehlende Schlüssel in `apicore-backend-secret` und `apicore-telegram`, ohne vorhandene Werte zu ändern, und gibt nur Schlüsselnamen aus (`--dry-run` zeigt nur an). Die Secrets selbst legt der Inhaber an.
- `tests/`: `manifest-policy.test.mjs` prüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar). `render-stages.mjs` rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (`.gitea/workflows/manifests.yml`, nur `push` und `workflow_dispatch`, kein `pull_request`, weil der Runner im Cluster läuft) rendert mit `kubectl kustomize`, validiert mit `kubeconform -strict` und führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256. - `tests/`: `manifest-policy.test.mjs` prüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar). `render-stages.mjs` rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (`.gitea/workflows/manifests.yml`, nur `push` und `workflow_dispatch`, kein `pull_request`, weil der Runner im Cluster läuft) rendert mit `kubectl kustomize`, validiert mit `kubeconform -strict` und führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256.
- **Telegram-Secret.** `apicore-telegram` (Schlüssel `TELEGRAM_BOT_TOKEN`) bindet nur `apicore-worker` ein: als einzelne `secretKeyRef`-Variable mit `optional: true` am Haupt-Container, ohne `envFrom`, ohne Volume, nicht im initContainer. Die api bekommt das Secret nie. Fehlt es, startet der Worker normal, nur die Telegram-Schleifen laufen nicht. `TELEGRAM_BOT_USERNAME` steht in der ConfigMap `apicore-backend-config` (api und Worker lesen sie per `envFrom`; leerer Platzhalter, bis der Bot angelegt ist); das Token steht nie in einer ConfigMap. `manifest-policy.test.mjs` prüft für den Zielstand und jede Rollout-Stufe mit Worker: genau ein Worker-Pod, genau eine Token-Variable ohne Klartextwert, kein anderer Pod mit `TELEGRAM_BOT_TOKEN`. Einrichtung bei BotFather: je Umgebung ein eigener Bot, Gruppen aus (`/setjoingroups` → Disable), kein Webhook (der Worker ruft `getUpdates`). Der Worker läuft mit `replicas: 1`, weil der Versand das annimmt (Annahme 46); Claims und Empfangs-Lease bleiben auch mit mehr Pods korrekt.
**Rollen-Deployments** (ein Image, Rolle per `APICORE_ROLE`): **Rollen-Deployments** (ein Image, Rolle per `APICORE_ROLE`):
@@ -211,7 +300,7 @@ initContainer haben die Ressourcen der CronJob-Pods. Alle App-Pods laufen ohne S
**Images:** Das Base-Image `node:22-alpine` ist in beiden Dockerfiles per Digest gepinnt; ein Update ist ein bewusster Commit mit neuem Digest. Die CI taggt nur noch `sha-<commit>` und Versionen, kein `latest`. Das Overlay setzt die Digests der gebauten Images. Bis Schritt 3 bleibt beim Backend der Digest des laufenden Images. **Images:** Das Base-Image `node:22-alpine` ist in beiden Dockerfiles per Digest gepinnt; ein Update ist ein bewusster Commit mit neuem Digest. Die CI taggt nur noch `sha-<commit>` und Versionen, kein `latest`. Das Overlay setzt die Digests der gebauten Images. Bis Schritt 3 bleibt beim Backend der Digest des laufenden Images.
**Rollout:** nur nach dem Runbook im Plan `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md`, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA. **Rollout:** nur nach dem Runbook im Plan `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md`, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA. Stand 2026-10-16: Im Cluster wurde noch kein Schritt angewendet; vorbereitet sind nur die ConfigMap-Werte aus Schritt 1. Live laufen das Backend-Image `sha-f5b1964` und ein Frontend-Image vom 2026-10-12, im Namespace gelten nur die drei Policies aus `rke2-cluster`. Fortschritt: Gitea-Issue „Rollout in den Cluster“ (#28).
## 8. Backup & Restore ## 8. Backup & Restore
@@ -225,11 +314,11 @@ Der Seed (`node dist/seed.js`) läuft als zweiter initContainer `seed` nur im `a
Scheitert die Migration, scheitert der initContainer und der neue Pod startet nicht. Wegen `maxSurge: 1` und `maxUnavailable: 0` läuft der alte Pod weiter. Die Hauptprozesse starten ohne Prisma-CLI. Die `startupProbe` der api (bis 5 Minuten) bleibt für das alte Image, das die Migration noch im `CMD` ausführt. Scheitert die Migration, scheitert der initContainer und der neue Pod startet nicht. Wegen `maxSurge: 1` und `maxUnavailable: 0` läuft der alte Pod weiter. Die Hauptprozesse starten ohne Prisma-CLI. Die `startupProbe` der api (bis 5 Minuten) bleibt für das alte Image, das die Migration noch im `CMD` ausführt.
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren. **Erster Admin-Zugang:** `prisma/seed.ts` (`seedAdmin`) legt nur an, solange die `User`-Tabelle leer ist, also beim allerersten Start. Adresse aus `SEED_ADMIN_EMAIL` (Standard `[email protected]`), Passwort aus `SEED_ADMIN_PASSWORD`; fehlt es, erzeugt der Seed ein zufälliges. Ins Container-Log kommen einmalig URL (`FRONTEND_ORIGIN`), Benutzername und nur das erzeugte Passwort; ein vom Betrieb gesetztes Passwort erscheint nie im Log. Sobald irgendein Benutzer existiert, passiert nichts – auch nicht, wenn der Administrator seine Adresse geändert hat (früher entstand dann bei jedem Start ein weiterer ADMIN, #45). Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie: **Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`. - **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Seit T4b zusätzlich `PhenologyReport` (eigene Beobachtungen; Import nach `winterCheck`, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). `ApiaryPhenology` (abgeleiteter Saisonzustand, der Job `daily-evaluation` berechnet ihn neu) und `ApiaryWeatherDay` (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren `HoneyBatch`-Zeilen kein `initialAmount` tragen, werden beim Import wie in Migration `t4_honey_ledger` zurückgerechnet (Annahme 44, `src/backup/legacy-honey-ledger.ts`): `initialAmount` wird gesetzt; die `BOTTLING`-Buchungen mit der stabilen ID `legacy-bottling:<Abfüllung>` entstehen nur aus versiegelten Dateien, nach dem Abschnitt `batchMovement`, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur `initialAmount` bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (`honeyLedgerViolations`) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und `imported.honeyLedgerViolations = <Anzahl>` (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Seit T6 (Abschnitt 12) gehören `VeterinaryMedicine`, `VeterinaryMedicineLot`, `VeterinaryMedicineMovement`, `ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction` und `ComplianceAcknowledgement` zum versiegelten Bundle; keine dieser Tabellen steht in `MODELS_EXCLUDED_FROM_BACKUP` oder `LEGACY_UNSEALED_TABLES`, eine unversiegelte Altdatei überspringt sie und meldet sie unter `skipped`. Beim Einspielen stehen Mittel und Packung vor den Behandlungen, die Buchungen der Packung danach, Sperrbezirke folgen den Ständen. Behandlungen einer unversiegelten Datei verlieren `medicineId`, `medicineLotId` und `doseQuantity` (ohne Bewegungsbuch gäbe es eine Behandlung mit Packung, aber ohne Buchung, also eine Fälschung des Bestands); die Zahl steht als `imported.treatmentMedicineLinksDropped` in der Zusammenfassung, dazu eine Log-Warnung. Eine versiegelte Wiederherstellung ersetzt das Packungsbuch (Annahme 63). Hält im Zielsystem ein Entwurf den `openKey` einer Behördenanzeige unter anderer Id, gewinnt dieser Entwurf: Die Anzeige der Datei wird samt ihren Dokumenten (`AUTHORITY_NOTICE`) übersprungen und als `imported.authorityNoticeSkipped` bzw. `imported.authorityNoticeDocumentsSkipped` gezählt, die Ids stehen in `summary.skippedNoticeIds` (Annahme 64). Die Wiederherstellung schreibt keine Domänenereignisse, also auch kein `ColonyCreated` (Abschnitt 12.5). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits). Die Telegram-Tabellen werden nie exportiert: `TelegramLink` (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), `TelegramLinkToken` (Einmal-Tokens, nur Minuten gültig), `NotificationDelivery` (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und `TelegramPollState` (Update-Offset und Abfrage-Lease dieses Deployments). Die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `keLine truncated
- **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“). - **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“).
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe. - **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
@@ -244,14 +333,29 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad | | Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) | | Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt | | Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Step-up | Backup-Import, MFA ein/aus und Telegram verbinden verlangen aktuelles Passwort (+ TOTP); das Verbinden ist auf 10 pro Minute gedrosselt |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; `erp-inventory:read` zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix | | Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; `erp-inventory:read` zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` | | Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` |
| Wetter und Telegram | Wetter (Open-Meteo) nur aus der Worker-Schleife `phenology-weather` und der Wetter-Anzeige, über `src/weather/`: nur https-URLs aus der Konfiguration, keine Weiterleitungen (`redirect: 'error'`), 8 s Frist, Antwort höchstens 1 000 000 Byte, Fehlertexte fest (kein URL, keine Ursache, kein Schlüssel). Telegram (`api.telegram.org`) nur aus Worker-Schleifen über `TelegramClient` (`src/telegram/`): Klartext ohne `parse_mode`, Linkvorschau aus, Text höchstens 600 Zeichen ohne Steuer- und Richtungszeichen, keine Weiterleitungen, Antwort höchstens 256 kB; der Token steht nie in Logs oder Fehlern (`TelegramApiError` trägt Methode, Status, bereinigte Beschreibung, `retry_after` 1–3600 s, keine URL). Wächter `src/common/no-network-outside-loops.spec.ts`: Wetter-Hosts nur in `src/weather/`, `api.telegram.org` nur in `src/telegram/`, `fetch(` und Netzwerkpakete nur in den erlaubten Dateien (Graph-Client, Passwort-Prüfung, Webhook-Zustellung, Worker-Health, Wetter, Telegram), nie in Handlern oder Jobs; `handler-contract.spec.ts` verbietet den Import von `telegram-client` in Handlern |
| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | | Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Frontend-CSP | `src/proxy.ts` setzt für die Oberfläche `script-src 'self' 'unsafe-inline'` und `style-src 'self' 'unsafe-inline'` (dazu u. a. `media-src 'self' blob:` für die eigene Aufnahme). `'unsafe-inline'` ist ein getragenes Risiko: Next.js liefert eigene Inline-Bootstrap-Skripte, und sobald `script-src` einen Hash oder Nonce enthält, ignorieren Browser `'unsafe-inline'` und blockieren diese; ein Nonce würde jede Seite dynamisch rendern. Das Darstellungs-Skript im `<head>` (T5 A2) ist eine Konstante ohne Nutzerdaten. Wird die CSP verschärft, Hash für `appearanceScript()` ergänzen. |
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf | | Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings | | Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
**Umgebungsvariablen seit T4b/T4c** (Vorlagen in `.env.example`, `configmap.example.yaml`, `secret.example.yaml`; alle optional):
| Variable | Wo | Bedeutung |
|---|---|---|
| `WEATHER_HISTORY_SOURCE` | ConfigMap, API und Worker | Wetterquelle, Standard `open-meteo`; eine unbekannte Quelle stoppt den Start (Meldung nennt nur den Namen der Variable) |
| `OPEN_METEO_FORECAST_URL`, `OPEN_METEO_ARCHIVE_URL` | ConfigMap | Endpunkte, Standard die öffentlichen Open-Meteo-URLs; nur `https:` |
| `OPEN_METEO_API_KEY` | Secret | Bleibt leer (Annahme 42); falls gesetzt, wird er als `apikey` gesendet |
| `WEATHER_USER_AGENT_CONTACT` | ConfigMap | Kontakt für den Header `User-Agent: ApiCore/<Version> (+<Kontakt>)`; ohne Wert `(self-hosted beekeeping app)`; kein persönlicher Standard, auf druckbares ASCII reduziert |
| `TELEGRAM_BOT_USERNAME` | ConfigMap, API und Worker | Benutzername des Bots (ohne `@`, 5–32 Zeichen, `^[A-Za-z][A-Za-z0-9_]{4,31}$`), erzeugt den Deep Link; ungültig = nicht gesetzt, mit Warnung in jeder Rolle |
| `TELEGRAM_BOT_TOKEN` | Secret `apicore-telegram`, nur Worker | Bot-Token von BotFather. Der API-Pod darf das Secret nicht mounten. Fehlt er, ist Telegram aus; ein Benutzername ohne Token oder umgekehrt warnt im Log |
Die Manifeste binden das Secret nur am Worker ein (Abschnitt 7). Ohne Token laufen die Worker-Schleifen `telegram-send` und `telegram-updates` nicht (Abschnitt 10).
**Bekannte Restpunkte (bewusst nicht umgesetzt):** **Bekannte Restpunkte (bewusst nicht umgesetzt):**
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren. - Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
@@ -284,7 +388,7 @@ Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivitä
**Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre. **Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre.
**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen. **Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Neu sind seit T6 `ColonyCreated`, `ColonyMoved`, `ApiaryChanged` und `TskReferenceDayReached` (Abschnitt 12.5); `ColonyCreated` schreiben auch Teilen und CSV-Import. Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen.
**Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin. **Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin.
- Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`. - Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`.
@@ -296,12 +400,21 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende. **Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. **Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen; `Apiary` steht seit T6 an erster Stelle, also vor `Colony` (Annahme 50).
**Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören. **Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.
**Neue und geänderte Ereignisse.** `ColonySplit { parentColonyId, lineageId, queenSource | null, splitOn }` (Aggregat: das neue Volk) schreibt `ColoniesService.split` in der Teil-Transaktion. `QueenAssigned { colonyId, historyId, assignedOn, previousColonyId | null }` (Aggregat: die Königin) schreibt `QueensService.assignColony` nach dem Sperren beider Völker (eine Anweisung, aufsteigend nach id) und der Königin; ein geänderter vorheriger Stand zwischen Vorab-Lesung und Sperre ergibt 409. `ColonyEnded` trägt jetzt `{ status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `InspectionOverdue`, `VarroaMeasurementDue` und `QueenAged` haben zusätzliche, optionale Payload-Felder (Abschnitt „Jobs“, `daily-evaluation`). **Neue und geänderte Ereignisse.** `ColonySplit { parentColonyId, lineageId, queenSource | null, splitOn }` (Aggregat: das neue Volk) schreibt `ColoniesService.split` in der Teil-Transaktion. `QueenAssigned { colonyId, historyId, assignedOn, previousColonyId | null }` (Aggregat: die Königin) schreibt `QueensService.assignColony` nach dem Sperren beider Völker (eine Anweisung, aufsteigend nach id) und der Königin; ein geänderter vorheriger Stand zwischen Vorab-Lesung und Sperre ergibt 409. `ColonyEnded` trägt jetzt `{ status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `InspectionOverdue`, `VarroaMeasurementDue` und `QueenAged` haben zusätzliche, optionale Payload-Felder (Abschnitt „Jobs“, `daily-evaluation`).
**Ereignisse der Honig-Kette und der Pflanzenentwicklung (T4b).** Sieben Typen kommen in den Katalog (`domain-event-types.ts`), Aggregattypen `Extraction`, `HoneyBatch`, `PhenologyReport` und `Apiary` dazu; die Lieferung der Meldungs-API und der Autopilot-Regeln A4–A10 ergänzt `PhenologyRecomputeRequested` und `WinterCheckRecorded` (Aggregattyp `WinterCheck`):
- `ExtractionRecorded` und `ExtractionCorrected` (Aggregat `Extraction`, Payload `harvestIds`, bei der Korrektur nach der Änderung) schreibt `ExtractionsService` in der Transaktion des Schreibens, zusammen mit dem Audit-Eintrag.
- `HoneyBatchRecorded` und `HoneyBatchCorrected` (Aggregat `HoneyBatch`, Payload `extractionId`) schreibt `HoneyBatchesService` ebenso. `update` sperrt zuerst die Zeile und liest „vorher“ danach (Abschnitt 11.12).
- `PhenologyReported` und `PhenologyCorrected` (Aggregat `PhenologyReport`) schreibt `PhenologyService` mit Datensatz und Audit-Eintrag in einer Transaktion: Anlegen → `PhenologyReported`, Bearbeiten und Zurückziehen → `PhenologyCorrected` mit `previousApiaryId` (Annahme 47, Abschnitt 11.11). Ändert ein Bearbeiten nichts, entsteht kein Ereignis.
- `SeasonPhaseChanged` (Aggregat `Apiary`, Payload `apiaryId`, `phase`, `segment`, `previousPhase`, `previousSegment`, `source`) schreibt `PhenologyStateService`, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über `dailyDedupeKey`. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Der Handler `autopilot.apiary-sync` hört darauf; eine Einzelmeldung je Saisonwechsel gibt es bewusst nicht (Annahme 47).
- `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (Aggregat `Apiary`, ohne Schlüssel) schreibt der Handler `phenology.recompute` je aktivem Stand, wenn eine Meldung für alle Stände gilt; `phenology.recompute-apiary` rechnet damit einen Stand je Zustellung.
- `WinterCheckRecorded { colonyId }` (Aggregat `WinterCheck`) schreiben `WinterChecksService.create` und `update` in ihrer Transaktion (ein Verschieben auf ein anderes Volk meldet auch das alte); `autopilot.colony-sync` löst damit A9/A10 sofort auf.
`PhenologyReport`, `Extraction` und `HoneyBatch` stehen hinter `NotificationPreference` am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `Apiary` steht seit T6 an erster Stelle (Annahme 50). `NotificationDelivery` folgt danach, ihr schließen sich die T6-Tabellen an (Abschnitte 11.13 und 12.1).
**Handler-Übersicht (T4).** Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über `tx` schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (`lockRowForUpdate`; fehlt es, ist nichts zu tun), Status über `tx` neu lesen (beendet: nichts), Regel über `readRule(tx, …)` lesen (abgeschaltet: nichts vorschlagen). **Handler-Übersicht (T4).** Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über `tx` schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (`lockRowForUpdate`; fehlt es, ist nichts zu tun), Status über `tx` neu lesen (beendet: nichts), Regel über `readRule(tx, …)` lesen (abgeschaltet: nichts vorschlagen).
| Handler-ID | Ereignis | Wirkung | | Handler-ID | Ereignis | Wirkung |
@@ -314,21 +427,35 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
| `chains.queen-cells` | `InspectionRecorded` | Erledigt zuerst offene Ketten-Vorschläge (Notiz „Mit Durchsicht erledigt“): `SWARM_CHECK` bei Weiselzellen alle, sonst die bis zum Durchsichtstag fälligen; `QUEEN_ACCEPTANCE` (fällig bis Durchsichtstag) nur bei `queenSeen` oder `eggsPresent`. Bei `queenCellsPresent = true` und aktiver Regel `QUEEN_CELLS_SWARM_CHECK`: PLAN `SWARM_CHECK` (`inspections:read`), Anker Durchsicht-ID, fällig Durchsichtstag + `dueInDays` (7). | | `chains.queen-cells` | `InspectionRecorded` | Erledigt zuerst offene Ketten-Vorschläge (Notiz „Mit Durchsicht erledigt“): `SWARM_CHECK` bei Weiselzellen alle, sonst die bis zum Durchsichtstag fälligen; `QUEEN_ACCEPTANCE` (fällig bis Durchsichtstag) nur bei `queenSeen` oder `eggsPresent`. Bei `queenCellsPresent = true` und aktiver Regel `QUEEN_CELLS_SWARM_CHECK`: PLAN `SWARM_CHECK` (`inspections:read`), Anker Durchsicht-ID, fällig Durchsichtstag + `dueInDays` (7). |
| `chains.queen-acceptance` | `QueenAssigned` | Regel `QUEEN_ACCEPTANCE_CHECK` aktiv: PLAN `QUEEN_ACCEPTANCE` (`queens:read`), Anker `historyId`, fällig `assignedOn` + `dueInDays` (7). | | `chains.queen-acceptance` | `QueenAssigned` | Regel `QUEEN_ACCEPTANCE_CHECK` aktiv: PLAN `QUEEN_ACCEPTANCE` (`queens:read`), Anker `historyId`, fällig `assignedOn` + `dueInDays` (7). |
| `chains.split-schedule` | `ColonySplit` | Regel `SPLIT_SCHEDULE` aktiv und `queenSource` gesetzt: `RAISE_OWN` und `QUEEN_CELL` → PLAN `SPLIT_PLAN` (`colonies:read`), `QUEEN` → PLAN `QUEEN_ACCEPTANCE` (`queens:read`); Anker `lineageId`, Plan = Schritte der Variante ab `splitOn` (Abschnitt 11.6). | | `chains.split-schedule` | `ColonySplit` | Regel `SPLIT_SCHEDULE` aktiv und `queenSource` gesetzt: `RAISE_OWN` und `QUEEN_CELL` → PLAN `SPLIT_PLAN` (`colonies:read`), `QUEEN` → PLAN `QUEEN_ACCEPTANCE` (`queens:read`); Anker `lineageId`, Plan = Schritte der Variante ab `splitOn` (Abschnitt 11.6). |
| `autopilot.colony-sync` | 19 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` | Ruft `AutopilotService.syncColonyIn(tx, colonyId, berlinToday())` je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei `ColonySplit` neues und Muttervolk, bei `QueenAssigned` Volk und vorheriges Volk; Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). | | `autopilot.colony-sync` | 20 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue`, `WinterCheckRecorded` | Betrifft ein Ereignis mehrere Völker, sperrt der Handler sie zuerst in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend); dann ruft er `AutopilotService.syncColonyIn(tx, colonyId, berlinToday())` je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei `ColonySplit` neues und Muttervolk, bei `QueenAssigned` Volk und vorheriges Volk, bei `WinterCheckRecorded` das Volk der Prüfung (A9/A10 lösen sich sofort auf); Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). |
| `autopilot.apiary-sync` | `SeasonPhaseChanged` | Gleicht alle lebenden Völker des Stands ab (`AutopilotService.syncApiaryIn(tx, apiaryId, berlinToday())`): Stand sperren, Völker lesen, alle in einer Anweisung sperren (Id aufsteigend), Regeln und Saison einmal lesen, dann je Volk `syncColonyIn`. Ergänzt `autopilot.colony-sync`; es entsteht höchstens ein Ereignis je Stand und Tag, spätere Wechsel fängt der tägliche Schritt `autopilot` auf (Annahmen 48, 50). |
| `phenology.recompute` | `PhenologyReported`, `PhenologyCorrected` | Berechnet den Saisonzustand der betroffenen Stände in der Zustell-Transaktion neu (`PhenologyStateService.recomputeIn`, am späteren von Ereignistag und heute); bei `PhenologyCorrected` darf die Phase zurückfallen (`allowBackward`). Betroffen: Stand und, bei einer Korrektur, `previousApiaryId`; gilt eine Seite für alle Stände, schreibt er je aktivem Stand ein `PhenologyRecomputeRequested`. Fehler gehen als Wiederholung durch (Annahme 47). |
| `phenology.recompute-apiary` | `PhenologyRecomputeRequested` | Rechnet genau einen Stand neu; ein nicht mehr aktiver oder gelöschter Stand wird übersprungen. |
| `withdrawal.harvest-check` | `HarvestRecorded`, `HarvestCorrected` | Prüft Wartezeit-Konflikte der Ernte und schreibt die Meldung (Abschnitt 11.8). | | `withdrawal.harvest-check` | `HarvestRecorded`, `HarvestCorrected` | Prüft Wartezeit-Konflikte der Ernte und schreibt die Meldung (Abschnitt 11.8). |
| `withdrawal.bottling-check` | `BatchBottled`, `BottlingCorrected` | Wie oben für die Honigcharge der Abfüllung; bei `removed: true` nichts. | | `withdrawal.bottling-check` | `BatchBottled`, `BottlingCorrected` | Wie oben für die Honigcharge der Abfüllung; bei `removed: true` nichts. |
| `withdrawal.treatment-check` | `TreatmentRecorded`, `TreatmentCorrected` | Prüft Ernten des Volkes, die das (nachträglich erfasste oder verlängerte) Wartezeitfenster treffen. | | `withdrawal.treatment-check` | `TreatmentRecorded`, `TreatmentCorrected` | Prüft Ernten des Volkes, die das (nachträglich erfasste oder verlängerte) Wartezeitfenster treffen. |
| `withdrawal.extraction-check` | `ExtractionRecorded`, `ExtractionCorrected` | Prüft die Ernten der Schleuderung gegen die Wartezeitfenster (`conflictsForExtraction`) und meldet mit demselben Schlüssel wie Ernte und Abfüllung (`withdrawal-conflict:<Ernte>:<Volk>`): Ein Konflikt wird einmal gemeldet, egal welche Stufe ihn zuerst sieht (Abschnitt 11.8, 11.12). |
| `notifications.withdrawal-ended` | `WithdrawalEnded` | INFO „<Volk> ist wieder erntefähig“ (Abschnitt 11.8). | | `notifications.withdrawal-ended` | `WithdrawalEnded` | INFO „<Volk> ist wieder erntefähig“ (Abschnitt 11.8). |
| `notifications.task-due` | `TaskDue` | Erinnerung je fälliger Aufgabe (Abschnitt 11.9). | | `notifications.task-due` | `TaskDue` | Erinnerung je fälliger Aufgabe (Abschnitt 11.9). |
| `calendar-sync.queen-assignment` | `QueenAssigned` | `enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId)`. | | `calendar-sync.queen-assignment` | `QueenAssigned` | `enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId)`. |
| `calendar-sync.withdrawal` | `TreatmentRecorded`, `TreatmentCorrected` | `enqueueIn(tx, 'WITHDRAWAL', treatmentId)` (Abschnitt 11.8). | | `calendar-sync.withdrawal` | `TreatmentRecorded`, `TreatmentCorrected` | `enqueueIn(tx, 'WITHDRAWAL', treatmentId)` (Abschnitt 11.8). |
| `webhooks.forward` | erweitert um `QueenAssigned` | `queen.replaced` (siehe „Webhook-Zustellung“). | | `webhooks.forward` | erweitert um `QueenAssigned`, `HoneyBatchRecorded` | `queen.replaced`, `batch.created` (siehe „Webhook-Zustellung“). `HoneyBatchCorrected` sendet nichts. |
| `compliance.herd-change` | `ColonyCreated`, `ColonyMoved`, `ColonyStatusChanged` (lebend ↔ beendet), `ApiaryChanged` | HINWEIS „Bestand geändert …“ (Kategorie `COMPLIANCE`, höchstens eine ungelesene je Nutzer), legt nie eine Anzeige an (Abschnitt 12.9). |
| `compliance.tsk-reference-day` | `TskReferenceDayReached` | TSK-Entwurf (falls keiner offen) und WARNUNG „TSK-Stichtag …“; still bei gesendeter Meldung (Abschnitt 12.9). |
**Worker-Schleife `phenology-weather`** (T4b). Takt 3 600 000 ms, kein Weckkanal (keine Outbox); der erste Durchlauf folgt 10 s nach dem Start des Workers (`WEATHER_FIRST_TICK_DELAY_MS`). Ein Takt ruft `WeatherHistoryService.syncOnce(berlinToday(), signal)` auf und holt die fehlenden Tagesmittel der aktiven Stände mit Koordinaten (Ablauf und Regeln: Abschnitt 11.11). Die Schleife schreibt nur `ApiaryWeatherDay`: kein Ereignis, keine Meldung. Die Loops liegen in einer Karte nach Namen; `WorkerLoop.channel` ist optional. Zu den LISTEN-Kanälen kommt `notification_delivery` (Abschnitt 11.13). Der Test `test/worker-runtime.e2e-spec.ts` ersetzt `WEATHER_HISTORY_SOURCE` durch eine Attrappe, weil die Schleife sonst für jeden Stand der geteilten Test-DB den echten Dienst aufriefe.
**Zustellplanung.** Kein Handler: `NotificationsService.write` plant nach dem Einfügen der Meldungen (dieselbe Transaktion; ohne Aufrufer-Transaktion öffnet es eine eigene, damit Meldungen und Zustellungen zusammen bestehen) über den `NotificationDeliveryPlanner` je neu eingefügter Meldung höchstens eine `NotificationDelivery` für Telegram und weckt den Worker per `pg_notify('notification_delivery', '')`, nur wenn Zeilen entstanden sind. Eine wiederholte Meldung (gleicher `dedupeKey`) plant nichts. Regeln: Annahme 49, Abschnitt 11.13.
**Worker-Schleife `telegram-send`.** Takt 5 000 ms und auf `notification_delivery`; sie ist nur registriert, wenn `TELEGRAM_BOT_TOKEN` gültig gesetzt ist (`TelegramClient.isConfigured()`). Ein Takt ruft `TelegramSender.releaseStaleClaims()` und dann `drain(stopping.signal)` auf. Mit `telegram-updates` ist sie der einzige Netzzugriff zur Bot-API. Rechte kommen aus dem globalen `JobPermissionsModule` des Workers, `TelegramModule` importiert kein `PermissionsModule`.
**Worker-Schleife `telegram-updates`.** Takt 30 000 ms (die Wartezeit ohne Lease, nach 409 und nach Fehlern), kein Weckkanal; wie `telegram-send` nur mit gültigem `TELEGRAM_BOT_TOKEN` registriert. Ein Takt ruft `TelegramUpdatesService.pollOnce(stopping.signal)`; nach `'polled'` wird die Schleife sofort wieder geweckt, das Stop-Signal bricht einen laufenden Abruf ab. Störungen stehen nur in `TelegramPollState.lastError` (Verwaltung > System) und im Log; sie machen den Worker nicht ungesund. Ablauf und Regeln: Abschnitt 11.13.
### Webhook-Zustellung ### Webhook-Zustellung
**Einreihen.** `WebhooksService.enqueueDeliveries(db, event, payload)` legt je aktivem Abonnement des Webhook-Ereignisses eine `WebhookDelivery` an und weckt den Worker mit `pg_notify('webhook_delivery', '')`. Gesendet wird dabei nichts. Der Body `JSON.stringify({ event, data })` wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin: **Einreihen.** `WebhooksService.enqueueDeliveries(db, event, payload)` legt je aktivem Abonnement des Webhook-Ereignisses eine `WebhookDelivery` an und weckt den Worker mit `pg_notify('webhook_delivery', '')`. Gesendet wird dabei nichts. Der Body `JSON.stringify({ event, data })` wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin:
- Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`, `QueenAssigned` → `queen.replaced`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.updated` die Honigcharge, bei `queen.replaced` die Königin, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht. - Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`, `QueenAssigned` → `queen.replaced`, `HoneyBatchRecorded` → `batch.created`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.created` und `batch.updated` die Honigcharge (bei `batch.created` mit `honeyBatchInclude`, `src/honey-batches/honey-batch-include.ts`, sodass die Form von `GET /honey-batches/:id` gleich bleibt; `HoneyBatchCorrected` sendet nichts), bei `queen.replaced` die Königin, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht.
- Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `batch.created`, `queen.created`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie. - Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `queen.created`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie.
**Zustellung.** Die Worker-Schleife `webhook-deliveries` (alle 5 s und auf `webhook_delivery`) ruft `WebhookDeliveryWorker.releaseStaleClaims()` und dann `drain()` auf. Claim, Abschluss und Freigabe laufen über `outbox-claims.ts`: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (`createdAt`), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (`X-ApiCore-Signature: sha256=HMAC(secret, "<t>.<id>.<body>")`) und über `postWebhook` gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. `X-ApiCore-Delivery` ist die `WebhookDelivery.id` und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID. **Zustellung.** Die Worker-Schleife `webhook-deliveries` (alle 5 s und auf `webhook_delivery`) ruft `WebhookDeliveryWorker.releaseStaleClaims()` und dann `drain()` auf. Claim, Abschluss und Freigabe laufen über `outbox-claims.ts`: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (`createdAt`), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (`X-ApiCore-Signature: sha256=HMAC(secret, "<t>.<id>.<body>")`) und über `postWebhook` gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. `X-ApiCore-Delivery` ist die `WebhookDelivery.id` und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID.
@@ -344,18 +471,19 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
**`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit. **`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit.
**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PENDING`- und `PROCESSING`-Einträge bleiben immer stehen. **`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PROCESSING`-Einträge bleiben immer stehen, `PENDING` ebenso, mit der einen Ausnahme der Telegram-Zustellungen unten.
- Läufe, die seit mehr als 6 h `RUNNING` sind (`JOB_RUN_STALE_HOURS`, weit über jeder `activeDeadlineSeconds`), werden `FAILED` mit `error: 'aborted (pod ended without result)'`; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert. - Läufe, die seit mehr als 6 h `RUNNING` sind (`JOB_RUN_STALE_HOURS`, weit über jeder `activeDeadlineSeconds`), werden `FAILED` mit `error: 'aborted (pod ended without result)'`; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert.
- Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (`IdempotencyCleanupService`; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen). - Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (`IdempotencyCleanupService`; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen).
- `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten. - `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten.
- `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen. - `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen.
- `JobRun`-Zeilen nach 90 Tagen. - `JobRun`-Zeilen nach 90 Tagen.
- `NotificationDelivery` (nach `createdAt`, auch Antworten des Bots ohne Nutzer): `DELIVERED` und `SKIPPED` nach 30 Tagen, `FAILED` nach 90 Tagen, `PENDING` nach 7 Tagen (`PENDING_DELIVERY_MAX_AGE_DAYS`; eine so alte Zustellung ist hängen geblieben, sie wird mit Warn-Log samt Zahl gelöscht). `TelegramLinkToken`, die seit mehr als 24 h abgelaufen oder verbraucht sind.
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`). Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`, `notificationDeliveries.done`, `notificationDeliveries.failed`, `notificationDeliveries.pending`, `telegramLinkTokens`).
**`task-templates`** (täglich 05:00) ruft `TasksService.generateFromTemplates()` auf und meldet `{ created }`. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor `GET /tasks`. **`task-templates`** (täglich 05:00) ruft `TasksService.generateFromTemplates()` auf und meldet `{ created }`. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor `GET /tasks`.
**`daily-evaluation`** (täglich 04:30) führt feste Schritte in dieser Reihenfolge aus (`DAILY_EVALUATION_STEP_ORDER`, `runDailyEvaluationSteps` in `src/jobs/daily-evaluation-steps.ts`): `phenology`, `time-events`, `autopilot`, `suggestions-expiry`, `digest`. „Heute“ ist der Berliner Tag (`berlinToday()`, Annahme 41). Der Schritt `phenology` (Wetter, gemeldete Phasen) gehört zur Wetter-Stufe; sein Platz in der Reihenfolge steht schon fest. Ein scheiternder Schritt hält die übrigen nicht auf: Der Lauf endet `FAILED` mit allen Zählern (`JobPartialFailure`, `JobRun.result` bleibt erhalten), `error` nennt Schritt und Ursache (`Daily evaluation failed for: time-events: TaskDue`, bei Völkern `autopilot: 2 colonies`). **`daily-evaluation`** (täglich 04:30) führt feste Schritte in dieser Reihenfolge aus (`DAILY_EVALUATION_STEP_ORDER`, `runDailyEvaluationSteps` in `src/jobs/daily-evaluation-steps.ts`): `phenology`, `time-events`, `autopilot`, `suggestions-expiry`, `digest`. „Heute“ ist der Berliner Tag (`berlinToday()`, Annahme 41). Der Schritt `phenology` (`PhenologyStep`, Abschnitt 11.11) berechnet den Saisonzustand `ApiaryPhenology` für jeden aktiven Stand, je Stand eine Transaktion. Zähler `phenologyApiaries`, `seasonChanges`; ein gescheiterter Stand hält die anderen nicht auf (`JobPartialFailure`, Lauf `FAILED` mit Zählern). Er steht vor `time-events`, damit alle späteren Schritte den frischen Zustand sehen. Ein scheiternder Schritt hält die übrigen nicht auf: Der Lauf endet `FAILED` mit allen Zählern (`JobPartialFailure`, `JobRun.result` bleibt erhalten), `error` nennt Schritt und Ursache (`Daily evaluation failed for: time-events: TaskDue`, bei Völkern `autopilot: 2 colonies`).
- **`time-events`** (`src/jobs/steps/time-events.step.ts`) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den Tag des fachlichen Ereignisses. Je Typ eine Transaktion; scheitert eine Regel, laufen die übrigen weiter, und der Schritt wirft `JobPartialFailure` mit den Zählern der anderen. Das Ergebnis zählt die neuen Ereignisse je Typ. - **`time-events`** (`src/jobs/steps/time-events.step.ts`) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den Tag des fachlichen Ereignisses. Je Typ eine Transaktion; scheitert eine Regel, laufen die übrigen weiter, und der Schritt wirft `JobPartialFailure` mit den Zählern der anderen. Das Ergebnis zählt die neuen Ereignisse je Typ.
- `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): Ende des Wartezeitfensters über den Fenster-Helfer (Berliner Tag, Abschnitt 11.8), nur mit Wartezeit. Die SQL-Kandidatenabfrage nimmt einen Tag Rand auf jeder Seite, der Helfer entscheidet. - `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): Ende des Wartezeitfensters über den Fenster-Helfer (Berliner Tag, Abschnitt 11.8), nur mit Wartezeit. Die SQL-Kandidatenabfrage nimmt einen Tag Rand auf jeder Seite, der Helfer entscheidet.
- `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`). - `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`).
@@ -367,21 +495,27 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
- Handler: `TaskDue` → `notifications.task-due`; `WithdrawalEnded` → `notifications.withdrawal-ended` und `autopilot.colony-sync`; `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` → `autopilot.colony-sync`. - Handler: `TaskDue` → `notifications.task-due`; `WithdrawalEnded` → `notifications.withdrawal-ended` und `autopilot.colony-sync`; `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` → `autopilot.colony-sync`.
- **`autopilot`** (`AutopilotStep`): lädt Regeln und Saisonzustände einmal vorab (`preloadFor`) und gleicht dann jedes lebende Volk in einer eigenen Transaktion ab (`syncColonyIn`, Abschnitt 11.5). Zähler `autopilotColonies`, `suggestionsOpened`, `suggestionsResolved`. Scheitert ein Volk, läuft der Schritt weiter und endet mit `JobPartialFailure`. - **`autopilot`** (`AutopilotStep`): lädt Regeln und Saisonzustände einmal vorab (`preloadFor`) und gleicht dann jedes lebende Volk in einer eigenen Transaktion ab (`syncColonyIn`, Abschnitt 11.5). Zähler `autopilotColonies`, `suggestionsOpened`, `suggestionsResolved`. Scheitert ein Volk, läuft der Schritt weiter und endet mit `JobPartialFailure`.
- **`suggestions-expiry`**: `OPEN` mit `expiresAt` vor heute → `EXPIRED`, gibt den `dedupeKey` frei. Zähler `suggestionsExpired`; zweimal am selben Tag ändert nichts. - **`suggestions-expiry`**: `OPEN` mit `expiresAt` vor heute → `EXPIRED`, gibt den `dedupeKey` frei. Zähler `suggestionsExpired`; zweimal am selben Tag ändert nichts.
- **`digest`**: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler `digestsWritten`; ein Wiederholungslauf am selben Tag schreibt 0. - **`digest`**: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler `digestsWritten` und, nur wenn mindestens eine Übersicht geschrieben wurde, `digestWindowEnd` (Beginn des Schritts, ISO-Zeit; Zeitfenster der Saison-Zeile, Abschnitt 11.9); ein Wiederholungslauf am selben Tag schreibt 0 und merkt kein Fensterende.
**`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“. **`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“.
**`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22. **`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22.
**`voice-transcribe`** (CronJob alle 2 Minuten, Phasen `--phase=fetch` und `--phase=ingest` um den Whisper-Container): Abschnitt 14.3.
**Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `TimeEventsStep.run(Tag)` mit Tagen in den Jahren 2001 und 2002 auf: Der Schritt wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen. **Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `TimeEventsStep.run(Tag)` mit Tagen in den Jahren 2001 und 2002 auf: Der Schritt wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen.
### System-Seite ### System-Seite
Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist). Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist).
## 11. Logik-Ketten und Saison-Autopilot (T4a) **Telegram-Karte** (nur mit `system:manage`; Betrieb des Bots: Abschnitt 11.13). `GET /system/notification-deliveries?status=` liefert `telegram` und `deliveries`. `telegram.botNameSet` heißt nur, dass der Bot-Name gesetzt ist: Ob der Versand läuft, weiß die api nicht, weil sie das Token nie hat; er läuft nur im Worker, wenn das Secret vorhanden ist. `receiver`: `leaseOwner` (nur bei laufender Lease), `lastPollAgeSeconds` (DB-Uhr), `lastError`. `sender`: `pending`, `oldestPendingAgeSeconds` (längste Überfälligkeit; wegen der Ruhezeit Wartende zählen nicht), `failed24h` (in den letzten 24 h angelegt und fehlgeschlagen), `lastErrors` (letzte 5, mit `createdAt` = angelegt am). `deliveries`: die neuesten 100 (Status, Kategorie, Vorlage, Versuche, `availableAt`, Statuscode, Fehler, angelegt/zugestellt), nie Chat-ID, Nutzer oder Text. `POST /system/notification-deliveries/:id/retry` (204): nur `FAILED` (409 sonst, 404 unbekannt); setzt `PENDING`, Versuche 0, `availableAt = now()`, schreibt ein Audit und weckt den Worker. `POST /system/notification-deliveries/retry-failed` (200 `{ retried }`): dasselbe für die neuesten `FAILED`, höchstens 100 je Aufruf (bei mehr erneut aufrufen), unter Zeilensperre, ein Audit je Zeile.
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. Nicht Teil dieser Lieferung sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`, T4b), Wetter und gemeldete Pflanzenphasen, die Autopilot-Regeln A4–A10 und die Telegram-Zustellung (T4c). **Telegram-Karte (Oberfläche).** Die Karte erscheint nur mit `system:manage`, weil `GET /system/notification-deliveries` dieses Recht verlangt (die Seite selbst braucht `system:read`). Sie zeigt Bot (`botNameSet`, Name, Hinweis, dass der Versand nur im Worker läuft), Empfang (Lease oder „Kein Empfänger aktiv“, Alter des letzten Abrufs, `lastError` im Wortlaut des Servers), Versand (wartend, ältestes Wartendes mit Warnung ab 600 Sekunden, `FAILED` der letzten 24 Stunden, letzte fünf Fehler mit HTTP-Code) und die neuesten 100 Zustellungen (Status immer mit Wort). Liegt der letzte Abruf länger als 120 Sekunden zurück, erscheint auch mit Lease die feste Warnung „Kein Abruf seit über 2 Minuten“ (`role="status"`), das Alter steht getrennt daneben. „Erneut versuchen“ gibt es nur an `FAILED`-Zeilen; „Alle fehlgeschlagenen erneut versuchen“ fragt per `ConfirmDialog` nach („Mindestens N sind fehlgeschlagen.“, immer „Es werden höchstens 100 gesendet, neueste zuerst.“; Annahme 58). Offline sind die Knöpfe mit sichtbarem Grund „Nur mit Verbindung“ gesperrt. Die Ereignisnamen der Warteschlange stehen in `DOMAIN_EVENT_LABELS`.
## 11. Logik-Ketten und Saison-Autopilot (T4a bis T4c)
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13); T4 ist vollständig (Stand 2026-10-15).
### 11.1 Vorschläge ### 11.1 Vorschläge
@@ -389,7 +523,7 @@ Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensc
**PLAN und DO.** `PLAN` hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus `PLAN_STEP_TITLES`, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. `DO` ist genau eine Aufgabe und braucht ein Datum (eigenes oder `dueDate` des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus `renderSuggestion` mit dem `actionTemplate` der Regel. Die Aufgabe entsteht über `TasksService.createInTx` (Audit, `TaskCreated`, danach Kalender und Webhook). `sourceInspectionId` wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter `FOR SHARE` geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein `assignedUserId`, der nicht existiert oder inaktiv ist: 400 mit `issues`. **PLAN und DO.** `PLAN` hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus `PLAN_STEP_TITLES`, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. `DO` ist genau eine Aufgabe und braucht ein Datum (eigenes oder `dueDate` des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus `renderSuggestion` mit dem `actionTemplate` der Regel. Die Aufgabe entsteht über `TasksService.createInTx` (Audit, `TaskCreated`, danach Kalender und Webhook). `sourceInspectionId` wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter `FOR SHARE` geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein `assignedUserId`, der nicht existiert oder inaktiv ist: 400 mit `issues`.
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference). **Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference; davor seit T6 `Apiary`, Annahme 50).
| von | nach | Auslöser | | von | nach | Auslöser |
|---|---|---| |---|---|---|
@@ -425,7 +559,7 @@ Verwaltung > Automatisierung hat für die zehn Regeln mit eigenen Werten je ein
### 11.3 Saison und Pflanzenentwicklung ### 11.3 Saison und Pflanzenentwicklung
`SeasonService.statesFor` liefert je Stand den Zustand aus der Regel `PHENOLOGY` (`SeasonModule`). Heute entsteht der Abschnitt allein aus dem Kalender-Rückfall (Annahme 36; ein nicht aufsteigender `calendarFallback` fällt auf den Standard). `springSince` ist der Tag, an dem `SALWEIDE` dieses Jahres erreicht ist, sonst `null`. `SeasonService.statesFor` liefert je Stand den Zustand aus der Regel `PHENOLOGY` (`SeasonModule`). Der Abschnitt entsteht aus der gespeicherten Zeile `ApiaryPhenology` des laufenden Jahres (Phase aus Meldung, GTS und Kalender, Annahme 36; ein nicht aufsteigender `calendarFallback` fällt auf den Standard). Ohne Zeile oder bei einer Zeile, die älter als 2 Tage ist, gilt der reine Kalenderzustand. `springSince` ist der Tag, an dem `SALWEIDE` dieses Jahres erreicht ist (genähert, siehe Annahme 36), sonst `null`.
- **Durchsicht fällig** (`inspectionDueOn`): letzte Durchsicht (ohne Eintrag die Anlage des Volkes) plus das Intervall des heutigen Abschnitts aus `INSPECTION_INTERVAL` (Schwarmzeit 7 Tage; die Oberfläche prüft 7–9 Tage für die Schwarmzeit, sonst 7–35, der Server entscheidet). Wer vor Beginn von `FRUEHJAHR` zuletzt durchgesehen wurde, ist ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“). In `WINTERRUHE` ist nie etwas fällig. Fällt der Termin in den Winter, rückt er auf den Frühjahrsbeginn. - **Durchsicht fällig** (`inspectionDueOn`): letzte Durchsicht (ohne Eintrag die Anlage des Volkes) plus das Intervall des heutigen Abschnitts aus `INSPECTION_INTERVAL` (Schwarmzeit 7 Tage; die Oberfläche prüft 7–9 Tage für die Schwarmzeit, sonst 7–35, der Server entscheidet). Wer vor Beginn von `FRUEHJAHR` zuletzt durchgesehen wurde, ist ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“). In `WINTERRUHE` ist nie etwas fällig. Fällt der Termin in den Winter, rückt er auf den Frühjahrsbeginn.
- **Varroa-Messung fällig** (`varroaMeasurementDue`): letzte Messung (sonst Anlage) plus `varroaMeasureIntervalDays` (28); in `WINTERRUHE` einmal im Fenster 15.11.–20.12., wenn seit 01.11. nicht gemessen wurde (Winterfenster = Annahme nach LWG-Konzept, Restentmilbung im brutfreien Volk). Ein Fälligkeitstag, der in den Winter gefallen ist, rückt auf `springSince`. - **Varroa-Messung fällig** (`varroaMeasurementDue`): letzte Messung (sonst Anlage) plus `varroaMeasureIntervalDays` (28); in `WINTERRUHE` einmal im Fenster 15.11.–20.12., wenn seit 01.11. nicht gemessen wurde (Winterfenster = Annahme nach LWG-Konzept, Restentmilbung im brutfreien Volk). Ein Fälligkeitstag, der in den Winter gefallen ist, rückt auf `springSince`.
@@ -446,17 +580,24 @@ Quelle der Grenzen ist die LWG Veitshöchheim; die Zahlen stehen als Bänder in
### 11.5 Saison-Autopilot und „Die nächsten Schritte“ ### 11.5 Saison-Autopilot und „Die nächsten Schritte“
**Abgleich.** `AutopilotService.syncColonyIn(tx, colonyId, today)` (`AutopilotModule`) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (`loadAutopilotInputIn`) und die Regeln (`SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `VARROA_THRESHOLD` nur `recheckAfterDays`, `PHENOLOGY`). Ein beendetes Volk wird übersprungen (das Aufräumen ist `suggestions.colony-ended`). Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren `dedupeKey` nicht mehr vorkommt, werden `RESOLVED` („Bedingung entfällt“); bei gleichem Schlüssel frischt `refreshIn` Belege, Fälligkeit, Priorität und `reasonCode` auf; ein neuer Kandidat geht über `proposeIn` (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; `rules.A<n> = false` schaltet eine Regel ab, `INSPECTION_INTERVAL.active = false` nur A1. Der Handler `autopilot.colony-sync` und der Job-Schritt `autopilot` rufen ihn auf (Abschnitte 10 und „Jobs“). **Abgleich.** `AutopilotService.syncColonyIn(tx, colonyId, today)` (`AutopilotModule`) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (`loadAutopilotInputIn`) und die Regeln (`SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `VARROA_THRESHOLD` nur `recheckAfterDays`, `PHENOLOGY`). Ein beendetes Volk wird übersprungen (das Aufräumen ist `suggestions.colony-ended`). Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren `dedupeKey` nicht mehr vorkommt, werden `RESOLVED` („Bedingung entfällt“); bei gleichem Schlüssel frischt `refreshIn` Belege, Fälligkeit, Priorität und `reasonCode` auf; ein neuer Kandidat geht über `proposeIn` (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; `rules.A<n> = false` schaltet eine Regel ab, `INSPECTION_INTERVAL.active = false` nur A1. Der Handler `autopilot.colony-sync` und der Job-Schritt `autopilot` rufen ihn auf (Abschnitte 10 und „Jobs“). Wechselt ein Stand seine Saison (`SeasonPhaseChanged`), gleicht `autopilot.apiary-sync` alle seine lebenden Völker ab (`syncApiaryIn`, Sperrreihenfolge Stand, dann alle Völker in einer Anweisung: Annahme 50). Eine Winterprüfung (`WinterCheckRecorded`) gleicht das betroffene Volk sofort ab, sodass A9/A10 ohne Warten auf den Tagesjob verschwinden.
**Regeln A1–A3** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`; A4–A10 folgen später): **Regeln A1–A10** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`, Annahme 48):
| Nr | Absicht | Wann | Anker | Fälligkeit, Priorität | | Nr | Absicht | Wann | Anker | Fälligkeit, Priorität |
|---|---|---|---|---| |---|---|---|---|---|
| A1 | `INSPECT` | Abschnitt ≠ `WINTERRUHE`, `inspectionDueOn` ≤ heute + 2 (`INSPECTION_DUE`, bzw. `INSPECTION_FIRST_OF_YEAR`) | ID der letzten Durchsicht oder `none` | `inspectionDueOn`; überfällig = `HIGH`, sonst `NORMAL` | | A1 | `INSPECT` | Abschnitt ≠ `WINTERRUHE`, `inspectionDueOn` ≤ heute + 2 (`INSPECTION_DUE`, bzw. `INSPECTION_FIRST_OF_YEAR`) | ID der letzten Durchsicht oder `none` | `inspectionDueOn`; überfällig = `HIGH`, sonst `NORMAL` |
| A2 | `VARROA_MEASURE` | `varroaMeasurementDue` ≤ heute (`VARROA_MEASURE_DUE`, im Winterfenster `VARROA_WINTER_MEASURE`) | ID der letzten Messung oder `winter-<Jahr>` | Fälligkeitstag; wird von A3 unterdrückt, solange A3 gilt | | A2 | `VARROA_MEASURE` | `varroaMeasurementDue` ≤ heute (`VARROA_MEASURE_DUE`, im Winterfenster `VARROA_WINTER_MEASURE`) | ID der letzten Messung oder `winter-<Jahr>` | Fälligkeitstag; wird von A3 unterdrückt, solange A3 gilt |
| A3 | `VARROA_RECHECK` | Behandlungsende + `recheckAfterDays` ≤ heute, keine Messung nach dem Ende, Ende höchstens 60 Tage her (Annahme 38); `requiredPermission` `varroa:read` | Behandlungs-ID | Tag, ab dem die Bedingung gilt | | A3 | `VARROA_RECHECK` | Behandlungsende + `recheckAfterDays` ≤ heute, keine Messung nach dem Ende, Ende höchstens 60 Tage her (Annahme 38); `requiredPermission` `varroa:read` | Behandlungs-ID | Tag, ab dem die Bedingung gilt |
| A4 | `SUMMER_TREATMENT` (`PLAN`) | Abschnitt `SPAETSOMMER`, keine Behandlung seit Abschnittsbeginn (`SUMMER_TREATMENT_DUE`) | Jahr | heute; `HIGH` |
| A5 | `FEED` (`DO`) | `SPAETSOMMER` oder `HERBST`, keine Fütterung seit Beginn von `SPAETSOMMER` (`AUTUMN_FEED_MISSING`); in `HERBST` gilt der Kalendertag von `TRACHTENDE`, höchstens der Beginn von `HERBST` (Annahme 48) | Jahr | heute |
| A6 | `FEED` (`DO`) | nicht `WINTERRUHE`; Futter der letzten Durchsicht `WEAK` oder `CRITICAL`, sonst Volkskarte `CRITICAL` (`FEED_LOW`, Beleg `source` `INSPECTION` oder `COLONY_CARD`); keine Fütterung seit der Durchsicht (ohne Durchsicht: seit dem späteren von Anlagetag und heute − 60 Tage) | ID der Durchsicht oder `none` | heute |
| A7 | `HONEY_SUPER` (`DO`) | Phase ab `KIRSCHE` bis vor `LINDE` (Reihenfolge `PHENOLOGY_PHASES`), `colonyStrength` einer Durchsicht ab `springSince` des Jahres (sonst ab 1. Januar) ≥ `honeySuperMinFrames` (`HONEY_SUPER_CHECK`) | Jahr | heute |
| A8 | `QUEEN_REPLACE` (`PLAN`) | `SCHWARMZEIT` oder `SOMMER`, Königin gealtert (`isQueenAged`, `queenMaxAgeYears`; `QUEEN_OLD`) | `<Königin>-<Jahr>` | heute |
| A9 | `WINTER_CHECK` (`DO`) | `HERBST`, kein `WinterCheck` `AUTUMN` mit `winterYear` = Jahr (`WINTER_CHECK_AUTUMN_MISSING`) | `<Jahr>-AUTUMN` | heute |
| A10 | `WINTER_CHECK` (`DO`) | `FRUEHJAHR`, Einwinterung des Vorwinters vorhanden, `SPRING` fehlt (`WINTER_CHECK_SPRING_MISSING`) | `<Jahr−1>-SPRING` | heute |
Als „letzte Behandlung“ zählt das späteste Ende (`endDate ?? date`); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf `expiresAt` siehe Annahme 38. Als „letzte Behandlung“ zählt das späteste Ende (`endDate ?? date`); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf `expiresAt` siehe Annahme 38; alle Regeln sind einzeln über `config.rules` schaltbar. Der Grund von A4 und A5 nennt die Quelle der Phase aus `SeasonState.phaseSource` (gemeldet, geschätzt, Kalender).
**Nächste Schritte.** `NextStepsService` liefert `GET /colonies/:id/next-steps` (`ColonyNextSteps`, höchstens `maxStepsPerColony` = 3) und `GET /autopilot/today` (`AutopilotToday`: dringlichster Schritt über alle lebenden Völker mit demselben Vergleich, fällige Durchsichten je Stand, Zahl offener Vorschläge, Abschnitt je Stand); beide `colonies:read`, 404 bei unbekanntem Volk. Die Zahl der Abfragen ist unabhängig von der Zahl der Völker. **Nächste Schritte.** `NextStepsService` liefert `GET /colonies/:id/next-steps` (`ColonyNextSteps`, höchstens `maxStepsPerColony` = 3) und `GET /autopilot/today` (`AutopilotToday`: dringlichster Schritt über alle lebenden Völker mit demselben Vergleich, fällige Durchsichten je Stand, Zahl offener Vorschläge, Abschnitt je Stand); beide `colonies:read`, 404 bei unbekanntem Volk. Die Zahl der Abfragen ist unabhängig von der Zahl der Völker.
- Schritte = offene Aufgaben (`OPEN`, `IN_PROGRESS`) plus sichtbare offene Vorschläge, nur mit `tasks:read` (ohne: keine Schritte). Ein offener Vorschlag entfällt, wenn für das Volk eine offene Aufgabe derselben Absicht besteht. - Schritte = offene Aufgaben (`OPEN`, `IN_PROGRESS`) plus sichtbare offene Vorschläge, nur mit `tasks:read` (ohne: keine Schritte). Ein offener Vorschlag entfällt, wenn für das Volk eine offene Aufgabe derselben Absicht besteht.
@@ -487,20 +628,22 @@ Oberfläche: Das Aufräum-Sheet (von Vorschlagsliste, Schritten und „Heute“)
### 11.8 Wartezeit-Kette ### 11.8 Wartezeit-Kette
Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10) bestimmt die Wartezeit. Die Prüfung läuft bei Ernte, Abfüllung und nachträglich erfasster oder geänderter Behandlung (Schleuderung folgt mit T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10) bestimmt die Wartezeit. Die Prüfung läuft bei Ernte, Abfüllung und nachträglich erfasster oder geänderter Behandlung sowie seit T4b bei der Schleuderung (Handler `withdrawal.extraction-check`, Abschnitt 11.12).
- **Warnungen.** `POST/PATCH /harvests` und `/bottling-batches` antworten mit `warnings` (Format `PlausibilityWarning`: Code `WITHDRAWAL_ACTIVE`, Stufe `WARNUNG`; Stufen `HINWEIS`, `WARNUNG`, `BESTAETIGEN`) und nie mit 4xx wegen der Wartezeit. Text: „B3: Wartezeit nach Oxuvar endet am 20.08.2026.“. Überdecken mehrere Fenster den Erntetag, nennt die Warnung das längste. Die Prüfung läuft in der Transaktion nach dem Schreiben. - **Warnungen.** `POST/PATCH /harvests` und `/bottling-batches` antworten mit `warnings` (Format `PlausibilityWarning`: Code `WITHDRAWAL_ACTIVE`, Stufe `WARNUNG`; Stufen `HINWEIS`, `WARNUNG`, `BESTAETIGEN`) und nie mit 4xx wegen der Wartezeit. Text: „B3: Wartezeit nach Oxuvar endet am 20.08.2026.“. Überdecken mehrere Fenster den Erntetag, nennt die Warnung das längste. Die Prüfung läuft in der Transaktion nach dem Schreiben.
- **Meldung.** Pro Ernte und Volk höchstens eine Meldung (WARNUNG, Kategorie `WITHDRAWAL`, an Inhaber von `harvests:read` und `treatments:read`, Link `/harvests/<id>`, Schlüssel `withdrawal-conflict:<Ernte>:<Volk>`), auch wenn Abfüllung oder Behandlung erneut warnen; die erste Meldung gewinnt. Wurde die Ernte schon abgefüllt, hängt sie „Bereits abgefüllt: Los L-1, L-2.“ an (Rückruf-Relevanz). Korrekturen, die aus dem Fenster herausführen, nehmen eine frühere Meldung nicht zurück. - **Meldung.** Pro Ernte und Volk höchstens eine Meldung (WARNUNG, Kategorie `WITHDRAWAL`, an Inhaber von `harvests:read` und `treatments:read`, Link `/harvests/<id>`, Schlüssel `withdrawal-conflict:<Ernte>:<Volk>`), auch wenn Schleuderung, Abfüllung oder Behandlung erneut warnen; die erste Meldung gewinnt. Wurde die Ernte schon abgefüllt, hängt sie „Bereits abgefüllt: Los L-1, L-2.“ an (Rückruf-Relevanz). Korrekturen, die aus dem Fenster herausführen, nehmen eine frühere Meldung nicht zurück.
- **Ende.** `WithdrawalEnded` (Job `time-events`) löst `notifications.withdrawal-ended` aus: INFO „<Volk> ist wieder erntefähig“ (Kategorie `WITHDRAWAL`, `harvests:create` + `treatments:read`, Link `/colonies/<id>`, Schlüssel `withdrawal-ended:<Behandlung>:<endedOn>`). Der Handler schweigt, wenn `WITHDRAWAL_ENDED.notify` aus ist, das Volk nicht lebt, die Behandlung fehlt, ihr Ende nicht mehr `endedOn` ist (veraltetes Ereignis) oder ein weiteres Fenster des Volkes an diesem Tag läuft. - **Ende.** `WithdrawalEnded` (Job `time-events`) löst `notifications.withdrawal-ended` aus: INFO „<Volk> ist wieder erntefähig“ (Kategorie `WITHDRAWAL`, `harvests:create` + `treatments:read`, Link `/colonies/<id>`, Schlüssel `withdrawal-ended:<Behandlung>:<endedOn>`). Der Handler schweigt, wenn `WITHDRAWAL_ENDED.notify` aus ist, das Volk nicht lebt, die Behandlung fehlt, ihr Ende nicht mehr `endedOn` ist (veraltetes Ereignis) oder ein weiteres Fenster des Volkes an diesem Tag läuft.
- **Kalender.** Die Ansicht bekommt den Typ `WITHDRAWAL` als Spanne (`date` = erster Tag, `endDate` = letzter Tag einschließlich, id `withdrawal:<Behandlung>`), und zwar wie die Behandlungstermine nur mit `treatments:read`; eine ungültige `from`/`to`-Angabe antwortet 400. Der M365-Kalender bekommt einen ganztägigen Mehrtagestermin „Wartezeit <Volk> · <Mittel>“ (`date` = erster Tag, `endDate` exklusiv = erster erntefähiger Tag); ohne Wartezeittage oder bei einem Fenster von null Tagen (`withdrawalSpanOf`) entfällt der Termin (Eintrag `SKIPPED`, ein vorhandenes Graph-Event wird gelöscht). Abschaltbar über `syncWithdrawal` (Standard an). - **Kalender.** Die Ansicht bekommt den Typ `WITHDRAWAL` als Spanne (`date` = erster Tag, `endDate` = letzter Tag einschließlich, id `withdrawal:<Behandlung>`), und zwar wie die Behandlungstermine nur mit `treatments:read`; eine ungültige `from`/`to`-Angabe antwortet 400. Der M365-Kalender bekommt einen ganztägigen Mehrtagestermin „Wartezeit <Volk> · <Mittel>“ (`date` = erster Tag, `endDate` exklusiv = erster erntefähiger Tag); ohne Wartezeittage oder bei einem Fenster von null Tagen (`withdrawalSpanOf`) entfällt der Termin (Eintrag `SKIPPED`, ein vorhandenes Graph-Event wird gelöscht). Abschaltbar über `syncWithdrawal` (Standard an).
- **Oberfläche.** Die Formulare zeigen Warnungen nach dem Speichern als Toast „Ernte gespeichert · n Warnung(en)“ bzw. „Abfüllung angelegt · n Warnung(en)“ mit einer Zeile je Warnung; Warnungen bleiben stehen, bis sie bestätigt werden („Verstanden“), Hinweise laufen aus. Offline gespeicherte Ernten haben keine Warnungen; die Abfüllung arbeitet nur online. Die Bestätigungsliste (`WarningList`) ist für die Wassergehalt-Bestätigung vorbereitet. Der Kalender zeigt „Wartezeit“ an jedem Tag der Spanne. - **Oberfläche.** Die Formulare zeigen Warnungen nach dem Speichern als Toast „Ernte gespeichert · n Warnung(en)“ bzw. „Abfüllung angelegt · n Warnung(en)“ mit einer Zeile je Warnung; Warnungen bleiben stehen, bis sie bestätigt werden („Verstanden“), Hinweise laufen aus. Offline gespeicherte Ernten haben keine Warnungen; die Abfüllung arbeitet nur online. Die Bestätigungsliste (`WarningList`) ist für die Wassergehalt-Bestätigung vorbereitet. Der Kalender zeigt „Wartezeit“ an jedem Tag der Spanne.
### 11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht ### 11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` wird nie geschrieben. Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes. **Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`, `SEASON` und seit T6 `COMPLIANCE` („Behörden & Prüfung“, für die Hinweise „Bestand geändert“ und „TSK-Stichtag“, Abschnitt 12.1); die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
**Erinnerung je Aufgabe.** Handler `notifications.task-due` (`RemindersModule`, von `AppModule`, `WorkerModule` und `JobsModule` importiert) schreibt zu `TaskDue` je Empfänger eine Meldung (HINWEIS, `TASK_DUE`, Link `/tasks`, Schlüssel `task-due:<Aufgabe>:<dueOn>:<userId>`): „Heute fällig: <Titel>“, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: <Titel>“. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit `tasks:complete`; immer nur mit `tasks:read` und eingeschaltetem `taskDueEnabled` (fehlende Einstellung = an). Nur offene Aufgaben (`OPEN`, `IN_PROGRESS`); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen. **Erinnerung je Aufgabe.** Handler `notifications.task-due` (`RemindersModule`, von `AppModule`, `WorkerModule` und `JobsModule` importiert) schreibt zu `TaskDue` je Empfänger eine Meldung (HINWEIS, `TASK_DUE`, Link `/tasks`, Schlüssel `task-due:<Aufgabe>:<dueOn>:<userId>`): „Heute fällig: <Titel>“, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: <Titel>“. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit `tasks:complete`; immer nur mit `tasks:read` und eingeschaltetem `taskDueEnabled` (fehlende Einstellung = an). Nur offene Aufgaben (`OPEN`, `IN_PROGRESS`); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen.
**Morgenübersicht.** Der Schritt `digest` (`DigestService.runFor`) schreibt je aktivem Nutzer mit aktiver Rolle und `digestEnabled` höchstens eine Meldung je Tag (INFO, `DIGEST`, Link `/dashboard`, Schlüssel `digest:<Tag>:<userId>`). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (`tasks:read`), „Durchsicht fällig: n Völker am Stand …“ (`colonies:read` und `inspections:read`), „n Vorschläge warten.“ (`tasks:read`, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (`apiaries:read`, nur am Tag des Abschnittsbeginns). Ohne Zeile keine Übersicht. `requiredPermissions` der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird `FAILED`. **Morgenübersicht.** Der Schritt `digest` (`DigestService.runFor`) schreibt je aktivem Nutzer mit aktiver Rolle und `digestEnabled` höchstens eine Meldung je Tag (INFO, `DIGEST`, Link `/dashboard`, Schlüssel `digest:<Tag>:<userId>`). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (`tasks:read`), „Durchsicht fällig: n Völker am Stand …“ (`colonies:read` und `inspections:read`), „n Vorschläge warten.“ (`tasks:read`, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (`apiaries:read`, Saison-Zeile, siehe unten). Ohne Zeile keine Übersicht. `requiredPermissions` der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird `FAILED`.
**Saison-Zeile.** Saisonwechsel kommen nur hier vor, nie als Einzelmeldung (bewusste Abweichung von der Spec, Annahme 47). Die Zeile entsteht aus den `SeasonPhaseChanged`-Ereignissen mit `occurredAt` im Fenster `[von, bis)`: `bis` ist der Beginn des Schritts, `von` das `digestWindowEnd` des jüngsten abgeschlossenen `daily-evaluation`-Laufs (`SUCCEEDED` oder `FAILED`, unter den letzten 10), der eines gemerkt hat, sonst `bis` − 24 Stunden. Der nächste Lauf beginnt damit, wo der vorige aufhörte; jeder Wechsel steht genau einmal da, auch einer, den eine Meldung tagsüber nach 04:30 ausgelöst hat. Ein Lauf, der keine Übersicht schrieb (Wiederholung am selben Tag, kein Inhalt), merkt kein Fensterende, sodass das nächste Fenster weiter zurückreicht. Je Stand werden die Ereignisse zusammengezogen (erster vorheriger bis letzter Abschnitt); reine Phasenwechsel, der erste Zustand eines Jahres (kein vorheriger Abschnitt) und Wechsel ohne Nettoänderung ergeben keine Zeile, ebenso Stände ohne lebende Völker. Ein Wechsel vorwärts lautet „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“, ein Rückschritt (zurückgezogene Meldung; `HERBST` → `WINTERRUHE` zählt als vorwärts) neutral „Stand {Name}: wieder {Abschnitt}.“. Bekannte Ränder: Ein manueller Wiederholungslauf am selben Tag schreibt nichts, ein Ereignis, das nach `bis` committet, aber davor gestempelt ist (eine über 04:30 offene Transaktion), kann fehlen; beides ist selten und hingenommen.
**Job-Ausfall.** Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (Abschnitt 10, Jobs) einmal je Job und Berliner Tag (Kategorie `SYSTEM`). **Job-Ausfall.** Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (Abschnitt 10, Jobs) einmal je Job und Berliner Tag (Kategorie `SYSTEM`).
@@ -511,3 +654,494 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
**Übernahme.** `POST /inspections` nimmt `applyToColony` (höchstens 3 verschiedene Werte aus `occupiedFrames`, `broodStatus`, `feedStatus`). Durchsicht und Übernahme laufen in einer Transaktion: Der Server sperrt zuerst das Volk, speichert die Durchsicht, prüft dann, ob es eine neuere gibt (Datum, `createdAt`, `id`), und plant die Übernahme (`planColonyTakeover`) allein aus der gespeicherten Zeile, nie aus der Anfrage; `ColoniesService.updateIn(tx, …)` schreibt (inklusive Historie, Ereignissen und Audit-Eintrag mit Vorher/Nachher). Die Antwort trägt `colonyTakeover { applied, skipped[{ field, reason }] }` (leer, wenn nichts verlangt wurde). Gründe in der Prüfreihenfolge: `NO_PERMISSION` (braucht `colonies:update`, die Anfrage antwortet trotzdem 201), `COLONY_ENDED` (die Durchsicht bleibt gespeichert), `NEWER_INSPECTION` (blockiert jedes Feld), dann je Feld `NO_VALUE`, `OUT_OF_RANGE`. Eine Korrektur (PATCH) übernimmt nie etwas (`applyToColony` dort: 400). **Übernahme.** `POST /inspections` nimmt `applyToColony` (höchstens 3 verschiedene Werte aus `occupiedFrames`, `broodStatus`, `feedStatus`). Durchsicht und Übernahme laufen in einer Transaktion: Der Server sperrt zuerst das Volk, speichert die Durchsicht, prüft dann, ob es eine neuere gibt (Datum, `createdAt`, `id`), und plant die Übernahme (`planColonyTakeover`) allein aus der gespeicherten Zeile, nie aus der Anfrage; `ColoniesService.updateIn(tx, …)` schreibt (inklusive Historie, Ereignissen und Audit-Eintrag mit Vorher/Nachher). Die Antwort trägt `colonyTakeover { applied, skipped[{ field, reason }] }` (leer, wenn nichts verlangt wurde). Gründe in der Prüfreihenfolge: `NO_PERMISSION` (braucht `colonies:update`, die Anfrage antwortet trotzdem 201), `COLONY_ENDED` (die Durchsicht bleibt gespeichert), `NEWER_INSPECTION` (blockiert jedes Feld), dann je Feld `NO_VALUE`, `OUT_OF_RANGE`. Eine Korrektur (PATCH) übernimmt nie etwas (`applyToColony` dort: 400).
**Oberfläche.** Der Block „Volkskarte“ steht über „Durchsicht speichern“, nur mit `colonies:update` und nur, wenn Felder gefüllt sind: je Feld ein Kontrollkästchen mit „Futter: Ausreichend → Niedrig“, „Besetzte Waben: 6 → 9“, „Brut: Mittel → Gut“. Angehakt wird nur, was in dieser Durchsicht selbst eingegeben wurde (ein Wert aus „Letztes Mal“ hakt nie von allein an; Tippen auf das Feld hakt an, Leeren nimmt zurück, ein manuelles Abhaken bleibt). Gesendet wird `applyToColony` nur mit Recht und nur für gefüllte Felder. Online meldet ein Toast die Gründe („Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.“, „…: Das Volk ist beendet.“); gelungene Felder laden die Volks-Abfragen neu. Offline reiht die Warteschlange `applyToColony` mit ein und der Server entscheidet beim Sync still (NEWER_INSPECTION, COLONY_ENDED, NO_PERMISSION); „bisher“-Werte kommen aus der zwischengespeicherten Volksliste, ohne sie steht „bisher unbekannt“. **Oberfläche.** Der Block „Volkskarte“ steht über „Durchsicht speichern“, nur mit `colonies:update` und nur, wenn Felder gefüllt sind: je Feld ein Kontrollkästchen mit „Futter: Ausreichend → Niedrig“, „Besetzte Waben: 6 → 9“, „Brut: Mittel → Gut“. Angehakt wird nur, was in dieser Durchsicht selbst eingegeben wurde (ein Wert aus „Letztes Mal“ hakt nie von allein an; Tippen auf das Feld hakt an, Leeren nimmt zurück, ein manuelles Abhaken bleibt). Gesendet wird `applyToColony` nur mit Recht und nur für gefüllte Felder. Online meldet ein Toast die Gründe („Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.“, „…: Das Volk ist beendet.“); gelungene Felder laden die Volks-Abfragen neu. Offline reiht die Warteschlange `applyToColony` mit ein und der Server entscheidet beim Sync still (NEWER_INSPECTION, COLONY_ENDED, NO_PERMISSION); „bisher“-Werte kommen aus der zwischengespeicherten Volksliste, ohne sie steht „bisher unbekannt“.
### 11.11 Pflanzenentwicklung und Wetter
Stand: Schema, Wetterquelle, Berechnung, Job-Schritt, die Meldungs-API (Melden, Bearbeiten, Zurückziehen mit Neuberechnung) und die Oberfläche zum Melden (Offline-Warteschlange eingeschlossen) sind fertig.
**Tabellen** (Migration `20261015100000_t4_phenology`, Annahme 43). `PhenologyReport` hält eine gemeldete Phase (Stand oder alle Stände, Datum, Rücknahme per `revokedAt`; Index `(apiaryId, phase, date)`). `ApiaryPhenology (apiaryId, year)` ist der abgeleitete Saisonzustand. `ApiaryWeatherDay` ist der Wetter-Cache (Tagesmittel, `source` `forecast` oder `archive`, `fetchedAt`). Rechte `phenology:read`, `phenology:create`, `phenology:update` (Gruppe „Pflanzenentwicklung“). Backup: Abschnitt 8.
**Wetterquelle.** Open-Meteo, Annahme 42. Die Schnittstelle `WeatherHistorySource` (`src/weather/history/`) kapselt den Adapter `OpenMeteoHistorySource`. Das Tagesmittel ist `temperature_2m_mean` mit `timezone=Europe/Berlin`, also der Mittelwert der Stundenwerte des Berliner Kalendertags, auch an den Tagen der Zeitumstellung mit 23 und 25 Stunden. Die letzten 90 Tage kommen vom Forecast-Endpunkt (`past_days=92`, `forecast_days=1`), ältere Lücken vom Archiv (`start_date`, `end_date`). Das Fenster bestimmt der Dienst, nicht der Adapter. Werte außerhalb −50…50 °C und Nicht-Zahlen werden `null`. Jeder Fehler ist ein `WeatherSourceError` mit festem Text; er enthält weder URL noch Schlüssel noch Ursache. Konfiguration: Abschnitt 9. Jede Anfrage trägt `User-Agent: ApiCore/<Version> (+<WEATHER_USER_AGENT_CONTACT>)`, auch die Wetter-Anzeige der API (`WeatherService`, die dieselbe Konfiguration und Rundung nutzt).
**Schleife `phenology-weather`** (Abschnitt 10; `WeatherHistoryService.syncOnce`):
- Takt stündlich, erster Durchlauf 10 s nach dem Start des Workers.
- Aktive Stände mit Koordinaten, gruppiert nach dem auf 0,01° gerundeten Punkt: Stände am selben Punkt teilen eine Anfrage.
- Fehlend heißt: keine Zeile vom 1. Januar bis gestern (Berliner Tag), oder `meanTempC` ist in den letzten 90 Tagen `null` (ältere `null`-Werte werden nicht erneut geholt). Ein Stand, der nach dem 1. Januar angelegt wird, bekommt die Tage ab 1. Januar unabhängig vom Anlagedatum. Frühere Jahre für die Kalibrierung werden nicht nachgeholt; die Aufbewahrung beträgt 4 Jahre.
- Beide Bereiche eines Punkts werden zuerst geholt, dann in Blöcken von höchstens 200 Upserts je Transaktion geschrieben (`WEATHER_WRITE_CHUNK_SIZE`). Ein Fehler schreibt für den Punkt nichts, wird ohne URL geloggt und im nächsten Takt wiederholt.
- Abstand 300 ms zwischen den Punkten. Bei HTTP 429 endet der Durchlauf, und bis Retry-After (Sekunden oder HTTP-Datum, höchstens 24 h) wird auch über Takte hinweg nichts angefragt. Ein 503 mit Retry-After wirkt ebenso, ein 503 ohne ist ein gewöhnlicher Fehler. Drei fehlgeschlagene Punkte hintereinander beenden den Durchlauf (`endedEarly`); ein Erfolg setzt die Zählung zurück.
- Korrektur: Eine `forecast`-Zeile, die älter als das 90-Tage-Fenster ist, gilt als fehlend und wird durch den Archivwert ersetzt, höchstens einmal in 7 Tagen (`FORECAST_RECHECK_MS`, nach `fetchedAt`). Hat das Archiv keinen Wert, bleibt der gespeicherte stehen und nur `fetchedAt` wird aktualisiert; ein `null` aus dem Archiv überschreibt nie einen gespeicherten Wert.
- Je Durchlauf ein Log mit `points`, `daysWritten`, `failedPoints`, `endedEarly`.
- Der Job `cleanup` löscht `ApiaryWeatherDay` vor dem 1. Januar von (Berliner Jahr − 4); Ergebniszähler `weatherDays`.
**Berechnung.** Annahme 36 beschreibt Phase, GTS, Kalibrierung und Rückfall. Der Job-Schritt `phenology` (Abschnitt 10) und künftig jede Meldung oder Rücknahme rufen `PhenologyStateService.recomputeIn`. Die Funktion sperrt zuerst die Zeile `Apiary` und liest dann die gespeicherte Zeile, lädt die Meldungen der letzten 3 Jahre (Filter in SQL: eigener Stand oder alle Stände, nicht zurückgezogen) und das Wetter des Jahres sowie früherer Jahre mit Kalibrierungsmeldung, kalibriert die Schwellen und schreibt `ApiaryPhenology` per Upsert. Die Kalibrierung eines Vorjahres nimmt die GTS bis zum Vortag des ersten Meldetags, also das, was der Morgen jenes Tages gezeigt hätte. `recomputeAll` liest Regel und Meldungen einmal vorab und rechnet dann je aktivem Stand in einer eigenen Transaktion; ein gescheiterter Stand wird gezählt und geloggt, der Lauf geht weiter. Eine nach dem Vorab-Lesen zurückgezogene Meldung sieht nur die Neuberechnung dieser Meldung selbst. `ApiaryPhenology` speichert keine Notiz, kein `reportedOn` und kein `springSince`; `seasonStateFromRow` (`src/phenology/phenology-row.ts`) leitet sie ab (Notiz aus den Koordinaten, `reportedOn` leer). Die gespeicherte GTS ist nur gesetzt, wenn sie nutzbar war. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen, damit ein Rollout mitten in der Saison kein falsches `SeasonPhaseChanged` erzeugt. Der Handler bei `PhenologyCorrected` ruft `recomputeIn` mit `allowBackward: true` auf.
**Meldungen** (`PhenologyModule`, Annahme 47). Routen: `GET /phenology/state` (`apiaries:read`; je aktivem Stand Zustand ohne Koordinaten, `reportedOn` nur bei Quelle `REPORT` der früheste Meldetag der geltenden Phase), `GET /phenology/reports?year=` (`phenology:read`, Standard laufendes Jahr, auch zurückgezogene mit `revokedAt`, Stand- und Nutzername), `POST /phenology/reports` (`phenology:create`, `@Idempotent()`; Phase, Datum `JJJJ-MM-TT`, `apiaryId` oder `null` für alle, Notiz höchstens 500 Zeichen), `PATCH /phenology/reports/:id` (`phenology:update`; `null` auf Phase oder Datum: 400, auf Stand oder Notiz: leert), `POST /phenology/reports/:id/revoke` (`phenology:update`, `@Idempotent()`, schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (`lockRowForUpdate`, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler `phenology.recompute` und `phenology.recompute-apiary` (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9).
**Oberfläche** (Seite eines Standes). Der Kopf `ApiarySeason` zeigt Saisonabschnitt (`SeasonChip`), Phase und Quelle („gemeldet am TT.MM.“, „geschätzt“, „Kalender“) und bei schwacher Wetterbasis einen Hinweis. Das Intervall im Chip („alle 7 Tage“) kommt aus `GET /phenology/state` (`intervalDays` je Stand): das jetzt geltende Durchsichts-Intervall des Segments aus der Regel `INSPECTION_INTERVAL`, über dieselbe Funktion `inspectionIntervalOf` (`src/season/inspection-due.ts`) wie `inspectionDue[].intervalDays` und der Digest. Es ist `null` in der Winterruhe und bei inaktiver Regel; dann zeigt der Chip nur die Saison. Der Knopf „Pflanze blüht“ (`phenology:create`) öffnet das Blatt „Entwicklung melden“ (`PhenologyReportSheet`): acht Zeilen (Icon und Wort, 56 px), ein Tipp meldet die Phase für heute (Berliner Tag), vorgewählt „Für alle Stände“, wahlweise „Nur <Stand>“. Jede Phase und jeder Bereich ist eine eigene Meldung mit eigenem `Idempotency-Key`. Ein Serverfehler bleibt im Blatt sichtbar; während einer Meldung oder Rücknahme lässt es sich nicht schließen. Darunter stehen die Meldungen des Jahres (`phenology:read`; eigener Stand und „alle Stände“, zurückgezogene durchgestrichen) mit „Zurückziehen“ (`phenology:update`, nur online, sonst „Nur mit Verbindung“); das Zurückziehen einer Meldung für alle Stände fragt über den `ConfirmDialog` nach. Offline landet die Meldung im IndexedDB-Speicher `pending-phenology-reports` (`apicore-offline`, Version 4; gleiche Form wie die übrigen Einzel-Warteschlangen mit Schlüssel und Besitzer; Anzeige „Pflanzenmeldung“). Der Sync sendet ihn mit den anderen Warteschlangen (`POST /phenology/reports`, originaler Schlüssel; abgelehnte Einträge bleiben mit der Antwort des Servers stehen). Danach lösen Offline-Band und Warteschlangen-Sheet ein globales `invalidateQueries()` aus, sodass Saisonzustand und Meldungsliste neu laden. Eine Meldung für einen früheren Tag bietet die Oberfläche nicht an (nur die API per PATCH). Dass ein erneutes Öffnen des Blatts dieselbe Phase doppelt melden kann, ist Annahme 55.
### 11.12 Honig-Kette und Bewegungsbuch
Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook, die Laufzeit-Buchungen des Bewegungsbuchs und die Qualitätsprüfung der Abfüllung (API) sind fertig, ebenso die Oberfläche von Schleuderung, Honigcharge und Bewegungsbuch sowie die Abfüllungsmaske mit Artikelwerten und der Wassergehalt-Bestätigung (409).
**Ereignisse.** `ExtractionRecorded`/`ExtractionCorrected` und `HoneyBatchRecorded`/`HoneyBatchCorrected` entstehen in der Schreib-Transaktion mit dem Audit-Eintrag (Abschnitt 10). Der Webhook `batch.created` kommt über `webhooks.forward` aus `HoneyBatchRecorded`; `HoneyBatchCorrected` sendet nichts, wie vorher beim Ändern.
**Sperren.** `ExtractionsService.update` und `HoneyBatchesService.update` sperren die Zeile zuerst und lesen „vorher“ danach. Reihenfolge Schleuderung vor Honigcharge: `create` einer Charge sperrt die Schleuderung vor dem Schreiben (`lockRowForUpdate`). `update` liest die Schleuderungs-ID vorab, sperrt die Schleuderungen (bisherige und neue, `lockRowsForUpdate`), dann die Charge und liest sie neu; ist die Charge inzwischen zu einer anderen Schleuderung gewandert, antwortet die Anfrage 409. Die Mengenprüfung der Chargen einer Schleuderung läuft so unter der Schleuderungs-Sperre.
**Vorbelegung** (nur Vorschläge, Annahme 45; die Antwort-Klassen tragen in Swagger die Beschreibung „Vorschlag, keine Messung – nie stillschweigend übernehmen“):
- `GET /extractions/prefill?harvestIds=a,b` (Recht `extractions:create`, 1–50 verschiedene IDs, Komma-Liste oder wiederholter Parameter; vor `:id` deklariert): `amountKg` = Summe der Nettogewichte, nur wenn alle bekannt sind; `honeyType` nur, wenn alle Ernten dieselbe nicht leere Sorte haben; `sources` mit Berliner Tag; Hinweis `HARVEST_NET_MISSING`; Warnung `WITHDRAWAL_ACTIVE`. Unbekannte IDs: 400 mit derselben Meldung wie beim Anlegen.
- `GET /honey-batches/prefill?extractionId=` (Recht `honey-batches:create`, vor `:id`): `remainingKg` = Menge der Schleuderung minus Summe der vorhandenen Chargen (nie unter 0, `null` ohne Menge der Schleuderung), `honeyType` der Ernten, `waterContent` der Schleuderung, Warnungen. Unbekannte Schleuderung: 404.
- Beide Routen hängen an den Anlege-Rechten, weil sie dieselben Daten liefern wie der Anlege-Ablauf.
**Plausibilitätswarnungen** (`src/honey-batches/honey-plausibility.ts`; Antwort `warnings` bei `POST` und `PATCH`, nie 4xx; Toleranz 0,5 g; Zahlen deutsch formatiert):
- `EXTRACTION_EXCEEDS_HARVEST` (WARNUNG): Menge der Schleuderung größer als die Summe der Nettogewichte; nur wenn alle Nettogewichte bekannt sind.
- `HARVEST_NET_MISSING` (HINWEIS): mindestens eine Ernte ohne Nettogewicht, ein Vergleich ist nicht möglich.
- `BATCHES_EXCEED_EXTRACTION` (WARNUNG): Summe der Anfangsmengen der Chargen größer als die Menge der Schleuderung. Eine Charge ohne `initialAmount` zählt nicht (auch nicht für den Rest der Vorbelegung); der frühere Rückfall auf `amount` ist entfernt.
- `WITHDRAWAL_ACTIVE` bei der Schleuderung: Der Handler `withdrawal.extraction-check` und die Antwort prüfen über `WithdrawalCheckService.conflictsForExtraction`. Die Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`. Die Meldung nutzt denselben Schlüssel wie Ernte und Abfüllung (Abschnitt 11.8), ein Konflikt wird also nur einmal gemeldet.
**Bewegungsbuch.** Annahme 44. Schema und Rückrechnung: Migration `20261015100100_t4_honey_ledger`, `BatchMovement.honeyBatchId` (Fremdschlüssel `Restrict`, Index) und `sourceBottlingBatchId` (Fremdschlüssel `SetNull`, Index). Die Invariante `amount = initialAmount + Summe(quantity) / 1000` prüfen die Migration und der Import alter Bundles (Abschnitt 8) mit einer SQL-Abfrage (`INVARIANT_SQL` im Test-Helfer `test/utils/honey-ledger.ts`). Haltbarkeit: `Product.shelfLifeMonths` (Ganzzahl, optional; Migration `20261015100200_t4_product_shelf_life`). Laufzeit-Buchungen: Annahme 44.
**API des Buchs.** `GET /honey-batches/:id/movements` (`honey-batches:read`; 404 für eine unbekannte Charge) antwortet `{ items, hasMore }` mit höchstens 200 Buchungen, neueste zuerst (`id`, `type`, `quantityGrams`, `reason`, `notes`, `sourceBottlingBatchId`, `createdAt`, `userId`). `POST /honey-batches/:id/movements` (`honey-batches:update`, `@Idempotent()`) nimmt `{ type: 'WITHDRAWAL' | 'CORRECTION', deltaKg, reason?, notes? }` (`deltaKg` endlich, ungleich 0, Betrag höchstens 100 000; Notiz höchstens 500 Zeichen) und antwortet mit der Buchung. Eine `WITHDRAWAL` braucht einen Grund („Bitte einen Grund angeben.“) und eine negative Menge („Eine Entnahme braucht eine negative Menge.“); bei einer `CORRECTION` ist ein Grund erlaubt und wird gespeichert. Eine Charge ohne erfasste Menge ergibt 400 „Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.“; ein widersprüchlicher Bestand (`initialAmount` gesetzt, `amount` leer) 409. Die Buchung schreibt Audit `HoneyBatch UPDATE` und `HoneyBatchCorrected`. Die Oberfläche zeigt die Startmenge getrennt von der Liste der Buchungen und liest `.items`.
**Antwort-Form.** Antworten von `POST/PATCH /extractions` und `/honey-batches` tragen `warnings`. Eine Bestätigung (`acknowledgedWarnings`, 409) gibt es hier nicht; sie gehört zur Abfüllung (nächster Absatz).
**Qualität vor der Abfüllung** (Annahmen 52 und 53; `src/common/quality/water-content.ts`, `src/bottling/bottling-defaults.ts`). Der Wassergehalt der Honigcharge wird gegen die Regel `HONEY_WATER_CONTENT` geprüft (Standard: Warnschwelle 18 % nach D.I.B. „Echter Deutscher Honig“, Bestätigungsgrenze 20 % nach HonigV Anlage 2; je Honigsorte überschreibbar, etwa `{ "Heidehonig": { confirmAbovePercent: 23 } }`, Sorte ohne Beachtung der Groß-/Kleinschreibung, sonst exakt; ein Eintrag darf nur eine der beiden Grenzen setzen). Es zählt die neueste `HoneyMeasurement` (nach `measuredAt`), sonst `HoneyBatch.waterContent`.
- `WATER_CONTENT_HIGH` (WARNUNG): über der Warnschwelle. `WATER_CONTENT_MISSING` (HINWEIS): kein Wert. `WATER_CONTENT_ABOVE_LEGAL` (BESTÄTIGEN): streng über der Bestätigungsgrenze; genau 20,0 % warnt nur mit `WATER_CONTENT_HIGH`. `JAR_SIZE_DIFFERS` (HINWEIS): die gesendete Glasgröße weicht von `Product.packagingSize` ab.
- `POST /bottling-batches` antwortet bei `WATER_CONTENT_ABOVE_LEGAL` mit 409 `{ statusCode, code: 'CONFIRMATION_REQUIRED', message, waterContent, warnings }`, solange die Bestätigung fehlt. `warnings` enthält die Qualitätsbefunde der Anfrage (Wassergehalt und Glasgröße), nicht die Wartezeitwarnungen. Die Prüfung läuft in der Transaktion nach der Sperre der Honigcharge und vor dem Schreiben: Bei 409 entstehen weder Abfüllung noch Buchung, Ereignis oder Audit-Eintrag; eine zwischenzeitlich erfasste Messung zählt.
- Die bestätigte Wiederholung sendet denselben Body plus `acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']` (jeder andere Inhalt: 400) und `acknowledgedWaterContent` (Zahl 0–100) mit dem `waterContent` der 409-Antwort. Die Bestätigung gilt nur, wenn der unter der Sperre geprüfte Wert genau diesem Wert entspricht; sonst (neue Messung, Code ohne Wert, anderer Wert) folgt eine frische 409 mit dem neuen Wert. Der Idempotenz-Schlüssel wird bei Fehlern freigegeben, die Wiederholung darf also denselben `Idempotency-Key` nutzen; eine weitere Anfrage mit diesem Schlüssel nach dem 201 gibt die gespeicherte Antwort wieder.
- Die 201-Antwort trägt zuerst die Qualitätsbefunde (auch den bestätigten `WATER_CONTENT_ABOVE_LEGAL`), dann die Wartezeitwarnungen. `PATCH /bottling-batches/:id` und die Messungs-Routen der Honigcharge (`POST` und `PATCH …/measurements`, Antwort mit `warnings`) liefern nie 409. Das `PATCH` meldet Qualitätsbefunde nur, wenn sich Honigcharge oder Glasgröße ändern.
- Vorbelegung `GET /bottling-batches/prefill?honeyBatchId=&productId=&date=` (`bottling:create`, vor `:id` deklariert): `{ jarSize, jarSizeSource: 'PRODUCT' | null, bestBeforeDate, shelfLifeMonths, waterContent, warnings }`. Die Glasgröße kommt vom Artikel (`packagingSize`), das MHD aus `Product.shelfLifeMonths` (1–60, beim Anlegen und Ändern des Artikels setzbar, `null` löscht): Abfülltag plus Monate, gekürzt auf das Monatsende (31.01. + 1 Monat = 28.02.); ein Zeitstempel zählt als sein Berliner Tag, `date` ist standardmäßig heute. `honeyBatchId` fehlt: 400; unbekannte Charge oder Artikel: 404; Artikel nicht `BOTTLED_HONEY`: 400. Beim Anlegen gewinnt ein gesendeter Wert, sonst der Artikelwert; fehlt beides, antwortet die API mit 400 („Glasgröße und MHD fehlen. …“, „Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.“, „Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.“). Unmögliche Tage (30.02.) in `date`, `bestBeforeDate` und der Vorbelegung ergeben 400 „Das Datum ist ungültig. …“.
**Oberfläche der Honig-Kette.** Vorbelegungen heißen „Vorschlag aus …“ („aus 2 Ernten“, „aus den Ernten: Raps“, „aus der Schleuderung (Rest)“) und werden nur durch einen Knopf übernommen. Laden die Hinweise nicht, zeigt `PrefillStatus` (feste `aria-live`-Zeile) „Hinweise (z. B. Wartezeit) konnten nicht geladen werden.“ mit „Erneut versuchen“; Speichern bleibt erlaubt, das Backend prüft (Annahme 53). `lib/honey-batches/errors.ts` (`describeSaveError`, `saveErrorText`) ist der einzige Ort, der einen fehlgeschlagenen Speichervorgang liest (400-`issues` je Feld, Nest-`message[]` durch deutschen Text ersetzt, 409-Meldung durchgereicht, Netzfehler). Das Anlegen einer Honigcharge bietet „Wassergehalt (%)“ mit dem Vorschlag aus der Schleuderung. Die Detailseite hat die Karte „Bewegungen“ (Buchungen mit Link zur Abfüllung, Grund und Korrektur mit Vorzeichen; bei `hasMore` der Hinweis „Ältere Buchungen werden nicht angezeigt“). „Entnahme“ (nur `WITHDRAWAL`, Grund Pflicht und nicht vorgewählt, Menge mindestens 1 g und höchstens der Rest) und „Menge korrigieren“ (Ausgangswert beim Öffnen gesperrt, Differenz sichtbar, bei 0 „Keine Abweichung“, Grund optional, Buchung als „Korrektur“ beschriftet) gibt es mit `honey-batches:update` und erfasster Menge. Beide Auslöser sind offline gesperrt („Nur mit Verbindung“) und buchen mit eigenem Schlüssel je Buchung. Alle Abfragen der Detailseite liegen unter dem Schlüsselpräfix `["honey-batches"]`, das beide Dialoge ungültig machen. Die Bestätigung beim Wassergehalt (409) gehört zur Abfüllungsmaske (nächster Absatz), nicht zu diesen Dialogen.
**Abfüllungsmaske** (`/bottling/new`, Annahme 57). Sie fragt `GET /bottling-batches/prefill` je Honigcharge, Produkt und Datum ab; die letzte Antwort bleibt beim Nachladen stehen, zählt für Beschriftung, Differenz-Hinweise und Wassergehalt aber nur für genau diese Charge, dieses Produkt und diesen Tag. Glasgröße und MHD stammen als Stammdaten vom Artikel (keine Beobachtung) und nennen ihre Herkunft („aus Artikel: 24 Monate“, „aus Artikel: 250 g“, „aus Entwurf“, bei fehlgeschlagenem Abruf „nicht aus Artikel“). Eine eigene Eingabe gewinnt, bis sich das Produkt ändert (dann werden die Eingaben zurückgesetzt); ein Datumswechsel lässt sie stehen. Ein wiederhergestellter Entwurf behält die Kennzeichnung getippter Werte. Weichen Glasgröße oder MHD vom Artikel ab, erscheint der Hinweis schon vor dem Speichern („Glasgröße weicht vom Artikel ab (Artikel: 250 g).“). Wassergehalt der Charge und Server-Hinweise stehen in einem dauerhaften Statusbereich (Symbol plus Wort); `PrefillStatus` meldet Laden und Fehler mit „Erneut versuchen“, und „Abfüllung anlegen“ ist gesperrt, solange die Artikelwerte laden („Artikelwerte werden geladen …“). Die Abfüllung wird nicht offline eingereiht: Offline ist der Knopf mit sichtbarem Grund „Nur mit Verbindung“ gesperrt.
Antwortet das Anlegen mit 409 `CONFIRMATION_REQUIRED`, öffnet ein Dialog mit Fokusfalle den gemessenen Wert („Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?“). „Trotzdem abfüllen“ (genau eine Anfrage, auch bei Doppelklick) sendet denselben Body mit `acknowledgedWarnings` und `acknowledgedWaterContent` unter demselben `Idempotency-Key`; eine frische 409 mit neuem Wert fragt erneut. „Abbrechen“ schreibt nichts, behält das Formular, gibt den Fokus zurück und erneuert den Schlüssel. Der Schlüssel wird außerdem erneuert, sobald der Body vom zuletzt gesendeten abweicht (auch durch eine geänderte Vorbelegung), und nach einem Erfolg. Fehler beim Speichern liest allein `describeSaveError`.
**Haltbarkeit und Gewicht am Artikel.** Das Artikelformular (neu und bearbeiten) hat „Haltbarkeit (Monate)“ (ganze Monate 1–60, leerbar) und „Gewicht pro Glas (g)“ (`packagingSize`, ganze Gramm 1–10000). Im Bearbeiten-Dialog wird das Gewicht nur geprüft, wenn es geändert wurde; ein alter Dezimalwert wird nie umgerechnet. Die Prüfung von `packagingSize` im Backend (`@IsInt`, 1–10000) ist ein Folgepunkt, zuvor wird die Produktionsdatenlage geprüft (Rollout-Schritt mit Freigabe).
### 11.13 Telegram
Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfangen, die Betriebsansicht und die Oberfläche (Profil-Karte, Step-up, Systemkarte) sind fertig (`TelegramModule` importiert `WorkerRuntimeModule` und `WorkerModule`). `NotificationPreference.telegramEnabled` schaltet das Verbinden ein, das Trennen (und der Sender bei 403) aus (Abschnitt 11.9).
**Tabellen** (Migration `20261015110000_t4_telegram`). Enums `NotificationChannel` und `NotificationDeliveryStatus`. `TelegramLink` (Chat-Bindung eines Nutzers; eindeutig je Chat), `TelegramLinkToken` (Einmal-Token zum Verknüpfen), `NotificationDelivery` (Zustell-Warteschlange), `TelegramPollState` (genau eine Zeile `singleton`, per CHECK `id = 'singleton'`). Alle Fremdschlüssel auf `User` laufen mit `ON DELETE CASCADE`, ebenso `Notification.deliveries`. Keine dieser Tabellen ist Teil des Backups (Abschnitt 8).
**`NotificationDelivery`.** Eindeutig je `(notificationId, channel)`, Index auf `notificationId`. Der CHECK `NotificationDelivery_target_check` verlangt entweder Meldung und Nutzer oder Vorlage und Chat. Zustellungen mit Vorlage haben `chatId` und kein `userId`; sie entfernt die Nutzer-Kaskade nicht. Ihre Aufräumregeln nach Alter stehen im Job `cleanup` (Abschnitt 10).
**Dritte Outbox.** `NotificationDelivery` nutzt `src/common/outbox/outbox-claims.ts` wie Ereignisse, Webhooks und Kalender: `claimBatch`, `settleSuccess`, `settleFailure`, `releaseStaleClaims`. Neu sind `deferClaim` (zurück auf `PENDING` nach einer Wartezeit, Versuche unverändert, Wartezeit höchstens 24 h) und `settleSkipped` (`SKIPPED` mit Grund, höchstens 200 Zeichen). Beide sind Compare-and-Set auf `id` + `claimedAt` und nutzen die DB-Uhr. Weckkanal `notification_delivery` (`worker-wakeup.ts`); `NotificationDelivery` steht in der Sperr-Karte ganz zuletzt.
**`TelegramClient`** (`src/telegram/`; Konfiguration per Token `TELEGRAM_CONFIG`, bereitgestellt von `TelegramModule`). Aufrufe `getUpdates` (`allowed_updates ["message"]`; Updates ohne `message.chat` werden übersprungen) und `sendMessage`. Sicherheit (Annahme 46, Abschnitt 9):
- Der Token steht nie in Logs, Fehlern oder `lastError`. `TelegramApiError` trägt Methode, Status, bereinigte Beschreibung und `retry_after`; `describeTelegramFailure` folgt `cause` höchstens 5 Ebenen tief und gibt nie die Meldung zurück.
- Nur HTTPS zu `api.telegram.org`; `redirect: 'error'`, eine Weiterleitung ergibt „network error“ ohne URL und Token; Antwortkörper werden gestreamt und bei 256 kB abgebrochen.
- `retry_after` wird auf 1–3600 s begrenzt (Nicht-Ganzzahlen und Texte werden `null`).
- Der Text wird bereinigt (`sanitizeTelegramText`): Steuerzeichen und die Richtungs- und Nullbreitenzeichen U+200B–200F, U+202A–202E, U+2066–2069 entfernt, höchstens 600 Zeichen nach Codepunkten (kein halbes Surrogatpaar) mit Auslassungspunkten; Klartext, kein `parse_mode`.
- Ein fehlerhaft geformter Token macht den Client unbrauchbar („bot token not configured“), ohne dass er ruft.
- `assertTelegramToken(role, env, logger?)` (`src/runtime/startup-checks.ts`) prüft nur die Rollen `worker` und `all`. Ein fehlender Token ist in Ordnung. Ein fehlerhafter (in Produktion auch ein Platzhalter wie `change-me…`) wirft nie: Fehler-Log ohne Wert, Telegram bleibt aus. Ein Benutzername ohne Token oder umgekehrt warnt nur im Log.
- `TELEGRAM_BOT_USERNAME` verliert ein führendes `@` und muss `^[A-Za-z][A-Za-z0-9_]{4,31}$` erfüllen, sonst gilt er als nicht gesetzt (`telegramBotAvailable()` falsch) mit Warnung.
- Der Wächter `no-network-outside-loops.spec.ts` hält fest, dass das Netzwerk nur in den erlaubten Dateien und nur aus Schleifen genutzt wird (Abschnitt 9).
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE`, `DIGEST` und seit T6 `COMPLIANCE` (Migration `20261016100400_t6_notification_compliance_default`, Abschnitt 12.1); `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. `COMPLIANCE` hat wie `SYSTEM` keine Tageszeit (nur die Ruhezeit) und ist 24 Stunden frisch. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
**Texte** (`src/telegram/telegram-texts.ts`). `TELEGRAM_TEMPLATES` hält die festen Texte, `renderTelegramText` baut daraus den Klartext: ohne Details nur „Neue Meldung in ApiCore.“ und Link, Standardlink `/dashboard`, ein Schrägstrich am Ende von `FRONTEND_ORIGIN` wird toleriert. Höchstens 600 Zeichen nach Codepunkten; die Meldung wird mit Auslassungspunkten gekürzt, der Link bleibt ganz, Steuer- und unsichtbare Zeichen entfallen. Ohne `FRONTEND_ORIGIN` entfällt der Link (Annahme 46).
**Senden** (`TelegramSender`, Schleife `telegram-send`, Abschnitt 10). Ein Durchlauf übernimmt höchstens 25 Zustellungen (`claimBatch`, älteste zuerst). Je Batch geht höchstens eine Zustellung je Nutzer bzw. Chat hinaus, die übrigen kommen 1,1 s später (`deferClaim`); ein Speicher je Chat (10 s) hält auch aufeinanderfolgende Durchläufe und Bot-Antworten auseinander, und zwischen zwei Sendungen liegen mindestens 40 ms (25 pro Sekunde). `drain` wartet nach einer Runde mit zurückgehaltenen Nachrichten 1,1 s, damit ein Schwall für eine Person nicht auf den nächsten 5-s-Takt wartet; ein Batch ist auf 120 s begrenzt (`TELEGRAM_BATCH_BUDGET_MS`), danach gehen die übrigen ohne Fehlversuch zurück, ebenso beim Herunterfahren. Vor jeder Sendung prüft eine SQL-Abfrage mit frisch gelesenen Rechten (`PermissionResolverService.getFreshPermissions`) die Gründe aus Annahme 49 in dieser Reihenfolge: `user inactive`, `telegram disabled`, `not linked`, `category off`, `permission revoked`, `stale`, `task already done`, dazu `notification gone` und `unknown template`; ein Treffer ergibt `SKIPPED` mit Grund. Antworten des Bots (`templateCode`) überspringen alle Prüfungen. Die Frische rechnet in SQL (Berliner Tag bzw. 24 Stunden gegen `now()`).
**Antworten von Telegram.**
- 200: `DELIVERED`.
- 429: `deferClaim` um `retry_after` (ohne Angabe 5 s), kein Fehlversuch; der Sender pausiert für den ganzen Pod bis dahin (`pausedUntil`, der Rest des Batches geht zurück).
- 403 und 400 „chat not found“ (Bot blockiert, Chat gelöscht): Bei einer Nutzer-Zustellung sperrt eine Transaktion `NotificationPreference` (`lockRowForUpdate`), entfernt über den gemeinsamen Helfer `removeLinkIn` (siehe „Befehle“) die `TelegramLink` mit `userId` und `chatId`, schaltet nur dann `telegramEnabled` aus und schreibt die Meldung „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ (`SYSTEM`, HINWEIS), wenn die Verbindung noch auf diesen Chat zeigt; die Zustellung wird `SKIPPED` („chat unavailable“). Antworten des Bots werden nur `SKIPPED`.
- 401 (Token ungültig): `deferClaim` um 15 min, der Sender pausiert 15 min (monotone Uhr, kein Claim), und `system:manage` bekommt einmal je Berliner Tag „Telegram: Bot-Token ungültig.“ (Schlüsselpräfix `telegram-token-invalid:<Tag>`). Scheitert die Meldung, wird das nur geloggt.
- Alles andere (5xx, Zeitüberschreitung, Netzfehler, sonstige 4xx, unerwartete Ausnahmen): `settleFailure` mit `loggableError(...).message`, Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`).
**Verbinden.** `POST /profile/telegram/link-token` (jeder angemeldete Nutzer; Step-up mit aktuellem Passwort und bei MFA TOTP; 10 pro Minute; 201; nicht `@Idempotent`, weil die Antwort ein Geheimnis enthält; `Cache-Control: no-store`) antwortet `{ deepLink, expiresAt }` mit `https://t.me/<Bot>?start=<Token>`. Ohne `TELEGRAM_BOT_USERNAME` ergibt es 409 „Telegram ist nicht eingerichtet.“; das Bot-Token kennt die api nie. Das Token besteht aus 32 Zufallsbytes (Base64url, 43 Zeichen), gespeichert wird nur der SHA-256. Es gilt 10 Minuten (Ablauf nach DB-Uhr) und einmal; ein neues entwertet ältere offene Token desselben Nutzers. Der Job `cleanup` löscht Token, die seit mehr als 24 h abgelaufen oder verbraucht sind. `POST /profile/telegram/unlink` (204) trennt.
**Empfangen** (`TelegramUpdatesService.pollOnce`, Schleife `telegram-updates`, Abschnitt 10). Long Polling (`getUpdates`, `timeout=25`, `allowed_updates ['message']`), kein öffentlicher Eingang, nur private Chats. Genau ein Empfänger: eine Lease in `TelegramPollState` (60 s, nur DB-Uhr, jede Runde verlängert); wer sie nicht hat, wartet 30 s, ebenso nach 409 und nach Fehlern. Das setzt ein einzelnes Worker-Replikat voraus (Abschnitt 7). Jedes Update läuft in einer eigenen Transaktion, in der zuerst der Offset (`update_id + 1`) per Compare-and-Set auf Lease-Inhaber und alten Stand weitergeschoben wird; geht die Lease verloren, bricht die Transaktion samt Wirkung ab, jedes Update wirkt genau einmal. Nicht auswertbare Updates (etwa ohne `message.chat`) schiebt der Offset ohne Wirkung weiter, auch am Ende eines Batches (`getUpdates` liefert `{ updates, highestUpdateId }`). Ein Update, dessen Wirkung immer scheitert, wird nach 5 Versuchen oder 10 Minuten übersprungen (Annahme 51). Verbindungsfehler (Datenbank oder Netz nicht erreichbar, auch `TelegramApiError` ohne Status) werden gespeichert, zählen aber nie; die Zähler werden nach jedem Batch bereinigt (Einträge hinter dem Offset und ältere als das Fenster). Grenzen und Uhr kommen über das optionale Token `TELEGRAM_UPDATES_OPTIONS` (`{ attempts, windowMs, now }`) in den Dienst. Eine Antwort mit Einträgen, aber ohne `update_id`, gilt als Fehler (`lastError` „getUpdates answer without update ids“, Offset bleibt, 30 s Wartezeit). Auf 429 wartet der Empfang bis `retry_after` (ohne Angabe 30 s, höchstens 1 Stunde), ohne DB und Telegram anzufassen. Beim Beenden bricht das Stop-Signal den Abruf ab; `WorkerRuntime.onModuleDestroy` ruft nach dem Stopp der Schleifen `releaseLease()` auf (ohne gehaltene Lease ohne Wirkung, auch bei einem Stopp während der 30-s-Wartezeit), der nächste Empfänger kommt sofort zum Zug. `lastPollAt` steht nach dem Abruf; `lastError` (fester Text plus Klassenname, nie IDs oder Nachrichtentext) wird erst nach einem Batch ohne Fehler und ohne Überspringen gelöscht.
**Befehle.** `/start <Token>` (auch `/start@Bot <Token>`) verbraucht das Token per Compare-and-Set (nur offen und nicht abgelaufen), legt die Präferenz an, sperrt `NotificationPreference` aller betroffenen Nutzer in einer Anweisung (aufsteigend) und liest Verbindung und Präferenz erst danach (dieselbe Sperre wie der 403-Pfad des Senders). Es ersetzt eine frühere Verbindung desselben Nutzers (`linkedAt` beginnt neu), löst die Verbindung eines anderen Nutzers mit demselben Chat (Meldung „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“), schaltet Telegram ein und schreibt die Meldung „Telegram verbunden mit „{Vorname}“ am TT.MM.JJJJ. Warst du das nicht? In den Einstellungen trennen.“ (deckt einen mitgelesenen Link auf; Vorname bereinigt, höchstens 64 Zeichen). Ist Nutzer oder Rolle inaktiv, oder das Token ungültig, abgelaufen oder benutzt, antwortet der Bot `LINK_INVALID` ohne Hinweis auf Konten. `/stop` und „Trennen“ in der App entfernen die Verbindung und senden `STOPPED`. `/hilfe`, `/help`, `/start` ohne Token und anderer Text ergeben `HELP`. Antworten des Bots werden als `NotificationDelivery` mit `templateCode` und `chatId` eingereiht (kein Netzaufruf in der Transaktion) und vom Sender verschickt. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher, gezählt erst nach dem Commit); `LINKED` ist ausgenommen (Annahme 54). Jede Beendigung einer Verbindung (App, `/stop`, Chat zieht zu einem anderen Konto um, Bot blockiert oder Chat gelöscht im Sender) läuft über denselben Helfer `removeLinkIn` (`src/telegram/telegram-link-removal.ts`): Verbindung löschen (bei Bedarf nur, wenn sie noch auf diesen Chat zeigt), `telegramEnabled = false` und die wartenden Telegram-Zustellungen des Nutzers (`PENDING`, `PROCESSING`) in derselben Transaktion auf `SKIPPED` mit `claimedAt` leer und Grund `unlinked`, `chat unavailable` oder `relinked`. Wird die Verbindung eines Nutzers auf einen anderen Chat umgestellt, verfallen die wartenden Zustellungen ebenfalls (`relinked`); derselbe Chat noch einmal ändert nichts. Alte Nachrichten erreichen so nie einen später verbundenen Chat. Datenschutz: Vorname, Chat-ID und Token stehen nie in Logs; die Chat-ID verlässt den Server nicht (auch die Einstellungs-Antwort enthält sie nicht). Je Umgebung ein eigener Bot, weil ein Bot nur einen Empfänger haben darf.
**Betrieb.** Die Karte „Telegram“ unter Verwaltung > System (Abschnitt 10) zeigt Empfänger (Lease, Alter des letzten Abrufs, `lastError`) und Sender (wartend, überfällig, fehlgeschlagen in 24 h, letzte Fehler) und erlaubt das Wiederholen fehlgeschlagener Zustellungen. Die Einstellungsansicht (`GET /profile/notification-settings`) nennt das Feld `telegram.botNameSet` (Bot-Name gesetzt; das Token kennt die api nicht), derselbe Begriff wie am System-Endpunkt. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7.
**Oberfläche: Profil > Telegram** (`TelegramLinkCard`). Der Zustand steht in einer Statuszeile (`role="status"`): „Telegram ist nicht eingerichtet.“ (ohne `botNameSet`, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den `StepUpDialog`; danach erscheint der große Knopf „In Telegram öffnen“ (`target="_blank"`, `rel="noopener noreferrer"`) mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (`ConfirmDialog`) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit `https://t.me/` beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in `localStorage`/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (Stand T4c; `COMPLIANCE` liefert das Backend schon, die Karte zeigt sie mit T6 Task 18; `ToggleChip`, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per `PATCH`. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).
**Step-up** (`src/components/auth/step-up-dialog.tsx`, geteilt mit dem Backup-Import; `pendingLabel`, beim Import „Wird eingespielt…“). Der Dialog fragt das aktuelle Passwort und bei MFA den 6-stelligen Code; `onConfirm(proof)` darf werfen, dann bleibt er offen, leert die Felder, fokussiert das Passwortfeld und zeigt den deutschen Grund in einer dauerhaften Live-Region. Während der Ausführung sind die Eingaben `readOnly`, Esc, Hintergrund und Schließen-Knopf wirkungslos. Die Felder leben nur, solange der Dialog offen ist. Ein fehlgeschlagener Import erscheint im Dialog statt als Toast. Alle Step-up-Aufrufe senden mit `refreshOn401: false` (Annahme 56): `createTelegramLinkToken`, `importBackup`, `changePassword`, `deactivateAccount`, `confirmMfaEnrollment` und `disableMfa`. `stepUpErrorText` (`src/lib/auth/step-up-error.ts`, auch im Passwortformular des Profils) liefert: 429 „Zu viele Versuche. Bitte in einer Minute noch einmal.“, ein 401 ohne eigene Meldung „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“, deutsche Server-Meldungen unverändert. Der Client kennt den Ablauf des Tokens nicht und erneuert vor Step-up-Aufrufen nicht vorab.
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
## 12. Compliance (T6)
Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von `compliance:read` bzw. `veterinary-medicines:read`; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie `COMPLIANCE`, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk.
**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I), 13 (Prüfbereitschaft II), 14 (Oberfläche Prüfbereitschaft), 15 (Oberfläche Tierarzneimittel) 17 (Oberfläche Etikett), 16 (Behandlungsmaske), 18 (Anzeigen und Einstellungen) und 19 (Oberfläche Sperrbezirk). Task 1 bis 19 sind erledigt (T6a Backend: Task 1 bis 13; T6b Frontend: Task 14 bis 19). Offen sind die Browserprüfung aller T6-Seiten, die Rechtswerte-Prüfung durch den Inhaber und das Ausrollen (TASKS.md).
### 12.1 Schema und Rechte
**Migrationen** (alle additiv, nach `20261015110000_t4_telegram`): `20261016100000_t6_veterinary_medicines` (Enums `MedicineUnit`, `VeterinaryMedicineMovementType`; drei Tabellen; `Treatment.medicineId`, `medicineLotId`, `doseQuantity` mit Fremdschlüsseln `ON DELETE RESTRICT`, Indizes `Treatment_medicineId_idx` und `medicineLotId`; `DocumentEntityType` + `MEDICINE_LOT`; Rechte), `…100100_t6_label_particulars` (`SiteSettings.foodBusinessName`/`foodBusinessAddress`, `Product.legalName`, `LabelTemplate.minXHeightMm` Vorgabe 1,2, `AuditAction` + `PRINT`), `…100200_t6_authority_compliance` (`ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement`; `DocumentEntityType` + `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`; Rechte), `…100300_t6_notification_category_compliance` (nur `ALTER TYPE … ADD VALUE 'COMPLIANCE'`; der Wert wird in derselben Migration nicht benutzt) und `…100400_t6_notification_compliance_default` und `…100500_t6_compliance_settings_row` (Datenmigration: legt die Zeile `ComplianceSettings` mit der Id `singleton` an). Prüfungen und Annahmen zum Schema: Annahme 59. `netQuantityDigitRules` ist ein Prisma-`@default`, damit die SQL-Vorgabe keinen Drift gegen das Schema erzeugt; die Singleton-Zeile `ComplianceSettings` legt die Migration `…100500` an; `ComplianceSettingsService` und `herdStatusIn` behalten einen idempotenten `upsert` nur als Rückfall, der Etikettendruck dagegen schließt ohne Zeile (12.6). Es gibt keine Löschroute für Behandlungen; die RESTRICT-Schlüssel schützen eine Behandlung mit Buchung weiter. Ein künftiger Löschpfad müsste `P2003` auf 409 „Diese Behandlung hat eine Buchung aus einer Packung und kann nicht gelöscht werden.“ abbilden oder gegenbuchen.
**Rechte** (Katalog `permission-catalog.ts`; die Migrationen vergeben, der Katalog-Sync berechtigt nur System-Rollen):
| Recht | Gruppe (Sortierung) | ADMIN | BEEKEEPER | STAFF | READONLY | API_CLIENT |
|---|---|---|---|---|---|---|
| `veterinary-medicines:read` | Tierarzneimittel (3700) | ✓ | ✓ | ✓ | ✓ | – |
| `veterinary-medicines:create`, `:update` | Tierarzneimittel | ✓ | ✓ | ✓ | – | – |
| `compliance:read` | Prüfbereitschaft & Behörden (3800) | ✓ | ✓ | ✓ | ✓ | – |
| `compliance:manage` | Prüfbereitschaft & Behörden (3801) | ✓ | ✓ | – | – | – |
Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein eigenes Recht. Sperrbezirke lesen mit `apiaries:read` (Einzelheiten nur mit `compliance:read`, 12.7) und schreiben mit `compliance:manage`. `AuditAction` kennt `PRINT` (Etikettendruck, 12.6).
**Sperr-Reihenfolge.** `Apiary` steht an erster Stelle von `LOCKABLE_TABLES`, danach `Colony` und die übrigen Tabellen wie bisher; die acht neuen Tabellen stehen hinter `NotificationDelivery` (Annahme 50). Die Völker-Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände mit `lockRowsForShare(tx, 'Apiary', ids)` (aufsteigend), dann das Volk; `ApiaryRestrictionsService` sperrt die betroffenen Stände mit `lockRowsForUpdate`, damit sich eine Sperre und ein laufendes Umsetzen hintereinander einreihen. Wechselt der Stand eines Volkes zwischen Lesen und Sperren, endet die Anfrage mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, ohne automatischen Neuversuch. Die Umbauten der Aufrufer stehen in 12.7. Services, die T6 anfasst und `before` bisher außerhalb lasen (`SiteSettingsService.update`, `ProductsService.update`, `LabelTemplatesService.update`), lesen es seit Task 5 nach der Zeilensperre.
**Backup.** Die sieben neuen Tabellen gehören zum versiegelten Bundle; Reihenfolge, Verlust des Packungsbezugs in unversiegelten Dateien und die Regel für Behördenanzeigen stehen in Abschnitt 8 (Annahmen 63 und 64).
**Meldungs-Kategorie `COMPLIANCE`** („Behörden & Prüfung“). Sie trägt die Hinweise „Bestand geändert“ und „TSK-Stichtag“ (Task 11) und ist anders als `SYSTEM` für Telegram vorgewählt: Die Vorgabe `telegramCategories` ist `[TASK_DUE, DIGEST, COMPLIANCE]`, und die Daten-Migration hängt `COMPLIANCE` an jede bestehende, nichtleere Liste an. Eine leere Liste (der Nutzer hat bewusst alles abgewählt, Opt-out) bleibt leer; Kosten bei falscher Annahme: ein Nutzer mit Opt-out bekäme sonst Behördenhinweise. `IsEnum(NotificationCategory)` und die Längengrenze übernehmen den Wert aus dem Enum, es gibt keine zweite Kategorienliste im Code. Das Frontend (Task 18) ergänzt Label und Auswahl; bis dahin zeigt es den Wert als unbekannte Kategorie.
**Null-Verträge.** `describeNullContracts` (`src/common/validation/null-contract.spec-helper.ts`, per `tsconfig.build.json` aus dem Build ausgeschlossen) registriert je Update-DTO die drei Prüfungen der Null-Regel (Annahme 20); jede T6-Task legt ihre Verträge in eine eigene Spec-Datei.
### 12.2 Tierarzneimittel: Katalog, Packungen, Bewegungsbuch
`VeterinaryMedicinesModule` (`src/veterinary-medicines/`, in `AppModule`, nicht im `WorkerModule`: es hat keine Handler) trennt drei Ebenen: das Mittel (`VeterinaryMedicine`, Handelsname, Wirkstoff, Einheit, Wartezeit, Lagerhinweise), die Packung (`VeterinaryMedicineLot`, Beleg, Verfall) und die Bewegung (`VeterinaryMedicineMovement`: `TREATMENT` nur aus einer Behandlung, `CORRECTION` für die Inventur, `DISPOSAL` mit Pflichtgrund). Grundsätze: Annahmen 60 bis 62. Der Rest ist `roundToThousandths(lot.quantity + Σ movement.quantity)` (`src/common/quantity.ts`, die einzige Rundung auf Tausendstel; `roundKg` in `honey-plausibility.ts` ist ein Alias, `bottling.service.ts` ruft sie direkt auf, `-0` wird zu `0`). Die reinen Helfer stehen in `medicine-ledger.ts` (`summarizeLot`, `treatmentBookingDelta` für die Gegenbuchung bei geänderter Packung oder Dosis, Task 3), die Sichten in `medicine-views.ts` (eine `groupBy`-Abfrage je Liste, kein N+1).
**API** (Rechte `veterinary-medicines:read|create|update`, Datumsfelder der Antworten als Tagestexte `JJJJ-MM-TT`):
- `GET /veterinary-medicines?active=`, `GET /veterinary-medicines/suggestions` (eigene, noch nicht katalogisierte Handelsnamen aus `Treatment.product` ohne Beachtung der Groß-/Kleinschreibung, höchstens 50, mit den Werten der jüngsten Behandlung; legt nichts an), `GET/PATCH /veterinary-medicines/:id`, `POST /veterinary-medicines`, `POST …/:id/deactivate|reactivate` (ohne Änderung keine Schreibzeile und kein Audit).
- `GET /veterinary-medicine-lots?medicineId=&selectable=true&includeLotId=` (wählbar: Rest > 0 und Mittel aktiv, älteste Haltbarkeit zuerst; `includeLotId` hält die Packung einer bearbeiteten Behandlung zusätzlich wählbar), `GET/PATCH /veterinary-medicine-lots/:id` (mit Bewegungsbuch), `POST /veterinary-medicine-lots`, `POST …/:id/movements` mit `{ type: 'CORRECTION', actualRemaining }` oder `{ type: 'DISPOSAL', quantity, reason }`.
- Die Anlege-Endpunkte und `:id/movements` tragen `@Idempotent()`. Die Listen sind noch nicht paginiert; mit wachsendem Buch ist ein Limit zu ergänzen.
**Schreiben.** Mittel und Packungen lesen `before` nach der Zeilensperre und schreiben das Audit in derselben Transaktion; die Sperrfolge ist Mittel, dann Packung. Eine Inventur bucht die Differenz zwischen Buchrest und gezähltem Rest unter der Packungssperre (Delta 0 bucht nichts und schreibt kein Audit), zwei gleichzeitige Inventuren laufen nacheinander. Eine Entsorgung, die auf 0 rundet (unter 0,0005), ist 400. `name` und `reason` dürfen nicht nur aus Leerzeichen bestehen. `minTemperatureC <= maxTemperatureC` prüft der Dienst gegen den zusammengeführten Stand (400). Der Seed legt keine Mittel an; `seed-no-medicines.spec.ts` sichert das (nur die fünf bekannten Seed-Module, keine weitere `.ts` unter `prisma/`).
**Behandlung aus Packung (Task 3).** `POST /treatments`, `POST /treatments/bulk` und `PATCH /treatments/:id` nehmen zusätzlich `medicineId`, `medicineLotId` und `doseQuantity` (`null` leert); `product` entfällt, wenn Mittel oder Packung gewählt sind, `PATCH` kennt außerdem `apiaryId` (Stand am Behandlungstag, 404 bei unbekanntem Stand). Die Logik steckt in `treatment-medicine.ts` (freie Funktionen mit `tx`); `TreatmentsModule` importiert `VeterinaryMedicinesModule` nicht, die Buchung braucht nur `treatments:create`.
- **Prüfung** (`resolveMedicineIn`, am zusammengeführten Zustand): Eine Packung schließt `supplier`, `purchaseProof`, `batchNumber` und `prescribingVet` auf der Behandlung aus (400 „Diese Angabe kommt aus der Packung“); `medicineId: null` bei gesetzter Packung, eine Packung eines anderen Mittels, ein abweichendes `product` (ohne Groß-/Kleinschreibung, getrimmt) und ein inaktives Mittel beim Anlegen sind 400, ein unbekanntes Mittel oder eine unbekannte Packung 404. Die Dosis wird auf Tausendstel gerundet, unter 0,001 ist 400. Das Mittel wird `FOR SHARE` gesperrt. `activeIngredient`, `withdrawalPeriodDays` und `method` füllt der Server nie aus dem Katalog nach; `product` wird zum Handelsnamen und folgt dem Katalog nur, wenn der Patch `product`, `medicineId` oder `medicineLotId` anfasst. `update` prüft und bucht nur, wenn der Patch Mittel, Packung, Dosis, `product` oder eines der vier Packungs-Textfelder berührt; eine reine Notizänderung gelingt auch, wenn das Mittel der Packung inzwischen gewechselt hat.
- **Delta-Buchung** (`bookTreatmentIn`): Eine Behandlung mit Packung und Dosis bucht `TREATMENT` mit −Dosis in derselben Transaktion wie Behandlung, Audit und `TreatmentRecorded` (Sammelerfassung: je Volk eine Bewegung, alles oder nichts). Beim Bearbeiten bucht der Server nur das Delta je Packung aus `treatmentBookingDelta` über die Summe der bisherigen Bewegungen dieser Behandlung (Gegenbuchung auf der alten, neue Buchung auf der neuen Packung; Bewegungen werden nie geändert). Ein Umhängen auf ein anderes Volk lässt den Bestand unberührt. Eine Dosis ohne Packung wird gespeichert und bucht nichts; eine später gesetzte Packung bucht die volle Dosis nach. Leere oder abgelaufene Packungen blockieren nicht (der Rest wird negativ, die Prüfbereitschaft meldet es).
- **Sperr-Reihenfolge:** Volk (wenn `colonyId` im Patch steht) → Behandlung → Mittel (`FOR SHARE`) → Packungen aufsteigend in einer Anweisung (`lockRowsForUpdate`), also Colony vor Treatment wie in der Karte `LOCKABLE_TABLES`. Nach der Packungssperre prüft die Buchung, dass die Packung noch zum Mittel der Behandlung gehört (sonst 409 „Die Packung wurde gerade geändert. Bitte neu laden.“). Eine Inventur auf derselben Packung wartet auf dieselbe Sperre.
- **Bestandsbuch** (`export/treatment-log.ts`, `GET /export/treatment-log.pdf`): `resolveSourceValues` liest die Spalten aus genau einer Quelle (Packung, wenn gesetzt, sonst die Behandlung). Die Menge ist der Text der Behandlung, sonst die Dosis mit Einheit des Mittels („30 ml“, auch ohne Packung, sobald ein Katalogmittel da ist). `treatmentLogGaps` (`SUPPLIER`, `PROOF`, `AMOUNT`, `WITHDRAWAL` mit 0 Tagen als erfasst, `VET` nur bei verschreibungspflichtigem Mittel) ist die **einzige Quelle**: `treatmentLogCells` druckt „FEHLT“ genau an diesen Stellen (die Tierarzt-Lücke in der Spalte „a) Lieferant …“ als „; Tierarzt: FEHLT“), und die Prüfbereitschaft nutzt dieselbe Funktion; FEHLT und Lücken sind also gleich. Zeilen mit gleichen aufgelösten Werten gruppiert das Buch nur bei gleicher Packung und gleichem `prescriptionOnly`. Fußnote: „Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.“ **Der Export braucht `export:read` und zusätzlich `veterinary-medicines:read`** (Annahme 73); ohne dieses Recht 403, die Stammkarte bleibt mit `export:read` erreichbar.
### 12.3 Dokumente mit Rechten je Art
Für `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` gilt zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes. Die Zuordnung steht in `DOCUMENT_ENTITY_PERMISSIONS` (`documents.service.ts`): Packung lesen `veterinary-medicines:read`, schreiben `veterinary-medicines:update`; Behördenanzeige und Sperrbezirk lesen `compliance:read`, schreiben `compliance:manage`. „Schreiben“ umfasst Hochladen, Umhängen, Umbenennen und Löschen; beim Umhängen zählen die alte und die neue Art. `DocumentsService.assertEntityAccess(userId, entityType, mode)` wirft 403 ohne das Recht; Arten ohne Eintrag (Stand, Volk, Königin, Durchsicht, Honigcharge) prüfen nichts zusätzlich. Die Prüfung kommt vor jeder Datenbankabfrage (`findAll`, `create`) bzw. nach dem Laden des Dokuments und vor dem Lesen der Datei (`readFile`, `update`, `delete`), daher 403 vor 404 und kein Existenz-Orakel für Unberechtigte. Wer nur `documents:read` hat, sieht Belege, Anzeigen und Sperrbescheide weder in der Liste noch per Download-ID. `compliance:manage` schließt `compliance:read` nicht ein (Annahme 69). Speicherordner sind `medicine_lot`, `authority_notice` und `apiary_restriction`; MIME-Allowlist und Signaturprüfung gelten unverändert. Ein Fehler beim Löschen der Datei wird mit Dokument-Id und `loggableError()` protokolliert, Zeile, Audit und Antwort bleiben. Dokumente gelöschter Datensätze bleiben bestehen (Annahme 70). Das Recht wirkt über den 30-s-Cache des Resolvers verzögert, wie überall.
**Zwischenspeicher.** Alle API-Antworten tragen `Cache-Control: no-store` (Abschnitt 9, `noStoreMiddleware`); die e2e-Tests halten das für Liste und Download fest. Der Service Worker beachtet `no-store` bewusst **nicht**, weil es jede Antwort trägt (Abschnitt 2, Annahme 87). Stattdessen schließen Pfadregeln die Listen der drei Arten (`/documents?entityType=…`) und `documents/:id` aus; das Präfix `/api/v1/documents` wird nicht ganz gesperrt, sonst fehlten die Durchsichtsfotos offline. Nie gecacht werden auch die Compliance-, Arzneimittel- und Sperrbezirk-Pfade (`NEVER_CACHED_API_PATHS`, seit Task 14).
### 12.4 Etikett-Pflichtangaben
`checkLabel` (`src/label-templates/label-particulars.ts`) ist rein und ohne Datenbank; Regeln und Grenzen: Annahmen 65 bis 68. **Pflichtangaben** (sperren den Druck: 409 `LABEL_PARTICULARS_MISSING` mit `{ gaps }`, Abschnitt 12.6): `LEGAL_NAME` (ein Wort mit „honig“), `NET_QUANTITY` (Füllmenge der Abfüllung, „250,5 g“ wie „250.5 g“, „1 kg“ nur bei vollen Kilogramm; als ganze Wörter), `BEST_BEFORE` („Mindestens haltbar bis“ mit dem Datum), `LOT`, `ORIGIN` und `FOOD_BUSINESS` (Name und Anschrift). **Warnungen** (sperren nie): `LOT_PREFIX`, `X_HEIGHT` (unter `LabelTemplate.minXHeightMm`), `NET_QUANTITY_HEIGHT` (Ziffernhöhe unter der Regel aus `ComplianceSettings.netQuantityDigitRules`), `TEXT_CLIPPED`. Das Ergebnis trägt `requirements` (`minXHeightMm`, `netQuantityMinCapHeightMm`) und je Befund `elementIds` (bei Lücken die abgeschnittenen Elemente, die die Angabe getragen hätten). **Verfeinerungen (Runden 2 bis 4 der Task 5).**
- **Ziffern-Wächter je Element.** „Zahl direkt vor der Menge“ („1,250 g“ ist kein Treffer für 250 g) gilt nur im Text eines Elements, nie im verbundenen Text: Eine Losnummer oder ein Datum in einem Nachbarelement macht „500 g“ nicht ungültig. „250g“ gilt als „250 g“, „L2026“ bleibt.
- **Lesereihenfolge** kommt allein aus den Positionen (nach y, Zeilen mit 1 mm Toleranz gegen die erste Oberkante, je Zeile von links nach rechts), nie aus der Reihenfolge der Elemente in der Vorlage.
- **Verteilte Angaben** (Name und Anschrift): Zeilen, Blöcke in Lesereihenfolge oder Ketten wortgleicher Elemente, jeweils mit **symmetrischem** senkrechtem Abstand von höchstens 10 mm (`max(0, y − Unterkante, Oberkante − (y + h))`, gemessen an der laufenden Ober- und Unterkante der ganzen Gruppe, nicht am Vorgänger, `groupByDistance`). Elemente mit leerem normalisiertem Text werden ignoriert und überbrücken nichts; eine hohe Textbox überbrückt Zeilen um ihre Boxhöhe. Nur sichtbare (nicht abgeschnittene) Teile zählen (Annahme 75).
- **Zahl | Einheit** („500“ | „g“): zwei Elemente gelten als Paar, wenn sich die Boxen senkrecht überlappen, die Einheit rechts liegt und der waagerechte Abstand höchstens 10 mm beträgt (Annahme 74). Beide Teile zählen für `X_HEIGHT` und `NET_QUANTITY_HEIGHT`.
- **Träger** einer verteilten Angabe sind nur die Elemente, die sich mit dem Treffer im verbundenen Text überlappen (`matchParts`), nicht jedes Element mit einem gemeinsamen Wort; abgeschnittene Träger erscheinen über dieselbe Überlappung in `elementIds` der Lücke und von `TEXT_CLIPPED` (`clippedHolders`).
- **Zeitzonen-Unabhängigkeit.** Der Datumstag kommt aus `labelDayOf` (Berliner Kalendertag). Ein Quelltext-Wächter liest `label-templates.service.ts`, `label-particulars.ts`, `label-text.ts`, `font-metrics.ts`, `src/common/dates.ts` und `format-day.ts` und scheitert bei zeitzonenabhängigen Aufrufen (`getDate`, `getHours`, `toLocaleDateString` ohne `timeZone`, `Intl.DateTimeFormat(` mit oder ohne `new` ohne `timeZone`, mehrargumentiges `new Date(y, m, …)` u. a.); ein Selbsttest prüft die Muster an schlechten und guten Beispielen. Die Spec setzt keine Prozess-Zeitzone, sondern prüft wörtliche Berliner Tage.
- **Dokumentierte Grenzen:** Ein fremdes Element mit einem gemeinsamen Wort kann in eine Kette geraten; der waagerechte Abstand zählt bei Anschriften nicht; „honig“ in einem Wort genügt (Annahme 67); `NET_QUANTITY_HEIGHT` zählt die größte Nennung (Annahme 66). Eine Füllmenge, die auf drei oder mehr Elemente verteilt ist oder deren Einheit links steht, ist eine Lücke.
**Höhen.** mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica: x-Höhe 523, Versalhöhe 718). Der Zugriff auf das private pdfkit-Feld steckt allein in `font-metrics.ts` (`fontMetricsOf`, je Schnitt gecacht, Fehler bei fehlenden Werten, durch einen Spec-Test gesichert); `pdfFontName` und `MM_TO_PT` wohnen dort. Beispiel: 6 pt ergibt 1,11 mm (Warnung), 7 pt 1,29 mm (in Ordnung). Die Rechtswerte sind Eingaben, die Beispielregeln stehen nur in den Tests.
**Stammdaten und Platzhalter.** `SiteSettings.foodBusinessName` (200) und `foodBusinessAddress` (300), `Product.legalName` (200) und `LabelTemplate.minXHeightMm` (0,5 bis 5) bleiben leer bzw. bei der Vorgabe, bis der Inhaber sie pflegt; leer ist eine Lücke, kein Standardwert. Neue Platzhalter: `legalName`, `originCountry`, `foodBusinessName`, `foodBusinessAddress`, `bestBeforeLine` („Mindestens haltbar bis TT.MM.JJJJ“), `lotLine` („L <Losnummer>“); die Liste liegt als `test/fixtures/placeholder-keys.json` vor (Wächter `placeholder-keys.spec.ts`, das Frontend vergleicht seine Liste damit). `buildPlaceholderValues(bottlingBatch, settings)` setzt unbesetzte Stammdaten auf `''`. Die öffentliche Herkunftsseite und der Shop-Feed zeigen weder Anschrift noch Verkehrsbezeichnung (e2e belegt, auch mit gesetztem `legalName`). `holderName`/`holderAddress` laufen über denselben `trimmedOrNull`-Helfer (`null` löscht). Der Druck (12.6) ruft `labelDayOf(bottling.bestBeforeDate)` für `expected.bestBefore` und nutzt einen gemeinsamen Messer je Druck (`pdfTextMeasurer()`); `labelDayOf` wohnt in `label-text.ts` (vermeidet einen Importzyklus) und wird vom Service weiter exportiert.
### 12.5 Ereignisse für Völker und Stände
Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Compliance` neu (`'Apiary'` gab es schon). Alle tragen nur Ids, Codes, Jahr und Tag, nie Adresse, Koordinaten oder Namen, und sind nicht in `DOMAIN_EVENT_WEBHOOKS`; der Testhelfer `herd-payload.spec-helper.ts` prüft die wirklich geschriebenen Payloads gegen eine Allow-Liste.
- `ColonyCreated { apiaryId }` (Aggregat `Colony`): `ColoniesService.create` und für den Ableger in `split` (zusätzlich zu `ColonySplit`), jeweils in der Transaktion des Schreibens. Auch der CSV-Import schreibt es je angelegtem Volk in derselben Transaktion mit Datensatz und Audit (`Colony` CREATE), damit die Bestandsänderung die importierten Völker sieht (gemeinsamer Erzeuger `colonyCreatedEvent`, `src/colonies/colony-created-event.ts`). Der Import sendet weiterhin keinen Webhook `colony.created`; der Webhook bleibt im Altpfad (Annahme 11). Fehlerzeilen, Überspringen und Zähler des Imports sind unverändert. Eine Wiederherstellung aus dem Backup schreibt bewusst kein `ColonyCreated`.
- `ColonyMoved { fromApiaryId, toApiaryId }` (Aggregat `Colony`): `move` und `update` mit geändertem Stand. Ein Umsetzen in denselben Stand schreibt Historie und Audit, aber kein Ereignis.
- `ApiaryChanged { change }` (Aggregat `Apiary`), `change` = `CREATED` | `DEACTIVATED` | `REACTIVATED` | `LOCATION_CHANGED`. `LOCATION_CHANGED` nur, wenn Adresse, Ort, Breite oder Länge sich gegenüber dem gesperrten `before` ändern. Je Schreibvorgang ein Ereignis, ohne `dedupeKey`.
- `TskReferenceDayReached { year, referenceDay }` (Aggregat `Compliance`, `referenceDay` als `JJJJ-MM-TT`, tagesbezogen über `dailyDedupeKey`): nur im Katalog; der Erzeuger ist die Tagesauswertung (Task 11).
`ApiariesService` schreibt Datensatz, Audit und Ereignis seit Task 7 in **einer** Transaktion; `update`, `deactivate` und `reactivate` sperren zuerst die Standzeile (`lockRowForUpdate`) und lesen `before` danach, `deactivate` zählt lebende Völker erst danach über `tx`; `reactivate` behält die Audit-Aktion `UPDATE`. Die Handler (`compliance.herd-change`, `compliance.tsk-reference-day`) liegen im künftigen `ComplianceModule` (von `AppModule` und `WorkerModule` importiert, ohne `PermissionsModule`, Task 11); die Erzeuger laufen nur in der API. Frontend: `DOMAIN_EVENT_LABELS` kennt die vier Typen („Volk angelegt“, „Volk umgesetzt“, „Stand geändert“, „TSK-Stichtag erreicht“); Task 14 trägt sie nicht noch einmal ein.
### 12.6 Etikettendruck: Sperre, Probedruck, Audit
`LabelTemplatesService.renderLabelsPdf(templateId, bottlingBatchId, options, actingUserId)` mit `options = { startPosition?, proof? }` ersetzt die alte Signatur mit `startPosition`. Der Druck ruft vor dem Rendern dieselbe Prüfung `checkLabel` wie die Prüf-Endpunkte (eine private Methode `check`, dieselbe Aufbereitung der Platzhalter, dieselbe Füllmenge, derselbe MHD-Tag aus `labelDayOf`, dieselben Ziffernregeln).
- **Sperre.** Fehlt eine Pflichtangabe und ist `proof` nicht gesetzt, antwortet der Druck mit 409 `{ code: 'LABEL_PARTICULARS_MISSING', message, gaps }` und erzeugt kein PDF. Warnungen sperren nie, sie landen im Audit. `?proof=false` mit Lücke ist 409, `?proof=maybe` und `?proof=0` sind 400 (`ParseBoolPipe`).
- **Probedruck** (`?proof=true`): immer möglich; jedes Etikett trägt den Stempel „MUSTER – nicht verkaufsfähig“ (`label-proof-watermark.ts`: diagonal, 50 % Grau, halbtransparent, 80 % der Diagonale, höchstens 50 % der Etikettenhöhe; im gedrehten Bogen mit den Etikettenmaßen), damit ein Probeabzug nie als verkaufsfähig umläuft.
- **Audit vor dem PDF.** Jeder Druck, auch der Probedruck, schreibt vor der Auslieferung `PRINT` am `BottlingBatch` mit `{ templateId, copies, proof, gapCodes, warningCodes }`; `copies` ist immer `jarCount` der Abfüllung, unabhängig von `startPosition`. Schlägt das Audit fehl, schlägt der Druck fehl (fail closed; das Audit steht außerhalb einer Transaktion, es gibt keinen zu schützenden Datensatz).
- **Prüf-Endpunkte.** `GET /label-templates/:id/check/:bottlingBatchId` prüft eine gespeicherte Vorlage gegen eine Abfüllung, `POST /label-templates/check` (200, vor `:id` registriert) einen **ungespeicherten Entwurf**: nur Text-Elemente (`@Equals('text')`, andere Arten 400), höchstens 100, Element-Ids eindeutig, Breite und Höhe 0,1 bis 1000 mm, `x`/`y` −1000 bis 1000, `fontSize` höchstens 500, `minXHeightMm` 0,5 bis 5; ohne `bottlingBatchId` mit Beispielwerten (`sample: true`). `widthMm`/`heightMm` des Entwurfs werden nur auf Grenzen geprüft (10 bis 1000), maßgeblich sind die Feldgrößen der Elemente. `startPosition` liegt zwischen 1 und 1000, sonst 400.
- **Rechte.** Prüfen: `label-templates:read`, Drucken: `label-templates:print`; **zusätzlich `bottling:read`** bei `GET check`, `GET print` und bei `POST check` mit `bottlingBatchId` (sonst 403; der Controller prüft das über `PermissionResolverService.hasAny`, weil `@RequirePermission` nur „oder“ kennt). Der Entwurfs-Check mit Beispielwerten braucht nur `label-templates:read`. Zur Vergabe der Etikett-Rechte: Annahme 77.
- **Ziffernregeln nur aus `ComplianceSettings`.** Der Code kennt keine Rechtswerte und keine Rückfall-Vorgabe. `loadDigitRules` liest `ComplianceSettings.netQuantityDigitRules` der Zeile `singleton` (Migration `20261016100500`); fehlt die Zeile oder sind die Regeln unbrauchbar (`parseDigitRules`: leer, Höhe nicht größer als 0, nicht endlich, nicht genau eine Regel ohne Obergrenze), antworten Druck **und** Prüfung mit 409 `{ code: 'COMPLIANCE_SETTINGS_INVALID', message: 'Compliance-Einstellungen fehlen oder sind ungültig.' }` (fail closed; geloggt über `loggableError`, ohne Werte). Auch dann entsteht kein Audit.
- Alle Antworten (Prüfung wie PDF) sind `no-store` (die globale Middleware, Abschnitt 9); die Prüfantworten enthalten die Anschrift des Lebensmittelunternehmers. Das Frontend (Task 17) liest `GET`/`POST check` und die Lückenliste `gaps` des 409.
### 12.7 Sperrbezirk
`ApiaryRestriction` (Seuche, Behörde, Aktenzeichen, Beginn, Ende, Notiz) sperrt Bewegungen von Völkern in und aus einem Stand. **Aktiv** ist eine Sperre vom Beginn bis einschließlich Endtag, ohne Endtag bis auf Weiteres. Maßgeblich ist der **Berliner Kalendertag** (`berlinToday()` der Anwendung, nicht der UTC-Tag und kein Datenbank-`now()`; Entscheidung des Inhabers 2026-10-15). Eine Sperre wird nie gelöscht, sie endet über das Enddatum. Paragraphen und Fristen (BienSeuchV) sind Hinweise und vor dem Rollout gegen die Quelle zu prüfen.
- **Geschützte Pfade:** `ColoniesService.create` (Volk im Stand anlegen), `update` mit Standwechsel (alter oder neuer Stand; eine reine Namensänderung prüft nichts und sperrt keinen Stand), `move` (Quell- oder Zielstand), `merge` über verschiedene Stände und der **CSV-Import** (`ImportService.importColonyRow` sperrt den Stand `FOR SHARE`, prüft `ACTIVE` über `tx` und die Sperre ohne Ausnahme; Treffer sind Fehlerzeilen, Annahme 71).
- **Freie Pfade:** Ableger (`split`, im selben Stand), Auflösen (`dissolve`), Ernte (warnt nur, Task 13) und die Wiederherstellung aus dem Backup (prüft bewusst nichts); ebenso ein Umsetzen in den eigenen Stand und ein Vereinigen innerhalb eines Standes.
- **Antwort:** 409 `{ code: 'RESTRICTED_ZONE', message, restrictionIds, restrictions: [{ id, apiaryId, apiaryName, disease, startsOn }] }`; die Meldung nennt Stand, Seuche und Beginn `TT.MM.JJJJ`.
- **Ausnahme.** `restrictionOverrideReason` (getrimmt 10 bis 500 Zeichen) bei Anlegen, Bearbeiten, Umsetzen und Vereinigen. Ohne `compliance:manage` ist jede Begründung 403, **vorab** (vor Lesen und Sperren, auch ohne aktive Sperre; Annahme 72). Kein Step-up (Entscheidung des Inhabers). Mit dem Recht und aktiver Sperre landet die Begründung im Audit (`afterData.restrictionOverride = { reason, restrictionIds }`) und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“, vor einer vorhandenen Notiz; bei Anlegen und Vereinigen gibt es keine Historienzeile, dort nur das Audit). Die Begründung steht **nie am Volk**: sie wird vor jedem Prisma-Aufruf abgetrennt. Ohne aktive Sperre wird sie (mit dem Recht) ignoriert, `null` zählt wie weggelassen.
- **Sperrmuster und 409 „neu laden“.** Die Pfade lesen die Stand-Ids ungesperrt, sperren die Stände `FOR SHARE` aufsteigend, dann die Völker (beim Vereinigen Überlebendes und Aufgelöstes in einer Anweisung), vergleichen unter der Sperre den Stand des Volkes mit dem gelesenen (sonst 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, kein automatischer Neuversuch; ein verschwundenes Volk ist 404), lesen `assertApiaryActive` über `tx` und prüfen erst dann die Sperre. `ApiaryRestrictionsService` (`POST`, `PATCH`) sperrt die Stände `FOR UPDATE`: Ein laufendes Umsetzen und eine neu beginnende Sperre reihen sich hintereinander ein, eine Sperrenänderung wartet auf laufende Umsetzungen. `updateIn` nimmt selbst keinen Standlock; ein Patch mit Standwechsel ohne den Wächter aus `update()` wirft (Programmierfehler). Die Durchsicht-Übernahme (ohne `apiaryId`) sperrt nur das Volk.
- **Endpunkte.** `POST /apiary-restrictions` (`compliance:manage`, `@Idempotent`, 1 bis 50 verschiedene Stände, je Stand eine Zeile und ein Audit, eine Transaktion, unbekannter Stand 404 mit Rollback, `endsOn >= startsOn` sonst 400), `PATCH /apiary-restrictions/:id` (Stand `FOR UPDATE`, dann Sperre, `before` danach, `endsOn: null` öffnet wieder), `GET /apiary-restrictions?active=true|false`, `GET /apiaries/:id/restrictions` (`apiaries:read`).
- **Einzelheiten nur mit `compliance:read`:** Behörde, Aktenzeichen, Notiz und Ersteller liefern beide GET-Pfade nur dann; mit nur `apiaries:read` besteht eine Zeile aus `id`, `apiaryId`, `disease`, `startsOn`, `endsOn`. `active=true` liefert für alle die schlanke `ActiveRestrictionInfo` (ohne diese Felder). Öffentliche Seiten und der Shop-Feed nennen weder Seuche noch Sperre (e2e mit echter öffentlicher Charge).
- Das Frontend (Task 19, Abschnitt 12.11) bietet den Ausnahmedialog nur mit `compliance:manage` an und sendet `restrictionOverrideReason`.
### 12.8 Behördenpaket I: Einstellungen, Bestandsbild, Anzeigen
`ComplianceModule` (`src/compliance/`, in `AppModule` und seit Task 11 auch in `WorkerModule`, Abschnitt 12.9) enthält `ComplianceSettingsService`, `AuthorityNoticesService` und die reinen Helfer `tsk-day.ts`, `herd-snapshot.ts`, `herd-status.ts`. Kein Provider hängt am `PermissionResolverService`; das Modul exportiert beide Services.
- **Einstellungen.** `GET`/`PATCH /compliance/settings` (`compliance:read` / `compliance:manage`). `update` upsertet, sperrt `ComplianceSettings`, liest `before` danach und schreibt das Audit in derselben Transaktion. Prüfungen (400): `tskReferenceDay` und `tskReportDueDay` als `MM-TT` (02-29 und „1-1“ abgelehnt), `tskExcludedColonyTypes` ohne Doppelte, `netQuantityDigitRules` mit 1 bis 10 Regeln, Obergrenzen streng aufsteigend, nur die letzte ohne Grenze, `minMm` 0,5 bis 20; ein fehlender `maxGrams` wird vor der Prüfung zu `null` normalisiert und so gespeichert. Alle Rechts- und Fristwerte sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“.
- **Bestandsbild** (`herdStatusIn(db, now)`): lebende Völker (`LIVING_COLONY_WHERE`) an aktiven Ständen, bei der TSK-Zählung ohne `tskExcludedColonyTypes`; zwei ungesperrte Abfragen genügen für eine Anzeige. `herdDeviation` vergleicht `baseline.totalColonies` mit der aktuellen Zahl und erkennt neue, aufgegebene und verlegte Stände (Adresse, Ort, Koordinaten); ein Umsetzen zwischen Ständen löst nichts aus. `noticeOnColonyCountChange` und `noticeOnApiaryChange` schalten nur `triggered`, die Tatsachen stehen immer da.
- **Bezugsstand-Regeln.** Bezugsstand ist die jüngste `SENT`-Anzeige der Art `REGISTRATION` oder `HERD_CHANGE`, sortiert nach `sentOn`, dann `createdAt`, `id`; TSK-Zählnachweise zählen nicht. Ohne Bezugsstand ist `deviation = null` (Punkt „Erstanzeige fehlt“). `REGISTRATION` entsteht nur ohne Bezugsstand, `HERD_CHANGE` nur mit („Erst die Erstanzeige erfassen.“, 409); `TSK_REPORT` braucht `year`, andere Arten dürfen keines haben (400). Eine wiedergeöffnete Erstanzeige lässt sich erneut senden.
- **Lebenszyklus.** `DRAFT` → `SENT` (`mark-sent`) → `DRAFT` (`reopen`); `DRAFT` → `CANCELLED` (`cancel`). `recount` nur im Entwurf. `createDraftIn(tx, { kind, year?, referenceDate }, userId | null)` ist der gemeinsame Kern von `create` und dem des Tagesjobs (Task 11); `referenceDate` und `sentOn` beim Senden sind standardmäßig `berlinToday()`. Jeder Wechsel sperrt die Anzeige, liest `before` danach, prüft den Übergang (409, deutsche Meldung) und schreibt Audit `UPDATE` mit `beforeData`/`afterData` in derselben Transaktion. Die POST-Wechsel antworten wie die übrigen POST-Aktionen mit 201.
- **`openKey` und Mutex.** Anlegen, `mark-sent` und `reopen` sperren zuerst `ComplianceSettings` (`singleton`), dann die Anzeige (Reihenfolge der Karte). `openKey` (`REGISTRATION` | `HERD_CHANGE` | `TSK_REPORT:<Jahr>`, nur bei `DRAFT`, nullbar eindeutig) lässt höchstens einen offenen Entwurf je Art und Jahr zu; ein zweiter, auch im Rennen, antwortet 409 `{ code: 'DRAFT_EXISTS', existingId }`. Ein `P2002` ohne erklärenden Entwurf wird unverändert weitergeworfen. Die Prüfung auf einen offenen Entwurf entfällt, wenn die Anzeige mit `sentOn` angelegt wird.
- **`sentOn`-Regeln.** Mit `sentOn` beim Anlegen entsteht die Anzeige gleich als `SENT` ohne `openKey` (Weg „bereits angezeigt am …“ für Altbestand). `reopen` setzt `sentOn = null` und `openKey`, scheitert aber bei vorhandenem Entwurf (409 `DRAFT_EXISTS`). `PATCH` (`totalColonies`, `referenceDate`, `sentOn`, `sentVia`, `reference`, `notes`): `sentOn` auf einem Entwurf ist 400 „Ein Entwurf hat kein Sendedatum.“, das Sendedatum einer `SENT`-Anzeige ist nicht löschbar (400), eine `CANCELLED`-Anzeige nicht änderbar (409); fasst ein Patch `sentOn` an, nimmt `update` zuerst den Settings-Mutex.
- **Verwerfen statt Löschen.** `cancel` verwirft nur einen Entwurf (`CANCELLED`, `openKey` frei); es gibt keine Löschroute (Spec 7), angehängte Dokumente bleiben (Annahme 76). `GET /authority-notices?kind=&year=` liefert höchstens die 200 neuesten Einträge (`take: 200`), eine Seitenaufteilung fehlt noch.
- **Endpunkte.** `GET /authority-notices`, `GET …/:id`, `POST /authority-notices` (`@Idempotent`), `PATCH …/:id`, `POST …/:id/recount|mark-sent|reopen|cancel`; Lesen mit `compliance:read`, Schreiben mit `compliance:manage` (Nutzer ohne die Rechte: 403 auf Liste, Einzelanzeige und Einstellungen). Der PDF-Endpunkt `…/:id/pdf` steht im Absatz „PDFs (Task 10)“ am Ende dieses Abschnitts.
**PDFs (Task 10).** Aufbau: reines Modell `pdf/authority-notice-model.ts` (`buildNoticePdfModel`) getrennt vom Renderer `pdf/authority-notice-pdf.ts` (pdfkit, `PDF_MARGIN`, `drawTable`); `noticeHeaderLines` ist rein und einzeln testbar.
- **Endpunkt.** `GET /authority-notices/:id/pdf` (`compliance:read`) liefert `application/pdf`, `Cache-Control: no-store` und `Content-Disposition: attachment; filename="anzeige-<id>.pdf"` (Dateiname nur mit der Id, auch für TSK-Nachweise). `AuthorityNoticesService.pdf(id)` gibt `{ buffer, fileName }`; eine unbekannte Id ist 404.
- **Inhalt.** Erstanzeige „Anzeige der Bienenhaltung (§ 1a BienSeuchV)“ ohne Spalte „vorher“; Änderungsanzeige „Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)“ mit „vorher“ je Stand, Summenzeile und der Liste „Änderungen seit der Anzeige vom {Datum}“ (Stand neu, aufgegeben, Lage geändert, Zahl der Völker). Lage = Adresse, sonst Koordinaten mit fünf Nachkommastellen, sonst Ort, sonst `FEHLT`.
- **TSK-Zählnachweis in A4 quer.** Titel „Zählnachweis Bienenvölker zum Stichtag {Datum}“, eine Spalte je gezählter Völkerart (feste Reihenfolge) plus „Summe“, Zahlenspalten rechtsbündig, Spaltenbreite aus dem breitesten Wortteil (Mindestbreite 40 pt, Umbruch am Bindestrich), „Lage“ bekommt den Rest. Hinweis „Gezählt am {Tag}“, bei Zählung nach dem Stichtag „…, nicht am Stichtag. Bitte prüfen.“; Fußsatz „Ersetzt nicht die Meldung an die Tierseuchenkasse.“; kein Empfängerblock. Erst- und Änderungsanzeige bleiben Hochformat (Annahme 82). Rutscht die Unterschrift bei vielen Ständen auf Seite 2, ist das gewollt (kein Abschneiden).
- **`FEHLT` statt Weglassen.** Halter, Veterinäramt, TSK-Nummer und die Lage eines Standes druckt der Renderer als `FEHLT`; die Registriernummer als „–“ (Spec 8.1). Fehlt beim Halter nur die Anschrift (oder nur der Name), steht `FEHLT` an dieser Stelle („Imkerei Muster, FEHLT“); nur wenn beides leer ist, ist `holder = null` und der Renderer druckt `FEHLT`.
- **Summen aus dem gespeicherten `totalColonies`.** `totals.current` und `totals.previous` sind beide das gespeicherte `totalColonies` (der Anzeige bzw. der Bezugsanzeige); die Zeilen kommen weiter aus dem Bestandsbild. Weicht die Zahl von der Summe der Stände ab, steht vor den Änderungen „Gesamtzahl berichtigt (Summe der Stände: n)“; die Zeile „Zahl der Völker: a auf b“ nutzt dasselbe `totalColonies`. Das PDF druckt, was in der Anzeige gespeichert ist, keinen Live-Stand (Annahme 84).
- **Richtiger Bezugsstand.** Entwurf: jüngste `SENT`-Anzeige (`findBaselineIn`). Gesendete Anzeige: die davor gesendete (`sentOn` kleiner, bei Gleichstand `createdAt` kleiner, andere Id), damit ein altes PDF nicht gegen einen später gesendeten Stand verglichen wird. Halter aus `SiteSettings`, Empfänger und Nummern aus `ComplianceSettings`. Fußzeile „Stand der Daten {updatedAt} · erstellt mit ApiCore“ (Berliner Tag). Das Briefdatum ist der Berliner Tag von `now`; der Ort daneben kommt aus der Postleitzahl-Zeile der Halteranschrift und bleibt leer, wenn keine gefunden wird (Annahme 83).
- **Geteilter Tabellenhelfer.** `src/export/pdf-table.ts` hat zwei additive Optionen: `PdfColumn.align` (rechtsbündige Zahlenspalten) und `drawTable(…, { boldLastRow })` (fette Summenzeile). Standardverhalten und Kopfwiederholung beim Seitenumbruch bleiben unverändert. Nach `drawTable` setzt der Renderer `doc.x = PDF_MARGIN`, sonst liefen Notizen und Fußzeile im rechten Randstreifen.
- Layout-Tests: 15 Stände mit langen Adressen ergeben eine Hochformat-Seite, Notizen und Fußzeile beginnen bei `PDF_MARGIN`, der TSK-Nachweis hat eine Querformat-MediaBox. Beispiel-PDFs wurden gerendert und angesehen (lesbar, Umlaute korrekt).
### 12.9 Hinweise und Tagesauswertung
Task 11 verbindet die Anzeigen mit dem Ereignis-Rückgrat (Abschnitt 10). `ComplianceModule` samt `ComplianceEventHandlers` ist in `AppModule` **und** `WorkerModule`; `worker-runtime.e2e-spec.ts` und `authority-notices-herd.e2e-spec.ts` prüfen, dass `compliance.herd-change` und `compliance.tsk-reference-day` in beiden Registern stehen.
- **`recordForPermissionOnce`.** `NotificationsService.recordForPermissionOnce(permission, input, tx)` schreibt je Thema (`relatedType` + `relatedId`) und Nutzer höchstens eine **ungelesene** Meldung. Ein Advisory-Lock je Thema (`pg_advisory_xact_lock(hashtextextended('notify-once:<Typ>:<Id>', 0))`) ordnet Prüfung und Schreiben gleichzeitiger Transaktionen hintereinander (e2e: zwei parallele Transaktionen schreiben genau eine Zeile je Nutzer). Bewusst kein `dedupeKey`: Das Thema kommt wieder, sobald die Meldung gelesen ist. Empfänger sind aktive Nutzer mit aktiver Rolle und allen Rechten; `planIn` plant die Telegram-Zustellung in derselben Transaktion (Kategorie `COMPLIANCE`, Vorgabe-Kategorie). Texte unter 600 Zeichen, ohne Anschrift; der Link steht in `link`. Themen: `ComplianceItem` / `herd-change` und `ComplianceItem` / `tsk-report:<Jahr>`.
- **`compliance.herd-change`.** Ereignisse: `ColonyCreated`, `ColonyMoved`, `ColonyEnded`, `ColonyStatusChanged` (nur Wechsel zwischen lebend und beendet) und `ApiaryChanged`. Der Handler berechnet über `herdStatusIn` die Abweichung vom Bezugsstand (außerhalb der Sperre, READ COMMITTED: schlimmstenfalls zu früh oder zu spät gemeldet, nie doppelt). Löst sie aus (`noticeOnColonyCountChange`, `noticeOnApiaryChange`), erhält jeder aktive Nutzer mit `compliance:manage` höchstens eine ungelesene Meldung „Bestand geändert: …“ (Priorität `HINWEIS`, Kategorie `COMPLIANCE`, Link `/compliance`). **Er sendet nur eine Meldung und legt nie eine Anzeige an**, auch keinen Entwurf (Spec 5.5, Annahme 78). Ein Stand-Umzug (`ApiaryChanged` mit Ortswechsel) zählt, ein Umbenennen nicht, ein Umsetzen zwischen Ständen löst nichts aus. Der CSV-Import emittiert `ColonyCreated` (Task 7), damit auch importierte Völker die Meldung auslösen.
- **`compliance.tsk-reference-day`.** Ereignis `TskReferenceDayReached`. Unter der Sperre der Zeile `ComplianceSettings` legt der Handler einen TSK-Entwurf für das Jahr an (`createDraftIn`, 12.8), außer es gibt für das Jahr schon eine gesendete Anzeige oder einen offenen Entwurf. Dazu die Meldung „TSK-Stichtag TT.MM.JJJJ: n Völker gezählt. Bitte prüfen und melden.“ (`WARNUNG`, Kategorie `COMPLIANCE`, Link `/compliance/notices/<id>`). **Die Erinnerung geht auch, wenn nur ein Entwurf existiert** (kein neuer Entwurf, Link auf den vorhandenen, höchstens eine ungelesene je Nutzer); **bei einer gesendeten Anzeige bleibt der Handler still** (Annahme 79).
- **Tagesregel `TskReferenceDayReached`.** Aggregat `Compliance`, `aggregateId tsk:<Jahr>`, Payload `{ year, referenceDay }`, Schlüssel `TskReferenceDayReached:tsk:<Jahr>:<Tag>`. Die Regel prüft den Stichtag des **Jahres des Berliner Laufs und des Vorjahres** mit einem **Nachholfenster von 7 Tagen**: Ein verpasster Lauf wird nachgeholt (Lauf am 05.03. nach verpasstem 03.03. ergibt ein Ereignis, einen Entwurf und eine Meldung je Nutzer). Der Tag ist der Berliner Kalendertag; den Stichtag liest die Regel aus der Zeile `ComplianceSettings` (`tskReferenceDay`). Im Code steht kein Vorgabewert: Fehlt die Zeile, legt `complianceSettingsIn(db)` sie mit dem Schema-Vorgabewert (`01-01`) an; die Migration `20261016100500` sät sie. Zweimal am Stichtag gelaufen heißt: ein Ereignis, ein Entwurf.
- **Umschalter wirkt erst beim nächsten Ereignis.** Schaltet der Inhaber `noticeOnColonyCountChange` oder `noticeOnApiaryChange` von aus auf an und besteht bereits eine Abweichung, kommt die Meldung erst beim nächsten Herd-Ereignis; die Einstellung löst selbst kein Ereignis aus und kein Handler hört auf Einstellungsänderungen (Annahme 80).
- Die e2e-Dateien der Stufe sichern und stellen die geseedete Zeile `ComplianceSettings` wieder her (nie `deleteMany` auf der Tabelle) und räumen nur selbst angelegte Anzeigen ab.
### 12.10 Prüfbereitschaft I
Task 12 liefert den Rahmen der Prüfbereitschaft mit den ersten sieben Regeln, Task 13 ergänzt die übrigen elf (zusammen die 18 der Spec, Ergänzungen am Ende dieses Abschnitts).
- **Modul und Worker-Schutz.** `ReadinessModule` (`src/compliance/readiness/`) ist **nur in `AppModule`**. Der Controller hängt am `PermissionResolverService` und darf nicht in den Worker-Graphen. `readiness-worker-graph.spec.ts` läuft über die `imports` des `WorkerModule` und prüft, dass weder `ReadinessModule` noch `ReadinessController` darin liegen.
- **`READINESS_RULES_TOKEN`.** Die Regelliste (`READINESS_RULES` in `rules/index.ts`) wird unter dem Token bereitgestellt; `ReadinessService` und `AcknowledgementsService` bekommen sie per Konstruktor. Unit-Specs übergeben Fake-Regeln direkt, die e2e überschreibt den Provider (`[...READINESS_RULES, softRule]`), ohne die geteilte Liste zu verändern. `rules/index.spec.ts` prüft eindeutige Schlüssel und eindeutige `subjectId` je Regel.
- **Sieben reine Regeln.** Jede Regel ist eine pure Funktion `evaluate(ctx)`: `holder-data`, `food-business-data` (Pflicht), `vet-office` (Prüfen), `registration` (keine `SENT`-Erstanzeige), `herd-change` (Abweichung zum Bezugsstand laut Einstellung, nie ohne Bezugsstand), `tsk-report` (Stichtag erreicht ohne `SENT`-Zählung, „überfällig“ nach `tskReportDueDay`; das Vorjahr bleibt offen, solange sein Entwurf existiert) und `restriction-active` (ein Punkt mit allen gesperrten Ständen). Eine Regel ohne Befund ergibt genau einen erledigten Punkt (`DONE`, `subjectId` `global`). **Keine dieser sieben Regeln ist quittierbar** (Spec 5.7, Annahme 85); die Quittierung ist als Mechanik gebaut (`acknowledgeable`) und wird erst von den weichen Regeln aus Task 13 genutzt (vier quittierbare Regeln, siehe unten).
- **Live-Auswertung, nur Quittungen gespeichert.** `GET /compliance/readiness` lädt einen Kontext (Stammdaten, Einstellungen, Bestandsstatus, Anzeigen, aktive Sperren, jeweils begrenzt) und führt jede Regel aus; gespeichert wird allein `ComplianceAcknowledgement`. Tage (`today`, Stichtag, „überfällig“) sind Berliner Kalendertage; die Sperre zählt über `isRestrictionActive`, dieselbe Funktion wie der Wächter an den Völker-Pfaden (12.7), damit Anzeige und Sperre nie auseinanderlaufen.
- **Fingerabdruck.** sha256 über kanonisches JSON der für den Befund maßgeblichen Werte; Listen (z. B. `apiariesAdded/Removed/Relocated` bei `herd-change`) werden vorher **sortiert**, damit dieselben Daten in anderer Reihenfolge denselben Abdruck ergeben (Test).
- **Quittung, Rücknahme, veraltet.** Quittiert wird mit Notiz (10 bis 2000 Zeichen); der Fingerabdruck muss zum jetzt berechneten Punkt passen (sonst 409). Eine Quittung gilt nur, solange sie nicht zurückgenommen ist **und** ihr Fingerabdruck noch passt: Eine **zurückgenommene oder veraltete Quittung öffnet den Punkt wieder**. Quittieren und Zurücknehmen schreiben Audit (`CREATE`/`UPDATE` an `ComplianceAcknowledgement`); das Zurücknehmen sperrt die Zeile und setzt `revokedAt` in SQL.
- **Rot und Schwere.** Jeder offene Punkt ist rot; die Schwere steht als Wort („Pflicht“ oder „Prüfen“) daneben. Gesamtstatus `READY`, wenn kein Punkt offen ist, sonst `OPEN`.
- **Höchstens 50 Subjekte.** Betroffene Objekte stehen in `subjects` (höchstens 50), der Rest wird in `more` gezählt.
- **Maskierung je `subjectPermission`.** Titel und Detailtexte enthalten nie Namen, Anschriften, Aktenzeichen oder Notizen. Hat der Aufrufer das Recht der Regel (`subjectPermission`, hier `apiaries:read`) nicht, bleiben `subjects` leer und `more` zählt sie; `summary` wertet ohne Subjekte aus. Der Stammdaten-Punkt nennt nur die Lücke („Name“, „Anschrift“), nie den Wert. **Die Zählungen bleiben sichtbar** („N Stände liegen im Sperrbezirk“), auch maskiert; die Seite verlangt ohnehin `compliance:read`, nur die Namen hängen am Recht (Annahme 81). Die Arzneimittel-Regeln aus Task 13 halten Mittel, Packung, Lieferant und Tierarzt aus `detail` und `title` heraus; welches Recht die Namen freigibt, steht je Regel in der Tabelle unten.
- **Lesepfade schreiben nicht.** `loadReadinessContext` liest `SiteSettings` per `findUnique`; fehlt die Zeile, gilt ein leerer In-Memory-Standard (alle Felder `null`, `holder-data` meldet die Lücken), kein Upsert. `ComplianceSettings` kommt aus `herdStatusIn` (12.8), das nur anlegt, wenn die von der Migration gesäte Zeile fehlt.
- **Registrierungs-Bezug separat.** Die jüngste `SENT`-Erstanzeige (nach `sentOn`, `createdAt`, `id`) und der jüngste `DRAFT` werden in **eigenen Abfragen** gelesen, nicht aus einer begrenzten Gesamtliste; nur der TSK-Teil ist auf 100 begrenzt. Der Bezugsstand für `herd-change` kommt unverändert aus `herdStatusIn` (`findBaselineIn`).
- **Endpunkte.** `GET /compliance/readiness`, `GET /compliance/readiness/summary` (`compliance:read`); `POST /compliance/acknowledgements` (`@Idempotent`) und `POST /compliance/acknowledgements/:id/revoke` (`compliance:manage`). BEEKEEPER und ADMIN alles, STAFF und READONLY nur Lesen, API_CLIENT nichts.
- Oberfläche: Die Zuordnung „Schwere → Wort“ und „`OPEN` → rot mit Symbol und Wort“ hat seit Task 14 einen eigenen Test (`labels.spec.ts`, Abschnitt 12.11).
**Task 13: die 18 Prüfpunkte.** Die Liste `READINESS_RULES` hat jetzt 18 Regeln in fünf Bereichen; `rules/index.spec.ts` prüft Bereich, Schwere, `acknowledgeable` und `subjectPermission` aller 18. Alle Regeln sind weiter reine Funktionen auf dem Kontext (`loadReadinessContext(db, now, { withdrawal })` mit den Feldern `treatments`, `moves`, `lots`, `medicines`, `templates`, `harvests`; `ReadinessModule` importiert dafür `WithdrawalModule`).
| Bereich | Regel (Schlüssel) | Schwere | quittierbar | Namen frei mit |
|---|---|---|---|---|
| Stammdaten | `holder-data`, `food-business-data` | Pflicht | – | – |
| Stammdaten | `vet-office` | Prüfen | – | – |
| Behörden | `registration`, `herd-change`, `tsk-report`, `restriction-active` | Pflicht | – | `apiaries:read` bei `herd-change`, `restriction-active` |
| Tierarzneimittel | `treatment-log-gaps` (ein Punkt je Kalenderjahr) | Pflicht | – | `treatments:read` |
| Tierarzneimittel | `lot-gaps`, `lot-prescription` | Pflicht | – | `veterinary-medicines:read` |
| Tierarzneimittel | `treatment-location-uncertain` | Prüfen | – | `treatments:read` |
| Tierarzneimittel | `medicine-withdrawal-missing`, `lot-negative` | Prüfen | – | `veterinary-medicines:read` |
| Tierarzneimittel | `lot-expired-stock` (je Packung), `treatment-expired-lot` (je Behandlung) | Prüfen | ja | `veterinary-medicines:read` |
| Etiketten | `label-template-gaps` | Pflicht | – | – |
| Etiketten | `label-template-form` | Prüfen | ja | – |
| Ernte | `harvest-withdrawal` (je Ernte) | Prüfen | ja | `treatments:read` |
- **Gleiche Funktionen wie das PDF.** `treatment-log-gaps` nutzt `resolveSourceValues`, `treatmentLogGaps` und `groupTreatmentLog` des Bestandsbuchs (12.2): Rückblick sind das laufende und fünf vorige Kalenderjahre (Jahr der UTC-Zeit wie im PDF; ein Eintrag um Mitternacht zum Jahreswechsel kann im Nachbarjahr stehen). Eine lückenhafte Packung steht einmal als Subjekt `LOT` (Label „Packung der Behandlungen“), Behandlungen ohne Packung als Subjekt `TREATMENT` mit dem Volk als `parentId`; Mengen- und Wartezeitlücken bleiben auf der Behandlung, die Tierarzt-Lücke gibt es nur bei verschreibungspflichtigem Mittel. `treatment-location-uncertain` meldet die `*`-Zeilen (`apiaryOnDate(...).uncertain`). `medicine-withdrawal-missing` zählt 0 Tage als erfasst. `lot-expired-stock` nimmt den Rest in den Fingerabdruck auf: Ändert sich der Rest, öffnet sich die Quittung wieder.
- **Etiketten.** `label-template-gaps` und `label-template-form` prüfen jede aktive Vorlage mit demselben `checkLabel` wie der Druck (12.4, 12.6; `buildPlaceholderValues`, `expectedParticulars`, Ziffernregeln nur aus `ComplianceSettings`), je Vorlage einmal gemessen (`label-checks.ts`). `label-template-form` meldet nur Warnungen, sein Fingerabdruck sind die Warn-Codes. **Das Ziel der Prüfung ist die jüngste Abfüllung mit Restbestand**: Der Lader liest die Abfüllungen seitenweise (200, neueste zuerst) und hört bei der ersten mit Restbestand (`summarizeStock`) auf; gibt es keine, gilt die jüngste, sonst `SAMPLE_BOTTLING` (Beispielwerte). Ungültige `netQuantityDigitRules` ergeben in der Prüfbereitschaft „keine Regel“ (die Höhe der Füllmenge wird nicht beurteilt), der Druck lehnt sie dagegen mit 409 ab.
- **Ernte.** `harvest-withdrawal` rechnet keine Fenster selbst: Der Lader ruft `WithdrawalCheckService.conflictsForHarvests` für die Ernten der letzten 24 Monate auf, der Text kommt aus `withdrawalRangeText` (längstes Fenster).
- **Arzneimittel-Namen maskiert.** Regeln mit `subjectPermission: 'veterinary-medicines:read'` zeigen Packungen und Behandlungen ohne dieses Recht nur gezählt (`subjects` leer, `more` zählt). Titel, Text und Subjekt-Labels enthalten **nie Produktnamen** oder Mittelnamen; ein Label heißt allgemein („Packung der Behandlungen“), die Zuordnung zum Mittel steht erst hinter dem Beheben-Link. Die Namen der Lücken (`missing`) sind deutsche Feldnamen (Lieferant, Beleg, Menge, Wartezeit, Tierarzt) bzw. die Titel der Etikett-Befunde. Neue Subjekt-Typen: `TREATMENT`, `LOT`, `MEDICINE`, `LABEL_TEMPLATE`, `HARVEST`.
- **Prüfbericht als PDF.** `GET /compliance/readiness/report.pdf` (`compliance:read`) liefert `pruefbericht.pdf` (A4 hoch, `no-store`, `renderReadinessReportPdf` in `readiness-report.ts`). Inhalt: Kopf mit Halter (`FEHLT`, wenn leer), Datum, Gesamtstatus und dem Satz „Jeder offene Punkt ist zu behandeln.“, je Bereich eine Tabelle mit Status (Offen, Erledigt, Quittiert), Schwere als Wort, Titel, Text, Norm (130 pt breit, „Fertigpackungsverordnung“ in einer Zeile) und bei Quittungen Notiz, Nutzer und Datum. **Gedruckt werden nur Zählungen** („n Objekte“), nie Namen von Objekten (Annahme 90); Quittungsnotizen werden nach 600 Zeichen mit „…“ gekürzt. Die Auswertung läuft mit den Rechten des Aufrufers. Ein Aufrufer mit `compliance:read` ohne `veterinary-medicines:read` bekommt 200, und das PDF enthält weder Mittelnamen noch Behandlungsprodukte (e2e).
- **Kurzfassung gecacht.** `ReadinessService.summary(now)` hält das Ergebnis je Prozess 60 Sekunden (`SUMMARY_TTL_MS`, Zeitbasis ist das übergebene `now`). `AcknowledgementsService.create` (nach Erfolg) und `revoke` rufen `invalidateSummary()` auf; ein abgelehntes Quittieren lässt den Cache stehen. Liste (`GET /compliance/readiness`), PDF-Bericht und Quittierpfad rechnen immer live. Änderungen an den Daten und Quittungen anderer Prozesse zeigen sich in der Kurzfassung bis zu 60 Sekunden später (Annahme 86).
- **409-Codes beim Quittieren.** Die Antwort hat den Körper `{ code, message }` (Meldungen unverändert): `READINESS_FINGERPRINT_STALE` (Fingerabdruck passt nicht mehr; „Der Punkt hat sich geändert. Bitte neu laden.“), `READINESS_ITEM_NOT_OPEN` (Punkt weg oder erledigt; gleiche Meldung) bei `POST /compliance/acknowledgements`, und `READINESS_ACK_REVOKED` („Die Quittung ist schon zurückgenommen.“) bei `…/:id/revoke`. Die Konstanten stehen in `acknowledgements.service.ts`; die Oberfläche wertet die Codes aus (12.11), der Text ist nur Rückfall.
- **Lastgrenze.** Eine Auswertung lädt alle Packungen, sechs Jahre Behandlungen und die Ernte-Konflikte (`windowsOf` liest alle Behandlungen der beteiligten Völker). Das ist für den Betrieb unkritisch und der Grund für den 60-s-Cache der Kurzfassung (Dashboard); bei sehr vielen Daten sind Index oder weitere Zwischenspeicher zu prüfen. Einzelpunkte je Behandlung oder Ernte können lang werden, die Oberfläche klappt sie ein.
### 12.11 Oberfläche (Frontend)
Die Oberfläche des Compliance-Blocks entstand in Task 14 bis 19: Prüfbereitschaft (14), Tierarzneimittel (15), Behandlungsmaske (16), Etikett (17), Anzeigen und Einstellungen (18) und Sperrbezirk (19). Alle Aktionen sind online-only: Auslöser sind offline deaktiviert und nennen den sichtbaren Grund „Nur mit Verbindung“ (`aria-describedby`). Der Service-Worker-Ausschluss steht in Abschnitt 2.
**Prüfbereitschaft (Task 14).** Code in `lib/compliance/` (`types`, `api`, `fix-links`, `errors`, `labels`, `use-readiness`; Query-Schlüssel `['compliance', …]`) und `app/(app)/compliance/`.
- **Seite `/compliance`** zeigt `GET /compliance/readiness`. Oben der Status: „Prüfbereit“ (grün) oder „n offen“ (rot), jeweils mit Symbol und Wort. **Jeder offene Punkt ist rot** (Token `destructive`) und trägt Symbol, das Wort „Offen“, die **Schwere als Wort „Pflicht“ oder „Prüfen“** und den Titel; dazu ein Satz Detail und klein die Norm. Betroffene Objekte stehen eingeklappt mit einem „Beheben“-Link je Objekt, „und n weitere“ für den Rest; fehlt das Recht auf die Namen, steht nur „n betroffen“. Erledigte Punkte stehen eingeklappt unter „Erledigt (n)“, Quittierte ruhig mit Nutzer, Datum und Notiz. Die Zuordnung Schwere → Wort und offen → rot liegt allein in `labels.ts` und hat einen eigenen Test. Hauptaktion ist „Nächsten Punkt lösen“ (erster offener Pflicht-Punkt); ist dessen Ziel noch nicht gebaut, heißt sie „Nächsten Punkt ansehen“ und springt auf die Karte (`#item-<Schlüssel>-<Subjekt>`). Bei grünem Status steht dort „Bestandsbuch {Jahr} drucken“. Hinter „Mehr“ liegen der Bericht (PDF über `downloadReadinessReport`, als Blob mit Bearer-Header, Dateiname `pruefbericht.pdf`), das Bestandsbuch, Anzeigen, Sperrbezirke und Einstellungen. Downloads sind offline aus.
- **Beheben-Links aus einer Stelle:** `fixLinkFor(itemKey, subject)` in `fix-links.ts` bildet Subjekte auf Seiten ab (Typen ohne Beachtung der Groß-/Kleinschreibung). Die Ziele `/compliance/notices(/[id])`, `/compliance/restrictions` und `/compliance/settings` schaltet `ROUTE_AVAILABLE` seit Task 18 und 19 ein (vorher blendete die Menge solche Links aus, `available: false`). Eine Behandlung verlinkt auf `/colonies/{parentId}`, Stammdaten auf `/settings/automation?tab=social-media` (Reiter „Stammdaten & Web“). `herd-change` führt zur Anzeige (`/compliance/notices`), nicht zur Sperrenliste; `restriction-active` ohne Subjekt zur Sperrenliste.
- **Quittieren und Zurücknehmen** (`acknowledge-dialog.tsx`): nur bei `acknowledgeable` und offenem Punkt bzw. quittiertem Punkt, nur mit `compliance:manage`. Notiz 10 bis 2000 Zeichen, vor dem Senden geprüft; Zurücknehmen über `ConfirmDialog`. Fehler laufen über `readinessConflictText`, der die drei Codes `READINESS_FINGERPRINT_STALE`, `READINESS_ITEM_NOT_OPEN` und `READINESS_ACK_REVOKED` mit je eigenem deutschem Text auswertet (der Servertext ist nur Rückfall); bei jedem 409 laden alle `compliance`-Queries neu. Nach Erfolg und Neuladen geht der Fokus auf die Kartenüberschrift (`tabIndex=-1`), eine `role="status"`-Region sagt „Punkt quittiert“ bzw. „Quittung zurückgenommen“. `lib/compliance/errors.ts` liest außerdem `DRAFT_EXISTS` und `RESTRICTED_ZONE`, alles andere läuft über `saveErrorText`; es gibt keine zweite Fehlerlogik.
- **Dashboard-Zeile** (`components/today/compliance-line.tsx`): „n Punkte offen in der Prüfbereitschaft“, nur mit `compliance:read` und `openCount > 0`, aus `GET /compliance/readiness/summary` (60 s gecacht, Annahme 86). Schlägt das Laden fehl (kein Netzfehler; offline bleibt es still), steht eine **Fehlerzeile** „Prüfbereitschaft nicht geladen.“ mit „Erneut versuchen“.
- **Navigation:** `nav-config.ts` hat „Prüfbereitschaft“ (Symbol `ShieldCheck`, Gruppe „Betrieb“, Recht `compliance:read`).
**Tierarzneimittel (Task 15).** Seiten unter `app/(app)/medicines/`, Daten aus `lib/medicines/api.ts`; kein eigener Nav-Eintrag, der Einstieg ist die Karte „Tierarzneimittel“ auf `/storage` (nur mit `veterinary-medicines:read`).
- **Seiten.** `/medicines` (Katalog mit Rest und nächstem Verfall, Reiter Aktiv/Deaktiviert; bei leerem Katalog „Aus deinen Behandlungen übernehmen?“ mit `GET …/suggestions`: „Übernehmen“ öffnet nur das Formular, gespeichert wird erst beim Absenden), `/medicines/[id]` (Angaben, Packungen, Hauptknopf „Packung erfassen“) und `/medicines/lots/[id]` (Rest, Angaben, Bewegungsbuch, Beleg, Hauptknopf „Inventur“). Hauptknöpfe: „Mittel anlegen“ (`create`), „Packung erfassen“ (`create`), „Inventur“ (`update`); Entsorgung, Bearbeiten und (De-)Aktivieren sind Nebenaktionen mit `update`. Ist das Mittel inaktiv, ist „Packung erfassen“ mit sichtbarem Grund „Das Mittel ist deaktiviert.“ gesperrt (Annahme 61). Mit Packungen entfällt das Einheitsfeld (Annahme 62), `unit` wird nie gesendet; geleerte Felder gehen als `null` hinaus.
- **Dialoge.** Mittel, Packung, Inventur (zeigt die Differenz live in einem `aria-live`-Bereich, Buchen bei Differenz 0 gesperrt) und Entsorgung (Menge, Pflichtgrund; über den Rest hinaus erlaubt, mit roter Warnung samt Symbol und Wort „Negativ“). Server-Meldungen („Das Mittel ist deaktiviert.“, „Die Einheit des Mittels passt nicht zur Packung.“) erscheinen im Dialog. Ein negativer Rest erscheint überall rot mit Warnsymbol und Wort (`RestText`).
- **`use-attempt-key`** (`lib/medicines/use-attempt-key.ts`, Ansatz der Abfüllung): Jeder Anlege- und Buchungsaufruf trägt einen `Idempotency-Key`. Der Schlüssel bleibt nur, wenn der Body unverändert ist und der letzte Versuch am Netzwerk scheiterte; jede Serverantwort ohne Erfolg und jede Änderung des Bodys erneuert ihn (sonst würde der Server mit dem Schlüssel einen alten Body wiedergeben). Er gilt in allen vier Dialogen.
- **`parseDecimal`: Tausenderpunkt-Regel.** Das Komma ist immer das deutsche Dezimalzeichen („1,000“ ist 1). Ein Punkt gilt nur als Dezimalpunkt, wenn er das einzige Trennzeichen ist und nicht wie ein Tausenderpunkt aussieht (`^\d*[1-9]\d*\.\d{3}$`). „1,5“, „1.5“ und „0.001“ gehen; **„1.000“**, „12.345“ und jeder Text mit mehr als einem Trenner (auch „1.234,5“) sind ungültig mit „Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5“ (Annahme 88). Gilt in allen Mengenfeldern; sie sind Textfelder mit `inputMode="decimal"`.
- **Bewegungsbuch** (`lib/medicines/book.ts`): Eingang als erste Zeile, laufender Stand auf Tausendstel gerundet, neueste zuerst, Datum ist der Berliner Tag der Buchung. **Hinweis bei Abweichung:** Weicht der letzte rechnerische Stand von `lot.remaining` des Servers ab, steht unter der Tabelle „Stand weicht vom Server ab – bitte neu laden“.
- **Seite ohne Leserecht.** Die drei Seiten laden ohne `veterinary-medicines:read` nichts (`enabled: false`) und zeigen „Dazu fehlt dir das Recht.“ ohne Erneut-Knopf (`components/medicines/no-read-access.tsx`, Link zurück zum Lager).
- **Belege.** `DocumentUploadCard` (Art `MEDICINE_LOT`, Titel „Beleg“) hat die Eigenschaft `canWrite` (Vorgabe `true`); die Packungsseite übergibt `canWrite={canUpdate}`. Ohne sie entfallen Hochladen, Umbenennen und Löschen, Herunterladen bleibt. `DocumentEntityType` kommt aus `DOCUMENT_ENTITY_TYPES` und kennt `MEDICINE_LOT`, `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`.
- **Hinweis Bestandsbuch (Annahme 73).** Auf der Standseite ist der Menüeintrag „Bestandsbuch (PDF)“ ohne `veterinary-medicines:read` deaktiviert (und nicht fokussierbar, weil die Menükomponente `aria-disabled` nicht zulässt); dauerhaft steht darunter die Zeile „Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.“. Für Task 16: `fetchSelectableLots(medicineId, includeLotId?)` liefert nur Packungen aktiver Mittel mit Rest > 0.
**Etikett (Task 17).** Das Frontend entscheidet nichts über Pflichtangaben; es zeigt, was das Backend (12.4, 12.6) prüft.
- **Prüfpanel im Designer** (`components/labels/label-compliance-panel.tsx`): Der Designer sendet 500 ms nach der letzten Änderung nur die Text-Elemente an `POST /label-templates/check` (Hook `useDebouncedLabelCheck` in `lib/label-templates/check.ts`; nur Text-Änderungen lösen aus, Eingaben außerhalb des technischen Bereichs lösen keinen Aufruf aus). Ein neuer Entwurf und das Aushängen **brechen den laufenden Aufruf ab** (`AbortSignal`); bei einem ungültigen Entwurf bleibt das letzte Ergebnis stehen, ist aber als „Veraltet: …“ markiert (`stale`). Das Panel zeigt die sechs Pflichtangaben mit Symbol und „Erfüllt“/„Fehlt“, Hinweise getrennt und mit „Hinweis:“ beginnend; die Live-Region (`aria-live="polite"`) enthält nur die Summenzeile.
- **Keine Rechtszahlen im Frontend.** Mindest-x-Höhe und Ziffernhöhen kommen ausschließlich aus `requirements` der Prüfantwort („Die Prüfung verlangt aktuell mindestens X mm“); der Hinweistext am Feld „Mindest-x-Höhe (mm)“ nennt keine Zahl, `DEFAULT_MIN_X_HEIGHT_MM` gibt es nicht. Der Pflichtangaben-Block (`compliance-block.ts`, „Pflichtangaben-Block einfügen“, mit Rückgängig) rechnet die Schriftgrößen aus `requirements`. Die Schranken 0,5 bis 5 mm des Feldes sind der technische Bereich des DTO, kein Rechtswert. Eine neue Vorlage startet mit leerem Feld und sendet kein `minXHeightMm` (der Server setzt seinen Standard); bis ein Wert da ist, läuft keine Prüfung (Annahme 89).
- **Druckkarte der Abfüllung** (`bottling/[id]/print-labels-card.tsx`): Vor dem Anzeigen läuft `GET /label-templates/:id/check/:bottlingBatchId` (`gcTime: 0`, keine Persistenz, `cache: "no-store"`). Bei Lücken zeigt sie die **rote Lückenliste** (Region, Symbol und „Fehlt:“) mit Links zu Stammdaten, Produkt und Designer (der Produktlink entfällt ohne Produkt an der Abfüllung); „Etiketten als PDF“ ist dann `aria-disabled` (fokussierbar, Grund per `aria-describedby`). Der **Probedruck** („Probedruck (MUSTER)“, `proof=true`) bleibt immer möglich; sein Dateiname enthält „muster“, der des echten Drucks nicht. Antwortet der Druck mit 409 `LABEL_PARTICULARS_MISSING`, zeigt die Karte die Liste `gaps` des Servers und **setzt den Fokus darauf**. 409 `COMPLIANCE_SETTINGS_INVALID` und 403 erscheinen als deutsche Meldung (`labelCheckErrorText`). Ein zweiter Klick während der Wartezeit löst keinen zweiten Aufruf aus. Ohne `label-templates:read` steht nur ein Hinweis, ohne `:print` gibt es keine Druckknöpfe, ohne Netz „Nur mit Verbindung“. Prüf- und Druckaufrufe laufen mit `cache: "no-store"` (`apiFetchBlob` reicht `init` durch).
- **Stammdaten.** Im Reiter „Stammdaten & Web“ (Verwaltung > Automatisierung) stehen `foodBusinessName` (200) und `foodBusinessAddress` (300); **leere oder nur aus Leerzeichen bestehende Felder werden als `null` gesendet** (`value.trim() || null`), damit sie serverseitig löschen. „Vom Bienenhalter übernehmen“ füllt nur die Felder, ohne zu speichern. Der Text nennt, dass sie weder im Shop noch auf der Herkunftsseite erscheinen.
- **Produkt `legalName`.** Das Feld „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“ steht im Typ `Product`, im Neu-Formular, im Bearbeiten-Dialog und auf der Detailseite; Platzhalter-Schlüssel und deutsche Labels liegen in `lib/label-templates/placeholder-keys.ts` (Wächter-Spec gegen `test/fixtures/placeholder-keys.json`).
**Behandlung mit Mittel, Packung und Dosis (Task 16).** Formular „Neue Behandlung“, Dialog „Behandlung bearbeiten“ und Sammelerfassung.
- **Mittel.** „Aus dem Katalog wählen“ bietet die aktiven Mittel (nur mit `veterinary-medicines:read`; ohne das Recht gibt es nur „Anderes Mittel“, ohne Fehler); „Anderes Mittel“ ist die letzte Option mit dem Freitextfeld wie bisher. Ein Katalogmittel füllt Wirkstoff, Wartezeit, Methode und Dosis als sichtbaren, bearbeitbaren Vorschlag mit dem Vermerk „aus Katalog“. Er gewinnt gegen die Vorbelegung aus der letzten Behandlung, überschreibt aber nie, was der Nutzer getippt hat; der Server füllt nichts nach. Lade- und Fehlerzustand des Katalogs zeigt `PrefillStatus`, Speichern bleibt möglich.
- **Packung.** Beim Anlegen wird die Packung mit dem frühesten Verfall vorgewählt (mit Hinweis); „Ohne Packung“ bucht nichts und wird nicht wieder überschrieben. **Beim Bearbeiten wählt die Oberfläche nie selbst eine Packung vor** (nach einem Mittelwechsel steht „Ohne Packung“, der Nutzer wählt; Annahme 92). Ist eine Packung gesetzt, stammen Chargennummer, Lieferant, Beleg und Tierarzt aus der Packung (Zeile „aus Packung: …“); die Nutzlast lässt diese Felder weg (der Server lehnt Text daneben mit 400 ab), `product` entfällt mit Katalogmittel. Über „Speichern“ steht die Buchungszeile („Bucht 30 ml je Volk ab (× 6 = 180 ml).“, in der Sammelerfassung mal Anzahl der Völker). Abgelaufene oder zu kleine Packungen warnen, blockieren nie. Der Temperaturhinweis („Heute 9–17 °C. Laut Katalog 12–25 °C.“) kommt aus `GET /apiaries/:id/weather` (`apiaries:read`) und entfällt still, wenn das Wetter fehlt.
- **Dosis.** Dezimalfeld, kein `NumberStepper` (der kennt nur ganze Zahlen): Komma als Dezimalzeichen, mindestens 0,001, **höchstens 3 Nachkommastellen** („Höchstens 3 Nachkommastellen.“, Annahme 94), „1.000“ wird als mehrdeutig abgelehnt (Annahme 88).
- **Mittelwechsel beim Bearbeiten.** Der Dialog nutzt dieselbe Katalog-Vorbelegung wie das Anlegen; was die Behandlung schon enthält, gehört dem Nutzer, nur leere Felder füllt der Katalog. Eine vorhandene Wartezeit wird beim Wechsel nie still ersetzt: Weicht sie vom Katalogwert des neuen Mittels ab (oder kennt der Katalog keinen), steht am Feld „Wartezeit stammt vom vorherigen Mittel – bitte prüfen.“, dazu der Knopf „aus Katalog übernehmen“ (nur mit Katalogwert) als ausdrückliche Übernahme. Eine Eingabe im Feld entfernt die Warnung.
- **Patch.** `medicinePatch` sendet die Mittel-Gruppe (`product`, `medicineId`, `medicineLotId`, `doseQuantity`, vier Packungstexte) nur, wenn sich in ihr etwas geändert hat; eine reine Notiz-Änderung bleibt so immer möglich.
- **Fehler und Deep-Link.** Anlegen und Dialog lesen Fehler über **einen** Leser, `saveErrorText` (kennt auch Feldfehler-Listen des Servers). `?editTreatment=<id>` an der Volksseite öffnet den Dialog dieser Behandlung; findet die geladene Liste die ID nicht, sagt eine Live-Region „Behandlung nicht gefunden.“.
- **Entwurf wiederherstellen** (`treatments/new`): Ist das Mittel des Entwurfs nicht mehr im Katalog, nicht aktiv oder fehlt das Leserecht, fallen `medicineId` und `medicineLotId` weg, `product` bleibt als Freitext, und eine Statuszeile sagt „Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv. Bitte wähle es neu.“ Ist die Katalogliste noch nicht geladen, wird nichts verworfen.
**Anzeigen und Einstellungen (Task 18).** Seiten unter `app/(app)/compliance/`, Fehlertexte über `complianceErrorText` (`lib/compliance/errors.ts`).
- **Liste `/compliance/notices`:** Anzeigen ans Veterinäramt (Art, Jahr, Status mit Symbol und Wort, Völkerzahl, Stichtag), Filter nach Art und **Jahresfilter** (vier Ziffern, sonst Hinweis am Feld; Server-Query `year`). Ab 200 Einträgen steht der Hinweis „Es werden die neuesten 200 angezeigt.“ (Obergrenze der Liste).
- **Anlegen:** „Anzeige anlegen“ (nur `compliance:manage`) öffnet einen Dialog (Erstanzeige, Bestandsänderung, TSK-Zählung mit Jahr; „Bereits angezeigt“ mit Datum legt direkt eine gesendete an). Der Aufruf trägt einen `Idempotency-Key`. Gibt es schon einen Entwurf, antwortet der Server 409 `DRAFT_EXISTS` mit `existingId`; der Dialog verlinkt auf den vorhandenen Entwurf.
- **Detail `/compliance/notices/[id]`** mit drei sichtbaren Schritten (1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen; je Symbol und Wort Jetzt/Danach/Erledigt). Der **PDF-Download** (authentifiziert, kein Token im Link) meldet in der `aria-live`-Region „PDF heruntergeladen. Schick es jetzt ans Amt.“ und setzt den Fokus auf die neue Hauptaktion. **„Als gesendet markieren“ ist im Entwurf immer erreichbar**: als zweite Aktion (Umrissknopf neben „Neu zählen“), nach dem PDF als Hauptaktion; so geht es auch nach einem Neuladen ohne erneutes PDF. Das Sendedatum und „Gesendet am“ nutzen den **Berliner Kalendertag** (`berlinCalendarDay`); ein geleertes Datum zeigt „Das Datum kann nicht entfernt werden.“ „Neu zählen“ und „Anzeige stornieren“ gibt es nur im Entwurf, „Wieder öffnen“ nur bei gesendeter Anzeige; Kopie und Eingangsbestätigung sind Dokumente der Art `AUTHORITY_NOTICE`. Mit nur `compliance:read` fehlen die Aktionsknöpfe. `complianceErrorText` übersetzt 403 („Dazu fehlt dir das Recht.“) und 5xx („Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.“) und fügt eine 400-Antwort mit `message: string[]` zusammen; Nests Englisch erscheint nie.
- **Einstellungen `/compliance/settings`:** Veterinäramt, Erinnerungs-Schalter (Völkerzahl, Stand), TSK-Stichtag und Meldefrist, Mitgliedsnummer. Jeder Rechts- und Fristwert trägt am Feld „Annahme – bitte prüfen“. Stichtag und Meldefrist (leer erlaubt) prüft der Client vor dem Senden auf `MM-TT` (`isValidMonthDay`, Tage je Monat im Nichtschaltjahr, „02-29“ ungültig; Hinweis „Bitte einen Tag im Format MM-TT eingeben, zum Beispiel 03-31. Den 29. Februar gibt es hier nicht.“, `aria-describedby`, kein Request). Unter „Erweitert“ stehen die gezählten Völkerarten und der **Editor der Ziffernregeln** (1 bis 10 Regeln; „Regel hinzufügen“ fügt vor der letzten ein, „Entfernen, Regel n“; Höhe 0,5 bis 20 mm, Obergrenzen > 0 und aufsteigend, die letzte Regel hat keine Grenze und geht als `null` hinaus). Die Schranken sind die Grenzen des Backends (`assertDigitRules`, Konstanten in `compliance-settings.service.ts`), keine Rechtswerte; Fehler stehen an der Zeile (`role="alert"`) und als Satz „Bitte die Ziffernhöhen unter „Erweitert“ prüfen.“, weil der Abschnitt eingeklappt sein kann. Ohne `compliance:manage` sind die Felder nur lesbar.
- **Formular-Schutz:** Ein Hintergrund-Refetch setzt eine Eingabe nie zurück. Ohne Eingabe werden neue Serverdaten still übernommen; mit ungespeicherter Eingabe bleibt sie stehen, und „Daten wurden geändert – neu laden?“ mit „Neu laden“ erscheint. Nach eigenem Speichern wird der Zustand direkt übernommen.
- **Meldungskategorie `COMPLIANCE`** („Behörden & Prüfung“) steht in `lib/notifications/labels.ts` und damit in der Kategorienauswahl der Telegram-Karte. Die Glocke zeigt dafür das Kurz-Etikett „Behörden“ (Annahme 93).
**Sperrbezirk (Task 19).** Aktive Sperren lädt jede Seite einmal (`useActiveRestrictions`, Schlüssel `['compliance','restrictions','active']`, `staleTime` 30 s, Neuladen beim Fokus; Umsetzen- und Vereinigen-Dialog laden beim Öffnen neu). Der Server entscheidet endgültig, der Browser blendet nur früh aus.
- **Seite `/compliance/restrictions`** (Recht `compliance:read`, Pflege mit `compliance:manage`; ohne Leserecht `NoComplianceAccess` „Dazu fehlt dir das Recht.“ und keine Abfragen) mit drei Abschnitten **Aktiv / Geplant / Beendet** (Symbol und Wort; `restrictionState` rechnet nach dem Berliner Kalendertag, eine Sperre mit Beginn in der Zukunft steht unter „Geplant“, nie unter „Beendet“). Die Live-Region sagt nur Laden und Fehler. Die Verfügung hängt als Dokument (`APIARY_RESTRICTION`, `DocumentUploadCard`, schreibend nur mit `compliance:manage`; ohne Leserecht entfällt die Zeile „Verfügung“).
- **Anlegen:** „Sperrbezirk anlegen“ legt je gewähltem Stand (1 bis 50) einen Eintrag an (Seuche: Amerikanische Faulbrut, Kleiner Beutenkäfer oder Andere mit Freitext; Beginn, optional Ende, Behörde, Aktenzeichen, Notizen) mit `Idempotency-Key`.
- **Beenden:** Der Dialog setzt nur `endsOn` (der Eintrag wird nie gelöscht). **Vorgabe ist „gestern“** (nicht vor dem Beginn); `endsOn` gilt **einschließlich**. Ist der gewählte Endtag heute oder später, zeigen Hinweis und Toast „Die Sperre gilt noch bis einschließlich TT.MM.JJJJ.“. **Ein Aufheben am selben Tag ist nicht möglich** (Annahme 91): Ist die Sperre heute aufgehoben, trägt man gestern ein, außer sie begann heute, dann gilt sie bis einschließlich heute.
- **Rotes Band** am Stand, solange eine Sperre aktiv ist, mit dem Link „Sperrbezirke ansehen“ (Link nur mit `compliance:read`).
- **Wächter** bei Umsetzen (Quell- und Zielstand), Vereinigen über verschiedene Stände, Standkorrektur am Volk und neuem Volk: rote Meldung („Stand {Name} liegt im Sperrbezirk ({Krankheit}, seit {Datum}). … ist gesperrt.“; ohne Servertext der Rückfall „Der Stand liegt im Sperrbezirk.“) mit Symbol und Wort in einer persistenten `aria-live`-Region. Der Hauptknopf ist `aria-disabled` mit `aria-describedby` auf den Grund (bleibt fokussierbar, Klick und Submit sind gesperrt; `ConfirmDialog` hat dafür die additive Eigenschaft `triggerAriaDisabled`). Ableger (`split`) bleiben frei. Der Umsetzen-Dialog trägt den festen Hinweis zu BienSeuchV § 5 (Gesundheitsbescheinigung, im Zweifel beim Veterinäramt nachfragen) und keine Rechtswerte.
- **Ausnahme.** Nur mit `compliance:manage` erscheint der aufklappbare Abschnitt „Ausnahme mit Begründung“ (10 bis 500 Zeichen; Leerzeichen allein genügen nicht); die Begründung gilt erst nach der Rückfrage im `ConfirmDialog`, ohne Passwort-Abfrage (Inhaber-Entscheidung 2026-10-15). `restrictionOverrideReason` wird nur gesendet, wenn die Ausnahme genutzt wird; ändern sich die Sperren, verfällt sie. Ohne das Recht wäre die Antwort immer 403, darum wird das Feld nicht angeboten.
- **Ernte:** nur die Warnung „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“; sie speichert immer.
- **409.** `RESTRICTED_ZONE` und „Der Stand wurde gerade geändert. Bitte neu laden.“ erscheinen mit dem Servertext über dem Knopf, der Fokus springt auf die Meldung, die aktiven Sperren laden neu, und ein Knopf „Neu laden“ (Umsetzen, Vereinigen, Standkorrektur) lädt Volk und Stände neu und löscht die Meldung. Kein automatischer Neuversuch.
- **Service Worker.** Die Sperrbezirk-Pfade sind seit Task 14 nie zwischengespeichert; offline fehlt die frühe Sperre, der Server entscheidet.
## 13. Beute (3D-Aufbau)
Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. L1 ist das Backend (dieser Abschnitt), L2–L5 die Oberfläche (Abschnitt 13.1); Ziehen und Ablegen in 3D (Task 18) folgt.
**Modul.** `src/hive` (`HiveModule`): `hive.service.ts` (Aufbau je Volk), `hive-catalog.service.ts` (Katalog), `hive-ops.ts` (`applyHiveOps`), `hive-validate.ts` (Validator), `hive-facts.ts` (abgeleitete Fakten), `hive-state-schema.ts` (Zustandsfelder der Teile). Hive-Fakten schreiben nie in `Colony` oder `Inspection`; `Colony.occupiedFrames` bleibt die Volksstärke.
**Speicherform.**
- `HiveSystem` (Maße, `template`) und `HivePartType` (Art, Maße, `frameSlots`, `stateSchema`, `active`) bilden den Katalog. Die Startdaten (Zander nach Liebig, 10 Rähmchen, 14 Teile) kommen per Migration `20261017100100_hive_catalog_zander_liebig` mit festen Ids; `test/fixtures/hive-ops/catalog.json` ist dieselbe Quelle für das Frontend, ein e2e vergleicht beide. Die Futtertasche (`FRAME_FEEDER`, Migration `20261018100100_hive_catalog_frame_feeder`) ist ein Einsatz einer Zarge mit `slot` (erster belegter Rähmchenplatz) und der Katalogspalte `slotWidth` (Zahl belegter Plätze); die von ihr bedeckten Rähmchen sind `NONE`, Überschneidungen melden `RULE_FEEDER_SLOT` (Annahme 101).
- `HiveSetup` hält den aktuellen Aufbau eines Volks als JSON (Stand und `version`), `HiveChange` das Protokoll: je Änderung `occurredOn` (Berliner Tag), `source`, die Operationen und `stateAfter`. Der Stand an einem Tag kommt aus dem letzten `stateAfter` bis zu diesem Tag. Nachträge sind möglich, aber nie vor dem `INIT`-Tag und nie in der Zukunft.
- Katalogteile werden nie gelöscht, nur `active: false`. Bestehende Aufbauten mit einer inaktiven Art bleiben gültig.
**Operationen.** `addPart`, `addInsert`, `removePart`, `movePart`, `setState`, `setFrame`, `setFrames`, `setLabel`; höchstens 100 pro Anfrage, höchstens 30 Teile im Stapel, Beschriftungen und Rähmchen-Namen höchstens 40 Zeichen. Die Regeln und die Gründe (`reason`-Codes wie `RULE_FLOOR_BOTTOM`, `STATE_OUT_OF_RANGE`, `UNKNOWN_FIELD`, `LIMIT_PARTS`) stehen im Kopf von `hive-validate.ts`; die Prüfreihenfolge entscheidet, welcher Grund zuerst gemeldet wird, und das Frontend muss sie übernehmen: Wurzelform (`schemaVersion` 1, unbekannte Schlüssel), `LIMIT_PARTS`, dann je Stapelteil Id, unbekannte Felder, Art, Beschriftung, Zustand, Rähmchen und Einsätze (`RULE_INSERT_HOST`, `RULE_FEEDER_SLOT`), danach Boden (`RULE_FLOOR_BOTTOM`), zuletzt Dach (`RULE_ROOF_TOP`); Textlängen zählen UTF-16-Einheiten. `applyHiveOps` und `validateHiveState` sind reine Funktionen ohne Prisma; `applyHiveOps` prüft den Zustand nach jeder Operation, ein Fehler trägt Index und Grund. Scheitert eine Operation am aktuellen Stand, wird die ganze Änderung abgelehnt (409 `HIVE_CONFLICT`).
**Endpunkte** (`/colonies/:id/hive`): `GET` (Stand und Fakten), `POST` (Aufbau anlegen), `POST changes` (Änderung mit `Idempotency-Key`: dieselbe Wiederholung liefert dieselbe Antwort ohne zweite Änderung), `GET changes` (Historie mit Cursor), `GET at` (Stand an einem Tag). Katalog: `GET /hive-systems`, `GET /hive-systems/:id/template` (Vorlage mit frischen Ids je Aufruf), `POST` und `PATCH /hive-systems`, `POST /hive-systems/:id/part-types`, `PATCH /hive-part-types/:id`.
**Sperrreihenfolge.** `Colony` FOR UPDATE, dann `HiveSetup` FOR UPDATE, dann `HivePartType` (`LOCKABLE_TABLES` in `src/prisma/row-lock.ts`: `HiveSetup` direkt nach `Colony`). Die Version steigt je Änderung um 1; zwei gleichzeitige Änderungen laufen nacheinander, die zweite sieht den Stand der ersten.
**Rechte.** Lesen `colonies:read`, Aufbau anlegen und ändern `colonies:update`, Katalog ändern `hive-systems:manage` (Gruppe „Beutensysteme“). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung (`PermissionCatalogSyncService`) zur Rolle ADMIN, ohne Rechte-Migration. STAFF hat `colonies:update` nicht und kann die Beute nicht ändern (Annahme 99).
**Ereignis.** `HiveChanged` (Outbox, in derselben Transaktion wie die Änderung; Nutzlast `colonyId`, `version`, `source`, `inspectionId`). Ein nicht lebendes Volk ist schreibgeschützt: 409 wie `assertActive`.
**Durchsicht.** `POST /inspections` nimmt optional `hiveChange` (`ops`). Durchsicht und Beute-Änderung werden in einer Transaktion gespeichert und gemeinsam abgelehnt. Ohne `colonies:update` antwortet der Server 403 `HIVE_FORBIDDEN` und schreibt nichts. Der Tag der Änderung ist der Tag der Durchsicht (nicht in der Zukunft, nicht vor dem `INIT`-Tag); die Änderung hat `source` `INSPECTION` und verweist auf die Durchsicht.
**Backup.** Export und Import enthalten `HiveSystem`, `HivePartType`, `HiveSetup` und `HiveChange`. Katalogzeilen werden nach Id eingespielt (auch in eine frische Installation mit Startdaten). Je Volk gewinnt die Sicherung: Aufbau und Protokoll eines wiederhergestellten Volks ersetzen die vorhandenen. Nicht versiegelte Altdateien ohne diese Tabellen werden gemeldet und übersprungen (Abschnitt 8).
**Fehlercodes.**
| Code | Status | Bedeutung |
|---|---|---|
| `HIVE_CONFLICT` | 409 | Operation passt nicht zum aktuellen Stand; mit `opIndex`, `reason`, `current` (Version und Stand) |
| `HIVE_EXISTS` | 409 | Das Volk hat schon einen Aufbau |
| `HIVE_NOT_FOUND` | 404 | Das Volk hat noch keinen Aufbau |
| `HIVE_FORBIDDEN` | 403 | Durchsicht mit `hiveChange` ohne `colonies:update` |
| `TEMPLATE_NOT_SET` | 409 | Das System hat noch keine Vorlage |
| `SCHEMA_NOT_ADDITIVE` | 400 | Zustandsfelder dürfen nur angehängt werden |
| `TEMPLATE_INVALID` | 400 | Die Vorlage besteht den Validator nicht |
Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor `INIT`, mehr als 100 Operationen) sind 400. Ein neues System beginnt mit leerer Vorlage; sie wird per `PATCH` gesetzt, sobald die Teile existieren.
### 13.1 Frontend (Karte, Editor, Durchsicht, 3D)
- **Port von Regeln und Fixtures.** `src/lib/hive` spiegelt das Backend: `ops.ts` (`applyHiveOps`), `validate.ts`, `facts.ts`, `types.ts`. Ob beide Seiten gleich rechnen, sichern gemeinsame Fixtures: `ApiCore-Backend/test/fixtures/hive-ops/*.json` liest das Backend-Spec und das Frontend-Spec (`lib/hive/test-utils.ts`, `FIXTURE_DIR`); dazu `catalog.json` für den Katalog. Wer eine Regel ändert, ändert Backend, Frontend und Fixtures zusammen; die Prüfreihenfolge aus Abschnitt 13 gilt auch hier, `reason-text.ts` übersetzt die Gründe in Sätze.
- **Karte.** `components/hive/hive-card.tsx` auf der Volksseite (`mode` `view` oder `inspection`). Die Bühne (`HiveVisualSlot`) zeigt immer das SVG (`HiveSvg`, aus `lib/hive/layout.ts`), die 3D-Szene liegt darüber. Daneben stehen die Gliederung (`HiveOutline`, Baum aller Teile, per Tastatur bedienbar), Fakten und Fokusleiste. Teile und Rähmchen öffnen Sheets (`part-sheet`, `frame-sheet`); jede Änderung geht über `useHiveMutation` als `POST changes` mit eigenem `Idempotency-Key` je Versuch, einen Versand nach dem anderen, optimistisch auf dem Cache. Das Sheet zeigt bei `HIVE_CONFLICT` den aktuellen Stand aus der Antwort (`HiveConflictSheet`). Die Historie (`HiveHistory`) zeigt Änderungen und den Stand eines Tages (`GET at`).
- **Editor** `/colonies/[id]/hive` („Umbauen“): Mehrere Schritte werden lokal als Entwurf gesammelt (`useHiveDraft`: Schritte, Rückgängig und Wiederholen, `snapshot` des Ausgangsstands, Annahme 104) und mit einer Änderung gespeichert. Bis dahin berührt der Entwurf den Server nicht; Verlassen der Seite mit offenen Schritten fragt nach. Der Editor lädt den Aufbau neu, sobald das Fenster den Fokus bekommt, und alle 30 s, solange ein Entwurf besteht (`REFRESH_INTERVAL_MS`). Umbauen geht nur mit Netz.
- **Durchsicht.** `inspections/new` hat den Abschnitt „Beute“ (`hive-section.tsx`): die Karte im Modus `inspection` sammelt Operationen als Entwurf, sie gehen als `hiveChange` mit der Durchsicht in `POST /inspections` und in der Warteschlange mit dem Eintrag (Abschnitt 2). Die Sheets stoppen das `submit` an ihrem Formular (`stopPropagation`; sie liegen in einem Portal, doch React reicht Ereignisse im Komponentenbaum weiter), sonst würde „Speichern“ im Sheet die Durchsicht speichern; neue Formulare in Sheets müssen dasselbe tun. Zusätzlich stoppt der Abschnitt selbst `submit` an seinem Wrapper (`hive-section.tsx`).
- **3D-Szene.** `components/hive/hive-scene` (three, react-three-fiber, drei, postprocessing) wird nur über `next/dynamic` mit `ssr: false` geladen, sobald die Karte sichtbar wird (`IntersectionObserver`); andere Seiten laden nichts davon. Budget für den Chunk: höchstens 320 kB gzip (Spec 9.7); gemessen zuletzt etwa 296 kB. Die Szene misst die Bildrate (`quality.ts`): unter 45 fps fällt sie auf die Stufe `low` (kein Spiegelboden, kein MSAA, keine Tiefenunschärfe, keine Partikel), die Tiefenunschärfe läuft erst ab 55 fps. Bewegung folgt der Stufe `full`, `subtle` oder `off` (`useEffectiveMotionLevel`: Flüge, Überblendung 400, 150 oder 0 ms). **Rückfall auf das SVG** (`shouldUse3D`, `lib/hive/webgl.ts`): kein WebGL, erzwungene Farben, oder Bewegung `off` bei höchstens 2 Prozessorkernen; ein verlorener WebGL-Kontext baut die Szene einmal neu auf, der zweite lässt es beim SVG. Das SVG ist darunter immer gerendert und bleibt bedienbar, bis das erste 3D-Bild steht; im Druck entfällt die 3D-Ebene.
## 14. Sprache (T7 „Freihand“)
Spracheingabe für die Durchsicht am Stand, in drei Stufen, die einzeln abschaltbar sind. Spec: `docs/superpowers/specs/2026-10-14-t7-sprache-design.md`, Plan: `docs/superpowers/plans/2026-10-16-t7-sprache.md`. Eine Sprachnotiz füllt nie etwas selbst aus: Vorschläge landen erst nach einem Tipp im Formular (Abschnitt 14.6). Inhalte (Ton, Abschrift, Vorschlag) sind personenbezogen und leben nur verschlüsselt und nur kurz; Protokolle, Audit-Einträge und Ereignisse tragen nie Inhalt, nur Ids, Zähler und feste Codes.
**Stufen.**
1. **Diktat im Browser.** Die Spracherkennung des Browsers (`components/voice/use-dictation.ts`) schreibt in Textfelder der Durchsicht. Die Cloud-Erkennung mancher Browser ist ohne Einwilligung aus (`lib/voice/consent.ts`, nur `localStorage`). Kein Server beteiligt.
2. **Sprachnotiz mit Abschrift im Cluster.** Das Gerät nimmt bis zu 180 s auf, lädt die Aufnahme hoch, und der CronJob `apicore-job-voice-transcribe` schreibt sie mit Whisper ab (Abschnitt 14.3). Das Ergebnis ist ein Text (Status `TRANSCRIBED`), der im Formular nutzbar ist, auch ohne Stufe 3.
3. **Auswertung mit Claude.** Die Worker-Schleife `voice-extractions` ruft Claude, prüft die Antwort gegen die Abschrift und legt Vorschläge ab (Status `READY`, Abschnitt 14.4). Ohne Schlüssel bleibt die Notiz bei `TRANSCRIBED`.
**Datenmodell** (Migration `20261019100000_t7_voice_captures`).
- `VoiceCapture`: Besitzer, Volk, Berliner Tag der Durchsicht (`inspectionDate`), `source` (`AUDIO` oder `TEXT`), `status`, `attempts`, `availableAt`, `claimedAt`, `claimToken`, `lastErrorCode`, Spalten `transcriptEnc` und `suggestionEnc` (Chiffrat), Modell und Tokens, `contentExpiresAt` (Anlage + 7 Tage), `contentDeletedAt`, `inspectionId` (ohne Fremdschlüssel; ein CHECK bindet `CONFIRMED` an eine gesetzte `inspectionId`). `VoiceCaptureAudio` hält den Ton getrennt (`dataEnc`, höchstens 2 MB Klartext), damit Löschen ein `DELETE` ist und Listen den Ton nie laden.
- `VoiceExtractionCall`: Protokoll bezahlter Modellaufrufe (Modell, Token, `outcome`, optional `captureId`), ohne Inhalt und ohne Fremdschlüssel. Das Tagesbudget und die Kostenkarte lesen es, nicht die Notizen.
- Status: `QUEUED` → `TRANSCRIBING` → `TRANSCRIBED` → `EXTRACTING` → `READY` → `CONFIRMED`; dazu `DISCARDED`, `FAILED` (Code in `lastErrorCode`) und `EXPIRED`. Der Text-Weg beginnt bei `TRANSCRIBED`. Feste Fehlercodes: `src/voice/voice-error-codes.ts`; deutsche Texte: `voice-error-texts.ts`.
- `VoiceCapture`, `VoiceCaptureAudio` und `VoiceExtractionCall` stehen im Backup auf der Ausschlussliste (flüchtig, personenbezogen, nach einer Wiederherstellung nicht zustellbar). In der Sperrreihenfolge ist `VoiceCapture` die letzte Tabelle (`row-lock.ts`).
**Verschlüsselung.** Alles Inhaltliche liegt mit dem Schlüssel `VOICE_ENCRYPTION_KEY` (Zweck `voice`, Annahme 22) versiegelt. Die GCM-AAD bindet jeden Wert an seine Zeile und Art: `capture:<id>:transcript`, `capture:<id>:suggestion`, `capture:<id>:audio` (`voiceAad` in `core/voice-crypto.ts`). Ein in eine andere Notiz oder Art kopiertes Chiffrat öffnet sich nicht. Text hat das Format der anderen Zwecke; Ton ist `[Version][Schlüssel-Id][IV][Tag][Daten]`. Der Job `rekey` versiegelt nur offene Notizen neu.
**Aufbewahrung und Löschen.**
- Ton: gelöscht, sobald die Abschrift gespeichert ist, spätestens nach 24 h; eine Notiz, die dann noch auf ihre Abschrift wartet, endet `FAILED` mit `AUDIO_EXPIRED`.
- Inhalte (Abschrift, Vorschlag): spätestens 7 Tage (`contentExpiresAt`), dann `EXPIRED`. Inhalte deaktivierter Nutzer gehen mit derselben Regel.
- Zeilen und Protokoll: 90 Tage (`deleteOldVoiceRows`, `deleteOldVoiceCalls`); leere Läufe des Abschreib-Jobs räumt `deleteIdleVoiceRuns` nach einem Tag aus `JobRun`.
- Die reinen SQL-Regeln (`expireVoiceContent`, Datenbankuhr, kein Schlüssel nötig) laufen in jedem Lauf der Phase `fetch`, also alle 2 Minuten, und zusätzlich im täglichen `cleanup`, das jede Kategorie zählt (`voice.audioExpired`, `voice.contentExpired`, `voice.inactiveOwnersWiped`, `voice.rowsDeleted`, `voice.callsDeleted`).
- Sofort gelöscht: bei Übernahme (Durchsicht gespeichert, Abschnitt 14.2), bei „Verwerfen“, bei „Alle löschen“ (`POST /voice-captures/discard-all`) und, auf dem Gerät, beim Abmelden (Abschnitt 14.6).
### 14.1 Rechte, Routen und Grenzen
**Rechte.** Alle Routen außer den Zählern brauchen `voice-captures:use` (Gruppe „Sprache“; die Migration vergibt es an ADMIN, BEEKEEPER und STAFF, nicht an READONLY und API_CLIENT) und zusätzlich `inspections:create` (`InspectionCreateGuard`): wer keine Durchsicht anlegen darf, bekommt keine Sprachnotizen. `GET /voice-captures/stats` verlangt `system:manage`. Jede Abfrage filtert auf den Besitzer; eine fremde Id ist 404. Antworten tragen `Cache-Control: no-store`.
**Routen** (`src/voice/captures/voice-captures.controller.ts`; statische Pfade vor `:id`).
- `POST /voice-captures`: Aufnahme hochladen (multipart `audio`, `colonyId`, `inspectionDate`, `durationSec` 1 bis 240), `@Idempotent()`, Antwort `{ id, status: 'QUEUED' }`. Multer hält die Datei im RAM (`fileSize` 2 MB, ein Teil); der Typ kommt aus den ersten Bytes (WebM, Ogg, MP4), nie aus dem `Content-Type` (415 `UNSUPPORTED_AUDIO`, 413 `TOO_LARGE`).
- `POST /voice-captures/text`: Text zur Auswertung (`colonyId`, `inspectionDate`, `text`, nach NFKC und ohne Steuerzeichen 20 bis 4000 Zeichen), `@Idempotent()`, Status `TRANSCRIBED`.
- `GET /voice-captures?status=` (eigene Notizen ohne Inhalt, höchstens 200), `GET /voice-captures/:id` (bei `READY`, `TRANSCRIBED` und `FAILED` mit Abschrift und Vorschlägen; ändert nichts), `GET /voice-captures/config`, `GET /voice-captures/stats`.
- `POST /voice-captures/:id/retry`: nur bei `FAILED` mit vorhandenem Text oder frischem Ton (unter 24 h) und `contentExpiresAt` in der Zukunft; zurück auf `TRANSCRIBED` oder `QUEUED`, `attempts = 0`; sonst 409 `VOICE_CAPTURE_GONE`. `POST /voice-captures/:id/discard` (204, idempotent) und `POST /voice-captures/discard-all`.
- `POST /inspections` nimmt `voiceCaptureId`; `VoiceConfirmationService.confirmIn` läuft als Letztes in der Anlege-Transaktion (Annahme 107). Eine fremde, fehlende oder abgeschlossene Notiz ist 409 `VOICE_CAPTURE_GONE`, eine Notiz eines anderen Volks 409 `VOICE_CAPTURE_COLONY_MISMATCH`; eine Wiederholung mit demselben `Idempotency-Key` ändert nichts.
**Grenzen.** Je Nutzer höchstens 10 offene Notizen (`QUEUED` bis `READY`), 30 neue je UTC-Tag und 10 neue je Minute, unter einer Advisory-Sperre je Nutzer (sonst 429 `TOO_MANY_OPEN`, Text „Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.“); ein Wiederholen zählt wie eine neue Notiz. Dazu Throttle je Netz: 10/min für Upload, 5/min für Wiederholen (Annahme 109). Das Volk muss leben (400), der Tag der Durchsicht höchstens 7 Tage zurück und nicht in der Zukunft.
**Schalter.** `VOICE_RECORDING_ENABLED` (ConfigMap `apicore-backend-config`, Voreinstellung `false`) steuert die Aufnahme. Aus: Upload und Text-Weg antworten 403 `VOICE_RECORDING_DISABLED`, die Oberfläche blendet die Einstiege aus; Liste, Lesen, Wiederholen und Verwerfen bleiben (Annahme 113). `GET /voice-captures/config` meldet `recordingEnabled`, `extractionEnabled` (der API-Pod kennt den Schlüssel nicht und schließt „aus“ daraus, dass der Worker in der letzten Stunde eine Notiz mit `EXTRACT_DISABLED` geparkt hat), das Modell und die Fristen (24 h, 7 Tage, 90 Tage). Weitere Einstellungen: `VOICE_MODEL` (`claude-sonnet-5-5`, `claude-haiku-5-5`, `claude-opus-5-5`; die Modell-Ids stehen nur in `voice-config.ts`), `VOICE_DAILY_EXTRACTIONS` (60, erlaubt 1 bis 10000), `WHISPER_MODEL` (`small` oder `medium`), `VOICE_SERVER_FALLBACK` (`default` oder `off`). Ungültige Werte verhindern den Start.
**Ansprüche, Wiederholungen, Verfallsfenster.** Beide Phasen teilen sich die Hilfen in `claims/voice-claims.ts` und die Regeln in `claims/voice-phases.ts`. Ein Lauf beansprucht eine Notiz atomar (`claimToken`); Abschluss, Fehlversuch und Verschieben prüfen den Token (Compare-and-Set), sodass ein Prozess, der den Anspruch verloren hat, nichts mehr schreibt. Ein Abschluss der Phase `transcribe` setzt `attempts` auf 0, damit `extract` seine eigenen 3 Versuche hat.
| Phase | Von → in Arbeit → fertig | Versuche | Wartezeit nach Fehlversuch | Anspruch veraltet nach | Endcode |
|---|---|---|---|---|---|
| `transcribe` | `QUEUED` → `TRANSCRIBING` → `TRANSCRIBED` | 3 | 5 min, 15 min | 30 min | `TRANSCRIBE_FAILED` |
| `extract` | `TRANSCRIBED` → `EXTRACTING` → `READY` | 3 | 30 s, 2 min | 5 min | `EXTRACT_FAILED` |
Ein veralteter Anspruch geht beim nächsten Lauf zurück und zählt einen Versuch; ist der letzte verbraucht, endet die Notiz `FAILED` und `VoiceCaptureFailed` wird geschrieben.
### 14.2 Übernahme
Das Speichern der Durchsicht bestätigt die Notiz in jedem offenen Zustand: Status `CONFIRMED`, `inspectionId` gesetzt, Ton, Abschrift und Vorschlag gelöscht, alles in der Transaktion der Durchsicht. Ein laufender Job verliert seinen Anspruch und kann nicht mehr abschließen. Scheitert die Bestätigung, scheitert das ganze Speichern.
### 14.3 Abschreiben im Cluster
**Job.** `voice-transcribe` (`src/jobs/voice-transcribe.job.ts`, Argumente `--phase=fetch` und `--phase=ingest`) ist die Node-Hälfte um den Whisper-Container; Komponente `deploy/k8s/components/voice`, CronJob `apicore-job-voice-transcribe`: `*/2 * * * *`, `Europe/Berlin`, `concurrencyPolicy: Forbid`, `backoffLimit: 0`, `activeDeadlineSeconds: 1500`. Drei Container laufen nacheinander mit derselben uid 1000 und fsGroup 1000:
1. `fetch` (initContainer, Backend-Image, Rolle `job`): wendet die Aufbewahrungsregeln an, gibt veraltete Ansprüche frei, beansprucht bis zu 5 Notizen mit zusammen höchstens 600 s Ton, entschlüsselt in den Speicher und legt Ton und Manifest nach `/work/in`.
2. `whisper` (initContainer, Image `apicore-whisper` aus `ApiCore-Whisper/`, faster-whisper auf CPU, `int8`, Deutsch, `beam_size` 1, VAD-Filter, 2 Threads): liest `/work/in` (nur lesbar), schreibt je Notiz ein Ergebnis nach `/work/out`. Kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, kein Datenbankzugang; zusätzlich sperrt das Skript `socket.socket`. Das Modell steckt mit Prüfsummen im Image; `av` ist auf 18.1.0 festgelegt (Annahme 106).
3. `ingest` (Hauptcontainer, Backend-Image): übernimmt nur Ergebnisse zu Notizen des eigenen Manifests mit passendem `claimToken`, prüft sie streng, versiegelt den Text, löscht den Ton, setzt `TRANSCRIBED` und weckt den Worker (`pg_notify('voice_extraction')`), je Notiz in einer eigenen Transaktion. Zu kurzer Text (unter 5 Zeichen) ist `NO_SPEECH`, mehr als 240 s `TOO_LONG`; beide enden die Notiz sofort `FAILED`. Ein nicht dekodierbarer Ton zählt als Fehlversuch (`TRANSCRIBE_FAILED`).
**Härtung.** `/work` ist ein `emptyDir` mit `medium: Memory` (32Mi): entschlüsselter Ton und Abschriften berühren nie eine Platte; die Verzeichnisse sind 0700, die Dateien 0600. `fetch` und `ingest` teilen ein `/tmp` (64Mi), `whisper` hat ein eigenes im RAM (16Mi), damit ein kompromittierter Decoder `ingest` (das alle Secrets lädt) keinen Code unterschieben kann. Das Pod-Template trägt `app: apicore-job` und `db-client: "true"`; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Das CronJob-Objekt selbst trägt `app: apicore-voice-job`, damit der gemeinsame Job-Patch keinen weiteren Container `job` einsetzt. Whisper: Request und Limit je 1280Mi Speicher (Messung vor der Freigabe, Abschnitt 14.7). Das Restrisiko eines gemeinsamen Pod-Netzes steht in Annahme 106.
### 14.4 Auswertung mit Claude
**Schleife.** `VoiceExtractionService` (`src/voice/extraction/`) läuft als Worker-Schleife `voice-extractions` (15 s Sicherheitsabfrage, sofort bei `voice_extraction`, ein Lauf zur Zeit, höchstens 20 Notizen je Lauf). Der Anspruch, das Ergebnis und das Ereignis einer Notiz committen zusammen.
**Aufruf.** Der Adapter `AnthropicStructuredModel` ist die einzige Stelle, die das SDK einbindet (`@anthropic-ai/sdk` 0.131.0, exakt festgelegt; eine Netz-Wächterprüfung sichert das). Der Endpunkt ist fest `https://api.anthropic.com`; `ANTHROPIC_BASE_URL` und `ANTHROPIC_CUSTOM_HEADERS` aus der Umgebung werden mit Warnung ignoriert. Je Aufruf `max_tokens` 2000, Timeout 60 s, 2 SDK-Wiederholungen, insgesamt höchstens 240 s, `effort: low`. Der Schlüssel `ANTHROPIC_API_KEY` kommt nur im Worker an (Secret `apicore-anthropic`, optional, einzelne Variable per `patch-worker-voice.yaml`); API- und Job-Pods warnen beim Start, wenn sie ihn trotzdem sehen.
**Prompt als Daten.** Der Systemprompt ist fester Text ohne Datum, Volk, Nutzer oder Modell-Id (`voice-prompt.ts`, `VOICE_PROMPT_VERSION`): Feldliste mit Wertebereichen aus dem Formular, die vier Skalen mit Ankerwörtern, die Futtercodes, neun Regeln (nur Ausdrückliches, Verneinung ist ein Wert, wörtliches Zitat, keine Namen, kein Wetter, Korrektur gewinnt) und die Fachwortliste. Die Abschrift steht in `<transkript>…</transkript>` und gilt als Datenmaterial, nie als Anweisung; `<` und `>` im Text werden zu ‹ und ›, damit sie den Rahmen weder schließen noch öffnen.
**Schema und Prüfung.** Strukturierte Ausgabe `{ findings: [{ field, value, quote, corrected }], unclear: [] }` (`voice-schema.ts`, ohne Grenzen wie `maxItems`, die das Schema nicht zulässt). Der Server prüft jede Antwort (`validate-suggestions.ts`, rein und deterministisch): höchstens 20 Einträge und 5 Unklarheiten (je 300 Zeichen); Feld und Wert gegen die Regeln des Formulars (`core/inspection-field-specs.ts`: Bereiche, Skalen 1 bis 5, Futtercodes, gekürzte Texte); das Zitat (höchstens 120 Zeichen) muss wörtlich in der Abschrift stehen, wobei eine Dezimalzahl ein Wort bleibt (Annahme 108); je Feld bleibt ein Wert (eine Korrektur schlägt eine Nichtkorrektur, dann das spätere Zitat), der stärkste andere Wert wird als `previous` mitgegeben. Verworfene Einträge zählen nur als Zahl (`dropped`, `truncated`). Das Ergebnis `{ suggestions, unclear, dropped, truncated }` liegt versiegelt in `suggestionEnc`.
**Budget und Ausgänge.** Das Tagesbudget zählt bezahlte Aufrufe aus `VoiceExtractionCall` auf dem UTC-Tag der Datenbankuhr, alle Ausgänge außer `AUTH` und `ABORTED` (Annahmen 110 und 111). Ist es erreicht, wartet die Notiz bis zum Tagesende (`DAILY_LIMIT`, kein Fehlversuch). Ein Aufruf wird vor dem Abschluss der Notiz gebucht, damit ein verlorener Anspruch ihn nicht kostenlos macht.
- Kein brauchbarer Schlüssel oder Ablehnung des Schlüssels (401, 403): die Notiz wartet eine Stunde mit `EXTRACT_DISABLED` (kein Fehlversuch); je Periode entsteht ein Ereignis für `system:manage`.
- Modell lehnt ab (`refusal`): sofort `FAILED` mit `REFUSED`. Abgeschnitten, ungültige Antwort oder `BAD_REQUEST`: Fehlversuch mit `EXTRACT_INVALID`. Netz, Zeitüberschreitung, 408, 409, 429, 5xx: Fehlversuch mit `EXTRACT_RETRY`, nach dem dritten `EXTRACT_FAILED`.
- Abbruch beim Herunterfahren: nicht gezählt, die Notiz wird sofort wieder frei (`ABORTED`).
- Serverseitiger Ausweichweg: für Sonnet und Opus setzt der Adapter bei `VOICE_SERVER_FALLBACK=default` die Beta-Kennung `server-side-fallback-2026-07-01` und `fallbacks: "default"`; eine abgelehnte Anfrage läuft dann bei Anthropic auf dem empfohlenen Modell neu. Gebucht wird das tatsächlich antwortende Modell (Annahme 115). Haiku hat keinen Ausweichweg.
**Messung.** `npm run eval:voice` (Gold-Menge `test/fixtures/voice/gold/g01` bis `g30`, Schwellen: Genauigkeit ≥ 98 %, Trefferquote ≥ 85 %) läuft ohne Zusatz offline gegen eine Attrappe; `-- --live --yes` ruft Claude auf und kostet etwa 0,21 $ für 30 Fälle (Sonnet). Es läuft nie in der CI.
### 14.5 Ereignisse und Meldungen
`VoiceCaptureReady` und `VoiceCaptureFailed` (Outbox, in derselben Transaktion wie der Zustandswechsel; Nutzlast Ids und Code). Handler (`voice.handlers.ts`, über `VoiceHandlersModule` im Worker): `voice.notify-ready` legt dem Besitzer eine Meldung `HINWEIS` der Kategorie `SUGGESTION` an („Sprachnotiz für {Volk} ist fertig“, Link `/inspections/new?colonyId=…&voice=<id>`, einmal je Notiz), `voice.notify-failed` eine `WARNUNG` mit dem deutschen Hinweistext des Codes und Link `/voice`, nur wenn die Notiz `FAILED` ist. `EXTRACT_DISABLED` erzeugt zusätzlich einen Hinweis ohne Nutzer-, Volk- und Notizbezug an `system:manage`. Beide Meldungen verlangen `voice-captures:use` beim Empfänger. Ein Fehlschlag des Jobs meldet wie bei allen Jobs an `system:manage`; die Systemkarte zeigt Zähler je Status und Fehlercode der letzten 7 Tage sowie Tokens je Modell und die daraus geschätzten Kosten (`lib/voice/prices.ts`, dieselbe Preistabelle wie `src/voice/eval/voice-prices.ts`; Annahme 114).
### 14.6 Frontend
- **Aufnahme.** `components/voice/voice-recorder.tsx` mit `lib/voice/recorder.ts`: höchstens 180 s (`MAX_RECORDING_SEC`, Warnung ab 150 s), Bildschirm bleibt wach; nach einer Mikrofonverweigerung hilft `microphone-help.tsx`. Der Server nimmt bis 240 s an, damit eine Messabweichung die Notiz nicht verwirft. Ein Hinweis zur Aufnahme erscheint einmal je Gerät (`recording-notice-dialog.tsx`). Endet die Aufnahme von selbst, geht sie sofort in die Geräte-Warteschlange (Annahme 112).
- **Geräte-Warteschlange.** IndexedDB, Version 6, Store `pending-voice` (Index `ownerUserId`, `lib/voice/pending-voice.ts`, Typen in `lib/offline/db.ts`). Der Upload hängt am Synclauf (`uploadPendingVoice` nach Datensätzen und Fotos): ältester zuerst, mit einem festen `uploadIdempotencyKey` je Notiz, damit ein verlorener Antwortweg nichts doppelt anlegt. 2xx löscht den lokalen Ton sofort; eine endgültige Ablehnung (4xx) bleibt mit Fehler an der Notiz und wartet auf den Nutzer; Netzfehler, 5xx, 408 und 429 beenden den Lauf, `TOO_MANY_OPEN` sperrt den Eintrag, bis Platz ist. Notizen eines anderen Kontos werden gezählt, nie gesendet. Beim Abmelden löscht `clearQueueOf` die Sprachnotizen des Kontos; die Abmeldefrage nennt vorher die Zahl der noch nicht gesendeten Notizen.
- **Seiten.** `/voice` (Liste: Notizen vom Server und vom Gerät, Verwerfen, Wiederholen), `/voice/new` (aufnehmen, Volk und Tag wählen), Einstiege per `VoiceEntryLink` in Schnell-Anlegen, Navigation, Volksseite und Durchsicht; sie erscheinen nur, wenn der Schalter `on` oder `unknown` (offline) ist (`useVoiceRecordingSwitch`: `loading`, `on`, `off`, `unknown`). `/settings/voice` („Sprache & Datenschutz“): Info- und Datenschutztext, Einwilligung zur Browser-Cloud-Erkennung (aus), „Alle löschen“ für Server und Gerät.
- **Durchsicht.** `inspections/new?voice=<id>` zeigt oben die Notiz (`VoiceReviewBox`, `useVoiceReview`: fragt alle 5 s, höchstens 10 min) und je Feld einen Vorschlag mit Zitat (`VoiceFieldRow`, „Übernehmen“, „Alle übernehmen“, Ignorieren). Nichts wird ohne Tipp übernommen; die Abschrift lässt sich einsehen und in die Zwischenablage kopieren, auch wenn die Auswertung aus ist (`TRANSCRIBED`). Beim Speichern geht `voiceCaptureId` mit.
- **Weg bei Konflikt.** 409 `VOICE_CAPTURE_GONE` beim Speichern: `createInspection` versucht es genau einmal ohne `voiceCaptureId` und mit demselben `Idempotency-Key` erneut (der Interceptor gibt den Schlüssel bei Handlerfehler frei und prüft den Rumpf nicht; ein neuer Schlüssel könnte doppelt anlegen). 409 `VOICE_CAPTURE_COLONY_MISMATCH` wird nie wiederholt; in der Warteschlange erscheint „Ohne Sprachnotiz senden“ (`retryInspectionWithoutVoice`), und der Nutzer entscheidet.
- **Textweg.** „Ganze Durchsicht auswerten“ (`EvaluateDictationButton`, unter den Textfeldern) sendet den noch in den vier Freitextfeldern stehenden diktierten Text (20 bis 4000 Zeichen, so normalisiert wie auf dem Server) an `POST /voice-captures/text` mit einem `Idempotency-Key` je Inhalt. Daneben steht „Der Text geht zur Auswertung an Anthropic.“. Offline ist der Knopf gesperrt (nichts wird eingereiht); mit `?voice=` fehlt er; die Antwort kommt als Sprachnotiz oben im Formular, und nach 2 min Warten ohne Ergebnis verweist ein Hinweis auf die Meldung.
- **Service-Worker.** `public/sw-routes.js` cacht `/api/v1/voice-captures` nie (Abschrift und Vorschläge sind Inhalt) und hält nur die Hüllen `/voice` und `/voice/new` vor.
### 14.7 Rollout
Reihenfolge, jeder Schritt mit Freigabe:
1. **Schlüssel vor dem Image.** `deploy/k8s/scripts/ensure-secrets.sh` legt `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret` an (48 Zufallsbytes, Base64URL); er muss existieren und gesichert sein, bevor das Backend-Image mit T7 startet, denn in Produktion startet sonst keine Rolle (Annahme 22). Dann Image mit Migration. Die Auswertung bleibt ohne Anthropic-Schlüssel aus (`EXTRACT_DISABLED`).
2. **Whisper-Image.** `.gitea/workflows/whisper-image.yml` baut es; der Digest ersetzt den Platzhalter im Overlay `deploy/k8s/overlays/encry/kustomization.yaml` (Eintrag `apicore-whisper`).
3. **Komponente `voice`** (im Overlay eingebunden, erst nach Schritt 1 und 2 anwenden): CronJob und Worker-Patch. Erster Lauf von Hand (`kubectl create job --from=cronjob/apicore-job-voice-transcribe`); das Log darf keinen Inhalt enthalten.
4. **Anthropic.** Der Inhaber legt das Secret `apicore-anthropic` (Schlüssel `ANTHROPIC_API_KEY`) an und verschiebt den Schlüssel aus `apicore-backend-secret`, wo er über `envFrom` auch API- und Job-Pods erreichte; er setzt in der Anthropic-Konsole ein Ausgabenlimit (Vorschlag 10 $ im Monat). Mit 60 Aufrufen am Tag (`VOICE_DAILY_EXTRACTIONS`) bleibt der Verbrauch dahinter.
5. **Messung (Task 24)** auf dem Zielknoten: Speicherspitze des Whisper-Containers gegen 1280Mi (inklusive der Speicher-Volumes), Zeit und CPU-Drosselung, Trefferquote der Fachwörter (Ziel ≥ 90 %, sonst `medium` oder längere Fachwortliste), einmal `npm run eval:voice -- --live --yes`.
6. **Freigabe (Task 25).** `VOICE_RECORDING_ENABLED` auf `"true"` und Frontend ausrollen. Rückweg: Schalter auf `"false"` und die Komponente `voice` abschalten; offene Notizen laufen über die Fristen ab, es gehen nur flüchtige Daten verloren.
+41
View File
@@ -56,6 +56,16 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach # Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
# jedem Lauf geloescht (Standard: 14). # jedem Lauf geloescht (Standard: 14).
BACKUP_RETENTION_COUNT=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 # Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in # HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
@@ -72,9 +82,12 @@ BACKUP_RETENTION_COUNT=14
# MFA_ENCRYPTION_KEY="" # MFA_ENCRYPTION_KEY=""
# WEBHOOK_ENCRYPTION_KEY="" # WEBHOOK_ENCRYPTION_KEY=""
# BACKUP_ENCRYPTION_KEY="" # BACKUP_ENCRYPTION_KEY=""
# Seals voice notes (audio, transcripts, suggestions); required in production.
# VOICE_ENCRYPTION_KEY=""
# MFA_ENCRYPTION_KEY_PREVIOUS="" # MFA_ENCRYPTION_KEY_PREVIOUS=""
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS="" # WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
# BACKUP_ENCRYPTION_KEY_PREVIOUS="" # BACKUP_ENCRYPTION_KEY_PREVIOUS=""
# VOICE_ENCRYPTION_KEY_PREVIOUS=""
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert # Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
@@ -108,3 +121,31 @@ ENABLE_SWAGGER=false
# APICORE_ROLE=api # APICORE_ROLE=api
# Port des Health-Endpunkts des Worker-Prozesses. # Port des Health-Endpunkts des Worker-Prozesses.
# WORKER_HEALTH_PORT=3002 # 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=
+32 -86
View File
@@ -1,98 +1,44 @@
<p align="center"> # ApiCore-Backend
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 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).
[circleci-url]: https://circleci.com/gh/nestjs/nest
<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> ## Einrichten
<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
```bash ```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 ```bash
# development npm run lint # eslint mit --fix
$ npm run start npm run typecheck # tsc --noEmit
npm test # Jest-Unit-Tests
# watch mode npm run test:e2e # Jest-e2e gegen die Datenbank aus .env.test (vorher migrieren)
$ npm run start:dev npm run build # dist/main.js, dist/worker.js, dist/cli.js, dist/seed.js
# production mode
$ npm run start:prod
``` ```
## 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 ## Aufbau
# unit tests
$ npm run test
# e2e tests - `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`.
$ npm run test:e2e - `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).
# 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).
+72
View File
@@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.131.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -213,6 +214,27 @@
"tslib": "^2.1.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -674,6 +696,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -3213,6 +3244,12 @@
"@sinonjs/commons": "^3.0.1" "@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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -6925,6 +6962,12 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT" "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": { "node_modules/fast-uri": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
@@ -8780,6 +8823,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -11249,6 +11305,16 @@
"node": ">=8" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11785,6 +11851,12 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "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: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", "test:e2e": "jest --config ./test/jest-e2e.json",
"migrate:deploy": "prisma migrate deploy", "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": { "dependencies": {
"@anthropic-ai/sdk": "0.131.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -102,6 +105,7 @@
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node",
"testTimeout": 20000
} }
} }
@@ -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");
+654 -57
View File
@@ -104,6 +104,8 @@ model User {
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
inspections Inspection[] inspections Inspection[]
voiceCaptures VoiceCapture[]
hiveChanges HiveChange[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[] swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[] winterChecks WinterCheck[]
@@ -111,6 +113,7 @@ model User {
feedings Feeding[] feedings Feeding[]
harvests Harvest[] harvests Harvest[]
extractions Extraction[] extractions Extraction[]
phenologyReports PhenologyReport[]
honeyMeasurements HoneyMeasurement[] honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[] stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee") assignedTasks Task[] @relation("TaskAssignee")
@@ -126,12 +129,21 @@ model User {
rawMaterialLots RawMaterialLot[] rawMaterialLots RawMaterialLot[]
notifications Notification[] notifications Notification[]
notificationPreference NotificationPreference? notificationPreference NotificationPreference?
telegramLink TelegramLink?
telegramLinkTokens TelegramLinkToken[]
notificationDeliveries NotificationDelivery[]
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[] webhookSubscriptions WebhookSubscription[]
documents Document[] documents Document[]
honeyBatchRelocations HoneyBatchRelocation[] honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[] apiKeys ApiKey[]
veterinaryMedicineLots VeterinaryMedicineLot[]
veterinaryMedicineMovements VeterinaryMedicineMovement[]
authorityNotices AuthorityNotice[]
apiaryRestrictions ApiaryRestriction[]
complianceAcknowledgements ComplianceAcknowledgement[]
@@index([roleId]) @@index([roleId])
} }
@@ -198,6 +210,7 @@ enum AuditAction {
MFA_ENABLED MFA_ENABLED
MFA_DISABLED MFA_DISABLED
ACCOUNT_LOCKED ACCOUNT_LOCKED
PRINT
} }
model AuditLog { model AuditLog {
@@ -240,11 +253,15 @@ model Apiary {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deactivatedAt DateTime? deactivatedAt DateTime?
colonies Colony[] colonies Colony[]
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
harvests Harvest[] harvests Harvest[]
treatments Treatment[] treatments Treatment[]
rawMaterialLots RawMaterialLot[] rawMaterialLots RawMaterialLot[]
phenologyReports PhenologyReport[]
phenologyStates ApiaryPhenology[]
weatherDays ApiaryWeatherDay[]
restrictions ApiaryRestriction[]
@@index([status]) @@index([status])
} }
@@ -316,6 +333,8 @@ model Colony {
harvests HarvestColony[] harvests HarvestColony[]
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
suggestions Suggestion[] suggestions Suggestion[]
hiveSetup HiveSetup?
voiceCaptures VoiceCapture[]
@@index([apiaryId]) @@index([apiaryId])
@@index([status]) @@index([status])
@@ -486,7 +505,8 @@ model Inspection {
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
tasks Task[] tasks Task[]
hiveChange HiveChange?
@@index([colonyId, date]) @@index([colonyId, date])
} }
@@ -786,11 +806,11 @@ model WinterCheck {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
model Treatment { model Treatment {
id String @id @default(cuid()) id String @id @default(cuid())
colonyId String colonyId String
colony Colony @relation(fields: [colonyId], references: [id]) colony Colony @relation(fields: [colonyId], references: [id])
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
date DateTime date DateTime
startDate DateTime? startDate DateTime?
endDate DateTime? endDate DateTime?
@@ -806,13 +826,25 @@ model Treatment {
// day of the treatment - kept with the treatment because the colony may // day of the treatment - kept with the treatment because the colony may
// be moved later - and the supplier / purchase proof / prescribing vet. // be moved later - and the supplier / purchase proof / prescribing vet.
apiaryId String? apiaryId String?
apiary Apiary? @relation(fields: [apiaryId], references: [id]) apiary Apiary? @relation(fields: [apiaryId], references: [id])
supplier String? supplier String?
purchaseProof String? purchaseProof String?
prescribingVet String? prescribingVet String?
createdAt DateTime @default(now()) // 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([colonyId, date])
@@index([medicineId])
@@index([medicineLotId])
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -928,6 +960,9 @@ model HoneyBatch {
extraction Extraction @relation(fields: [extractionId], references: [id]) extraction Extraction @relation(fields: [extractionId], references: [id])
honeyType String? honeyType String?
amount Float? amount Float?
// Ausgangsmenge in kg; amount = initialAmount + Summe der Buchungen / 1000.
// null = Menge nicht erfasst, dann wird nicht gebucht.
initialAmount Float?
waterContent Float? waterContent Float?
storageContainerId String? storageContainerId String?
storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id]) storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id])
@@ -939,6 +974,7 @@ model HoneyBatch {
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[] relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[] containerAllocations HoneyBatchContainerAllocation[]
movements BatchMovement[]
@@index([status]) @@index([status])
@@index([extractionId]) @@index([extractionId])
@@ -1105,18 +1141,22 @@ enum ShopCategory {
} }
model Product { model Product {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
description String? description String?
sku String? @unique sku String? @unique
packagingSize Float? packagingSize Float?
weight Float? weight Float?
salesUnit String? salesUnit String?
status ProductStatus @default(ACTIVE) // Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung).
kind ProductKind @default(BOTTLED_HONEY) shelfLifeMonths Int?
shopCategory ShopCategory @default(HONIG) // Bezeichnung des Lebensmittels auf dem Etikett, z. B. "Bluetenhonig" (T6).
createdAt DateTime @default(now()) legalName String?
updatedAt DateTime @updatedAt status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[] productionBatches ProductionBatch[]
@@ -1150,6 +1190,8 @@ model LabelTemplate {
// Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten // Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten
// Etikettenbögen, die nicht geschnitten werden. // Etikettenbögen, die nicht geschnitten werden.
cropMarks Boolean @default(true) 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) isDefault Boolean @default(false)
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1174,7 +1216,8 @@ model BottlingBatch {
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
movements BatchMovement[] movements BatchMovement[]
causedMovements BatchMovement[] @relation("BatchMovementSourceBottling")
@@index([honeyBatchId]) @@index([honeyBatchId])
@@index([productId]) @@index([productId])
@@ -1288,28 +1331,36 @@ enum WithdrawalReason {
// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der // Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der
// Migration). // Migration).
model BatchMovement { model BatchMovement {
id String @id @default(cuid()) id String @id @default(cuid())
bottlingBatchId String? bottlingBatchId String?
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict) bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String? productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict) productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String? rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict) rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
type BatchMovementType // 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. // Mit Vorzeichen: minus = raus, plus = rein.
quantity Int quantity Int
reason WithdrawalReason? reason WithdrawalReason?
notes String? notes String?
shopRequestId String? shopRequestId String?
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict) shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
userId String? userId String?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
apiKeyId String? apiKeyId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([bottlingBatchId]) @@index([bottlingBatchId])
@@index([productionBatchId]) @@index([productionBatchId])
@@index([rawMaterialLotId]) @@index([rawMaterialLotId])
@@index([honeyBatchId])
@@index([sourceBottlingBatchId])
@@index([shopRequestId]) @@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id). // ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id]) @@index([createdAt, id])
@@ -1383,25 +1434,27 @@ enum NotificationCategory {
WITHDRAWAL WITHDRAWAL
SEASON SEASON
SYSTEM SYSTEM
COMPLIANCE
} }
// Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung // Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte // beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
// des Inhalts; dedupeKey = "<praefix>:<userId>". // des Inhalts; dedupeKey = "<praefix>:<userId>".
model Notification { model Notification {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
priority NotificationPriority priority NotificationPriority
category NotificationCategory category NotificationCategory
message String message String
relatedType String? relatedType String?
relatedId String? relatedId String?
link String? link String?
requiredPermissions String[] @default([]) requiredPermissions String[] @default([])
dedupeKey String? @unique dedupeKey String? @unique
read Boolean @default(false) read Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
deliveries NotificationDelivery[]
@@index([userId, read]) @@index([userId, read])
@@index([priority]) @@index([priority])
@@ -1417,11 +1470,83 @@ model NotificationPreference {
quietFrom String? @default("21:00") quietFrom String? @default("21:00")
quietUntil String? @default("06:30") quietUntil String? @default("06:30")
telegramEnabled Boolean @default(false) telegramEnabled Boolean @default(false)
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST]) telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST, COMPLIANCE])
telegramDetail Boolean @default(true) telegramDetail Boolean @default(true)
updatedAt DateTime @updatedAt 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 // Webhooks (Abschnitt 27) – v1.x Modul 28
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1452,6 +1577,9 @@ enum DocumentEntityType {
QUEEN QUEEN
INSPECTION INSPECTION
HONEY_BATCH HONEY_BATCH
MEDICINE_LOT
AUTHORITY_NOTICE
APIARY_RESTRICTION
} }
model Document { model Document {
@@ -1542,18 +1670,22 @@ model AutomationSequence {
// site-wide social links exists, and a fixed-id upsert is simpler than // site-wide social links exists, and a fixed-id upsert is simpler than
// enforcing "at most one row" any other way. // enforcing "at most one row" any other way.
model SiteSettings { model SiteSettings {
id String @id @default("singleton") id String @id @default("singleton")
youtubeUrl String? youtubeUrl String?
instagramUrl String? instagramUrl String?
tiktokUrl String? tiktokUrl String?
websiteUrl String? websiteUrl String?
// Name and address of the bee keeper, printed on the Bestandsbuch. Never // Name and address of the bee keeper, printed on the Bestandsbuch. Never
// part of any public page. // part of any public page.
holderName String? holderName String?
holderAddress 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. // Country of origin printed on labels and shop output.
originCountry String @default("Deutschland") originCountry String @default("Deutschland")
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1789,3 +1921,468 @@ model ThrottlerHit {
@@id([key, throttlerName]) @@id([key, throttlerName])
@@index([expiresAt]) @@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])
}
+63 -34
View File
@@ -16,18 +16,28 @@ function generateRandomPassword(): string {
} }
// Only printed the moment the account is actually created (first deploy on // 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 // have changed the password via /profile, so repeating it would just be
// stale and would leak credentials into logs indefinitely for no reason. // 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 url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const line = '='.repeat(60); const line = '='.repeat(60);
console.log(line); log(line);
console.log('Seed: Admin-Zugang eingerichtet.'); log('Seed: Admin-Zugang eingerichtet.');
console.log(` URL: ${url}`); log(` URL: ${url}`);
console.log(` Benutzer: ${email}`); log(` Benutzer: ${email}`);
console.log(` Passwort: ${password}`); log(
console.log(line); password === null
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
: ` Passwort: ${password}`,
);
log(line);
} }
// Duplicated from src/task-templates/recurrence.ts rather than imported: // 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> { type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
await seedDefaultTaskTemplates(prisma); /**
* Creates the first administrator, but only while the user table is still
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL; * empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword(); * its password) on every start after the real admin changed their address.
const existing = await prisma.user.findUnique({ where: { email } }); */
if (existing) { export async function seedAdmin(
console.log(`Seed: admin user ${email} already exists, skipping.`); prisma: AdminSeedClient,
await prisma.$disconnect(); env: NodeJS.ProcessEnv,
return; 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 passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({ const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' }, where: { key: 'ADMIN' },
@@ -218,27 +232,42 @@ async function main(): Promise<void> {
roleId: adminRole.id, roleId: adminRole.id,
}, },
}); });
printLoginBanner(email, password);
} catch (error: unknown) { } catch (error: unknown) {
// P2002 = unique constraint violation on email: another replica's // P2002 = unique constraint violation on email: another replica's
// startup won this race between the existence check above and this // startup won this race between the count above and this create()
// create() call. The user now exists either way, so this is a no-op, // call. The user now exists either way, so this is a no-op, not a
// not a failure - container startup must not crash on it. // failure - container startup must not crash on it.
if ( if (
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002' error.code === 'P2002'
) { ) {
console.log( log(`Seed: admin user ${email} was created concurrently, skipping.`);
`Seed: admin user ${email} was created concurrently, skipping.`, return 'skipped';
);
} else {
throw error;
} }
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(); await prisma.$disconnect();
} }
main().catch((error: unknown) => { // Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
console.error(error); if (require.main === module) {
process.exit(1); 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,11 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module'; 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 { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service'; import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service'; import { ApiaryFieldService } from './apiary-field.service';
@Module({ @Module({
imports: [AutopilotModule], imports: [AutopilotModule, DomainEventsModule, HiveModule],
controllers: [ApiariesController], controllers: [ApiariesController],
providers: [ApiariesService, ApiaryFieldService], providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService], exports: [ApiariesService],
@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApiariesService } from './apiaries.service'; import { ApiariesService } from './apiaries.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.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', () => { describe('ApiariesService', () => {
let service: ApiariesService; let service: ApiariesService;
let prisma: { let order: string[];
apiary: { let tx: {
findMany: jest.Mock; $queryRaw: jest.Mock;
findUnique: jest.Mock; apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
create: jest.Mock;
update: jest.Mock;
};
colony: { count: jest.Mock }; colony: { count: jest.Mock };
}; };
let prisma: {
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
const existingApiary = { const existingApiary = {
id: 'apiary-1', id: 'apiary-1',
name: 'Heimstand', name: 'Heimstand',
status: 'ACTIVE', status: 'ACTIVE',
deactivatedAt: null, 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(() => { beforeEach(() => {
prisma = { order = [];
tx = {
$queryRaw: jest.fn().mockImplementation(() => {
order.push('lock');
return Promise.resolve([{ id: 'apiary-1' }]);
}),
apiary: { apiary: {
findMany: jest.fn(), findUnique: jest.fn().mockImplementation(() => {
findUnique: jest.fn(), order.push('read');
return Promise.resolve(existingApiary);
}),
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
colony: { count: jest.fn().mockResolvedValue(0) }, 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) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService( service = new ApiariesService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, 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 () => { it('findById throws NotFoundException when missing', async () => {
prisma.apiary.findUnique.mockResolvedValue(null); prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf( await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
); );
}); });
it('create persists the apiary and records an audit entry', async () => { it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
prisma.apiary.create.mockResolvedValue(existingApiary); tx.apiary.create.mockResolvedValue(existingApiary);
const result = await service.create({ name: 'Heimstand' }, 'user-1'); const result = await service.create({ name: 'Heimstand' }, 'user-1');
expect(result).toEqual(existingApiary); expect(result).toEqual(existingApiary);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
entityType: 'Apiary', entityType: 'Apiary',
action: 'CREATE', action: 'CREATE',
userId: 'user-1', 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 () => { it('update of a missing apiary answers 404', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary); tx.apiary.findUnique.mockResolvedValue(null);
prisma.apiary.update.mockResolvedValue({ 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, ...existingApiary,
status: 'INACTIVE', status: 'INACTIVE',
deactivatedAt: new Date(),
}); });
const result = await service.deactivate('apiary-1', 'user-1'); const result = await service.deactivate('apiary-1', 'user-1');
expect(result.status).toBe('INACTIVE'); expect(result.status).toBe('INACTIVE');
expect(auditLog.record).toHaveBeenCalledWith( expect(order).toEqual(['lock', 'read']);
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }), expect(tx.colony.count).toHaveBeenCalledWith({
);
});
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({
where: { where: {
apiaryId: 'apiary-1', apiaryId: 'apiary-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] }, 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 () => { it('deactivate throws ConflictException when active colonies remain', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary); tx.colony.count.mockResolvedValue(2);
prisma.colony.count.mockResolvedValue(2);
await expect( await expect(
service.deactivate('apiary-1', 'user-1'), service.deactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException); ).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 () => { it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect( await expect(
service.reactivate('apiary-1', 'user-1'), service.reactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
expect(events.record).not.toHaveBeenCalled();
}); });
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => { it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
prisma.apiary.findUnique.mockResolvedValue({ tx.apiary.findUnique.mockResolvedValue({
...existingApiary, ...existingApiary,
status: 'INACTIVE', status: 'INACTIVE',
deactivatedAt: new Date(), deactivatedAt: new Date(),
}); });
prisma.apiary.update.mockResolvedValue({ tx.apiary.update.mockResolvedValue(existingApiary);
...existingApiary,
status: 'ACTIVE',
deactivatedAt: null,
});
const result = await service.reactivate('apiary-1', 'user-1'); const result = await service.reactivate('apiary-1', 'user-1');
expect(result.status).toBe('ACTIVE'); expect(result.status).toBe('ACTIVE');
expect(prisma.apiary.update).toHaveBeenCalledWith({ expect(tx.apiary.update).toHaveBeenCalledWith({
where: { id: 'apiary-1' }, where: { id: 'apiary-1' },
data: { status: 'ACTIVE', deactivatedAt: null }, data: { status: 'ACTIVE', deactivatedAt: null },
}); });
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }), expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
tx,
); );
expect(changes()).toEqual(['REACTIVATED']);
}); });
}); });
+133 -62
View File
@@ -5,8 +5,11 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.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 { 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 { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto'; import { UpdateApiaryDto } from './dto/update-apiary.dto';
@@ -15,11 +18,22 @@ export interface ApiaryFilter {
location?: string; 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() @Injectable()
export class ApiariesService { export class ApiariesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
) {} ) {}
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> { async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
@@ -43,15 +57,21 @@ export class ApiariesService {
} }
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> { async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.create({ data: { ...dto } }); return this.prisma.$transaction(async (tx) => {
await this.auditLog.record({ const apiary = await tx.apiary.create({ data: { ...dto } });
entityType: 'Apiary', await this.auditLog.record(
entityId: apiary.id, {
action: 'CREATE', entityType: 'Apiary',
userId: actingUserId, entityId: apiary.id,
afterData: apiary, action: 'CREATE',
userId: actingUserId,
afterData: apiary,
},
tx,
);
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
return apiary;
}); });
return apiary;
} }
async update( async update(
@@ -59,69 +79,120 @@ export class ApiariesService {
dto: UpdateApiaryDto, dto: UpdateApiaryDto,
actingUserId: string, actingUserId: string,
): Promise<Apiary> { ): Promise<Apiary> {
const before = await this.findById(id); return this.prisma.$transaction(async (tx) => {
const apiary = await this.prisma.apiary.update({ const before = await this.lockApiary(tx, id);
where: { id }, const apiary = await tx.apiary.update({
data: { ...dto }, where: { id },
data: { ...dto },
});
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;
}); });
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
} }
async deactivate(id: string, actingUserId: string): Promise<Apiary> { async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id); return this.prisma.$transaction(async (tx) => {
// Colony.apiaryId is a plain FK, not enforced against apiary status - const before = await this.lockApiary(tx, id);
// without this guard a colony can silently keep pointing at an // Colony.apiaryId is a plain FK, not enforced against apiary status -
// inactive apiary (still shows ACTIVE, still countable, but its // without this guard a colony can silently keep pointing at an
// "location" has vanished from the active apiary list). Move the // inactive apiary (still shows ACTIVE, still countable, but its
// colonies elsewhere or dissolve them first instead. // "location" has vanished from the active apiary list). Move the
const activeColonyCount = await this.prisma.colony.count({ // colonies elsewhere or dissolve them first instead. Counted under the
where: { apiaryId: id, ...LIVING_COLONY_WHERE }, // apiary lock: the colony paths hold the apiary FOR SHARE, so a move
}); // into it that is still running finishes first.
if (activeColonyCount > 0) { const activeColonyCount = await tx.colony.count({
throw new ConflictException( where: { apiaryId: id, ...LIVING_COLONY_WHERE },
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`, });
if (activeColonyCount > 0) {
throw new ConflictException(
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
);
}
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
});
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
); );
} await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
const apiary = await this.prisma.apiary.update({ return apiary;
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
}); });
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
} }
async reactivate(id: string, actingUserId: string): Promise<Apiary> { async reactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id); return this.prisma.$transaction(async (tx) => {
if (before.status === 'ACTIVE') { const before = await this.lockApiary(tx, id);
throw new ConflictException('Apiary is already active'); if (before.status === 'ACTIVE') {
throw new ConflictException('Apiary is already active');
}
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
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');
} }
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary; 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 },
});
}
} }
@@ -4,6 +4,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service'; import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveService } from '../hive/hive.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
describe('ApiaryFieldService', () => { describe('ApiaryFieldService', () => {
let service: ApiaryFieldService; let service: ApiaryFieldService;
@@ -17,6 +19,8 @@ describe('ApiaryFieldService', () => {
}; };
let permissionResolver: { getPermissions: jest.Mock }; let permissionResolver: { getPermissions: jest.Mock };
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock }; let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
let hives: { getMany: jest.Mock };
let hiveCatalogs: { listSystems: jest.Mock };
const stepsOf = ( const stepsOf = (
colonyId: string, colonyId: string,
@@ -87,10 +91,14 @@ describe('ApiaryFieldService', () => {
), ),
warningCounts: jest.fn().mockResolvedValue(new Map()), warningCounts: jest.fn().mockResolvedValue(new Map()),
}; };
hives = { getMany: jest.fn().mockResolvedValue({}) };
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
service = new ApiaryFieldService( service = new ApiaryFieldService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService, permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService, nextSteps as unknown as NextStepsService,
hives as unknown as HiveService,
hiveCatalogs as unknown as HiveCatalogService,
); );
}); });
@@ -129,6 +137,40 @@ describe('ApiaryFieldService', () => {
expect(prisma.task.findMany).not.toHaveBeenCalled(); 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 () => { it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read'); grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z'); const date = new Date('2026-05-01T10:00:00.000Z');
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service'; 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 { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { import type {
@@ -12,6 +14,15 @@ import type {
const INSPECTIONS_PER_COLONY = 3; 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]; type PackColony = OfflinePackV1['colonies'][number];
interface FieldData { interface FieldData {
@@ -33,6 +44,8 @@ export class ApiaryFieldService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService, private readonly nextSteps: NextStepsService,
private readonly hives: HiveService,
private readonly hiveCatalogs: HiveCatalogService,
) {} ) {}
async colonyOverview( async colonyOverview(
@@ -45,12 +58,14 @@ export class ApiaryFieldService {
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> { async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview, nextSteps } = const { apiary, colonies, permissions, overview, nextSteps } =
await this.load(apiaryId, userId); await this.load(apiaryId, userId);
const [queens, inspections, tasks] = await Promise.all([ const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null, permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read') permissions.has('inspections:read')
? this.loadInspections(colonies) ? this.loadInspections(colonies)
: null, : null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null, permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
this.hives.getMany(colonies.map((colony) => colony.id)),
this.hiveCatalogs.listSystems(),
]); ]);
return { return {
version: 1, version: 1,
@@ -61,6 +76,8 @@ export class ApiaryFieldService {
queens, queens,
inspections, inspections,
tasks, tasks,
hives,
hiveCatalog: activeOrUsed(hiveCatalog, hives),
nextSteps: Object.fromEntries( nextSteps: Object.fromEntries(
colonies.map((colony) => [ colonies.map((colony) => [
colony.id, colony.id,
@@ -7,6 +7,8 @@ import type {
Queen, Queen,
Task, Task,
} from '../../generated/prisma'; } 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 { NextStep } from '../autopilot/next-step-ranking';
import type { SeasonSegmentKey } from '../season/season-types'; import type { SeasonSegmentKey } from '../season/season-types';
@@ -106,4 +108,11 @@ export interface OfflinePackV1 {
* returns as `steps`). Absent in packs cached before T4. * returns as `steps`). Absent in packs cached before T4.
*/ */
nextSteps?: Record<string, NextStep[]>; 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 },
) {}
+12
View File
@@ -28,6 +28,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module'; import { QrModule } from './qr/qr.module';
import { PhenologyModule } from './phenology/phenology.module';
import { RemindersModule } from './reminders/reminders.module'; import { RemindersModule } from './reminders/reminders.module';
import { ProductsModule } from './products/products.module'; import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module'; import { BottlingModule } from './bottling/bottling.module';
@@ -59,6 +60,11 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
import { AutopilotModule } from './autopilot/autopilot.module'; import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module'; import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module'; import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -114,6 +120,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
SuggestionsModule, SuggestionsModule,
CleanupModule, CleanupModule,
AutopilotModule, AutopilotModule,
PhenologyModule,
WithdrawalModule, WithdrawalModule,
RemindersModule, RemindersModule,
LabelTemplatesModule, LabelTemplatesModule,
@@ -130,6 +137,11 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
ErpExportModule, ErpExportModule,
ApiKeysModule, ApiKeysModule,
SystemModule, SystemModule,
VeterinaryMedicinesModule,
HiveModule,
ComplianceModule,
ReadinessModule,
VoiceModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [
@@ -123,6 +123,18 @@ export const PERMISSION_CATALOG = [
], ],
850, 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( ...group(
'Behandlungen', 'Behandlungen',
'treatments', 'treatments',
@@ -379,6 +391,32 @@ export const PERMISSION_CATALOG = [
[['read', 'Inventar-Spiegel für ERPNext lesen']], [['read', 'Inventar-Spiegel für ERPNext lesen']],
3600, 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[]; ] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -94,6 +94,59 @@ describe('PermissionResolverService', () => {
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2); 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', () => { describe('isTokenValid', () => {
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
@@ -37,6 +37,19 @@ export class PermissionResolverService {
return entry.permissions; 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 * Always read fresh (one primary-key lookup) and stored as the cache
* entry the PermissionsGuard then uses: a deactivation, role change or * 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)) { if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
throw new UnauthorizedException( 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 = const passwordMatches =
@@ -45,9 +45,7 @@ export class ReauthService {
(await bcrypt.compare(proof.password, user.passwordHash)); (await bcrypt.compare(proof.password, user.passwordHash));
if (!passwordMatches) { if (!passwordMatches) {
await this.accountGuard.recordFailure(user.id); await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException( throw new UnauthorizedException('Das Passwort stimmt nicht.');
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
);
} }
if (user.mfaEnabled) { if (user.mfaEnabled) {
const codeMatches = const codeMatches =
@@ -55,9 +53,7 @@ export class ReauthService {
(await this.accountGuard.consumeTotp(user, proof.mfaCode)); (await this.accountGuard.consumeTotp(user, proof.mfaCode));
if (!codeMatches) { if (!codeMatches) {
await this.accountGuard.recordFailure(user.id); await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException( throw new UnauthorizedException('Der Code stimmt nicht.');
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
);
} }
} }
await this.accountGuard.recordSuccess(user.id); await this.accountGuard.recordSuccess(user.id);
@@ -1,6 +1,6 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config'; import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar'; import { calendarSeasonState } from '../season/season-calendar';
import { loadAutopilotInputIn } from './autopilot-input'; import { lateSummerStart, loadAutopilotInputIn } from './autopilot-input';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const season = calendarSeasonState( const season = calendarSeasonState(
@@ -55,7 +55,13 @@ describe('loadAutopilotInputIn', () => {
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null }, { id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
]); ]);
const input = await loadAutopilotInputIn(db as never, 'c1', season); const input = await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(input?.lastTreatment).toEqual({ expect(input?.lastTreatment).toEqual({
id: 'old', id: 'old',
@@ -74,7 +80,13 @@ describe('loadAutopilotInputIn', () => {
}, },
]); ]);
await loadAutopilotInputIn(db as never, 'c1', season); await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({ expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } }, where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
@@ -82,9 +94,119 @@ describe('loadAutopilotInputIn', () => {
}); });
it('has no last treatment without treatments', async () => { it('has no last treatment without treatments', async () => {
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season); const input = await loadAutopilotInputIn(
tx([]) as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(input?.lastTreatment).toBeNull(); expect(input?.lastTreatment).toBeNull();
expect(input?.varroaSinceLastTreatmentEnd).toBe(false); expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
}); });
it('counts feedings and treatments from the real start of SPAETSOMMER', async () => {
const lateSummer = {
...season,
segment: 'SPAETSOMMER' as const,
segmentSince: '2026-07-25',
};
const db = tx([]);
await loadAutopilotInputIn(
db as never,
'c1',
lateSummer,
'08-01',
'2026-08-20',
);
expect(db.feeding.count).toHaveBeenLastCalledWith({
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
});
expect(db.treatment.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
});
});
it('counts nothing for late summer outside SPAETSOMMER and HERBST', async () => {
const db = tx([]);
const input = await loadAutopilotInputIn(
db as never,
'c1',
{ ...season, segment: 'SOMMER' },
'08-01',
'2026-08-20',
);
expect(input?.feedingsSinceLateSummer).toBe(0);
expect(input?.treatmentsSinceLateSummer).toBe(0);
expect(db.treatment.count).not.toHaveBeenCalled();
});
it('without an inspection counts feedings from the later of creation and the last 60 days', async () => {
const db = tx([]);
await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
// created 2026-04-01, today 2026-08-20: 60 days back wins
expect(db.feeding.count).toHaveBeenNthCalledWith(1, {
where: { colonyId: 'c1', date: { gte: d('2026-06-21') } },
});
const young = tx([]);
young.colony.findUnique.mockResolvedValue({
id: 'c1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdAt: d('2026-08-10'),
feedStatus: null,
currentQueen: null,
});
await loadAutopilotInputIn(
young as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(young.feeding.count).toHaveBeenNthCalledWith(1, {
where: { colonyId: 'c1', date: { gte: d('2026-08-10') } },
});
});
});
describe('lateSummerStart', () => {
const state = (
segment: 'SPAETSOMMER' | 'HERBST' | 'SOMMER',
segmentSince: string,
) => ({ ...season, year: 2026, segment, segmentSince });
it('is the start of the segment in SPAETSOMMER', () => {
expect(lateSummerStart(state('SPAETSOMMER', '2026-07-25'), '08-01')).toBe(
'2026-07-25',
);
});
it('is the calendar day of TRACHTENDE in HERBST, at the latest the start of HERBST', () => {
expect(lateSummerStart(state('HERBST', '2026-09-15'), '08-01')).toBe(
'2026-08-01',
);
expect(lateSummerStart(state('HERBST', '2026-07-20'), '08-01')).toBe(
'2026-07-20',
);
});
it('is null in every other segment', () => {
expect(lateSummerStart(state('SOMMER', '2026-06-20'), '08-01')).toBeNull();
});
}); });
@@ -1,21 +1,35 @@
import type { Prisma } from '../../generated/prisma'; import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates'; import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types'; import type { SeasonState } from '../season/season-types';
import type { AutopilotInput } from './autopilot.types'; import type { AutopilotInput } from './autopilot.types';
/**
* Until Task 27 passes the real start of the late summer, the feedings and
* treatments of A4-A6 are counted from this day of the year (the built-in
* calendar day of TRACHTENDE).
*/
const LATE_SUMMER_FALLBACK_START = '08-01';
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const; const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
function lateSummerStart(season: SeasonState): string | null { /** Without an inspection a feeding counts for A6 if it is at most this old. */
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST' const FEEDING_LOOKBACK_DAYS = 60;
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
: null; /**
* The day SPAETSOMMER began, for the feedings and treatments of A4/A5. In
* SPAETSOMMER that is the real start of the segment. The stored state keeps
* only the start of the current segment, so in HERBST it is the calendar day
* of TRACHTENDE (`calendarTrachtende`, MM-DD), at the latest the start of
* HERBST.
*
* TODO: store the real start as `lateSummerSince` on ApiaryPhenology (needs
* a migration) so HERBST no longer depends on the calendar day.
*/
export function lateSummerStart(
season: SeasonState,
calendarTrachtende: string,
): string | null {
if (season.segment === 'SPAETSOMMER') {
return season.segmentSince;
}
if (season.segment !== 'HERBST') {
return null;
}
const calendarDay = `${season.year}-${calendarTrachtende}`;
return calendarDay < season.segmentSince ? calendarDay : season.segmentSince;
} }
/** /**
@@ -28,6 +42,8 @@ export async function loadAutopilotInputIn(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
colonyId: string, colonyId: string,
season: SeasonState, season: SeasonState,
calendarTrachtende: string,
today: string,
): Promise<AutopilotInput | null> { ): Promise<AutopilotInput | null> {
const colony = await tx.colony.findUnique({ const colony = await tx.colony.findUnique({
where: { id: colonyId }, where: { id: colonyId },
@@ -86,13 +102,18 @@ export async function loadAutopilotInputIn(
(await tx.varroaMeasurement.count({ (await tx.varroaMeasurement.count({
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } }, where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
})) > 0; })) > 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({ const feedingsSinceInspection = await tx.feeding.count({
where: { where: { colonyId, date: { gte: feedingsSince } },
colonyId,
...(inspection === null ? {} : { date: { gte: inspection.date } }),
},
}); });
const lateSummer = lateSummerStart(season); const lateSummer = lateSummerStart(season, calendarTrachtende);
const sinceLateSummer = const sinceLateSummer =
lateSummer === null ? null : { gte: dayToDate(lateSummer) }; lateSummer === null ? null : { gte: dayToDate(lateSummer) };
const feedingsSinceLateSummer = const feedingsSinceLateSummer =
@@ -1,5 +1,11 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config'; import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar'; import { calendarSeasonState } from '../season/season-calendar';
import type {
PhaseSourceKey,
PhenologyPhaseKey,
SeasonSegmentKey,
SeasonState,
} from '../season/season-types';
import { evaluateAutopilot } from './autopilot-rules'; import { evaluateAutopilot } from './autopilot-rules';
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types'; import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
@@ -62,6 +68,12 @@ const inspectionOn = (date: string) => ({
queenCellsPresent: 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' }; const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
describe('autopilot rules', () => { describe('autopilot rules', () => {
@@ -227,6 +239,7 @@ describe('autopilot rules', () => {
lastInspection: inspectionOn('2026-08-10'), lastInspection: inspectionOn('2026-08-10'),
lastVarroa: { id: 'var_3', date: '2026-08-01' }, lastVarroa: { id: 'var_3', date: '2026-08-01' },
lastTreatment: TREATMENT, lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}), }),
configs(), configs(),
today, today,
@@ -256,6 +269,8 @@ describe('autopilot rules', () => {
input(early, { input(early, {
lastInspection: inspectionOn('2026-08-10'), lastInspection: inspectionOn('2026-08-10'),
lastTreatment: TREATMENT, lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
}), }),
configs(), configs(),
early, early,
@@ -268,6 +283,8 @@ describe('autopilot rules', () => {
input(late, { input(late, {
lastInspection: inspectionOn('2026-08-15'), lastInspection: inspectionOn('2026-08-15'),
lastTreatment: TREATMENT, lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
varroaSinceLastTreatmentEnd: true, varroaSinceLastTreatmentEnd: true,
}), }),
configs(), configs(),
@@ -286,6 +303,7 @@ describe('autopilot rules', () => {
input(today, { input(today, {
lastInspection: inspectionOn('2026-08-05'), lastInspection: inspectionOn('2026-08-05'),
lastTreatment: TREATMENT, lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}), }),
cfg, cfg,
today, today,
@@ -370,3 +388,437 @@ describe('autopilot rules', () => {
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]); 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']);
});
});
+243 -10
View File
@@ -1,6 +1,8 @@
import type { AutopilotRuleId } from '../automation/rule-config'; import type { AutopilotRuleId } from '../automation/rule-config';
import { addDays, daysBetween } from '../common/dates'; import { addDays, daysBetween } from '../common/dates';
import { inspectionDueOn } from '../season/inspection-due'; 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 { varroaMeasurementDue } from '../season/varroa-due';
import type { import type {
AutopilotCandidate, AutopilotCandidate,
@@ -127,15 +129,247 @@ const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
}; };
}; };
/** A4-A10 follow with Task 27. */ /** The phase window of A7: from KIRSCHE up to, not including, LINDE. */
export const AUTOPILOT_RULES: Readonly< const HONEY_SUPER_FROM_PHASE = PHENOLOGY_PHASES.indexOf('KIRSCHE');
Partial<Record<AutopilotRuleId, AutopilotRule>> const HONEY_SUPER_UNTIL_PHASE = PHENOLOGY_PHASES.indexOf('LINDE');
> = {
A1: a1Inspection, /** The season facts the texts of A4, A5, A7 and A10 quote. */
A2: a2VarroaMeasure, function seasonEvidence(input: AutopilotInput): {
A3: a3VarroaRecheck, 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[] = [ const RULE_ORDER: readonly AutopilotRuleId[] = [
'A1', 'A1',
'A2', 'A2',
@@ -164,11 +398,10 @@ export function evaluateAutopilot(
} }
const candidates: AutopilotCandidate[] = []; const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) { for (const id of RULE_ORDER) {
const rule = AUTOPILOT_RULES[id]; if (!configs.autopilot.config.rules[id]) {
if (rule === undefined || !configs.autopilot.config.rules[id]) {
continue; continue;
} }
const candidate = rule(input, configs, today); const candidate = AUTOPILOT_RULES[id](input, configs, today);
if (candidate !== null) { if (candidate !== null) {
candidates.push(candidate); candidates.push(candidate);
} }
@@ -4,9 +4,13 @@ import type {
DomainEventType, DomainEventType,
} from '../domain-events/domain-event-types'; } from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { AutopilotEventHandlers } from './autopilot.handlers'; import { AutopilotEventHandlers } from './autopilot.handlers';
import type { AutopilotService } from './autopilot.service'; import type { AutopilotService } from './autopilot.service';
jest.mock('../prisma/row-lock');
const lockRows = jest.mocked(lockRowsForUpdate);
const EXPECTED_TYPES: DomainEventType[] = [ const EXPECTED_TYPES: DomainEventType[] = [
'InspectionRecorded', 'InspectionRecorded',
'InspectionCorrected', 'InspectionCorrected',
@@ -27,6 +31,7 @@ const EXPECTED_TYPES: DomainEventType[] = [
'QueenAged', 'QueenAged',
'InspectionOverdue', 'InspectionOverdue',
'VarroaMeasurementDue', 'VarroaMeasurementDue',
'WinterCheckRecorded',
]; ];
function event( function event(
@@ -48,13 +53,17 @@ function event(
} }
describe('AutopilotEventHandlers', () => { describe('AutopilotEventHandlers', () => {
let autopilot: { syncColonyIn: jest.Mock }; let autopilot: { syncColonyIn: jest.Mock; syncApiaryIn: jest.Mock };
let tx: { task: { findUnique: jest.Mock } }; let tx: { task: { findUnique: jest.Mock } };
let handlers: AutopilotEventHandlers; let handlers: AutopilotEventHandlers;
beforeEach(() => { beforeEach(() => {
lockRows.mockReset();
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') }); jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) }; autopilot = {
syncColonyIn: jest.fn().mockResolvedValue({}),
syncApiaryIn: jest.fn().mockResolvedValue({}),
};
tx = { task: { findUnique: jest.fn() } }; tx = { task: { findUnique: jest.fn() } };
handlers = new AutopilotEventHandlers( handlers = new AutopilotEventHandlers(
autopilot as unknown as AutopilotService, autopilot as unknown as AutopilotService,
@@ -93,6 +102,7 @@ describe('AutopilotEventHandlers', () => {
['TreatmentCorrected', { colonyId: 'c1' }], ['TreatmentCorrected', { colonyId: 'c1' }],
['FeedingRecorded', { colonyId: 'c1' }], ['FeedingRecorded', { colonyId: 'c1' }],
['FeedingCorrected', { colonyId: 'c1' }], ['FeedingCorrected', { colonyId: 'c1' }],
['WinterCheckRecorded', { colonyId: 'c1' }],
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }], ['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }], ['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
] as [DomainEventType, object][])( ] as [DomainEventType, object][])(
@@ -130,6 +140,26 @@ describe('AutopilotEventHandlers', () => {
expect(synced()).toEqual(['c1', 'c2', 'c3']); 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 () => { it('syncs the new and the parent colony of a split', async () => {
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' })); await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
@@ -178,4 +208,54 @@ describe('AutopilotEventHandlers', () => {
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })), run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
).rejects.toThrow('db down'); ).rejects.toThrow('db down');
}); });
describe('apiarySync', () => {
it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
AutopilotEventHandlers.prototype.apiarySync,
);
expect(entries).toHaveLength(1);
expect(entries[0].handlerId).toBe('autopilot.apiary-sync');
expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']);
});
// One event per apiary and day (dailyDedupeKey): a change later the same
// day is caught by the daily autopilot step.
it('syncs the apiary of the payload for the Berlin day', async () => {
await handlers.apiarySync(
event('SeasonPhaseChanged', 'api-1', {
apiaryId: 'api-1',
phase: 'KIRSCHE',
segment: 'SCHWARMZEIT',
previousPhase: 'SALWEIDE',
previousSegment: 'FRUEHJAHR',
source: 'REPORT',
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
tx as unknown as Prisma.TransactionClient,
);
expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1);
expect(autopilot.syncApiaryIn).toHaveBeenCalledWith(
tx,
'api-1',
'2026-05-12',
);
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
});
it('lets errors propagate so the delivery is retried', async () => {
autopilot.syncApiaryIn.mockRejectedValue(new Error('db down'));
await expect(
handlers.apiarySync(
event('SeasonPhaseChanged', 'api-1', {
apiaryId: 'api-1',
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
tx as unknown as Prisma.TransactionClient,
),
).rejects.toThrow('db down');
});
});
}); });
@@ -6,6 +6,7 @@ import type {
DomainEventType, DomainEventType,
} from '../domain-events/domain-event-types'; } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { AutopilotService } from './autopilot.service'; import { AutopilotService } from './autopilot.service';
const COLONY_SYNC_EVENTS = [ const COLONY_SYNC_EVENTS = [
@@ -28,6 +29,7 @@ const COLONY_SYNC_EVENTS = [
'QueenAged', 'QueenAged',
'InspectionOverdue', 'InspectionOverdue',
'VarroaMeasurementDue', 'VarroaMeasurementDue',
'WinterCheckRecorded',
] as const satisfies readonly DomainEventType[]; ] as const satisfies readonly DomainEventType[];
type ColonySyncEvent = AnyDomainEventEnvelope< type ColonySyncEvent = AnyDomainEventEnvelope<
@@ -61,6 +63,7 @@ async function colonyIdsOf(
case 'FeedingRecorded': case 'FeedingRecorded':
case 'FeedingCorrected': case 'FeedingCorrected':
case 'WithdrawalEnded': case 'WithdrawalEnded':
case 'WinterCheckRecorded':
case 'QueenAged': case 'QueenAged':
return [event.payload.colonyId]; return [event.payload.colonyId];
case 'HarvestRecorded': case 'HarvestRecorded':
@@ -85,7 +88,9 @@ async function colonyIdsOf(
} }
/** /**
* Reconciles the autopilot suggestions of the colonies an event touches. * Reconciles the autopilot suggestions of the colonies an event touches
* (autopilot.colony-sync) and, for a season change, of all colonies of the
* apiary (autopilot.apiary-sync).
* Pure DB writer through the delivery transaction; the colonies are synced * Pure DB writer through the delivery transaction; the colonies are synced
* in id order, so two deliveries never lock them the other way round. * in id order, so two deliveries never lock them the other way round.
*/ */
@@ -104,9 +109,31 @@ export class AutopilotEventHandlers {
if (colonyIds.length === 0) { if (colonyIds.length === 0) {
return; return;
} }
if (colonyIds.length > 1) {
// All colonies in one statement before any sync, like syncApiaryIn.
await lockRowsForUpdate(tx, 'Colony', colonyIds);
}
const today = berlinToday(); const today = berlinToday();
for (const colonyId of colonyIds) { for (const colonyId of colonyIds) {
await this.autopilot.syncColonyIn(tx, colonyId, today); await this.autopilot.syncColonyIn(tx, colonyId, today);
} }
} }
/**
* A season change (new phase or segment) alters the rules for every colony
* of the apiary: reconcile them all. There is one such event per apiary and
* day (dailyDedupeKey); a change later the same day is caught by the daily
* autopilot step.
*/
@OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync')
async apiarySync(
event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.autopilot.syncApiaryIn(
tx,
event.payload.apiaryId,
berlinToday(),
);
}
} }
@@ -4,7 +4,7 @@ import {
readRule, readRule,
ruleConfigOf, ruleConfigOf,
} from '../automation/rule-config'; } from '../automation/rule-config';
import { lockRowForUpdate } from '../prisma/row-lock'; import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { calendarSeasonState } from '../season/season-calendar'; import { calendarSeasonState } from '../season/season-calendar';
import type { SuggestionsService } from '../suggestions/suggestions.service'; import type { SuggestionsService } from '../suggestions/suggestions.service';
import { loadAutopilotInputIn } from './autopilot-input'; import { loadAutopilotInputIn } from './autopilot-input';
@@ -19,6 +19,7 @@ jest.mock('../automation/rule-config', () => ({
jest.mock('./autopilot-input'); jest.mock('./autopilot-input');
const lockRow = jest.mocked(lockRowForUpdate); const lockRow = jest.mocked(lockRowForUpdate);
const lockRows = jest.mocked(lockRowsForUpdate);
const readRuleMock = jest.mocked(readRule); const readRuleMock = jest.mocked(readRule);
const loadInput = jest.mocked(loadAutopilotInputIn); const loadInput = jest.mocked(loadAutopilotInputIn);
@@ -73,7 +74,7 @@ function input(lastInspectedOn: string | null): AutopilotInput {
describe('AutopilotService', () => { describe('AutopilotService', () => {
let tx: { let tx: {
$connect?: undefined; $connect?: undefined;
colony: { findUnique: jest.Mock }; colony: { findUnique: jest.Mock; findMany: jest.Mock };
suggestion: { findMany: jest.Mock }; suggestion: { findMany: jest.Mock };
}; };
let suggestions: { let suggestions: {
@@ -92,6 +93,7 @@ describe('AutopilotService', () => {
findUnique: jest findUnique: jest
.fn() .fn()
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }), .mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
findMany: jest.fn().mockResolvedValue([]),
}, },
suggestion: { findMany: jest.fn().mockResolvedValue([]) }, suggestion: { findMany: jest.fn().mockResolvedValue([]) },
}; };
@@ -301,7 +303,7 @@ describe('AutopilotService', () => {
expect(result.opened).toBe(1); expect(result.opened).toBe(1);
expect(readRuleMock).not.toHaveBeenCalled(); expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled(); expect(seasons.statesFor).not.toHaveBeenCalled();
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season); expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season, '08-01', TODAY);
}); });
it('preloadFor reads the four rules and the seasons once', async () => { it('preloadFor reads the four rules and the seasons once', async () => {
@@ -330,4 +332,66 @@ describe('AutopilotService', () => {
expect(warn).toHaveBeenCalledTimes(2); expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore(); warn.mockRestore();
}); });
describe('syncApiaryIn', () => {
it('syncs every living colony of the apiary in id order with one read of rules and seasons', async () => {
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
loadInput.mockResolvedValue(input(null));
readRuleMock.mockClear();
const result = await service.syncApiaryIn(tx as never, 'api-1', TODAY);
expect(tx.colony.findMany).toHaveBeenCalledWith({
where: {
apiaryId: 'api-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
select: { id: true },
orderBy: { id: 'asc' },
});
expect(lockRow.mock.calls.map((call) => call.slice(1))).toEqual([
['Apiary', 'api-1'],
['Colony', 'col-1'],
['Colony', 'col-2'],
]);
expect(readRuleMock).toHaveBeenCalledTimes(4);
expect(seasons.statesFor).toHaveBeenCalledTimes(1);
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
expect(result).toEqual({ opened: 2, refreshed: 0, resolved: 0 });
});
it('locks the apiary, then all its colonies in one statement, before any colony is synced', async () => {
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
loadInput.mockResolvedValue(input(null));
await service.syncApiaryIn(tx as never, 'api-1', TODAY);
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['col-1', 'col-2']);
const apiaryLock = lockRow.mock.invocationCallOrder[0];
const listing = tx.colony.findMany.mock.invocationCallOrder[0];
const colonyLock = lockRows.mock.invocationCallOrder[0];
const firstSync = lockRow.mock.invocationCallOrder[1];
expect(apiaryLock).toBeLessThan(listing);
expect(listing).toBeLessThan(colonyLock);
expect(colonyLock).toBeLessThan(firstSync);
});
it('does nothing and reads nothing for an apiary without living colonies', async () => {
readRuleMock.mockClear();
expect(await service.syncApiaryIn(tx as never, 'api-1', TODAY)).toEqual({
opened: 0,
refreshed: 0,
resolved: 0,
});
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
});
it('refuses a client that is not a transaction', async () => {
await expect(
service.syncApiaryIn({ $connect: jest.fn() } as never, 'api-1', TODAY),
).rejects.toThrow('AutopilotService.syncApiaryIn');
});
});
}); });
@@ -1,11 +1,14 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma'; import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config'; import { readRule } from '../automation/rule-config';
import { isLivingColonyStatus } from '../colonies/colony-status'; import {
isLivingColonyStatus,
LIVING_COLONY_WHERE,
} from '../colonies/colony-status';
import { addDays } from '../common/dates'; import { addDays } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types'; import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client'; import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock'; import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import type { SeasonState } from '../season/season-types'; import type { SeasonState } from '../season/season-types';
import { SeasonService } from '../season/season.service'; import { SeasonService } from '../season/season.service';
import { SuggestionsService } from '../suggestions/suggestions.service'; import { SuggestionsService } from '../suggestions/suggestions.service';
@@ -113,7 +116,13 @@ export class AutopilotService {
this.logger.warn(`No season state for the apiary of colony ${colonyId}`); this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
return NOTHING; return NOTHING;
} }
const input = await loadAutopilotInputIn(tx, colonyId, season); const input = await loadAutopilotInputIn(
tx,
colonyId,
season,
configs.phenology.calendarFallback.TRACHTENDE,
today,
);
if (input === null) { if (input === null) {
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`); this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
return NOTHING; return NOTHING;
@@ -173,6 +182,51 @@ export class AutopilotService {
return { opened, refreshed, resolved }; 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> { private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
return { return {
autopilot: await readRule(db, 'SEASON_AUTOPILOT'), autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
@@ -18,6 +18,7 @@ describe('NextStepsService', () => {
treatment: { findMany: jest.Mock }; treatment: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock; findMany: jest.Mock }; automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
apiary: { findMany: jest.Mock }; apiary: { findMany: jest.Mock };
apiaryPhenology: { findMany: jest.Mock };
}; };
let permissionResolver: { getPermissions: jest.Mock }; let permissionResolver: { getPermissions: jest.Mock };
@@ -70,6 +71,7 @@ describe('NextStepsService', () => {
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
}, },
apiary: { findMany: jest.fn().mockResolvedValue([]) }, apiary: { findMany: jest.fn().mockResolvedValue([]) },
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
}; };
permissionResolver = { getPermissions: jest.fn() }; permissionResolver = { getPermissions: jest.fn() };
service = new NextStepsService( service = new NextStepsService(
@@ -16,7 +16,10 @@ import {
} from '../common/dates'; } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service'; import { SeasonService } from '../season/season.service';
import { inspectionDueOn } from '../season/inspection-due'; import {
inspectionDueOn,
inspectionIntervalOf,
} from '../season/inspection-due';
import type { SeasonSegmentKey, SeasonState } from '../season/season-types'; import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
import { buildEvidenceLookup } from '../suggestions/evidence-lookup'; import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
import { renderSuggestion } from '../suggestions/suggestion-texts'; import { renderSuggestion } from '../suggestions/suggestion-texts';
@@ -311,10 +314,7 @@ export class NextStepsService {
apiaryId: season.apiaryId, apiaryId: season.apiaryId,
apiaryName: nameOf(season.apiaryId), apiaryName: nameOf(season.apiaryId),
segment: season.segment, segment: season.segment,
intervalDays: intervalDays: inspectionIntervalOf(season.segment, intervalDays),
season.segment === 'WINTERRUHE'
? null
: intervalDays[season.segment],
colonies: due, colonies: due,
}, },
]; ];
@@ -2,6 +2,7 @@ import { BadRequestException, Logger } from '@nestjs/common';
import * as path from 'node:path'; import * as path from 'node:path';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
import { import {
BackupBundle,
BackupService, BackupService,
LEGACY_UNSEALED_TABLES, LEGACY_UNSEALED_TABLES,
MODELS_EXCLUDED_FROM_BACKUP, MODELS_EXCLUDED_FROM_BACKUP,
@@ -34,6 +35,7 @@ const BUSINESS_ENTITY_KEYS = [
'varroaMeasurement', 'varroaMeasurement',
'swarmControlCheck', 'swarmControlCheck',
'winterCheck', 'winterCheck',
'phenologyReport',
'treatment', 'treatment',
'feeding', 'feeding',
'harvest', 'harvest',
@@ -63,6 +65,23 @@ const BUSINESS_ENTITY_KEYS = [
'backupSettings', 'backupSettings',
'calendarSyncSettings', 'calendarSyncSettings',
'calendarSyncOutboxEntry', 'calendarSyncOutboxEntry',
'veterinaryMedicine',
'veterinaryMedicineLot',
'veterinaryMedicineMovement',
'complianceSettings',
'authorityNotice',
'apiaryRestriction',
'complianceAcknowledgement',
] as const;
const T6_MODELS = [
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'VeterinaryMedicineMovement',
'ComplianceSettings',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
] as const; ] as const;
const ALL_ENTITY_KEYS = [ const ALL_ENTITY_KEYS = [
@@ -95,7 +114,15 @@ describe('BackupService', () => {
document: { findMany: jest.Mock; count: jest.Mock }; document: { findMany: jest.Mock; count: jest.Mock };
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & { let tx: Record<
string,
{
upsert: jest.Mock;
update?: jest.Mock;
create?: jest.Mock;
deleteMany?: jest.Mock;
}
> & {
permission: { findUnique: jest.Mock }; permission: { findUnique: jest.Mock };
user: { upsert: jest.Mock; findMany: jest.Mock }; user: { upsert: jest.Mock; findMany: jest.Mock };
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock }; queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
@@ -118,6 +145,13 @@ describe('BackupService', () => {
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }), upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
} as never; } as never;
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([]);
for (const key of ['hiveChange', 'hiveSetup']) {
tx[key].create = jest.fn().mockResolvedValue({});
tx[key].deleteMany = jest.fn().mockResolvedValue({ count: 0 });
}
tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null); tx.document.findUnique = jest.fn().mockResolvedValue(null);
@@ -1451,6 +1485,579 @@ describe('BackupService', () => {
}); });
}); });
describe('phenology and honey ledger', () => {
const importData = (data: Record<string, Row[]>, trusted = true) =>
service.importAll(
{ version: 3, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
{ trusted },
);
const created = (key: string): Row[] =>
(tx[key].upsert.mock.calls as [{ create: Row }][]).map(
([arg]) => arg.create,
);
it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', async () => {
prisma.phenologyReport.findMany.mockResolvedValue([
{ id: 'p1', phase: 'KIRSCHE', userId: 'known-user' },
]);
const bundle = await service.exportAll();
expect(bundle.data.phenologyReport).toHaveLength(1);
expect(bundle.data).not.toHaveProperty('apiaryPhenology');
expect(bundle.data).not.toHaveProperty('apiaryWeatherDay');
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryPhenology).toMatch(
/recomputed by daily-evaluation/,
);
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryWeatherDay).toMatch(
/refetched by the worker/,
);
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport');
});
it('excludes the Telegram models and the delivery queue from the backup, each with a reason', async () => {
const bundle = await service.exportAll();
for (const [model, key] of [
['TelegramLink', 'telegramLink'],
['TelegramLinkToken', 'telegramLinkToken'],
['NotificationDelivery', 'notificationDelivery'],
['TelegramPollState', 'telegramPollState'],
] as const) {
expect(bundle.data).not.toHaveProperty(key);
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toEqual(
expect.stringMatching(/.{30,}/),
);
}
expect(MODELS_EXCLUDED_FROM_BACKUP.TelegramLink).toMatch(/re-link/);
expect(MODELS_EXCLUDED_FROM_BACKUP.NotificationDelivery).toMatch(
/resend old messages/,
);
});
it('excludes VoiceCapture and VoiceCaptureAudio with a reason', async () => {
const bundle = await service.exportAll();
for (const [model, key] of [
['VoiceCapture', 'voiceCapture'],
['VoiceCaptureAudio', 'voiceCaptureAudio'],
] as const) {
expect(bundle.data).not.toHaveProperty(key);
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toMatch(
/ephemeral, personal data, not deliverable after a restore/,
);
}
});
it('excludes the VoiceExtractionCall ledger with a reason', async () => {
const bundle = await service.exportAll();
expect(bundle.data).not.toHaveProperty('voiceExtractionCall');
expect(MODELS_EXCLUDED_FROM_BACKUP.VoiceExtractionCall).toMatch(
/Operational ledger/,
);
});
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
await importData({
apiary: [{ id: 'apiary-1' }],
phenologyReport: [
{ id: 'p1', apiaryId: 'apiary-1', userId: 'known-user' },
{ id: 'p2', apiaryId: null, userId: 'ghost' },
],
});
expect(created('phenologyReport').map((row) => row.userId)).toEqual([
'known-user',
'admin-1',
]);
expect(tx.apiary.upsert.mock.invocationCallOrder[0]).toBeLessThan(
tx.phenologyReport.upsert.mock.invocationCallOrder[0],
);
});
it('an old bundle without initialAmount is recalculated like migration 6', async () => {
await importData({
honeyBatch: [
{ id: 'hb-1', amount: 20 },
{ id: 'hb-2', amount: null },
{ id: 'hb-3', amount: 5 },
],
bottlingBatch: [
{
id: 'bt-1',
honeyBatchId: 'hb-1',
totalAmount: 7.5,
createdAt: '2026-08-01T10:00:00.000Z',
},
{
id: 'bt-2',
honeyBatchId: 'hb-1',
totalAmount: 2.0004,
createdAt: '2026-08-02T10:00:00.000Z',
},
{ id: 'bt-3', honeyBatchId: 'hb-2', totalAmount: 1 },
],
});
expect(
created('honeyBatch').map((row) => [row.id, row.initialAmount]),
).toEqual([
['hb-1', 29.5],
['hb-2', undefined],
['hb-3', 5],
]);
expect(created('batchMovement')).toEqual([
expect.objectContaining({
honeyBatchId: 'hb-1',
sourceBottlingBatchId: 'bt-1',
type: 'BOTTLING',
quantity: -7500,
createdAt: '2026-08-01T10:00:00.000Z',
notes: 'Übernahme aus Abfüllung (T4)',
}),
expect.objectContaining({
honeyBatchId: 'hb-1',
sourceBottlingBatchId: 'bt-2',
type: 'BOTTLING',
quantity: -2000,
}),
]);
// After the batch and the bottling it points at.
expect(tx.bottlingBatch.upsert.mock.invocationCallOrder[0]).toBeLessThan(
tx.batchMovement.upsert.mock.invocationCallOrder[0],
);
});
it('leaves a bundle that carries initialAmount untouched', async () => {
await importData({
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
bottlingBatch: [{ id: 'bt-1', honeyBatchId: 'hb-1', totalAmount: 10 }],
batchMovement: [
{
id: 'm1',
honeyBatchId: 'hb-1',
type: 'BOTTLING',
quantity: -10000,
},
],
});
expect(created('honeyBatch')[0].initialAmount).toBe(30);
expect(created('batchMovement').map((row) => row.id)).toEqual(['m1']);
});
it('sets initialAmount from an unsealed file but books no movements', async () => {
await importData(
{
honeyBatch: [{ id: 'hb-new', amount: 20 }],
bottlingBatch: [
{ id: 'bt-2', honeyBatchId: 'hb-new', totalAmount: 5 },
],
},
false,
);
expect(created('honeyBatch')[0].initialAmount).toBe(25);
expect(tx.batchMovement.upsert).not.toHaveBeenCalled();
});
it('reports honey batches that break the invariant after the import, without failing it', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([{ id: 'hb-1' }]);
const summary = await importData({
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
});
expect(summary.imported.honeyLedgerViolations).toBe(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hb-1'));
warn.mockRestore();
});
it('adds no violation count when the ledger is consistent', async () => {
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([]);
const summary = await importData({
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 20 }],
});
expect(summary.imported).not.toHaveProperty('honeyLedgerViolations');
});
});
describe('T6 compliance tables', () => {
const T6_KEYS = T6_MODELS.map(modelKey);
it('no T6 model is excluded from the backup or listed as legacy-unsealed', async () => {
const bundle = await service.exportAll();
for (const model of T6_MODELS) {
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty(model);
expect(bundle.data).toHaveProperty(modelKey(model));
}
for (const key of T6_KEYS) {
expect(LEGACY_UNSEALED_TABLES as readonly string[]).not.toContain(key);
}
});
it('restores medicine, lot, treatment and movement in foreign-key order', async () => {
const one = (id: string) => [{ id }];
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: one('apiary-1'),
apiaryRestriction: one('restriction-1'),
veterinaryMedicineMovement: one('movement-1'),
treatment: one('treatment-1'),
veterinaryMedicineLot: one('lot-1'),
veterinaryMedicine: one('medicine-1'),
},
},
'admin-1',
TRUSTED,
);
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
const dependencies: [parent: string, child: string][] = [
['veterinaryMedicine', 'veterinaryMedicineLot'],
['veterinaryMedicineLot', 'treatment'],
['veterinaryMedicine', 'treatment'],
['treatment', 'veterinaryMedicineMovement'],
['veterinaryMedicineLot', 'veterinaryMedicineMovement'],
['apiary', 'apiaryRestriction'],
];
for (const [parent, child] of dependencies) {
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
{ parent, child, before: true },
);
}
});
it('remaps the user columns of the T6 rows like every other table', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
veterinaryMedicineLot: [
{ id: 'lot-1', userId: 'gone-user' },
{ id: 'lot-2', userId: 'known-user' },
{ id: 'lot-3', userId: null },
],
veterinaryMedicineMovement: [{ id: 'movement-1', userId: 'gone' }],
authorityNotice: [{ id: 'notice-1', createdByUserId: 'gone' }],
apiaryRestriction: [{ id: 'restriction-1', createdByUserId: null }],
complianceAcknowledgement: [
{ id: 'ack-1', userId: 'gone-user', revokedByUserId: 'gone' },
],
},
},
'admin-1',
TRUSTED,
);
const created = (key: string, call = 0) =>
(tx[key].upsert.mock.calls[call] as [{ create: Row }])[0].create;
expect(created('veterinaryMedicineLot', 0).userId).toBe('admin-1');
expect(created('veterinaryMedicineLot', 1).userId).toBe('known-user');
expect(created('veterinaryMedicineLot', 2).userId).toBeNull();
expect(created('veterinaryMedicineMovement').userId).toBe('admin-1');
expect(created('authorityNotice').createdByUserId).toBe('admin-1');
expect(created('apiaryRestriction').createdByUserId).toBeNull();
expect(created('complianceAcknowledgement').userId).toBe('admin-1');
});
it('upserts the compliance settings singleton by id', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
complianceSettings: [
{ id: 'singleton', registrationNumber: 'X-1' },
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.complianceSettings.upsert.mock.calls as [
[{ where: Row; update: Row }],
];
expect(call.where).toEqual({ id: 'singleton' });
expect(call.update).toEqual({ registrationNumber: 'X-1' });
});
it('skips a notice whose open draft key is held by another row here and counts it', async () => {
(
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
).authorityNotice.findUnique = jest
.fn()
.mockResolvedValueOnce({ id: 'other-draft' })
.mockResolvedValueOnce({ id: 'notice-2' });
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
authorityNotice: [
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
{ id: 'notice-2', openKey: 'TSK_REPORT:2027' },
{ id: 'notice-3', openKey: null },
],
},
},
'admin-1',
TRUSTED,
);
expect(tx.authorityNotice.upsert).toHaveBeenCalledTimes(2);
expect(summary.imported.authorityNotice).toBe(2);
expect(summary.imported.authorityNoticeSkipped).toBe(1);
});
it('an unsealed legacy file skips every T6 table and reports it', async () => {
const data = Object.fromEntries(
T6_KEYS.map((key) => [key, [{ id: `${key}-1` }]]),
);
const summary = await service.importAll(
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
{ trusted: false },
);
for (const key of T6_KEYS) {
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
key,
calls: 0,
});
}
expect([...summary.skipped].sort()).toEqual([...T6_KEYS].sort());
});
it('an unsealed file keeps the pack link off its treatments', async () => {
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{
id: 'treatment-1',
product: 'Mittel',
medicineId: 'medicine-1',
medicineLotId: 'lot-1',
doseQuantity: 5,
},
],
},
},
'admin-1',
{ trusted: false },
);
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
expect(call.create).not.toHaveProperty('medicineId');
expect(call.create).not.toHaveProperty('medicineLotId');
expect(call.create).not.toHaveProperty('doseQuantity');
expect(call.create.product).toBe('Mittel');
});
it('counts and logs the treatments whose pack link an unsealed file lost', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{ id: 't1', medicineLotId: 'lot-1' },
{ id: 't2', doseQuantity: 3 },
{ id: 't3', product: 'ohne Verknüpfung', medicineId: null },
],
},
},
'admin-1',
{ trusted: false },
);
expect(summary.imported.treatmentMedicineLinksDropped).toBe(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('2 Behandlung'),
);
warn.mockRestore();
});
it('adds no link counter when a sealed file keeps the links', async () => {
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { treatment: [{ id: 't1', medicineLotId: 'lot-1' }] },
},
'admin-1',
TRUSTED,
);
expect(summary.imported).not.toHaveProperty(
'treatmentMedicineLinksDropped',
);
});
it('leaves out the documents of a skipped notice and lists its id', async () => {
(
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
).authorityNotice.findUnique = jest
.fn()
.mockResolvedValue({ id: 'other-draft' });
const pdf = Buffer.from('%PDF-1.4').toString('base64');
const doc = (id: string, entityType: string, entityId: string) => ({
id,
entityType,
entityId,
mimeType: 'application/pdf',
contentBase64: pdf,
});
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
authorityNotice: [
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
{ id: 'notice-2', openKey: null },
],
document: [
doc('d1', 'AUTHORITY_NOTICE', 'notice-1'),
doc('d2', 'AUTHORITY_NOTICE', 'notice-2'),
doc('d3', 'COLONY', 'notice-1'),
],
},
},
'admin-1',
TRUSTED,
);
expect(summary.skippedNoticeIds).toEqual(['notice-1']);
expect(summary.imported.authorityNoticeDocumentsSkipped).toBe(1);
expect(summary.imported.document).toBe(2);
const calls = tx.document.upsert.mock.calls as [
{ where: { id: string } },
][];
const ids = calls.map(([arg]) => arg.where.id);
expect(ids).toEqual(['d2', 'd3']);
});
it('a sealed file keeps the pack link of its treatments', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{ id: 'treatment-1', medicineLotId: 'lot-1', doseQuantity: 5 },
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
expect(call.create).toMatchObject({
medicineLotId: 'lot-1',
doseQuantity: 5,
});
});
});
describe('importAll hive models', () => {
const hiveBundle = (): BackupBundle => ({
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
hiveSystem: [{ id: 'hive-system-1', name: 'System' }],
hivePartType: [{ id: 'hive-part-1', systemId: 'hive-system-1' }],
colony: [{ id: 'colony-1' }, { id: 'colony-2' }],
hiveSetup: [
{ colonyId: 'colony-1', systemId: 'hive-system-1', version: 2 },
{ colonyId: 'colony-other', systemId: 'hive-system-1', version: 1 },
],
hiveChange: [
{ id: 'change-1', colonyId: 'colony-1', userId: 'known-user' },
{ id: 'change-2', colonyId: 'colony-1', userId: 'gone-user' },
{ id: 'change-3', colonyId: 'colony-other', userId: 'known-user' },
],
},
});
it('upserts the catalog by id so seeded rows are neither duplicated nor rejected', async () => {
await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
expect(tx.hiveSystem.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'hive-system-1' } }),
);
expect(tx.hivePartType.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'hive-part-1' } }),
);
});
it('replaces setup and history of each restored colony, remapping the history user', async () => {
const summary = await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
expect(tx.hiveChange.deleteMany).toHaveBeenCalledWith({
where: { colonyId: { in: ['colony-1'] } },
});
expect(tx.hiveSetup.deleteMany).toHaveBeenCalledWith({
where: { colonyId: { in: ['colony-1'] } },
});
expect(tx.hiveSetup.create).toHaveBeenCalledTimes(1);
const created = tx.hiveChange.create!.mock.calls.map(
([arg]: [{ data: { id: string; userId: string } }]) => arg.data,
);
expect(created).toEqual([
expect.objectContaining({ id: 'change-1', userId: 'known-user' }),
expect.objectContaining({ id: 'change-2', userId: 'admin-1' }),
]);
expect(summary.imported).toMatchObject({
hiveSystem: 1,
hivePartType: 1,
hiveSetup: 2,
hiveChange: 3,
});
});
it('takes no hive table from an unsealed file and reports them', async () => {
const summary = await service.importAll(hiveBundle(), 'admin-1', {
trusted: false,
});
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
expect(tx.hiveSystem.upsert).not.toHaveBeenCalled();
expect(summary.skipped).toEqual(
expect.arrayContaining([
'hiveSystem',
'hivePartType',
'hiveSetup',
'hiveChange',
]),
);
});
});
// Drift guard: a model added to the schema has to be backed up or listed // Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing. // in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => { describe('model coverage', () => {
+360 -12
View File
@@ -21,6 +21,7 @@ import {
openBackup, openBackup,
sealBackup, sealBackup,
} from './backup-crypto'; } from './backup-crypto';
import { legacyHoneyLedger } from './legacy-honey-ledger';
// 3: production, raw material, stock ledger, number sequence, calendar sync // 3: production, raw material, stock ledger, number sequence, calendar sync
// and backup schedule tables. Older bundles simply lack those sections and // and backup schedule tables. Older bundles simply lack those sections and
@@ -41,6 +42,12 @@ export interface BackupImportSummary {
* table outside LEGACY_UNSEALED_TABLES. * table outside LEGACY_UNSEALED_TABLES.
*/ */
skipped: string[]; skipped: string[];
/**
* Authority notices of the file left out because the target already holds
* the open draft for their kind and year (the target wins). Their documents
* are left out too.
*/
skippedNoticeIds?: string[];
/** False for an old, unsealed file: restricted and add-only. */ /** False for an old, unsealed file: restricted and add-only. */
sealed: boolean; sealed: boolean;
} }
@@ -71,6 +78,9 @@ export interface BackupImportSummary {
* - siteSettings: keeper name/address printed on the Bestandsbuch and the * - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links. * public links.
* - document: writes files to the server's disk. * - document: writes files to the server's disk.
* - honeyBatch.initialAmount: an unsealed file may set it (derived from
* its bottlings) but never books ledger rows - batchMovement stays sealed
* only, so the BOTTLING bookings of an old bundle come with sealed files.
* - notification, notificationPreference: messages shown to users as coming * - notification, notificationPreference: messages shown to users as coming
* from ApiCore, and settings of a user. * from ApiCore, and settings of a user.
*/ */
@@ -140,6 +150,20 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
'Delivery log and retry queue - restoring it would resend old events to receivers.', 'Delivery log and retry queue - restoring it would resend old events to receivers.',
JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.", JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.",
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.', ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
ApiaryPhenology: 'Derived season state, recomputed by daily-evaluation.',
ApiaryWeatherDay: 'Weather cache, refetched by the worker.',
TelegramLink:
"Chat binding to this deployment's bot - users re-link after a restore (like ApiKey).",
TelegramLinkToken: 'Single-use link tokens, valid for minutes.',
NotificationDelivery:
'Delivery queue - restoring it would resend old messages.',
TelegramPollState: 'Update offset and poll lease of this deployment.',
VoiceCapture:
'Voice notes are ephemeral, personal data, not deliverable after a restore (content is deleted after at most 7 days).',
VoiceCaptureAudio:
'Encrypted audio of voice notes: ephemeral, personal data, not deliverable after a restore.',
VoiceExtractionCall:
'Operational ledger of paid model calls (daily budget, cost card), kept 90 days, meaningless after a restore.',
}; };
const DOCUMENT_ENTITY_TYPES = new Set<string>( const DOCUMENT_ENTITY_TYPES = new Set<string>(
@@ -167,6 +191,17 @@ function asString(row: Row, key: string): string | null {
return typeof value === 'string' ? value : null; return typeof value === 'string' ? value : null;
} }
// An unsealed file cannot book the pack ledger, so it cannot link a treatment
// to a catalogue entry or a pack either (that would be a dose without a
// booking).
function withoutMedicineLinks(row: Row): Row {
const rest: Row = { ...row };
delete rest.medicineId;
delete rest.medicineLotId;
delete rest.doseQuantity;
return rest;
}
// Bundles written before the feed vocabulary change carry free text in // Bundles written before the feed vocabulary change carry free text in
// Inspection.feedStatus: known words map to the enum, the rest goes to notes. // Inspection.feedStatus: known words map to the enum, the rest goes to notes.
function withCurrentFeedVocabulary(row: Row): Row { function withCurrentFeedVocabulary(row: Row): Row {
@@ -244,6 +279,32 @@ export class BackupService {
}); });
} }
/** Imported honey batches whose amount differs from the ledger by more than 0.5 g. */
private async honeyLedgerViolations(
tx: Prisma.TransactionClient,
ids: string[],
): Promise<string[]> {
if (ids.length === 0) {
return [];
}
const rows = await tx.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT hb."id"
FROM "HoneyBatch" hb
LEFT JOIN (
SELECT "honeyBatchId", sum("quantity") AS grams
FROM "BatchMovement"
WHERE "honeyBatchId" = ANY(${ids})
GROUP BY "honeyBatchId"
) m ON m."honeyBatchId" = hb."id"
WHERE hb."id" = ANY(${ids})
AND hb."initialAmount" IS NOT NULL
AND hb."amount" IS NOT NULL
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.grams, 0) / 1000.0)) > 0.0005
ORDER BY hb."id"
`);
return rows.map((row) => row.id);
}
async exportAll(): Promise<BackupBundle> { async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {}; const data: Record<string, unknown[]> = {};
@@ -269,20 +330,31 @@ export class BackupService {
}); });
data.apiary = await this.prisma.apiary.findMany(); data.apiary = await this.prisma.apiary.findMany();
data.apiaryRestriction = await this.prisma.apiaryRestriction.findMany();
data.queen = await this.prisma.queen.findMany(); data.queen = await this.prisma.queen.findMany();
data.colony = await this.prisma.colony.findMany(); data.colony = await this.prisma.colony.findMany();
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany(); data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
data.colonyLineage = await this.prisma.colonyLineage.findMany(); data.colonyLineage = await this.prisma.colonyLineage.findMany();
data.hiveSystem = await this.prisma.hiveSystem.findMany();
data.hivePartType = await this.prisma.hivePartType.findMany();
data.hiveSetup = await this.prisma.hiveSetup.findMany();
data.queenEvaluation = await this.prisma.queenEvaluation.findMany(); data.queenEvaluation = await this.prisma.queenEvaluation.findMany();
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany(); data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany(); data.inspection = await this.prisma.inspection.findMany();
data.hiveChange = await this.prisma.hiveChange.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany(); data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.suggestion = await this.prisma.suggestion.findMany(); data.suggestion = await this.prisma.suggestion.findMany();
data.task = await this.prisma.task.findMany(); data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany(); data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany(); data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.winterCheck = await this.prisma.winterCheck.findMany(); data.winterCheck = await this.prisma.winterCheck.findMany();
data.phenologyReport = await this.prisma.phenologyReport.findMany();
data.veterinaryMedicine = await this.prisma.veterinaryMedicine.findMany();
data.veterinaryMedicineLot =
await this.prisma.veterinaryMedicineLot.findMany();
data.treatment = await this.prisma.treatment.findMany(); data.treatment = await this.prisma.treatment.findMany();
data.veterinaryMedicineMovement =
await this.prisma.veterinaryMedicineMovement.findMany();
data.feeding = await this.prisma.feeding.findMany(); data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany(); data.harvest = await this.prisma.harvest.findMany();
data.harvestColony = await this.prisma.harvestColony.findMany(); data.harvestColony = await this.prisma.harvestColony.findMany();
@@ -310,6 +382,10 @@ export class BackupService {
data.automationRule = await this.prisma.automationRule.findMany(); data.automationRule = await this.prisma.automationRule.findMany();
data.automationSequence = await this.prisma.automationSequence.findMany(); data.automationSequence = await this.prisma.automationSequence.findMany();
data.siteSettings = await this.prisma.siteSettings.findMany(); data.siteSettings = await this.prisma.siteSettings.findMany();
data.complianceSettings = await this.prisma.complianceSettings.findMany();
data.authorityNotice = await this.prisma.authorityNotice.findMany();
data.complianceAcknowledgement =
await this.prisma.complianceAcknowledgement.findMany();
data.backupSettings = await this.prisma.backupSettings.findMany(); data.backupSettings = await this.prisma.backupSettings.findMany();
data.calendarSyncSettings = data.calendarSyncSettings =
await this.prisma.calendarSyncSettings.findMany(); await this.prisma.calendarSyncSettings.findMany();
@@ -385,6 +461,8 @@ export class BackupService {
// Document files the restored rows no longer point at - deleted only // Document files the restored rows no longer point at - deleted only
// once the transaction has committed. // once the transaction has committed.
const replacedDocumentFiles: string[] = []; const replacedDocumentFiles: string[] = [];
// Notices of the file the target's open draft replaced (see below).
const skippedNoticeIds = new Set<string>();
const imported: Record<string, number> = {}; const imported: Record<string, number> = {};
@@ -459,6 +537,17 @@ export class BackupService {
update: {}, update: {},
}); });
} }
const violations = await this.honeyLedgerViolations(
tx,
rowsOf('honeyBatch').map((row) => row.id as string),
);
if (violations.length > 0) {
imported.honeyLedgerViolations = violations.length;
this.logger.warn(
`Honigcharge(n) mit verletzter Mengen-Invariante nach dem Import: ${violations.join(', ')}`,
);
}
imported.rolePermission = rowsOf('rolePermission').length; imported.rolePermission = rowsOf('rolePermission').length;
// Built after role/user import so a user id that only exists // Built after role/user import so a user id that only exists
@@ -486,6 +575,21 @@ export class BackupService {
} }
imported.apiary = rowsOf('apiary').length; imported.apiary = rowsOf('apiary').length;
for (const row of rowsOf('apiaryRestriction')) {
const restriction = {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
};
await tx.apiaryRestriction.upsert({
where: { id: row.id as string },
create: restriction as never,
update: updateWith(restriction) as never,
});
}
imported.apiaryRestriction = rowsOf('apiaryRestriction').length;
// Queens already here before this import keep their mother line when // Queens already here before this import keep their mother line when
// the file is unsealed (add-only, see updateWith). // the file is unsealed (add-only, see updateWith).
const preexistingQueenIds = new Set<string>( const preexistingQueenIds = new Set<string>(
@@ -514,6 +618,27 @@ export class BackupService {
} }
imported.queen = rowsOf('queen').length; imported.queen = rowsOf('queen').length;
// Catalog first: a fresh install already carries the seeded rows with
// the same fixed ids, so upsert by id neither fails nor duplicates.
for (const row of rowsOf('hiveSystem')) {
await tx.hiveSystem.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
});
}
imported.hiveSystem = rowsOf('hiveSystem').length;
for (const row of rowsOf('hivePartType')) {
await tx.hivePartType.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
});
}
imported.hivePartType = rowsOf('hivePartType').length;
// Restore deliberately emits no ColonyCreated (no domain events).
for (const row of rowsOf('colony')) { for (const row of rowsOf('colony')) {
await tx.colony.upsert({ await tx.colony.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -523,6 +648,33 @@ export class BackupService {
} }
imported.colony = rowsOf('colony').length; imported.colony = rowsOf('colony').length;
// A restore replaces, never merges: the backup's setup wins, so the
// existing setup and history of each restored colony go first (the
// history rows reference the setup). Their backup history follows
// after the inspections.
const hiveSetupRows = rowsOf('hiveSetup');
const restoredColonyIds = new Set(
rowsOf('colony').map((row) => row.id as string),
);
const setupsToReplace = hiveSetupRows.filter((row) =>
restoredColonyIds.has(row.colonyId as string),
);
const replacedColonyIds = setupsToReplace.map(
(row) => row.colonyId as string,
);
if (replacedColonyIds.length > 0) {
await tx.hiveChange.deleteMany({
where: { colonyId: { in: replacedColonyIds } },
});
await tx.hiveSetup.deleteMany({
where: { colonyId: { in: replacedColonyIds } },
});
}
for (const row of setupsToReplace) {
await tx.hiveSetup.create({ data: row as never });
}
imported.hiveSetup = hiveSetupRows.length;
for (const row of rowsOf('queen')) { for (const row of rowsOf('queen')) {
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) { if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
await tx.queen.update({ await tx.queen.update({
@@ -597,6 +749,21 @@ export class BackupService {
} }
imported.inspection = rowsOf('inspection').length; imported.inspection = rowsOf('inspection').length;
const replacedColonies = new Set(replacedColonyIds);
const hiveChangeRows = rowsOf('hiveChange');
for (const row of hiveChangeRows) {
if (!replacedColonies.has(row.colonyId as string)) {
continue;
}
await tx.hiveChange.create({
data: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.hiveChange = hiveChangeRows.length;
for (const row of rowsOf('taskTemplate')) { for (const row of rowsOf('taskTemplate')) {
await tx.taskTemplate.upsert({ await tx.taskTemplate.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -709,20 +876,85 @@ export class BackupService {
} }
imported.winterCheck = rowsOf('winterCheck').length; imported.winterCheck = rowsOf('winterCheck').length;
for (const row of rowsOf('phenologyReport')) {
const report = {
...row,
userId: resolveUserId(asString(row, 'userId')),
};
await tx.phenologyReport.upsert({
where: { id: row.id as string },
create: report as never,
update: updateWith(report) as never,
});
}
imported.phenologyReport = rowsOf('phenologyReport').length;
// Catalogue and packs before the treatments that point at them; the
// pack ledger after the treatments its bookings belong to.
for (const row of rowsOf('veterinaryMedicine')) {
await tx.veterinaryMedicine.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
});
}
imported.veterinaryMedicine = rowsOf('veterinaryMedicine').length;
for (const row of rowsOf('veterinaryMedicineLot')) {
const lot = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.veterinaryMedicineLot.upsert({
where: { id: row.id as string },
create: lot as never,
update: updateWith(lot) as never,
});
}
imported.veterinaryMedicineLot = rowsOf('veterinaryMedicineLot').length;
let medicineLinksDropped = 0;
for (const row of rowsOf('treatment')) { for (const row of rowsOf('treatment')) {
if (
!trusted &&
(row.medicineId != null ||
row.medicineLotId != null ||
row.doseQuantity != null)
) {
medicineLinksDropped += 1;
}
const treatment = {
...(trusted ? row : withoutMedicineLinks(row)),
userId: resolveUserId(asString(row, 'userId')),
};
await tx.treatment.upsert({ await tx.treatment.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: { create: treatment as never,
...row, update: updateWith(treatment) as never,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
}) as never,
}); });
} }
imported.treatment = rowsOf('treatment').length; imported.treatment = rowsOf('treatment').length;
if (medicineLinksDropped > 0) {
imported.treatmentMedicineLinksDropped = medicineLinksDropped;
this.logger.warn(
`${medicineLinksDropped} Behandlung(en) ohne Mittel-/Packungsverknüpfung übernommen: Eine unversiegelte Datei darf das Packungsbuch nicht schreiben.`,
);
}
for (const row of rowsOf('veterinaryMedicineMovement')) {
const movement = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.veterinaryMedicineMovement.upsert({
where: { id: row.id as string },
create: movement as never,
update: movement as never,
});
}
imported.veterinaryMedicineMovement = rowsOf(
'veterinaryMedicineMovement',
).length;
for (const row of rowsOf('feeding')) { for (const row of rowsOf('feeding')) {
await tx.feeding.upsert({ await tx.feeding.upsert({
@@ -796,11 +1028,25 @@ export class BackupService {
} }
imported.storageContainer = rowsOf('storageContainer').length; imported.storageContainer = rowsOf('storageContainer').length;
// Bundles from before the honey ledger: initialAmount is derived
// like migration 6 does. Only a sealed file also gets the BOTTLING
// bookings; an unsealed one may not write ledger rows.
const legacyLedger = legacyHoneyLedger(
rowsOf('honeyBatch'),
rowsOf('bottlingBatch'),
);
for (const row of rowsOf('honeyBatch')) { for (const row of rowsOf('honeyBatch')) {
const initialAmount = legacyLedger.initialAmounts.get(
row.id as string,
);
const batch =
!('initialAmount' in row) && initialAmount !== undefined
? { ...row, initialAmount }
: row;
await tx.honeyBatch.upsert({ await tx.honeyBatch.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: batch as never,
update: updateWith(row) as never, update: updateWith(batch) as never,
}); });
} }
imported.honeyBatch = rowsOf('honeyBatch').length; imported.honeyBatch = rowsOf('honeyBatch').length;
@@ -967,6 +1213,19 @@ export class BackupService {
} }
imported.batchMovement = rowsOf('batchMovement').length; imported.batchMovement = rowsOf('batchMovement').length;
// After the batches and bottlings they point at; never rewritten.
for (const row of trusted ? legacyLedger.movements : []) {
const movement = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.batchMovement.upsert({
where: { id: row.id as string },
create: movement as never,
update: {},
});
}
for (const row of rowsOf('equipment')) { for (const row of rowsOf('equipment')) {
await tx.equipment.upsert({ await tx.equipment.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -1037,6 +1296,70 @@ export class BackupService {
} }
imported.siteSettings = rowsOf('siteSettings').length; imported.siteSettings = rowsOf('siteSettings').length;
for (const row of rowsOf('complianceSettings')) {
const { id, ...withoutId } = row;
await tx.complianceSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.complianceSettings = rowsOf('complianceSettings').length;
// The open draft of a kind and year is unique (openKey): a draft of
// this database that holds the key under another id is kept and the
// file's row skipped, instead of aborting the whole restore.
let noticesWritten = 0;
let noticesSkipped = 0;
for (const row of rowsOf('authorityNotice')) {
const notice = {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
};
const openKey = asString(row, 'openKey');
if (openKey !== null) {
const holder = await tx.authorityNotice.findUnique({
where: { openKey },
select: { id: true },
});
if (holder && holder.id !== row.id) {
noticesSkipped += 1;
skippedNoticeIds.add(row.id as string);
continue;
}
}
await tx.authorityNotice.upsert({
where: { id: row.id as string },
create: notice as never,
update: notice as never,
});
noticesWritten += 1;
}
imported.authorityNotice = noticesWritten;
if (noticesSkipped > 0) {
imported.authorityNoticeSkipped = noticesSkipped;
this.logger.warn(
`${noticesSkipped} Behördenanzeige(n) übersprungen: Im Zielsystem gibt es schon einen offenen Entwurf.`,
);
}
for (const row of rowsOf('complianceAcknowledgement')) {
const acknowledgement = {
...row,
userId: resolveUserId(asString(row, 'userId')),
};
await tx.complianceAcknowledgement.upsert({
where: { id: row.id as string },
create: acknowledgement as never,
update: acknowledgement as never,
});
}
imported.complianceAcknowledgement = rowsOf(
'complianceAcknowledgement',
).length;
for (const row of rowsOf('backupSettings')) { for (const row of rowsOf('backupSettings')) {
const { id, ...withoutId } = row; const { id, ...withoutId } = row;
await tx.backupSettings.upsert({ await tx.backupSettings.upsert({
@@ -1085,7 +1408,17 @@ export class BackupService {
// written here is simply orphaned on disk (wasted space), not a // written here is simply orphaned on disk (wasted space), not a
// correctness problem, since no DB row ends up pointing at it. // correctness problem, since no DB row ends up pointing at it.
let documentCount = 0; let documentCount = 0;
let noticeDocumentsSkipped = 0;
for (const row of rowsOf('document')) { for (const row of rowsOf('document')) {
// The open draft of the target wins: no document of a skipped
// notice is restored, so none is left without its notice.
if (
asString(row, 'entityType') === 'AUTHORITY_NOTICE' &&
skippedNoticeIds.has(asString(row, 'entityId') ?? '')
) {
noticeDocumentsSkipped += 1;
continue;
}
const contentBase64 = row.contentBase64 as string | null | undefined; const contentBase64 = row.contentBase64 as string | null | undefined;
if (!contentBase64) { if (!contentBase64) {
continue; continue;
@@ -1144,6 +1477,9 @@ export class BackupService {
documentCount += 1; documentCount += 1;
} }
imported.document = documentCount; imported.document = documentCount;
if (noticeDocumentsSkipped > 0) {
imported.authorityNoticeDocumentsSkipped = noticeDocumentsSkipped;
}
const activeUserIds = ( const activeUserIds = (
await tx.user.findMany({ await tx.user.findMany({
@@ -1250,12 +1586,24 @@ export class BackupService {
entityId: 'import', entityId: 'import',
action: 'CREATE', action: 'CREATE',
userId: actingUserId, userId: actingUserId,
afterData: { imported, skipped, sealed: trusted }, afterData: {
imported,
skipped,
sealed: trusted,
skippedNoticeIds: [...skippedNoticeIds],
},
}); });
await this.removeReplacedDocumentFiles(replacedDocumentFiles); await this.removeReplacedDocumentFiles(replacedDocumentFiles);
return { imported, skipped, sealed: trusted }; return {
imported,
skipped,
sealed: trusted,
...(skippedNoticeIds.size > 0
? { skippedNoticeIds: [...skippedNoticeIds] }
: {}),
};
} }
/** /**
@@ -0,0 +1,57 @@
import {
LEGACY_BOTTLING_NOTE,
legacyBottlingMovementId,
legacyHoneyLedger,
} from './legacy-honey-ledger';
describe('legacyHoneyLedger', () => {
it('derives initialAmount = amount + bottlings and one BOTTLING booking per bottling in grams', () => {
const ledger = legacyHoneyLedger(
[{ id: 'hb', amount: 20 }],
[
{ id: 'a', honeyBatchId: 'hb', totalAmount: 7.5, createdAt: 't1' },
{ id: 'b', honeyBatchId: 'hb', totalAmount: 2.5, createdAt: 't2' },
],
);
expect(ledger.initialAmounts.get('hb')).toBe(30);
expect(ledger.movements).toEqual([
{
id: legacyBottlingMovementId('a'),
honeyBatchId: 'hb',
sourceBottlingBatchId: 'a',
type: 'BOTTLING',
quantity: -7500,
notes: LEGACY_BOTTLING_NOTE,
userId: null,
createdAt: 't1',
},
expect.objectContaining({ quantity: -2500, createdAt: 't2' }),
]);
});
it('ignores a batch with initialAmount (new format) and a batch without amount', () => {
const ledger = legacyHoneyLedger(
[
{ id: 'new', amount: 5, initialAmount: 8 },
{ id: 'none', amount: null },
],
[
{ id: 'a', honeyBatchId: 'new', totalAmount: 3 },
{ id: 'b', honeyBatchId: 'none', totalAmount: 3 },
],
);
expect(ledger.initialAmounts.size).toBe(0);
expect(ledger.movements).toEqual([]);
});
it('rounds half up to the gram like the migration', () => {
const ledger = legacyHoneyLedger(
[{ id: 'hb', amount: 1 }],
[{ id: 'a', honeyBatchId: 'hb', totalAmount: 0.0125 }],
);
expect(ledger.movements[0].quantity).toBe(-13);
});
});
@@ -0,0 +1,74 @@
// Bundles written before the honey ledger (T4 migration 6) carry no
// HoneyBatch.initialAmount and no BOTTLING movements. The import derives
// both with the rule of the migration, so amount = initialAmount + sum of the
// movements holds for the restored batches too.
type Row = Record<string, unknown>;
export const LEGACY_BOTTLING_NOTE = 'Übernahme aus Abfüllung (T4)';
export interface LegacyHoneyLedger {
/** initialAmount per honey batch id of a legacy batch with a recorded amount. */
initialAmounts: ReadonlyMap<string, number>;
/** The BOTTLING bookings of those batches, one per bottling. */
movements: Row[];
}
function isLegacyBatch(row: Row): boolean {
return !('initialAmount' in row);
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
// Sums of kilograms carry float noise; a microgram grid hides it.
function roundKg(value: number): number {
return Math.round(value * 1e6) / 1e6;
}
/** Id of the booking made for a bottling; stable so a repeated restore adds nothing. */
export function legacyBottlingMovementId(bottlingBatchId: string): string {
return `legacy-bottling:${bottlingBatchId}`;
}
/**
* @param honeyBatches rows of the bundle's `honeyBatch` section
* @param bottlingBatches rows of the bundle's `bottlingBatch` section
*/
export function legacyHoneyLedger(
honeyBatches: readonly Row[],
bottlingBatches: readonly Row[],
): LegacyHoneyLedger {
const initialAmounts = new Map<string, number>();
for (const batch of honeyBatches) {
const amount = asNumber(batch.amount);
if (isLegacyBatch(batch) && amount !== null) {
initialAmounts.set(batch.id as string, amount);
}
}
const movements: Row[] = [];
for (const bottling of bottlingBatches) {
const honeyBatchId = bottling.honeyBatchId as string;
const base = initialAmounts.get(honeyBatchId);
const total = asNumber(bottling.totalAmount);
if (base === undefined || total === null) {
continue;
}
// The same gram rounding as the booking, so the invariant holds exactly.
const grams = Math.round(total * 1000);
initialAmounts.set(honeyBatchId, roundKg(base + grams / 1000));
movements.push({
id: legacyBottlingMovementId(bottling.id as string),
honeyBatchId,
sourceBottlingBatchId: bottling.id,
type: 'BOTTLING',
quantity: -grams,
notes: LEGACY_BOTTLING_NOTE,
userId: bottling.userId ?? null,
createdAt: bottling.createdAt,
});
}
return { initialAmounts, movements };
}
@@ -0,0 +1,87 @@
import { BadRequestException } from '@nestjs/common';
import {
bestBeforeFromShelfLife,
dayOfDate,
jarSizeWarnings,
resolveJarAndBestBefore,
} from './bottling-defaults';
const article = { id: 'p1', packagingSize: 500, shelfLifeMonths: 24 };
describe('bottling defaults', () => {
it('a timestamp counts as the Berlin day, a plain day stays', () => {
expect(dayOfDate('2026-07-31')).toBe('2026-07-31');
expect(dayOfDate('2026-07-31T22:30:00.000Z')).toBe('2026-08-01');
});
it('best before is the day plus the shelf life, cut at the month end', () => {
expect(bestBeforeFromShelfLife('2027-01-31', 1)).toBe('2027-02-28');
expect(bestBeforeFromShelfLife('2026-05-10', 24)).toBe('2028-05-10');
expect(bestBeforeFromShelfLife('2026-05-10', null)).toBeNull();
});
it('takes both values from the article', () => {
expect(resolveJarAndBestBefore({ date: '2026-05-10' }, article)).toEqual({
jarSize: 500,
bestBeforeDate: '2028-05-10',
warnings: [],
});
});
it('sent values win', () => {
const result = resolveJarAndBestBefore(
{ date: '2026-05-10', jarSize: 250, bestBeforeDate: '2027-01-01' },
article,
);
expect(result.jarSize).toBe(250);
expect(result.bestBeforeDate).toBe('2027-01-01');
expect(result.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
});
it('the same jar size gives no hint, an article without a size neither', () => {
expect(jarSizeWarnings(500, article)).toEqual([]);
expect(jarSizeWarnings(250, { ...article, packagingSize: null })).toEqual(
[],
);
expect(jarSizeWarnings(250, null)).toEqual([]);
});
it('names what is missing', () => {
expect(() => resolveJarAndBestBefore({ date: '2026-05-10' }, null)).toThrow(
new BadRequestException(
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
),
);
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-05-10' },
{ ...article, shelfLifeMonths: null },
),
).toThrow('Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.');
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-05-10' },
{ ...article, packagingSize: null },
),
).toThrow('Ohne Packungsgröße am Artikel');
});
});
describe('impossible days', () => {
it('are a 400, never a 500', () => {
expect(() => dayOfDate('2026-02-30')).toThrow(BadRequestException);
expect(() => dayOfDate('not a date')).toThrow(BadRequestException);
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-02-30', jarSize: 500, bestBeforeDate: '2027-01-01' },
null,
),
).toThrow(BadRequestException);
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-02-01', jarSize: 500, bestBeforeDate: '2027-02-30' },
null,
),
).toThrow(BadRequestException);
});
});
@@ -0,0 +1,109 @@
import { BadRequestException } from '@nestjs/common';
import { addMonthsClamped, berlinDayOf, dayToDate } from '../common/dates';
import {
WARNING_SEVERITY,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import { formatNumberDe } from '../suggestions/texts/format-number';
/** What a bottling takes over from its article (spec 7.8). */
export interface ArticleDefaults {
id: string;
packagingSize: number | null;
shelfLifeMonths: number | null;
}
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const INVALID_DATE =
'Das Datum ist ungültig. Bitte ein Datum wie 2026-07-31 angeben.';
/** The calendar day of a date or timestamp; a timestamp counts in Berlin. */
export function dayOfDate(date: string): string {
try {
const day = DAY.test(date) ? date : berlinDayOf(new Date(date));
dayToDate(day);
return day;
} catch {
throw new BadRequestException(INVALID_DATE);
}
}
/** Bottling day plus the article's shelf life; null without a shelf life. */
export function bestBeforeFromShelfLife(
date: string,
shelfLifeMonths: number | null,
): string | null {
return shelfLifeMonths === null
? null
: addMonthsClamped(dayOfDate(date), shelfLifeMonths);
}
export interface ResolvedJarAndBestBefore {
jarSize: number;
/** YYYY-MM-DD, or the text the client sent. */
bestBeforeDate: string;
warnings: PlausibilityWarning[];
}
/**
* Jar size and best-before date of a bottling: what was sent wins, else the
* article's packaging size and date + shelf life. There is no default shelf
* life: without one the beekeeper enters the date (spec 7.8).
*/
export function resolveJarAndBestBefore(
sent: { jarSize?: number; bestBeforeDate?: string; date: string },
article: ArticleDefaults | null,
): ResolvedJarAndBestBefore {
// An impossible day is the client's error (400), never a 500.
dayOfDate(sent.date);
if (sent.bestBeforeDate !== undefined) {
dayOfDate(sent.bestBeforeDate);
}
const jarSize = sent.jarSize ?? article?.packagingSize ?? null;
const bestBeforeDate =
sent.bestBeforeDate ??
bestBeforeFromShelfLife(sent.date, article?.shelfLifeMonths ?? null);
if (jarSize === null || bestBeforeDate === null) {
throw new BadRequestException(
missingMessage(jarSize, bestBeforeDate, article),
);
}
const warnings =
sent.jarSize === undefined ? [] : jarSizeWarnings(sent.jarSize, article);
return { jarSize, bestBeforeDate, warnings };
}
function missingMessage(
jarSize: number | null,
bestBeforeDate: string | null,
article: ArticleDefaults | null,
): string {
if (article === null || (jarSize === null && bestBeforeDate === null)) {
return 'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.';
}
return jarSize === null
? 'Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.'
: 'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.';
}
/** The hint for a jar size that differs from the article's packaging size. */
export function jarSizeWarnings(
jarSize: number,
article: ArticleDefaults | null,
): PlausibilityWarning[] {
if (
article === null ||
article.packagingSize === null ||
article.packagingSize === jarSize
) {
return [];
}
return [
{
code: 'JAR_SIZE_DIFFERS',
severity: WARNING_SEVERITY.JAR_SIZE_DIFFERS,
message: `Die Glasgröße (${formatNumberDe(jarSize, 0)} g) weicht vom Artikel ab (${formatNumberDe(article.packagingSize, 0)} g).`,
refs: [{ type: 'Product', id: article.id }],
},
];
}
@@ -0,0 +1,105 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { PrismaService } from '../prisma/prisma.service';
import { BottlingPrefillService } from './bottling-prefill.service';
describe('BottlingPrefillService', () => {
let service: BottlingPrefillService;
let prisma: {
honeyBatch: { findUnique: jest.Mock };
honeyMeasurement: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
product: { findUnique: jest.Mock };
};
beforeEach(() => {
prisma = {
honeyBatch: {
findUnique: jest.fn().mockResolvedValue({
id: 'h1',
honeyType: null,
waterContent: 19,
}),
},
honeyMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
product: { findUnique: jest.fn() },
};
service = new BottlingPrefillService(prisma as unknown as PrismaService);
});
it('offers jar size, best before and water content with the warning', async () => {
prisma.product.findUnique.mockResolvedValue({
kind: 'BOTTLED_HONEY',
packagingSize: 500,
shelfLifeMonths: 24,
});
const result = await service.prefill({
honeyBatchId: 'h1',
productId: 'p1',
date: '2026-05-10',
});
expect(result).toMatchObject({
jarSize: 500,
jarSizeSource: 'PRODUCT',
bestBeforeDate: '2028-05-10',
shelfLifeMonths: 24,
waterContent: 19,
});
expect(result.warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_HIGH']);
});
it('without an article only the water content is offered', async () => {
const result = await service.prefill({ honeyBatchId: 'h1' });
expect(result).toMatchObject({
jarSize: null,
jarSizeSource: null,
bestBeforeDate: null,
shelfLifeMonths: null,
waterContent: 19,
});
});
it('an article without a shelf life gives no best before', async () => {
prisma.product.findUnique.mockResolvedValue({
kind: 'BOTTLED_HONEY',
packagingSize: 250,
shelfLifeMonths: null,
});
const result = await service.prefill({
honeyBatchId: 'h1',
productId: 'p1',
});
expect(result.jarSize).toBe(250);
expect(result.bestBeforeDate).toBeNull();
});
it('reports an unknown honey batch or article', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(
service.prefill({ honeyBatchId: 'missing' }),
).rejects.toBeInstanceOf(NotFoundException);
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
prisma.product.findUnique.mockResolvedValue(null);
await expect(
service.prefill({ honeyBatchId: 'h1', productId: 'missing' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses an article that is not bottled honey', async () => {
prisma.product.findUnique.mockResolvedValue({
kind: 'MANUFACTURED',
packagingSize: null,
shelfLifeMonths: null,
});
await expect(
service.prefill({ honeyBatchId: 'h1', productId: 'p2' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,99 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { berlinToday } from '../common/dates';
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
import { PrismaService } from '../prisma/prisma.service';
import { bestBeforeFromShelfLife } from './bottling-defaults';
import type { BottlingPrefillQuery } from './dto/bottling-prefill.query';
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
const SOURCE_NOTE =
'Stammdaten des Artikels, keine Beobachtung. Das Formular nennt die Herkunft.';
/** What the bottling form may fill in for a honey batch and an article. */
export class BottlingPrefill {
@ApiProperty({ type: Number, nullable: true, description: SOURCE_NOTE })
jarSize!: number | null;
@ApiProperty({ enum: ['PRODUCT'], nullable: true })
jarSizeSource!: 'PRODUCT' | null;
@ApiProperty({
type: String,
nullable: true,
description: 'Abfülldatum plus Haltbarkeit des Artikels (YYYY-MM-DD)',
})
bestBeforeDate!: string | null;
@ApiProperty({ type: Number, nullable: true })
shelfLifeMonths!: number | null;
@ApiProperty({
type: Number,
nullable: true,
description: 'Jüngste Messung der Charge, sonst ihr eigener Wert',
})
waterContent!: number | null;
@ApiProperty({ type: 'array', items: { type: 'object' } })
warnings!: PlausibilityWarning[];
}
export type BottlingPrefillInput = Pick<
BottlingPrefillQuery,
'honeyBatchId' | 'productId' | 'date'
>;
/** Read only: nothing is written, nothing is locked. */
@Injectable()
export class BottlingPrefillService {
constructor(private readonly prisma: PrismaService) {}
async prefill(query: BottlingPrefillInput): Promise<BottlingPrefill> {
const water = await waterContentWarningsOfBatch(
this.prisma,
query.honeyBatchId,
);
if (water === null) {
throw new NotFoundException('Honey batch not found');
}
const product =
query.productId === undefined
? null
: await this.prisma.product.findUnique({
where: { id: query.productId },
select: {
kind: true,
packagingSize: true,
shelfLifeMonths: true,
},
});
if (query.productId !== undefined) {
if (!product) {
throw new NotFoundException('Product not found');
}
if (product.kind !== 'BOTTLED_HONEY') {
throw new BadRequestException(
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
);
}
}
const shelfLifeMonths = product?.shelfLifeMonths ?? null;
const jarSize = product?.packagingSize ?? null;
return {
jarSize,
jarSizeSource: jarSize === null ? null : 'PRODUCT',
bestBeforeDate: bestBeforeFromShelfLife(
query.date ?? berlinToday(),
shelfLifeMonths,
),
shelfLifeMonths,
waterContent: water.waterContent,
warnings: water.warnings,
};
}
}
@@ -15,6 +15,8 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
import { BottlingPrefillService } from './bottling-prefill.service';
import { BottlingPrefillQuery } from './dto/bottling-prefill.query';
import { Idempotent } from '../common/idempotency/idempotent.decorator'; import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto'; import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
@@ -26,6 +28,7 @@ import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
export class BottlingController { export class BottlingController {
constructor( constructor(
private readonly bottlingService: BottlingService, private readonly bottlingService: BottlingService,
private readonly prefillService: BottlingPrefillService,
private readonly stock: StockService, private readonly stock: StockService,
) {} ) {}
@@ -35,6 +38,15 @@ export class BottlingController {
return this.bottlingService.findAll({ honeyBatchId }); return this.bottlingService.findAll({ honeyBatchId });
} }
// Before ':id', so 'prefill' is not read as an id. Needs the create
// permission, like the other prefill routes: it offers what the create
// flow needs.
@RequirePermission('bottling:create')
@Get('prefill')
prefill(@Query() query: BottlingPrefillQuery) {
return this.prefillService.prefill(query);
}
@RequirePermission('bottling:read') @RequirePermission('bottling:read')
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
@@ -1,13 +1,20 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module'; import { AutomationModule } from '../automation/automation.module';
import { HoneyBatchesModule } from '../honey-batches/honey-batches.module';
import { StockModule } from '../stock/stock.module'; import { StockModule } from '../stock/stock.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module'; import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { BottlingController } from './bottling.controller'; import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
import { BottlingPrefillService } from './bottling-prefill.service';
@Module({ @Module({
imports: [AutomationModule, StockModule, WithdrawalModule], imports: [
AutomationModule,
HoneyBatchesModule,
StockModule,
WithdrawalModule,
],
controllers: [BottlingController], controllers: [BottlingController],
providers: [BottlingService], providers: [BottlingService, BottlingPrefillService],
}) })
export class BottlingModule {} export class BottlingModule {}
@@ -11,18 +11,10 @@ import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning'; import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service'; import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import {
type AmountChange = number | { increment?: number; decrement?: number }; HoneyBatchLedgerService,
type LedgerBooking,
function applyAmountChange( } from '../honey-batches/honey-batch-ledger.service';
current: number | null,
change: AmountChange | undefined,
): number | null {
if (change === undefined) return current;
if (typeof change === 'number') return change;
if (current === null) return null;
return current + (change.increment ?? 0) - (change.decrement ?? 0);
}
/** /**
* What one bottling write records in a single recordAll - so both share a * What one bottling write records in a single recordAll - so both share a
@@ -78,12 +70,12 @@ describe('BottlingService', () => {
update: jest.Mock; update: jest.Mock;
delete: jest.Mock; delete: jest.Mock;
}; };
honeyBatch: { honeyMeasurement: { findMany: jest.Mock };
findUnique: jest.Mock; automationRule: { findUnique: jest.Mock };
update: jest.Mock; honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
updateMany: jest.Mock; batchMovement: { count: jest.Mock };
};
}; };
let ledger: { bookIn: jest.Mock };
let stock: { let stock: {
remainingByBottling: jest.Mock; remainingByBottling: jest.Mock;
stockChangedEvent: jest.Mock; stockChangedEvent: jest.Mock;
@@ -101,9 +93,14 @@ describe('BottlingService', () => {
tx = { tx = {
$queryRaw: jest.fn(() => { $queryRaw: jest.fn(() => {
steps.push('lock'); steps.push('lock');
return Promise.resolve([]); return Promise.resolve([{ id: 'row' }]);
}), }),
product: { findUnique: jest.fn() }, product: { findUnique: jest.fn() },
// No measurements and no rule row: the built-in limits apply.
honeyMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
// By default a bottling has drawn from the ledger (own BOTTLING booking).
batchMovement: { count: jest.fn().mockResolvedValue(1) },
bottlingBatch: { bottlingBatch: {
findUnique: jest.fn(), findUnique: jest.fn(),
create: jest.fn(), create: jest.fn(),
@@ -115,48 +112,51 @@ describe('BottlingService', () => {
const row = honeyBatches.get(where.id); const row = honeyBatches.get(where.id);
return Promise.resolve(row ? { id: where.id, ...row } : null); return Promise.resolve(row ? { id: where.id, ...row } : null);
}), }),
// Only the status is written here; the amount belongs to the ledger.
update: jest.fn( update: jest.fn(
({ ({
where, where,
data, data,
}: { }: {
where: { id: string }; where: { id: string };
data: { amount?: AmountChange; status?: string }; data: { status?: string };
}) => { }) => {
const row = honeyBatches.get(where.id) ?? { amount: null }; const row = honeyBatches.get(where.id) ?? { amount: null };
honeyBatches.set(where.id, { honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount), ...row,
status: data.status ?? row.status, status: data.status ?? row.status,
}); });
return Promise.resolve({}); return Promise.resolve({});
}, },
), ),
updateMany: jest.fn(
({
where,
data,
}: {
where: { id: string; amount?: { gte?: number } };
data: { amount: AmountChange; status?: string };
}) => {
const row = honeyBatches.get(where.id);
const minimum = where.amount?.gte;
if (
!row ||
row.amount === null ||
(minimum !== undefined && row.amount < minimum)
) {
return Promise.resolve({ count: 0 });
}
honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount),
status: data.status ?? row.status,
});
return Promise.resolve({ count: 1 });
},
),
}, },
}; };
// The ledger as the movement book keeps it, reduced to the rest: a
// negative booking needs enough rest, an untracked batch stays untracked.
ledger = {
bookIn: jest.fn((_tx: unknown, booking: LedgerBooking) => {
const row = honeyBatches.get(booking.honeyBatchId);
if (!row) {
return Promise.reject(new NotFoundException('Honey batch not found'));
}
if (row.amount === null) {
return Promise.resolve({ amountKg: null, movementId: null });
}
if (booking.deltaKg < 0 && row.amount < -booking.deltaKg - 0.0005) {
return Promise.reject(
new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
),
);
}
const amountKg = Math.max(
0,
Math.round((row.amount + booking.deltaKg) * 1000) / 1000,
);
honeyBatches.set(booking.honeyBatchId, { ...row, amount: amountKg });
return Promise.resolve({ amountKg, movementId: 'm1' });
}),
};
prisma = { prisma = {
honeyBatch: { findUnique: jest.fn(), update: jest.fn() }, honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
product: { findUnique: jest.fn() }, product: { findUnique: jest.fn() },
@@ -202,6 +202,7 @@ describe('BottlingService', () => {
automation as unknown as AutomationService, automation as unknown as AutomationService,
stock as unknown as StockService, stock as unknown as StockService,
withdrawalCheck as unknown as WithdrawalCheckService, withdrawalCheck as unknown as WithdrawalCheckService,
ledger as unknown as HoneyBatchLedgerService,
); );
}); });
@@ -237,6 +238,12 @@ describe('BottlingService', () => {
id: 'h1', id: 'h1',
amount: 5, amount: 5,
}); });
honeyBatches.set('h1', { amount: 5 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 10,
});
await expect( await expect(
service.create( service.create(
@@ -327,14 +334,19 @@ describe('BottlingService', () => {
); );
}); });
it('refuses a bottling when the stored amount shrank after the first check', async () => { it('refuses a bottling when the rest under the lock is too small and writes nothing', async () => {
// The pre-check saw 25 kg, but a concurrent bottling already took most // The first read saw 25 kg, but a concurrent bottling already took most
// of it - the conditional booking inside the transaction must catch that. // of it - the ledger decides under the batch lock.
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1', id: 'h1',
amount: 25, amount: 25,
}); });
honeyBatches.set('h1', { amount: 3 }); honeyBatches.set('h1', { amount: 3 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 10,
});
await expect( await expect(
service.create( service.create(
@@ -349,9 +361,66 @@ describe('BottlingService', () => {
'user-1', 'user-1',
), ),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(honeyBatches.get('h1')?.amount).toBe(3); expect(honeyBatches.get('h1')?.amount).toBe(3);
expect(events.recordAll).not.toHaveBeenCalled(); expect(events.recordAll).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('create books the bottling in the ledger against the bottling it created', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: -5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
});
});
it('a batch without a recorded amount is bottled without a booking and marked BOTTLED', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: null });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(honeyBatches.get('h1')).toEqual({ amount: null, status: 'BOTTLED' });
}); });
it('update writes an audit log entry with before and after data in its transaction', async () => { it('update writes an audit log entry with before and after data in its transaction', async () => {
@@ -417,7 +486,7 @@ describe('BottlingService', () => {
tx, tx,
); );
// Nothing moved: the stale honey batch is never touched. // Nothing moved: the stale honey batch is never touched.
expect(tx.honeyBatch.updateMany).not.toHaveBeenCalled(); expect(ledger.bookIn).not.toHaveBeenCalled();
}); });
it('update records BottlingCorrected and StockChanged in its transaction', async () => { it('update records BottlingCorrected and StockChanged in its transaction', async () => {
@@ -554,6 +623,7 @@ describe('BottlingService', () => {
jarCount: 10, jarCount: 10,
totalAmount: 5, totalAmount: 5,
}); });
honeyBatches.set('h1', { amount: 0 });
await expect( await expect(
service.update('b1', { jarCount: 40 }, 'user-1'), service.update('b1', { jarCount: 40 }, 'user-1'),
@@ -687,6 +757,184 @@ describe('BottlingService', () => {
); );
}); });
describe('ledger bookings', () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
it('a smaller jar count gives the difference back as a BOTTLING booking', async () => {
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
honeyBatches.set('h1', { amount: 2 });
await service.update('b1', { jarCount: 6 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: 2,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
});
});
it('books nothing when the amount of the bottling stays the same', async () => {
lockedRows(before, { ...before, lotNumber: 'L-2' });
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('a change of the honey batch gives back to the old one and takes from the new one', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
([, booking]) => booking,
),
).toEqual([
{
honeyBatchId: 'h1',
deltaKg: 5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
},
{
honeyBatchId: 'h2',
deltaKg: -5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
},
]);
});
it('locks the bottling, then both honey batches in one statement, before it books', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
ledger.bookIn.mock.invocationCallOrder[0],
);
});
it('a failed take from the new honey batch leaves the bottling row unchanged', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 1 });
await expect(
service.update('b1', { honeyBatchId: 'h2' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
});
it('create locks the honey batch before it writes the bottling', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.bottlingBatch.create.mock.invocationCallOrder[0],
);
});
it('a bottling that never drew from the ledger books nothing when it changes', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
honeyBatches.set('h1', { amount: 2 });
await service.update('b1', { jarCount: 6 }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
expect(tx.batchMovement.count).toHaveBeenCalledWith({
where: {
honeyBatchId: 'h1',
sourceBottlingBatchId: 'b1',
type: 'BOTTLING',
},
});
});
it('moving a bottling that never drew from the ledger only takes from the new batch', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
([, booking]) => [booking.honeyBatchId, booking.deltaKg],
),
).toEqual([['h2', -5]]);
});
it('removing a bottling that never drew from the ledger gives nothing back', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
await service.remove('b1', 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
expect(tx.bottlingBatch.delete).toHaveBeenCalled();
});
it('remove books the amount back with the note "Abfüllung entfernt"', async () => {
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
await service.remove('b1', 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: 5,
type: 'BOTTLING',
notes: 'Abfüllung entfernt',
actingUserId: 'user-1',
});
// The booking is made while the bottling still exists (FK); the delete
// follows.
expect(ledger.bookIn.mock.invocationCallOrder[0]).toBeLessThan(
tx.bottlingBatch.delete.mock.invocationCallOrder[0],
);
});
});
describe('withdrawal warnings', () => { describe('withdrawal warnings', () => {
const conflict = { const conflict = {
harvestId: 'harvest-1', harvestId: 'harvest-1',
@@ -763,4 +1011,311 @@ describe('BottlingService', () => {
); );
}); });
}); });
describe('quality before bottling (spec 7.8)', () => {
const base = {
honeyBatchId: 'h1',
date: '2026-01-31',
jarCount: 10,
lotNumber: 'L-1',
};
/** A honey batch in the transaction with the given water content. */
function batchWith(waterContent: number | null, honeyType: string | null) {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.honeyBatch.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({
id: where.id,
amount: 25,
waterContent,
honeyType,
}),
);
tx.bottlingBatch.create.mockResolvedValue({
id: 'b1',
honeyBatchId: 'h1',
});
tx.bottlingBatch.findUnique.mockResolvedValue({ id: 'b1' });
}
function article(extra: Record<string, unknown> = {}) {
const product = {
id: 'p1',
kind: 'BOTTLED_HONEY',
packagingSize: 500,
shelfLifeMonths: 24,
...extra,
};
prisma.product.findUnique.mockResolvedValue(product);
return product;
}
it('19 % is written and answers WATER_CONTENT_HIGH', async () => {
batchWith(19, null);
const result = await service.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1);
});
it('above the legal limit it refuses with CONFIRMATION_REQUIRED and writes nothing', async () => {
batchWith(20.5, null);
const error = await service
.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
)
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ConflictException);
const body = (error as ConflictException).getResponse() as {
code: string;
message: string;
warnings: PlausibilityWarning[];
};
expect(body.code).toBe('CONFIRMATION_REQUIRED');
expect(body.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(typeof body.message).toBe('string');
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('the confirmed repeat is written and still reports the finding', async () => {
batchWith(20.5, null);
const result = await service.create(
{
...base,
jarSize: 500,
bestBeforeDate: '2027-01-01',
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1);
});
it('a newer measurement decides, read under the batch lock', async () => {
batchWith(21, null);
tx.honeyMeasurement.findMany.mockResolvedValue([
{ waterContent: 17, measuredAt: new Date('2026-01-20') },
]);
const result = await service.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
);
expect(result.warnings).toEqual([]);
expect(steps[0]).toBe('lock');
});
it('a missing water content is a hint, not a stop', async () => {
batchWith(null, null);
const result = await service.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('takes jar size and best before from the article, month end cut', async () => {
batchWith(17, null);
article({ shelfLifeMonths: 1 });
await service.create({ ...base, productId: 'p1' }, 'user-1');
expect(tx.bottlingBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({
jarSize: 500,
totalAmount: 5,
bestBeforeDate: new Date('2026-02-28'),
}) as unknown,
});
});
it('a sent value wins, and another jar size gives JAR_SIZE_DIFFERS', async () => {
batchWith(17, null);
article();
const result = await service.create(
{
...base,
productId: 'p1',
jarSize: 250,
bestBeforeDate: '2027-05-05',
},
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
expect(tx.bottlingBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({
jarSize: 250,
bestBeforeDate: new Date('2027-05-05'),
}) as unknown,
});
});
it('refuses with a clear text when neither jar size nor best before is known', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
await expect(service.create(base, 'user-1')).rejects.toThrow(
new BadRequestException(
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
),
);
});
it('refuses without a shelf life on the article when no best before is sent', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
article({ shelfLifeMonths: null });
await expect(
service.create({ ...base, productId: 'p1' }, 'user-1'),
).rejects.toThrow(
new BadRequestException(
'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.',
),
);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
});
it('a correction only reports: never a 409', async () => {
const before = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
productId: null,
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(before, before);
tx.honeyBatch.findUnique.mockResolvedValue({
id: 'h1',
amount: 10,
waterContent: 22,
honeyType: null,
});
honeyBatches.set('h1', { amount: 10 });
const result = await service.update('b1', { jarSize: 250 }, 'user-1');
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
});
it('a correction that touches neither honey batch nor jar size reports no quality finding', async () => {
const before = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
productId: null,
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(before, before);
tx.honeyBatch.findUnique.mockResolvedValue({
id: 'h1',
amount: 10,
waterContent: 22,
honeyType: null,
});
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(result.warnings).toEqual([]);
});
async function refusal(
extra: Record<string, unknown>,
): Promise<{ waterContent: number; code: string }> {
batchWith(20.5, null);
const error = await service
.create(
{
...base,
jarSize: 500,
bestBeforeDate: '2027-01-01',
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
...extra,
},
'user-1',
)
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ConflictException);
return (error as ConflictException).getResponse() as {
waterContent: number;
code: string;
};
}
it('the 409 names the checked value', async () => {
batchWith(20.5, null);
const error = await service
.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
)
.catch((e: unknown) => e);
expect(
((error as ConflictException).getResponse() as { waterContent: number })
.waterContent,
).toBe(20.5);
});
it('a confirmation for another value gets a fresh 409 with the new value', async () => {
const body = await refusal({ acknowledgedWaterContent: 20.2 });
expect(body).toMatchObject({
code: 'CONFIRMATION_REQUIRED',
waterContent: 20.5,
});
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
});
it('the code without the value is not a confirmation', async () => {
const body = await refusal({});
expect(body.code).toBe('CONFIRMATION_REQUIRED');
});
it('an impossible day is a 400', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
await expect(
service.create(
{
...base,
date: '2026-02-30',
jarSize: 500,
bestBeforeDate: '2027-01-01',
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
}); });
+236 -132
View File
@@ -5,16 +5,23 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { roundToThousandths } from '../common/quantity';
import { DbClient } from '../prisma/db-client'; import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock'; import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types'; import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { withWarnings } from '../common/warnings/plausibility-warning'; import {
withWarnings,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service'; import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
import { HoneyBatchLedgerService } from '../honey-batches/honey-batch-ledger.service';
import { jarSizeWarnings, resolveJarAndBestBefore } from './bottling-defaults';
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -27,82 +34,9 @@ export interface BottlingBatchFilter {
honeyBatchId?: string; honeyBatchId?: string;
} }
/**
* Amounts are kept in kilograms with gram precision. Rounding on every write
* keeps a honey batch's remaining amount from drifting when a bottling batch
* is corrected or removed and its amount is given back.
*/
function roundKg(amount: number): number {
return Math.round(amount * 1000) / 1000;
}
const INSUFFICIENT_AMOUNT_MESSAGE =
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
/** const REMOVED_NOTE = 'Abfüllung entfernt';
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
* database - not a value read earlier - decides whether enough is left.
* A batch without a recorded amount (null) is not tracked and stays null.
*/
async function takeFromHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
const current = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (!current) {
throw new NotFoundException('Honey batch not found');
}
if (current.amount === null) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
return;
}
// Tolerance of half a gram absorbs float noise from earlier writes.
const booked = await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
data: { amount: { decrement: amount }, status: 'BOTTLED' },
});
if (booked.count === 0) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
await normalizeAmount(tx, honeyBatchId);
}
async function giveBackToHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { not: null } },
data: { amount: { increment: amount } },
});
await normalizeAmount(tx, honeyBatchId);
}
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
const row = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (row?.amount == null) return;
const normalized = Math.max(0, roundKg(row.amount));
if (normalized !== row.amount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: normalized },
});
}
}
/** /**
* Bottlings of a honey batch. Every write records its audit entry and its * Bottlings of a honey batch. Every write records its audit entry and its
@@ -119,8 +53,59 @@ export class BottlingService {
private readonly automation: AutomationService, private readonly automation: AutomationService,
private readonly stock: StockService, private readonly stock: StockService,
private readonly withdrawalCheck: WithdrawalCheckService, private readonly withdrawalCheck: WithdrawalCheckService,
private readonly ledger: HoneyBatchLedgerService,
) {} ) {}
/**
* Books a bottling's honey in the movement book of its honey batch: minus
* for a bottling taking honey, plus for giving it back. The ledger locks
* the batch and decides whether the rest is enough.
*/
private async book(
tx: Tx,
honeyBatchId: string,
deltaKg: number,
actingUserId: string,
source: { sourceBottlingBatchId: string } | { notes: string },
): Promise<void> {
await this.ledger.bookIn(tx, {
honeyBatchId,
deltaKg,
type: 'BOTTLING',
actingUserId,
...source,
});
}
/**
* Whether this bottling took honey out of the batch's ledger. A bottling
* made while the batch had no recorded amount has no BOTTLING booking of
* its own: later changes or its removal must not give back or take
* anything the ledger never lost.
*/
private async drewFromLedger(
tx: Tx,
honeyBatchId: string,
bottlingId: string,
): Promise<boolean> {
const bookings = await tx.batchMovement.count({
where: {
honeyBatchId,
sourceBottlingBatchId: bottlingId,
type: 'BOTTLING',
},
});
return bookings > 0;
}
/** A bottled batch changes its status, whether or not its amount is kept. */
private async markBottled(tx: Tx, honeyBatchId: string): Promise<void> {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
}
/** /**
* The bottling as the response body plus the withdrawal warnings of the * The bottling as the response body plus the withdrawal warnings of the
* harvests behind its honey batch. Bottling inside a window is written all * harvests behind its honey batch. Bottling inside a window is written all
@@ -130,15 +115,36 @@ export class BottlingService {
db: DbClient, db: DbClient,
honeyBatchId: string, honeyBatchId: string,
bottling: T, bottling: T,
qualityWarnings: readonly PlausibilityWarning[] = [],
) { ) {
const conflicts = await this.withdrawalCheck.conflictsForHoneyBatch( const conflicts = await this.withdrawalCheck.conflictsForHoneyBatch(
db, db,
honeyBatchId, honeyBatchId,
); );
return withWarnings( return withWarnings(bottling, [
bottling, ...qualityWarnings,
await this.withdrawalCheck.warningsFor(db, conflicts), ...(await this.withdrawalCheck.warningsFor(db, conflicts)),
); ]);
}
/**
* The quality findings for the honey batch as it stands in the caller's
* transaction: its newest water content against the rule (spec 7.8) and
* the hint for a jar size that differs from the article.
*/
private async qualityWarnings(
tx: Tx,
honeyBatchId: string,
jarWarnings: readonly PlausibilityWarning[],
): Promise<{
warnings: PlausibilityWarning[];
waterContent: number | null;
}> {
const water = await waterContentWarningsOfBatch(tx, honeyBatchId);
return {
warnings: [...(water?.warnings ?? []), ...jarWarnings],
waterContent: water?.waterContent ?? null,
};
} }
async findAll(filter: BottlingBatchFilter = {}) { async findAll(filter: BottlingBatchFilter = {}) {
@@ -174,10 +180,7 @@ export class BottlingService {
} }
/** Honey is bottled; other articles keep their stock in production batches. */ /** Honey is bottled; other articles keep their stock in production batches. */
private async assertBottledProduct( private async assertBottledProduct(productId: string, tx?: Tx) {
productId: string,
tx?: Tx,
): Promise<void> {
const db: DbClient = tx ?? this.prisma; const db: DbClient = tx ?? this.prisma;
const product = await db.product.findUnique({ const product = await db.product.findUnique({
where: { id: productId }, where: { id: productId },
@@ -190,23 +193,24 @@ export class BottlingService {
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen', 'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
); );
} }
return product;
} }
async create(dto: CreateBottlingBatchDto, actingUserId: string) { async create(dto: CreateBottlingBatchDto, actingUserId: string) {
const honeyBatch = await this.prisma.honeyBatch.findUnique({ const honeyBatch = await this.prisma.honeyBatch.findUnique({
where: { id: dto.honeyBatchId }, where: { id: dto.honeyBatchId },
select: { id: true },
}); });
if (!honeyBatch) { if (!honeyBatch) {
throw new NotFoundException('Honey batch not found'); throw new NotFoundException('Honey batch not found');
} }
if (dto.productId) { const article = dto.productId
await this.assertBottledProduct(dto.productId); ? await this.assertBottledProduct(dto.productId)
} : null;
const resolved = resolveJarAndBestBefore(dto, article);
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000); const totalAmount = roundToThousandths(
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) { (resolved.jarSize * dto.jarCount) / 1000,
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); );
}
const lotNumber = const lotNumber =
dto.lotNumber ?? dto.lotNumber ??
@@ -218,24 +222,39 @@ export class BottlingService {
} }
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
// Booked against the stored amount inside the transaction, never // The honey batch first (like update and remove, which hold the
// against the value read above: two bottlings submitted at the same // bottling and then the batch): the ledger books under this lock.
// time (double tap, two devices) must not both pass the check and if (!(await lockRowForUpdate(tx, 'HoneyBatch', dto.honeyBatchId))) {
// overwrite each other's deduction. throw new NotFoundException('Honey batch not found');
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount); }
// Under the batch lock: a measurement entered meanwhile counts.
const quality = await this.qualityWarnings(
tx,
dto.honeyBatchId,
resolved.warnings,
);
this.assertConfirmed(quality, dto);
const created = await tx.bottlingBatch.create({ const created = await tx.bottlingBatch.create({
data: { data: {
honeyBatchId: dto.honeyBatchId, honeyBatchId: dto.honeyBatchId,
productId: dto.productId, productId: dto.productId,
date: new Date(dto.date), date: new Date(dto.date),
jarSize: dto.jarSize, jarSize: resolved.jarSize,
jarCount: dto.jarCount, jarCount: dto.jarCount,
totalAmount, totalAmount,
bestBeforeDate: new Date(dto.bestBeforeDate), bestBeforeDate: new Date(resolved.bestBeforeDate),
lotNumber, lotNumber,
userId: actingUserId, userId: actingUserId,
}, },
}); });
// Booked in the ledger under the batch lock, never against a value read
// earlier: two bottlings submitted at the same time (double tap, two
// devices) must not both pass the check. A refusal rolls the bottling
// back with it.
await this.book(tx, dto.honeyBatchId, -totalAmount, actingUserId, {
sourceBottlingBatchId: created.id,
});
await this.markBottled(tx, dto.honeyBatchId);
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -257,10 +276,52 @@ export class BottlingService {
tx, tx,
created.honeyBatchId, created.honeyBatchId,
await this.findById(created.id, tx), await this.findById(created.id, tx),
quality.warnings,
); );
}); });
} }
/**
* A finding that needs a confirmation (BESTAETIGEN) stops the write with
* 409 CONFIRMATION_REQUIRED, unless the request names its code in
* `acknowledgedWarnings` AND the checked value in `acknowledgedWaterContent`:
* a confirmation belongs to the value the beekeeper saw. If the value under
* the lock differs (a new measurement meanwhile), a fresh 409 with the new
* value follows. The body carries every finding of the request, so the form
* can show them in one dialog. The idempotency interceptor frees the key on
* this error: the confirmed repeat may use the same key.
*/
private assertConfirmed(
quality: {
warnings: readonly PlausibilityWarning[];
waterContent: number | null;
},
sent: Pick<
CreateBottlingBatchDto,
'acknowledgedWarnings' | 'acknowledgedWaterContent'
>,
): void {
const acknowledged: readonly string[] = sent.acknowledgedWarnings ?? [];
const open = quality.warnings.filter(
(warning) =>
warning.severity === 'BESTAETIGEN' &&
!(
acknowledged.includes(warning.code) &&
sent.acknowledgedWaterContent !== undefined &&
sent.acknowledgedWaterContent === quality.waterContent
),
);
if (open.length > 0) {
throw new ConflictException({
statusCode: 409,
code: 'CONFIRMATION_REQUIRED',
message: open.map((warning) => warning.message).join(' '),
waterContent: quality.waterContent,
warnings: quality.warnings,
});
}
}
/** /**
* Corrects any field of an existing bottling batch. Changing the honey * Corrects any field of an existing bottling batch. Changing the honey
* batch, the jar size or the jar count also rebalances the remaining amount * batch, the jar size or the jar count also rebalances the remaining amount
@@ -279,49 +340,63 @@ export class BottlingService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'BottlingBatch', id); await lockRowForUpdate(tx, 'BottlingBatch', id);
const before = await this.findById(id, tx); const before = await this.findById(id, tx);
if (dto.productId) { const article = dto.productId
await this.assertBottledProduct(dto.productId, tx); ? await this.assertBottledProduct(dto.productId, tx)
} : dto.productId === undefined && before.productId
? await tx.product.findUnique({ where: { id: before.productId } })
: null;
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId; const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId; const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
const jarSize = dto.jarSize ?? before.jarSize; const jarSize = dto.jarSize ?? before.jarSize;
const jarCount = dto.jarCount ?? before.jarCount; const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000); const totalAmount = roundToThousandths((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch if (movedToOtherHoneyBatch) {
? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } }) // Both batches in one statement in ascending id order, so two
: before.honeyBatch; // bottlings moving the other way cannot wait for each other.
if (!targetHoneyBatch) { await lockRowsForUpdate(tx, 'HoneyBatch', [
throw new NotFoundException('Honey batch not found'); before.honeyBatchId,
} honeyBatchId,
]);
// Early, friendly check; the booking below is still conditional on const target = await tx.honeyBatch.findUnique({
// the stored amount (takeFromHoneyBatch). where: { id: honeyBatchId },
const availableOnTarget = select: { id: true },
targetHoneyBatch.amount === null });
? null if (!target) {
: roundKg( throw new NotFoundException('Honey batch not found');
targetHoneyBatch.amount + }
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
} }
const amountChanged = const amountChanged =
movedToOtherHoneyBatch || totalAmount !== before.totalAmount; movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
if (amountChanged) { const tracked = await this.drewFromLedger(tx, before.honeyBatchId, id);
// Give the previous amount back first, then take the corrected one -
// a correction never double-charges the source batch.
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
}
if (movedToOtherHoneyBatch) { if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({ // Give the previous amount back first (only if it was taken), then
where: { id: honeyBatchId }, // take the corrected one - a correction never double-charges.
data: { status: 'BOTTLED' }, if (tracked) {
await this.book(
tx,
before.honeyBatchId,
before.totalAmount,
actingUserId,
{ sourceBottlingBatchId: id },
);
}
await this.book(tx, honeyBatchId, -totalAmount, actingUserId, {
sourceBottlingBatchId: id,
}); });
} else if (amountChanged && tracked) {
await this.book(
tx,
honeyBatchId,
roundToThousandths(before.totalAmount - totalAmount),
actingUserId,
{ sourceBottlingBatchId: id },
);
}
if (amountChanged) {
await this.markBottled(tx, honeyBatchId);
} }
await tx.bottlingBatch.update({ await tx.bottlingBatch.update({
where: { id }, where: { id },
@@ -340,6 +415,20 @@ export class BottlingService {
}); });
const bottlingBatch = await this.findById(id, tx); const bottlingBatch = await this.findById(id, tx);
// A correction only reports (spec 7.8): the confirmation belongs to
// the creation.
// Only when the correction touches what the findings are about.
const qualityChecked =
movedToOtherHoneyBatch || jarSize !== before.jarSize;
const quality = qualityChecked
? (
await this.qualityWarnings(
tx,
honeyBatchId,
jarSizeWarnings(jarSize, article),
)
).warnings
: [];
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -360,7 +449,12 @@ export class BottlingService {
payload: { honeyBatchId, removed: false }, payload: { honeyBatchId, removed: false },
actorUserId: actingUserId, actorUserId: actingUserId,
}); });
return this.withWithdrawalWarnings(tx, honeyBatchId, bottlingBatch); return this.withWithdrawalWarnings(
tx,
honeyBatchId,
bottlingBatch,
quality,
);
}); });
} }
@@ -387,8 +481,18 @@ export class BottlingService {
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen', 'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
); );
} }
// Booked first, while the bottling still exists; deleting it then
// clears the reference of the booking (ON DELETE SET NULL).
if (await this.drewFromLedger(tx, before.honeyBatchId, id)) {
await this.book(
tx,
before.honeyBatchId,
before.totalAmount,
actingUserId,
{ notes: REMOVED_NOTE },
);
}
await tx.bottlingBatch.delete({ where: { id } }); await tx.bottlingBatch.delete({ where: { id } });
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsISO8601,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
const MAX_ID_LENGTH = 64;
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
export class BottlingPrefillQuery {
@ApiProperty({ description: 'Die Honigcharge, die abgefüllt wird' })
@IsString()
@MaxLength(MAX_ID_LENGTH)
@Matches(ID_PATTERN)
honeyBatchId!: string;
@ApiPropertyOptional({ description: 'Der Artikel (Honig-Artikel)' })
@IsOptional()
@IsString()
@MaxLength(MAX_ID_LENGTH)
@Matches(ID_PATTERN)
productId?: string;
@ApiPropertyOptional({
description: 'Abfülldatum (Standard: heute), Grundlage für das MHD',
})
@IsOptional()
@IsISO8601({ strict: true })
date?: string;
}
@@ -1,6 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
Max,
Min,
ArrayMaxSize,
ArrayUnique,
IsArray,
IsDateString, IsDateString,
IsIn,
IsInt, IsInt,
IsNumber, IsNumber,
IsOptional, IsOptional,
@@ -8,6 +14,12 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
export const BOTTLING_ACKNOWLEDGEABLE_WARNINGS = [
'WATER_CONTENT_ABOVE_LEGAL',
] as const;
export type AcknowledgeableWarning =
(typeof BOTTLING_ACKNOWLEDGEABLE_WARNINGS)[number];
export class CreateBottlingBatchDto { export class CreateBottlingBatchDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -19,22 +31,30 @@ export class CreateBottlingBatchDto {
productId?: string; productId?: string;
@ApiProperty() @ApiProperty()
@IsDateString() @IsDateString({ strict: true })
date!: string; date!: string;
@ApiProperty({ description: 'Glasgröße in Gramm' }) @ApiPropertyOptional({
description:
'Glasgröße in Gramm. Fehlt sie, gilt die Packungsgröße des Artikels.',
})
@IsOptional()
@IsNumber() @IsNumber()
@IsPositive() @IsPositive()
jarSize!: number; jarSize?: number;
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@IsPositive() @IsPositive()
jarCount!: number; jarCount!: number;
@ApiProperty() @ApiPropertyOptional({
@IsDateString() description:
bestBeforeDate!: string; 'MHD. Fehlt es, gilt Abfülldatum plus Haltbarkeit des Artikels (Monatsende abgeschnitten).',
})
@IsOptional()
@IsDateString({ strict: true })
bestBeforeDate?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
@@ -43,4 +63,27 @@ export class CreateBottlingBatchDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
lotNumber?: string; lotNumber?: string;
@ApiPropertyOptional({
enum: BOTTLING_ACKNOWLEDGEABLE_WARNINGS,
isArray: true,
description:
'Bestätigte Warnungen. Über der gesetzlichen Wassergehaltsgrenze nötig, sonst 409 CONFIRMATION_REQUIRED.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(1)
@ArrayUnique()
@IsIn(BOTTLING_ACKNOWLEDGEABLE_WARNINGS, { each: true })
acknowledgedWarnings?: AcknowledgeableWarning[];
@ApiPropertyOptional({
description:
'Der Wassergehalt (%), den die Bestätigung meint: der Wert aus der 409-Antwort. Weicht der geprüfte Wert ab, folgt eine neue 409.',
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
acknowledgedWaterContent?: number;
} }
@@ -1,8 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module';
import { ColoniesController } from './colonies.controller'; import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service'; import { ColoniesService } from './colonies.service';
// ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the
// colony paths that enforce them (AppModule imports ColoniesModule).
@Module({ @Module({
imports: [ApiaryRestrictionsModule],
controllers: [ColoniesController], controllers: [ColoniesController],
providers: [ColoniesService], providers: [ColoniesService],
exports: [ColoniesService], exports: [ColoniesService],
@@ -4,7 +4,9 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { berlinToday } from '../common/dates'; import { berlinToday } from '../common/dates';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
describe('ColoniesService', () => { describe('ColoniesService', () => {
let service: ColoniesService; let service: ColoniesService;
@@ -15,6 +17,8 @@ describe('ColoniesService', () => {
colonyLineage: { create: jest.Mock }; colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock }; queenColonyHistory: { updateMany: jest.Mock };
suggestion: { updateMany: jest.Mock }; suggestion: { updateMany: jest.Mock };
apiary: { findUnique: jest.Mock };
apiaryRestriction: { findMany: jest.Mock };
}; };
let prisma: { let prisma: {
colony: { colony: {
@@ -31,6 +35,7 @@ describe('ColoniesService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let events: { record: jest.Mock; recordAll: jest.Mock }; let events: { record: jest.Mock; recordAll: jest.Mock };
let resolver: { getPermissions: jest.Mock };
const parentColony = { const parentColony = {
id: 'colony-1', id: 'colony-1',
@@ -48,10 +53,12 @@ describe('ColoniesService', () => {
colonyLineage: { create: jest.fn() }, colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() }, queenColonyHistory: { updateMany: jest.fn() },
suggestion: { updateMany: jest.fn() }, suggestion: { updateMany: jest.fn() },
apiary: { findUnique: jest.fn() },
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
}; };
prisma = { prisma = {
colony: { colony: {
findMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(), findUnique: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
@@ -68,6 +75,10 @@ describe('ColoniesService', () => {
callback(tx), callback(tx),
), ),
}; };
// The apiary is read inside the transaction now: same answers as before.
tx.apiary.findUnique.mockImplementation(
(args: unknown) => prisma.apiary.findUnique(args) as Promise<unknown>,
);
// The locked re-read sees what the pre-read saw unless a test says otherwise. // The locked re-read sees what the pre-read saw unless a test says otherwise.
tx.colony.findUnique.mockImplementation( tx.colony.findUnique.mockImplementation(
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>, (args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
@@ -78,11 +89,17 @@ describe('ColoniesService', () => {
record: jest.fn().mockResolvedValue(undefined), record: jest.fn().mockResolvedValue(undefined),
recordAll: jest.fn().mockResolvedValue(0), recordAll: jest.fn().mockResolvedValue(0),
}; };
resolver = {
getPermissions: jest
.fn()
.mockResolvedValue(new Set(['compliance:manage'])),
};
service = new ColoniesService( service = new ColoniesService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService, webhooks as unknown as WebhooksService,
events as unknown as DomainEventsService, events as unknown as DomainEventsService,
resolver as unknown as PermissionResolverService,
); );
}); });
@@ -194,9 +211,12 @@ describe('ColoniesService', () => {
'user-1', 'user-1',
); );
const [[, event]] = events.record.mock.calls as [ const event = (
[unknown, { payload: { queenSource: unknown; splitOn: string } }], events.record.mock.calls as [
]; unknown,
{ type: string; payload: { queenSource: unknown; splitOn: string } },
][]
).find(([, recorded]) => recorded.type === 'ColonySplit')![1];
expect(event.payload.queenSource).toBeNull(); expect(event.payload.queenSource).toBeNull();
expect(event.payload.splitOn).toBe(berlinToday()); expect(event.payload.splitOn).toBe(berlinToday());
}); });
@@ -233,17 +253,23 @@ describe('ColoniesService', () => {
}); });
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => { it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique prisma.colony.findUnique.mockResolvedValue(parentColony);
.mockResolvedValueOnce(parentColony) prisma.colony.findMany.mockResolvedValue([
.mockResolvedValueOnce({ { id: 'colony-3', apiaryId: 'apiary-1' },
...parentColony, ]);
id: 'colony-1', tx.colony.findUnique.mockImplementation(
}); ({ where }: { where: { id: string } }) =>
tx.colony.findUnique.mockResolvedValue({ Promise.resolve(
id: 'colony-3', where.id === 'colony-1'
status: 'ACTIVE', ? parentColony
currentQueenId: 'queen-2', : {
}); id: 'colony-3',
apiaryId: 'apiary-1',
status: 'ACTIVE',
currentQueenId: 'queen-2',
},
),
);
tx.colony.update.mockResolvedValue({ tx.colony.update.mockResolvedValue({
id: 'colony-3', id: 'colony-3',
status: 'MERGED', status: 'MERGED',
@@ -514,6 +540,110 @@ describe('ColoniesService', () => {
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled(); expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
}); });
describe('herd events', () => {
// Whatever a test recorded, the herd payloads carry only ids.
afterEach(() => {
expectOnlyIdPayloads(events.record.mock.calls);
});
function recorded(type: string): unknown[] {
return (events.record.mock.calls as [unknown, { type: string }][]).filter(
([, event]) => event.type === type,
);
}
it('create records ColonyCreated with the apiary inside the transaction', async () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
await service.create(
{ code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' },
'user-1',
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: 'colony-9',
actorUserId: 'user-1',
payload: { apiaryId: 'apiary-1' },
});
});
it('split records ColonyCreated for the child next to ColonySplit', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({
id: 'colony-2',
apiaryId: 'apiary-1',
});
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
const types = (
events.record.mock.calls as [unknown, { type: string }][]
).map(([, event]) => event.type);
expect(types).toEqual(
expect.arrayContaining(['ColonySplit', 'ColonyCreated']),
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: 'colony-2',
actorUserId: 'user-1',
payload: { apiaryId: 'apiary-1' },
});
});
it('move records ColonyMoved with from and to', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyMoved',
aggregateType: 'Colony',
aggregateId: 'colony-1',
actorUserId: 'user-1',
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
});
});
it('update with a changed apiary records ColonyMoved and without one records none', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
await service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(recorded('ColonyMoved')).toHaveLength(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'ColonyMoved',
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
}),
);
events.record.mockClear();
tx.colony.update.mockResolvedValue(parentColony);
await service.update('colony-1', { notes: 'x' }, 'user-1');
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
expect(recorded('ColonyMoved')).toHaveLength(0);
});
});
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => { it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => {
prisma.colony.findUnique.mockResolvedValue({ prisma.colony.findUnique.mockResolvedValue({
...parentColony, ...parentColony,
@@ -617,10 +747,15 @@ describe('ColoniesService', () => {
it('merge records ColonyEnded "MERGED" for every merged colony', async () => { it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
{ id: 'colony-4', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation( tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) => ({ where }: { where: { id: string } }) =>
Promise.resolve({ Promise.resolve({
id: where.id, id: where.id,
apiaryId: 'apiary-1',
status: 'ACTIVE', status: 'ACTIVE',
healthStatus: null, healthStatus: null,
currentQueenId: null, currentQueenId: null,
@@ -701,7 +836,8 @@ describe('ColoniesService', () => {
}); });
await service.reactivate('colony-1', 'user-1'); await service.reactivate('colony-1', 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(4); // update 1, move 2 (apiary share + colony), dissolve 1, reactivate 1.
expect(tx.$queryRaw).toHaveBeenCalledTimes(5);
}); });
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => { it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
@@ -752,9 +888,18 @@ describe('ColoniesService', () => {
it('locks every merged colony', async () => { it('locks every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
{ id: 'colony-4', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation( tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) => ({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }), Promise.resolve({
id: where.id,
apiaryId: 'apiary-1',
status: 'ACTIVE',
healthStatus: null,
}),
); );
tx.colony.update.mockImplementation( tx.colony.update.mockImplementation(
({ where }: { where: { id: string } }) => ({ where }: { where: { id: string } }) =>
@@ -767,10 +912,12 @@ describe('ColoniesService', () => {
'user-1', 'user-1',
); );
// One multi-row statement, not one per colony. // The apiary rows (share), then ONE multi-row statement for the colonies.
expect(tx.$queryRaw).toHaveBeenCalledTimes(1); expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]]; const [, [query]] = tx.$queryRaw.mock.calls as [unknown, [{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE'); expect(query.sql).toContain(
'"Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
);
}); });
describe('a colony that ends through an edit', () => { describe('a colony that ends through an edit', () => {
@@ -824,4 +971,521 @@ describe('ColoniesService', () => {
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
}); });
}); });
describe('restricted zone', () => {
const reason = 'Amtstierarzt hat die Verbringung genehmigt';
const reloadMessage = 'Der Stand wurde gerade geändert. Bitte neu laden.';
const startedLongAgo = new Date('2000-01-01T00:00:00.000Z');
// An open restriction on each of the given apiaries.
function restrict(...apiaryIds: string[]): void {
tx.apiaryRestriction.findMany.mockImplementation(
({ where }: { where: { apiaryId: { in: string[] } } }) =>
Promise.resolve(
apiaryIds
.filter((apiaryId) => where.apiaryId.in.includes(apiaryId))
.map((apiaryId) => ({
id: `r-${apiaryId}`,
apiaryId,
apiary: { name: `Stand ${apiaryId}` },
disease: 'Faulbrut',
startsOn: startedLongAgo,
endsOn: null,
})),
),
);
}
function lockSql(): string[] {
return (tx.$queryRaw.mock.calls as [{ sql: string }][]).map(
([query]) => query.sql,
);
}
function setupMove(): void {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
}
function setupMerge(mergedApiaryId = 'apiary-2'): void {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: mergedApiaryId },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: {
id: 'colony-3',
apiaryId: mergedApiaryId,
status: 'ACTIVE',
currentQueenId: null,
},
),
);
tx.colony.update.mockResolvedValue({
id: 'colony-3',
status: 'MERGED',
healthStatus: null,
});
}
const createDto = {
code: 'V-9',
apiaryId: 'apiary-1',
colonyType: 'PRODUCTION_COLONY' as const,
};
it('split inside the apiary is allowed', async () => {
restrict('apiary-1');
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({
id: 'colony-2',
apiaryId: 'apiary-1',
});
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('create in a restricted apiary is refused', async () => {
restrict('apiary-1');
await expect(service.create(createDto, 'user-1')).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it.each([
['into', 'apiary-2'],
['out of', 'apiary-1'],
])('move %s a restricted apiary is refused', async (_way, restricted) => {
setupMove();
restrict(restricted);
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('move to the same apiary moves nothing and checks nothing', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
restrict('apiary-1');
await expect(
service.move('colony-1', { apiaryId: 'apiary-1' }, 'user-1'),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it.each([
['the old apiary', 'apiary-1'],
['the new apiary', 'apiary-2'],
])('update with a changed apiary checks %s', async (_which, restricted) => {
setupMove();
restrict(restricted);
await expect(
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('a name change checks nothing and takes no apiary lock', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
restrict('apiary-1', 'apiary-2');
await service.update('colony-1', { code: 'V-neu' }, 'user-1');
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
expect(lockSql().every((sql) => !sql.includes('"Apiary"'))).toBe(true);
});
it('merge across apiaries is refused when one is restricted', async () => {
setupMerge('apiary-2');
restrict('apiary-2');
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyLineage.create).not.toHaveBeenCalled();
});
it('merge within one apiary is allowed even in a restricted apiary', async () => {
setupMerge('apiary-1');
restrict('apiary-1');
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
const scenarios: Record<
string,
{
run: (restrictionOverrideReason: string) => Promise<unknown>;
setup: () => void;
restricted: string[];
}
> = {
create: {
setup: () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
},
restricted: ['apiary-1'],
run: (restrictionOverrideReason) =>
service.create({ ...createDto, restrictionOverrideReason }, 'user-1'),
},
update: {
setup: setupMove,
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.update(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason },
'user-1',
),
},
move: {
setup: setupMove,
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason },
'user-1',
),
},
merge: {
setup: () => setupMerge('apiary-2'),
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3'], restrictionOverrideReason },
'user-1',
),
},
};
it.each(Object.keys(scenarios))(
'the override reason is never part of the data written to the colony (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
restrict(...scenario.restricted);
await scenario.run(reason);
const written = JSON.stringify([
tx.colony.create.mock.calls,
tx.colony.update.mock.calls,
]);
expect(written).not.toContain(reason);
expect(written).not.toContain('restrictionOverrideReason');
},
);
it.each(Object.keys(scenarios))(
'an override writes the reason and the restriction ids into the audit entry (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
restrict(...scenario.restricted);
await scenario.run(reason);
const audits = (
auditLog.record.mock.calls as [
{ afterData: { restrictionOverride?: unknown } },
][]
).filter(([entry]) => entry.afterData.restrictionOverride);
expect(audits).toHaveLength(1);
expect(audits[0][0].afterData.restrictionOverride).toEqual({
reason,
restrictionIds: [`r-${scenario.restricted[0]}`],
});
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
},
);
it('an override writes the reason into ColonyApiaryHistory.notes, in front of an existing note', async () => {
setupMove();
restrict('apiary-2');
await service.move(
'colony-1',
{
apiaryId: 'apiary-2',
notes: 'Wanderung',
restrictionOverrideReason: reason,
},
'user-1',
);
await service.update(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
);
const notes = (
tx.colonyApiaryHistory.create.mock.calls as [
{ data: { notes: string } },
][]
).map(([call]) => call.data.notes);
expect(notes).toEqual([
`Ausnahme Sperrbezirk: ${reason}\nWanderung`,
`Ausnahme Sperrbezirk: ${reason}\nKorrektur über Volk bearbeiten`,
]);
});
it('an override without compliance:manage answers 403 and writes nothing', async () => {
setupMove();
restrict('apiary-2');
resolver.getPermissions.mockResolvedValue(new Set(['colonies:update']));
await expect(
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
),
).rejects.toMatchObject({ status: 403 });
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('a reason without an active restriction is ignored for a user with the right: no note, no audit field', async () => {
setupMove();
await service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
);
expect(
JSON.stringify(tx.colonyApiaryHistory.create.mock.calls),
).not.toContain('Ausnahme');
expect(JSON.stringify(auditLog.record.mock.calls)).not.toContain(
'restrictionOverride',
);
});
it.each(Object.keys(scenarios))(
'a reason without compliance:manage answers 403 up front, even without any restriction (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
resolver.getPermissions.mockResolvedValue(new Set());
await expect(scenario.run(reason)).rejects.toMatchObject({
status: 403,
});
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colony.create).not.toHaveBeenCalled();
},
);
it('merge answers 404, not "moved meanwhile", when a merged colony vanished between read and lock', async () => {
setupMerge('apiary-2');
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(where.id === 'colony-1' ? parentColony : null),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 404 });
});
it('a null reason counts as omitted and asks for no permission', async () => {
setupMove();
restrict('apiary-2');
await expect(
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: null },
'user-1',
),
).rejects.toMatchObject({ status: 409 });
expect(resolver.getPermissions).not.toHaveBeenCalled();
});
it('the restriction check runs inside the transaction after the apiary rows (share, ascending) and then the colony row are locked', async () => {
setupMove();
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
]);
const [[shareQuery]] = tx.$queryRaw.mock.calls as [
[{ values: unknown[] }],
];
expect(shareQuery.values).toEqual([['apiary-1', 'apiary-2']]);
const order = [
tx.$queryRaw.mock.invocationCallOrder[0],
tx.$queryRaw.mock.invocationCallOrder[1],
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
tx.colony.update.mock.invocationCallOrder[0],
];
expect(order).toEqual([...order].sort((a, b) => a - b));
expect(tx.apiary.findUnique.mock.invocationCallOrder[0]).toBeGreaterThan(
tx.$queryRaw.mock.invocationCallOrder[0],
);
});
it('create locks the apiary (share) before it checks and writes', async () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
await service.create(createDto, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
]);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
);
expect(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
).toBeLessThan(tx.colony.create.mock.invocationCallOrder[0]);
});
it('merge locks the apiaries (share), then survivor and merged colonies in one statement', async () => {
setupMerge('apiary-2');
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3'] },
'user-1',
);
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
]);
expect(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
).toBeGreaterThan(tx.$queryRaw.mock.invocationCallOrder[1]);
});
it('update, move and merge answer 409 when the colony changed its apiary between the unlocked read and the lock', async () => {
const movedAway = { ...parentColony, apiaryId: 'apiary-3' };
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue(movedAway);
await expect(
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
setupMerge('apiary-2');
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: { id: 'colony-3', apiaryId: 'apiary-3', status: 'ACTIVE' },
),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? movedAway
: { id: 'colony-3', apiaryId: 'apiary-2', status: 'ACTIVE' },
),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('an update without an apiary change still works when the colony was moved meanwhile', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-3',
});
tx.colony.update.mockResolvedValue(parentColony);
await expect(
service.update('colony-1', { notes: 'x' }, 'user-1'),
).resolves.toBeDefined();
});
it('updateIn called from an inspection takeover locks no apiary', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.updateIn(tx as never, 'colony-1', { notes: 'x' }, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
]);
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('updateIn refuses to move the apiary without the locks update() takes', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
await expect(
service.updateIn(
tx as never,
'colony-1',
{ apiaryId: 'apiary-2' },
'user-1',
),
).rejects.toThrow('moving the apiary');
expect(tx.colony.update).not.toHaveBeenCalled();
});
});
}); });
+243 -36
View File
@@ -1,6 +1,7 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -8,6 +9,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { colonyCreatedEvent } from './colony-created-event';
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma'; import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto'; import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto';
@@ -19,9 +22,51 @@ import {
isLivingColonyStatus, isLivingColonyStatus,
TERMINAL_COLONY_STATUSES, TERMINAL_COLONY_STATUSES,
} from './colony-status'; } from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock'; import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events'; import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates'; import { berlinToday, dayToDate } from '../common/dates';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
assertNoActiveRestriction,
RestrictionCheck,
RestrictionOverride,
} from '../compliance/restricted-zone';
import {
apiaryChangedMeanwhile,
NOT_OVERRIDDEN,
overrideNote,
overrideTrace,
} from './restriction-override';
function colonyMovedEvent(
colonyId: string,
fromApiaryId: string,
toApiaryId: string,
actorUserId: string,
): AnyNewDomainEvent<'ColonyMoved'> {
return {
type: 'ColonyMoved',
aggregateType: 'Colony',
aggregateId: colonyId,
actorUserId,
payload: { fromApiaryId, toApiaryId },
};
}
/** The fields update() writes: the override reason is read, never stored. */
type ColonyPatch = Omit<UpdateColonyDto, 'restrictionOverrideReason'>;
/**
* What update() hands to updateIn when it moves the colony to another apiary:
* the apiary the unlocked read saw (the apiary rows were locked for it) and
* the override the user may use.
*/
export interface ApiaryMoveGuard {
seenApiaryId: string;
override: RestrictionOverride;
}
const OVERRIDE_PERMISSION = 'compliance:manage';
export interface ColonyFilter { export interface ColonyFilter {
status?: ColonyStatus; status?: ColonyStatus;
@@ -35,6 +80,7 @@ export class ColoniesService {
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService, private readonly events: DomainEventsService,
private readonly permissions: PermissionResolverService,
) {} ) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> { async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -67,11 +113,14 @@ export class ColoniesService {
// this, a colony could be created or moved into an apiary that either // this, a colony could be created or moved into an apiary that either
// doesn't exist (a raw Prisma FK-constraint error instead of a clean // doesn't exist (a raw Prisma FK-constraint error instead of a clean
// 404) or is deactivated (silently orphaning the colony the same way an // 404) or is deactivated (silently orphaning the colony the same way an
// apiary deactivated out from under an existing colony would). // apiary deactivated out from under an existing colony would). Read inside
private async assertApiaryActive(apiaryId: string): Promise<void> { // the transaction, after the apiary rows are locked (FOR SHARE): the
const apiary = await this.prisma.apiary.findUnique({ // apiary cannot be deactivated before this colony path commits.
where: { id: apiaryId }, private async assertApiaryActiveIn(
}); tx: Prisma.TransactionClient,
apiaryId: string,
): Promise<void> {
const apiary = await tx.apiary.findUnique({ where: { id: apiaryId } });
if (!apiary) { if (!apiary) {
throw new NotFoundException('Bienenstand nicht gefunden'); throw new NotFoundException('Bienenstand nicht gefunden');
} }
@@ -82,24 +131,56 @@ export class ColoniesService {
} }
} }
// Asked once per request, and only when a reason was sent (null counts as
// omitted): the right to override is compliance:manage.
private async overrideOf(
reason: string | null | undefined,
actingUserId: string,
): Promise<RestrictionOverride> {
if (!reason) {
return { reason: null, canOverride: false };
}
const held = await this.permissions.getPermissions(actingUserId);
// Spec 5.6: a reason without compliance:manage is a 403 - always, also
// when no restriction is active. Checked before anything is read or locked.
if (!held.has(OVERRIDE_PERMISSION)) {
throw new ForbiddenException(
'Eine Begründung für eine Ausnahme vom Sperrbezirk darf nur mit dem Recht Prüfbereitschaft verwalten (compliance:manage) angegeben werden.',
);
}
return { reason, canOverride: true };
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> { async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId); const { restrictionOverrideReason, ...data } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const colony = await this.prisma.$transaction(async (tx) => { const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } }); await lockRowsForShare(tx, 'Apiary', [data.apiaryId]);
await this.assertApiaryActiveIn(tx, data.apiaryId);
const check = await assertNoActiveRestriction(
tx,
[data.apiaryId],
berlinToday(),
override,
);
const created = await tx.colony.create({ data });
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'Colony', entityType: 'Colony',
entityId: created.id, entityId: created.id,
action: 'CREATE', action: 'CREATE',
userId: actingUserId, userId: actingUserId,
afterData: created, afterData: { ...created, ...overrideTrace(check, override.reason) },
}, },
tx, tx,
); );
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
return created; return created;
}); });
// No domain event for a new colony in this stage - the webhook stays on // The webhook stays on the direct path (the event is not forwarded).
// the direct path.
await this.webhooks.dispatch('colony.created', colony); await this.webhooks.dispatch('colony.created', colony);
return colony; return colony;
} }
@@ -109,9 +190,25 @@ export class ColoniesService {
dto: UpdateColonyDto, dto: UpdateColonyDto,
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
const { after } = await this.prisma.$transaction((tx) => const { restrictionOverrideReason, ...patch } = dto;
this.updateIn(tx, id, dto, actingUserId), const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
); );
const seen = await this.findById(id);
// Only a change of the apiary involves apiaries: the old and the new one
// are locked first (FOR SHARE, ascending), then the colony (Task 1 order).
const movesApiary =
patch.apiaryId !== undefined && patch.apiaryId !== seen.apiaryId;
const { after } = await this.prisma.$transaction(async (tx) => {
if (movesApiary) {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, patch.apiaryId!]);
}
return this.updateIn(tx, id, patch, actingUserId, {
seenApiaryId: seen.apiaryId,
override,
});
});
return after; return after;
} }
@@ -120,22 +217,44 @@ export class ColoniesService {
* inspection handing values over to the colony card). Locks the colony row * inspection handing values over to the colony card). Locks the colony row
* and reads "before" only afterwards (404 when missing); writes the apiary * and reads "before" only afterwards (404 when missing); writes the apiary
* history, the status events and the audit entry with the change. * history, the status events and the audit entry with the change.
*
* It locks no apiary itself. A patch that moves the colony to another
* apiary needs `guard`: update() has then locked both apiaries (FOR SHARE)
* before the colony, so the restriction check and the write cannot be
* overtaken by a restriction or a deactivation. The inspection takeover
* passes no apiary and no guard.
*/ */
async updateIn( async updateIn(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
id: string, id: string,
dto: UpdateColonyDto, dto: ColonyPatch,
actingUserId: string, actingUserId: string,
guard?: ApiaryMoveGuard,
): Promise<{ before: Colony; after: Colony }> { ): Promise<{ before: Colony; after: Colony }> {
const before = await lockColony(tx, id); const before = await lockColony(tx, id);
const today = berlinToday();
const movesApiary = const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
let check: RestrictionCheck = NOT_OVERRIDDEN;
if (movesApiary) { if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!); if (!guard) {
throw new Error(
'ColoniesService.updateIn: moving the apiary needs update()',
);
}
if (before.apiaryId !== guard.seenApiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, dto.apiaryId!);
check = await assertNoActiveRestriction(
tx,
[before.apiaryId, dto.apiaryId!],
today,
guard.override,
);
} }
// An edit into an ended status ends the queen assignment like dissolve // An edit into an ended status ends the queen assignment like dissolve
// does, before the status is written (the pointer is read under the lock). // does, before the status is written (the pointer is read under the lock).
const today = berlinToday();
const endsColony = const endsColony =
dto.status !== undefined && dto.status !== undefined &&
TERMINAL_COLONY_STATUSES.includes(dto.status) && TERMINAL_COLONY_STATUSES.includes(dto.status) &&
@@ -156,9 +275,17 @@ export class ColoniesService {
colonyId: id, colonyId: id,
apiaryId: updated.apiaryId, apiaryId: updated.apiaryId,
movedByUserId: actingUserId, movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten', notes: overrideNote(
check,
guard?.override.reason,
'Korrektur über Volk bearbeiten',
),
}, },
}); });
await this.events.record(
tx,
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
);
} }
await this.events.recordAll( await this.events.recordAll(
tx, tx,
@@ -175,7 +302,10 @@ export class ColoniesService {
action: 'UPDATE', action: 'UPDATE',
userId: actingUserId, userId: actingUserId,
beforeData: before, beforeData: before,
afterData: updated, afterData: {
...updated,
...overrideTrace(check, guard?.override.reason),
},
}, },
tx, tx,
); );
@@ -187,23 +317,49 @@ export class ColoniesService {
dto: MoveColonyDto, dto: MoveColonyDto,
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
this.assertActive(await this.findById(id)); const { restrictionOverrideReason, ...data } = dto;
await this.assertApiaryActive(dto.apiaryId); const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const seen = await this.findById(id);
this.assertActive(seen);
const colony = await this.prisma.$transaction(async (tx) => { const colony = await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, data.apiaryId]);
const before = await lockColony(tx, id); const before = await lockColony(tx, id);
this.assertActive(before); this.assertActive(before);
if (before.apiaryId !== seen.apiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, data.apiaryId);
// A "move" within one apiary leaves the herd where it is.
const check =
before.apiaryId === data.apiaryId
? NOT_OVERRIDDEN
: await assertNoActiveRestriction(
tx,
[before.apiaryId, data.apiaryId],
berlinToday(),
override,
);
const updated = await tx.colony.update({ const updated = await tx.colony.update({
where: { id }, where: { id },
data: { apiaryId: dto.apiaryId }, data: { apiaryId: data.apiaryId },
}); });
await tx.colonyApiaryHistory.create({ await tx.colonyApiaryHistory.create({
data: { data: {
colonyId: id, colonyId: id,
apiaryId: dto.apiaryId, apiaryId: data.apiaryId,
movedByUserId: actingUserId, movedByUserId: actingUserId,
notes: dto.notes, notes: overrideNote(check, override.reason, data.notes),
}, },
}); });
if (before.apiaryId !== updated.apiaryId) {
await this.events.record(
tx,
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
);
}
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'Colony', entityType: 'Colony',
@@ -211,7 +367,7 @@ export class ColoniesService {
action: 'UPDATE', action: 'UPDATE',
userId: actingUserId, userId: actingUserId,
beforeData: before, beforeData: before,
afterData: updated, afterData: { ...updated, ...overrideTrace(check, override.reason) },
}, },
tx, tx,
); );
@@ -263,6 +419,7 @@ export class ColoniesService {
}, },
tx, tx,
); );
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
await this.events.record(tx, { await this.events.record(tx, {
type: 'ColonySplit', type: 'ColonySplit',
aggregateType: 'Colony', aggregateType: 'Colony',
@@ -285,24 +442,54 @@ export class ColoniesService {
dto: MergeColonyDto, dto: MergeColonyDto,
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
const { restrictionOverrideReason, ...data } = dto;
const survivor = await this.findById(id); const survivor = await this.findById(id);
this.assertActive(survivor); this.assertActive(survivor);
if (dto.mergedColonyIds.includes(survivor.id)) { if (data.mergedColonyIds.includes(survivor.id)) {
throw new BadRequestException( throw new BadRequestException(
'Ein Volk kann nicht mit sich selbst vereinigt werden', 'Ein Volk kann nicht mit sich selbst vereinigt werden',
); );
} }
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) { if (new Set(data.mergedColonyIds).size !== data.mergedColonyIds.length) {
throw new BadRequestException( throw new BadRequestException(
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden', 'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
); );
} }
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const today = berlinToday(); const today = berlinToday();
// The apiaries of the colonies as the unlocked read saw them: they are
// locked first, and compared with the locked rows afterwards.
const seenApiaries = new Map(
(
await this.prisma.colony.findMany({
where: { id: { in: data.mergedColonyIds } },
select: { id: true, apiaryId: true },
})
).map((colony) => [colony.id, colony.apiaryId]),
);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [
...new Set([survivor.apiaryId, ...seenApiaries.values()]),
]);
// One statement in id order, the same order bulk lockRowsForShare // One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write. // uses: no deadlock with a concurrent bulk write.
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds); await lockRowsForUpdate(tx, 'Colony', [
for (const mergedColonyId of dto.mergedColonyIds) { survivor.id,
...data.mergedColonyIds,
]);
const lockedSurvivor = await tx.colony.findUnique({ where: { id } });
if (!lockedSurvivor) {
throw new NotFoundException('Colony not found');
}
this.assertActive(lockedSurvivor);
if (lockedSurvivor.apiaryId !== survivor.apiaryId) {
throw apiaryChangedMeanwhile();
}
const mergedRows: Colony[] = [];
for (const mergedColonyId of data.mergedColonyIds) {
const merged = await tx.colony.findUnique({ const merged = await tx.colony.findUnique({
where: { id: mergedColonyId }, where: { id: mergedColonyId },
}); });
@@ -312,6 +499,22 @@ export class ColoniesService {
); );
} }
this.assertActive(merged); this.assertActive(merged);
if (seenApiaries.get(merged.id) !== merged.apiaryId) {
throw apiaryChangedMeanwhile();
}
mergedRows.push(merged);
}
// Uniting colonies of different apiaries brings bees from one stand to
// the other; within one apiary nothing leaves it.
const apiaryIds = new Set([
lockedSurvivor.apiaryId,
...mergedRows.map((merged) => merged.apiaryId),
]);
const check =
apiaryIds.size > 1
? await assertNoActiveRestriction(tx, [...apiaryIds], today, override)
: NOT_OVERRIDDEN;
for (const merged of mergedRows) {
// currentQueenId is unique across colonies - if it stayed set on // currentQueenId is unique across colonies - if it stayed set on
// this now-dead colony, any later attempt to assign her queen to a // this now-dead colony, any later attempt to assign her queen to a
// different colony would fail with a unique-constraint conflict. // different colony would fail with a unique-constraint conflict.
@@ -320,11 +523,11 @@ export class ColoniesService {
// merge is a beekeeper decision, not one to guess here). // merge is a beekeeper decision, not one to guess here).
const releasedQueenId = await releaseQueenIn( const releasedQueenId = await releaseQueenIn(
tx, tx,
mergedColonyId, merged.id,
dayToDate(today), dayToDate(today),
); );
const mergedAway = await tx.colony.update({ const mergedAway = await tx.colony.update({
where: { id: mergedColonyId }, where: { id: merged.id },
data: { status: 'MERGED', deactivatedAt: new Date() }, data: { status: 'MERGED', deactivatedAt: new Date() },
}); });
await this.events.recordAll( await this.events.recordAll(
@@ -338,21 +541,25 @@ export class ColoniesService {
await tx.colonyLineage.create({ await tx.colonyLineage.create({
data: { data: {
type: 'MERGE', type: 'MERGE',
fromColonyId: mergedColonyId, fromColonyId: merged.id,
toColonyId: survivor.id, toColonyId: lockedSurvivor.id,
date: new Date(), date: new Date(),
notes: dto.notes, notes: data.notes,
}, },
}); });
} }
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'Colony', entityType: 'Colony',
entityId: survivor.id, entityId: lockedSurvivor.id,
action: 'UPDATE', action: 'UPDATE',
userId: actingUserId, userId: actingUserId,
beforeData: survivor, beforeData: lockedSurvivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds }, afterData: {
...lockedSurvivor,
mergedColonyIds: data.mergedColonyIds,
...overrideTrace(check, override.reason),
},
}, },
tx, tx,
); );
@@ -0,0 +1,20 @@
import type { Colony } from '../../generated/prisma';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
/**
* The ColonyCreated event of a colony that was just written (create, split
* child, CSV import). The caller records it in the transaction that wrote the
* colony. Backup restore deliberately records none.
*/
export function colonyCreatedEvent(
colony: Pick<Colony, 'id' | 'apiaryId'>,
actorUserId: string,
): AnyNewDomainEvent<'ColonyCreated'> {
return {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: colony.id,
actorUserId,
payload: { apiaryId: colony.apiaryId },
};
}
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ColonyCondition, ColonyType } from '../../../generated/prisma'; import { ColonyCondition, ColonyType } from '../../../generated/prisma';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class CreateColonyDto { export class CreateColonyDto {
@ApiProperty() @ApiProperty()
@@ -67,4 +68,7 @@ export class CreateColonyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
} }
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator'; import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class MergeColonyDto { export class MergeColonyDto {
@ApiProperty({ type: [String] }) @ApiProperty({ type: [String] })
@@ -12,4 +13,7 @@ export class MergeColonyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
} }
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class MoveColonyDto { export class MoveColonyDto {
@ApiProperty() @ApiProperty()
@@ -10,4 +11,7 @@ export class MoveColonyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
} }
@@ -0,0 +1,32 @@
import { applyDecorators } from '@nestjs/common';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export const OVERRIDE_REASON_MIN = 10;
export const OVERRIDE_REASON_MAX = 500;
/**
* `restrictionOverrideReason`: why a colony may enter or leave an apiary in a
* restricted zone. Only read by ColoniesService (needs compliance:manage) and
* written to audit and apiary history - never to the colony. Trimmed before
* the length check, so blanks do not count; null counts as omitted.
*/
export function RestrictionOverrideReason(): PropertyDecorator {
return applyDecorators(
ApiPropertyOptional({
nullable: true,
minLength: OVERRIDE_REASON_MIN,
maxLength: OVERRIDE_REASON_MAX,
description:
'Begründung für eine Ausnahme vom Sperrbezirk (nur mit compliance:manage)',
}),
IsOptional(),
Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
),
IsString(),
MinLength(OVERRIDE_REASON_MIN),
MaxLength(OVERRIDE_REASON_MAX),
);
}
@@ -3,6 +3,7 @@ import { IsEnum } from 'class-validator';
import { ColonyStatus } from '../../../generated/prisma'; import { ColonyStatus } from '../../../generated/prisma';
import { CreateColonyDto } from './create-colony.dto'; import { CreateColonyDto } from './create-colony.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { RestrictionOverrideReason } from './restriction-override-reason';
/** /**
* `apiaryId` is editable here too, not only through POST /colonies/:id/move: * `apiaryId` is editable here too, not only through POST /colonies/:id/move:
@@ -32,4 +33,7 @@ export class UpdateColonyDto extends UpdatableColonyFieldsDto {
@IsOptionalNotNull() @IsOptionalNotNull()
@IsEnum(ColonyStatus) @IsEnum(ColonyStatus)
status?: ColonyStatus; status?: ColonyStatus;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
} }
@@ -0,0 +1,41 @@
import { ConflictException } from '@nestjs/common';
import type { RestrictionCheck } from '../compliance/restricted-zone';
/** 409 when the colony or an apiary changed between the unlocked read and the lock. */
export function apiaryChangedMeanwhile(): ConflictException {
return new ConflictException(
'Der Stand wurde gerade geändert. Bitte neu laden.',
);
}
export const NOT_OVERRIDDEN: RestrictionCheck = {
overridden: false,
restrictionIds: [],
};
/**
* The audit trace of an override: only the reason and the restriction ids,
* and only when an active restriction was really overridden. Spread into the
* audit entry's afterData - never into data written to the colony.
*/
export function overrideTrace(
check: RestrictionCheck,
reason: string | null | undefined,
): { restrictionOverride?: { reason: string; restrictionIds: string[] } } {
return check.overridden && reason
? { restrictionOverride: { reason, restrictionIds: check.restrictionIds } }
: {};
}
/** The apiary-history note: the override in front of an existing note. */
export function overrideNote(
check: RestrictionCheck,
reason: string | null | undefined,
note: string | undefined,
): string | undefined {
if (!check.overridden || !reason) {
return note;
}
const prefix = `Ausnahme Sperrbezirk: ${reason}`;
return note ? `${prefix}\n${note}` : prefix;
}
@@ -49,12 +49,23 @@ describe('legacy after-commit paths', () => {
it('only the modules without an event still dispatch webhooks directly', () => { it('only the modules without an event still dispatch webhooks directly', () => {
expect(filesCalling('webhooks.dispatch(')).toEqual([ expect(filesCalling('webhooks.dispatch(')).toEqual([
'src/colonies/colonies.service.ts', 'src/colonies/colonies.service.ts',
'src/honey-batches/honey-batches.service.ts',
'src/queens/queens.service.ts', 'src/queens/queens.service.ts',
'src/storage/storage.service.ts', 'src/storage/storage.service.ts',
]); ]);
}); });
it('apiaries.service.ts has no after-commit side effects', () => {
for (const call of [
'runAfterCommit(',
'webhooks.dispatch(',
'calendarSync.enqueue(',
]) {
expect(filesCalling(call)).not.toContain(
'src/apiaries/apiaries.service.ts',
);
}
});
it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => { it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => {
const removed = [ const removed = [
'evaluateInspection', 'evaluateInspection',
@@ -9,7 +9,7 @@ import {
/** /**
* Secrets the server must read back (TOTP secrets, webhook signing secrets, * Secrets the server must read back (TOTP secrets, webhook signing secrets,
* backup files) are encrypted with a key of their own per purpose, never * voice notes, backup files) are encrypted with a key of their own per purpose, never
* with a JWT secret. Each purpose has a key ring: the current key from * with a JWT secret. Each purpose has a key ring: the current key from
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still * `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
* opens, so a key can be rotated without losing data. A sealed value names * opens, so a key can be rotated without losing data. A sealed value names
@@ -33,13 +33,14 @@ export const ENCRYPTION_KEY_VARIABLES = {
mfa: 'MFA_ENCRYPTION_KEY', mfa: 'MFA_ENCRYPTION_KEY',
webhook: 'WEBHOOK_ENCRYPTION_KEY', webhook: 'WEBHOOK_ENCRYPTION_KEY',
backup: 'BACKUP_ENCRYPTION_KEY', backup: 'BACKUP_ENCRYPTION_KEY',
voice: 'VOICE_ENCRYPTION_KEY',
} as const; } as const;
export type EncryptionKeyVariable = export type EncryptionKeyVariable =
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES]; (typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */ /** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
export type SecretPurpose = 'mfa' | 'webhook'; export type SecretPurpose = 'mfa' | 'webhook' | 'voice';
export interface RingKey { export interface RingKey {
id: string; id: string;
+16
View File
@@ -1,5 +1,6 @@
import { import {
addDays, addDays,
addMonthsClamped,
berlinDateTime, berlinDateTime,
berlinDayOf, berlinDayOf,
berlinToday, berlinToday,
@@ -97,4 +98,19 @@ describe('dates', () => {
it('daysBetween counts whole days across a DST change', () => { it('daysBetween counts whole days across a DST change', () => {
expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2); expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2);
}); });
it('best before 2027-01-31 plus 1 month is 2027-02-28', () => {
expect(addMonthsClamped('2027-01-31', 1)).toBe('2027-02-28');
});
it('addMonthsClamped keeps the leap day and crosses year ends', () => {
expect(addMonthsClamped('2028-01-31', 1)).toBe('2028-02-29');
expect(addMonthsClamped('2026-11-15', 3)).toBe('2027-02-15');
expect(addMonthsClamped('2026-03-31', 24)).toBe('2028-03-31');
expect(addMonthsClamped('2026-08-31', 1)).toBe('2026-09-30');
});
it('addMonthsClamped rejects an impossible day', () => {
expect(() => addMonthsClamped('2026-02-30', 1)).toThrow();
});
}); });
+21
View File
@@ -55,6 +55,27 @@ export function addDays(day: string, days: number): string {
return result.toISOString().slice(0, 10); return result.toISOString().slice(0, 10);
} }
/**
* Calendar months on a YYYY-MM-DD string. A day the target month does not
* have is cut to its last day: 2027-01-31 + 1 month is 2027-02-28.
*/
export function addMonthsClamped(day: string, months: number): string {
const start = dayToDate(day);
const monthIndex = start.getUTCMonth() + months;
const lastDay = new Date(
Date.UTC(start.getUTCFullYear(), monthIndex + 1, 0),
).getUTCDate();
return new Date(
Date.UTC(
start.getUTCFullYear(),
monthIndex,
Math.min(start.getUTCDate(), lastDay),
),
)
.toISOString()
.slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */ /** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
export function daysBetween(from: string, to: string): number { export function daysBetween(from: string, to: string): number {
return dayNumber(to) - dayNumber(from); return dayNumber(to) - dayNumber(from);
@@ -1,4 +1,4 @@
import { matchesFileSignature } from './file-signature'; import { detectAudioType, matchesFileSignature } from './file-signature';
describe('matchesFileSignature', () => { describe('matchesFileSignature', () => {
it('accepts a buffer starting with the PNG signature', () => { it('accepts a buffer starting with the PNG signature', () => {
@@ -47,3 +47,63 @@ describe('matchesFileSignature', () => {
); );
}); });
}); });
describe('detectAudioType', () => {
it('knows EBML, OggS and ftyp and nothing else', () => {
const webm = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42]);
const ogg = Buffer.concat([Buffer.from('OggS', 'ascii'), Buffer.alloc(8)]);
const mp4 = Buffer.concat([
Buffer.from([0x00, 0x00, 0x00, 0x20]),
Buffer.from('ftypM4A ', 'ascii'),
]);
expect(detectAudioType(webm)).toBe('audio/webm');
expect(detectAudioType(ogg)).toBe('audio/ogg');
expect(detectAudioType(mp4)).toBe('audio/mp4');
expect(
detectAudioType(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])),
).toBeNull();
expect(detectAudioType(Buffer.from('RIFF....WAVE', 'ascii'))).toBeNull();
expect(detectAudioType(Buffer.from('%PDF-1.7', 'ascii'))).toBeNull();
});
it.each(['heic', 'avif', 'mif1', 'qt ', '3gp4'])(
'rejects an ftyp file with the brand %s',
(brand) => {
const file = Buffer.concat([
Buffer.from([0, 0, 0, 0x20]),
Buffer.from(`ftyp${brand}`, 'latin1'),
]);
expect(detectAudioType(file)).toBeNull();
},
);
it.each(['M4A ', 'isom', 'iso2', 'iso6', 'mp41', 'mp42', 'dash'])(
'accepts the audio brand %s',
(brand) => {
const file = Buffer.concat([
Buffer.from([0, 0, 0, 0x20]),
Buffer.from(`ftyp${brand}`, 'latin1'),
]);
expect(detectAudioType(file)).toBe('audio/mp4');
},
);
it('rejects buffers that are too short for any signature', () => {
expect(detectAudioType(Buffer.alloc(0))).toBeNull();
expect(detectAudioType(Buffer.from([0x1a, 0x45, 0xdf]))).toBeNull();
expect(
detectAudioType(Buffer.from('OggS', 'ascii').subarray(0, 3)),
).toBeNull();
expect(
detectAudioType(Buffer.from([0, 0, 0, 0x20, 0x66, 0x74])),
).toBeNull();
});
it('does not take a signature from the middle of the buffer', () => {
const late = Buffer.concat([
Buffer.alloc(16),
Buffer.from('OggS', 'ascii'),
]);
expect(detectAudioType(late)).toBeNull();
});
});
@@ -33,3 +33,47 @@ export function matchesFileSignature(
const check = SIGNATURE_CHECKS[declaredMimeType]; const check = SIGNATURE_CHECKS[declaredMimeType];
return check ? check(buffer) : true; return check ? check(buffer) : true;
} }
export type VoiceAudioMime = 'audio/webm' | 'audio/ogg' | 'audio/mp4';
// Major brands of MP4/M4A audio; HEIC, AVIF and other ISO-BMFF files are not.
const MP4_AUDIO_BRANDS: ReadonlySet<string> = new Set([
'M4A ',
'isom',
'iso2',
'iso3',
'iso4',
'iso5',
'iso6',
'mp41',
'mp42',
'dash',
]);
const EBML_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
/**
* Names the audio container by its first bytes - strict, unlike
* matchesFileSignature: anything unknown is null, and the declared type of
* the upload plays no part. EBML (WebM/Matroska), "OggS" and "ftyp" at byte 4
* (MP4/M4A) are the containers MediaRecorder produces.
*/
export function detectAudioType(buffer: Buffer): VoiceAudioMime | null {
if (buffer.length >= 4 && buffer.subarray(0, 4).equals(EBML_MAGIC)) {
return 'audio/webm';
}
if (
buffer.length >= 4 &&
buffer.subarray(0, 4).toString('latin1') === 'OggS'
) {
return 'audio/ogg';
}
if (
buffer.length >= 12 &&
buffer.subarray(4, 8).toString('latin1') === 'ftyp' &&
MP4_AUDIO_BRANDS.has(buffer.subarray(8, 12).toString('latin1'))
) {
return 'audio/mp4';
}
return null;
}
@@ -0,0 +1,537 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
/**
* Guard for outbound calls: the network is only reached from the worker
* loops' own directories. A static source scan (like handler-contract.spec):
* - weather hosts appear only in src/weather/, api.telegram.org only in
* src/telegram/,
* - `fetch` (call, reference or globalThis.fetch) and network packages
* (http, https, undici, axios, got, node-fetch, with or without `node:`)
* appear only in src/weather/, src/telegram/ and the older files that
* predate this guard,
* - a *.handlers.ts file or a file under src/jobs/ imports no network
* package and no module that itself reaches the network - checked on its
* own, whatever the fetch rule says,
* - the Anthropic SDK is imported by exactly one file, the voice adapter
* (plan T7, contradiction 17); the worker reaches it only through the
* StructuredModel port.
*/
const SRC = join(__dirname, '..');
interface SourceFile {
path: string;
source: string;
}
const WEATHER_HOSTS = /open-meteo\.com/i;
const TELEGRAM_HOST = /api\.telegram\.org/i;
// `--phase=fetch` is the argument of the voice job, not a network call.
const FETCH_USE =
/(?<![\w$.])(?<!['"`]--phase=)fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/;
const NETWORK_PACKAGES = new Set([
'http',
'https',
'undici',
'axios',
'got',
'node-fetch',
]);
const ANTHROPIC_SDK = '@anthropic-ai/sdk';
const ANTHROPIC_ADAPTER = 'voice/extraction/anthropic-structured-model.ts';
const IMPORT_SPECIFIER =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)(['"`])([^'"`]+)\1/g;
// The files that may (transitively) import the Anthropic adapter.
const ADAPTER_WIRING_FILES = [
'voice/voice-worker.module.ts',
'all-in-one.module.ts',
'main.ts',
'worker.ts',
'runtime/root-module.ts',
];
const ADAPTER_WIRING_DIRECTORIES = ['worker/'];
/**
* Handlers that reach a network module only indirectly, through a service
* whose network part runs elsewhere. A direct import of a network file is
* never excused.
*/
const INDIRECT_NETWORK_EXCEPTIONS: Readonly<Record<string, string>> = {
'calendar-sync/calendar-sync.handlers.ts':
'calls only CalendarSyncService.enqueueIn(tx, ...); Graph calls run in the worker loop',
};
const ALLOWED_DIRECTORIES = ['weather/', 'telegram/'];
// Files that reach the network and predate the guard.
const ALLOWED_FILES = [
'calendar-sync/graph-client.service.ts',
'common/password/password-policy.ts',
ANTHROPIC_ADAPTER,
'webhooks/webhook-delivery.ts',
'worker/worker-health.ts',
'worker/worker-health-server.service.ts',
];
function relativePath(path: string): string {
return path
.slice(SRC.length + 1)
.split(sep)
.join('/');
}
/** Source without comments, so prose like "fetch the data" does not count. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
function sourceFiles(): SourceFile[] {
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts'))
.map((file) => {
const path = join(SRC, file);
return {
path: relativePath(path),
source: readFileSync(path, 'utf8'),
};
});
}
function isAnthropicSdk(specifier: string): boolean {
return (
specifier === ANTHROPIC_SDK || specifier.startsWith(`${ANTHROPIC_SDK}/`)
);
}
function isNetworkPackage(specifier: string): boolean {
return (
NETWORK_PACKAGES.has(specifier.replace(/^node:/, '')) ||
isAnthropicSdk(specifier)
);
}
function importsOf(source: string): string[] {
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[2]);
}
function inAllowedPlace(path: string): boolean {
return (
ALLOWED_DIRECTORIES.some((directory) => path.startsWith(directory)) ||
ALLOWED_FILES.includes(path)
);
}
function reachesNetwork(file: SourceFile): boolean {
return (
FETCH_USE.test(file.source) || importsOf(file.source).some(isNetworkPackage)
);
}
function isHandlerOrJob(path: string): boolean {
return path.endsWith('.handlers.ts') || path.startsWith('jobs/');
}
/** The src-relative path a relative import points to, if it is one of `known`. */
function resolveImport(
from: string,
specifier: string,
known: ReadonlySet<string>,
): string | undefined {
if (!specifier.startsWith('.')) {
return undefined;
}
const base = resolve(dirname(join(SRC, from)), specifier);
return [`${base}.ts`, join(base, 'index.ts')]
.map(relativePath)
.find((candidate) => known.has(candidate));
}
/**
* The seeds plus every file that reaches one of them through relative
* imports, however many steps away.
*/
function reachingClosure(
files: readonly SourceFile[],
seeds: Iterable<string>,
): Set<string> {
const known = new Set(files.map((file) => file.path));
const reaching = new Set(seeds);
let grew = true;
while (grew) {
grew = false;
for (const { path, source } of files) {
if (reaching.has(path)) {
continue;
}
const reachesSeed = importsOf(source).some((specifier) => {
const target = resolveImport(path, specifier, known);
return target !== undefined && reaching.has(target);
});
if (reachesSeed) {
reaching.add(path);
grew = true;
}
}
}
return reaching;
}
function isAdapterWiring(path: string): boolean {
return (
path === ANTHROPIC_ADAPTER ||
ADAPTER_WIRING_FILES.includes(path) ||
ADAPTER_WIRING_DIRECTORIES.some((directory) => path.startsWith(directory))
);
}
/** Where the code breaks the rule; empty when it keeps it. */
function violations(rawFiles: readonly SourceFile[]): string[] {
const files = rawFiles.map(({ path, source }) => ({
path,
source: withoutComments(source),
}));
const directNetworkFiles = new Set(
files.filter(reachesNetwork).map((file) => file.path),
);
const networkFiles = reachingClosure(files, directNetworkFiles);
const adapterReachers = reachingClosure(
files,
files.some((file) => file.path === ANTHROPIC_ADAPTER)
? [ANTHROPIC_ADAPTER]
: [],
);
return files.flatMap(({ path, source }) => {
const found: string[] = [];
if (WEATHER_HOSTS.test(source) && !path.startsWith('weather/')) {
found.push(`${path}: weather host outside src/weather/`);
}
if (TELEGRAM_HOST.test(source) && !path.startsWith('telegram/')) {
found.push(`${path}: api.telegram.org outside src/telegram/`);
}
if (FETCH_USE.test(source) && !inAllowedPlace(path)) {
found.push(`${path}: fetch outside the allowed places`);
}
const specifiers = importsOf(source);
if (specifiers.some(isNetworkPackage) && !inAllowedPlace(path)) {
found.push(`${path}: network package import outside the allowed places`);
}
if (specifiers.some(isAnthropicSdk) && path !== ANTHROPIC_ADAPTER) {
found.push(
`${path}: ${ANTHROPIC_SDK} imported outside the voice adapter`,
);
}
if (adapterReachers.has(path) && !isAdapterWiring(path)) {
found.push(
`${path}: reaches the Anthropic adapter outside the worker wiring`,
);
}
if (isHandlerOrJob(path)) {
if (FETCH_USE.test(source)) {
found.push(`${path}: fetch in a handler or job`);
}
if (specifiers.some(isNetworkPackage)) {
found.push(`${path}: network package imported by a handler or job`);
}
const importsAny = (targets: ReadonlySet<string>): boolean =>
specifiers.some(
(specifier) => resolveImport(path, specifier, targets) !== undefined,
);
if (
importsAny(directNetworkFiles) ||
(importsAny(networkFiles) &&
!Object.hasOwn(INDIRECT_NETWORK_EXCEPTIONS, path))
) {
found.push(
`${path}: handler or job imports a module that reaches the network`,
);
}
}
return found;
});
}
describe('network calls stay inside their loop directories', () => {
it('network calls stay inside their loop directories', () => {
expect(violations(sourceFiles())).toEqual([]);
});
it('knows the real network files (the scan is not vacuous)', () => {
const found = sourceFiles()
.map(({ path, source }) => ({ path, source: withoutComments(source) }))
.filter(reachesNetwork)
.map((file) => file.path);
expect(found).toEqual(
expect.arrayContaining([
...ALLOWED_FILES,
'weather/weather.service.ts',
'telegram/telegram-client.ts',
]),
);
expect(found.every(inAllowedPlace)).toBe(true);
expect(existsSync(join(SRC, 'jobs'))).toBe(true);
});
it('@anthropic-ai/sdk is imported only by anthropic-structured-model.ts', () => {
const importers = sourceFiles()
.filter(({ source }) =>
importsOf(withoutComments(source)).some(isAnthropicSdk),
)
.map((file) => file.path);
expect(importers).toEqual([ANTHROPIC_ADAPTER]);
});
describe('with planted violations', () => {
it.each([
['colonies/colonies.service.ts', 'await fetch(url);'],
[
'colonies/colonies.service.ts',
"const a = '--phase=fetch'; await fetch(url);",
],
['colonies/colonies.service.ts', 'const a = --phase=fetch;'],
['colonies/colonies.service.ts', 'const f = fetch;'],
['colonies/colonies.service.ts', 'await globalThis.fetch(url);'],
['colonies/colonies.service.ts', "import https from 'node:https';"],
['colonies/colonies.service.ts', "import http from 'http';"],
['colonies/colonies.service.ts', "import { fetch } from 'undici';"],
['colonies/colonies.service.ts', "import axios from 'axios';"],
['colonies/colonies.service.ts', "import got from 'got';"],
['colonies/colonies.service.ts', "import fetch from 'node-fetch';"],
['colonies/colonies.service.ts', "const h = require('node:http');"],
['colonies/colonies.handlers.ts', 'await fetch(url);'],
['jobs/daily-evaluation.job.ts', 'await fetch(url);'],
['jobs/steps/phenology.step.ts', "import { get } from 'node:https';"],
['colonies/colonies.handlers.ts', "import axios from 'axios';"],
[
'notifications/notifications.service.ts',
"const u = 'https://api.telegram.org/bot';",
],
[
'notifications/notifications.service.ts',
"const u = 'https://api.open-meteo.com/v1';",
],
[
'telegram/telegram-client.ts',
"const u = 'https://api.open-meteo.com/v1';",
],
[
'weather/weather.service.ts',
"const u = 'https://api.telegram.org/bot';",
],
[
'weather/weather.service.ts',
"import Anthropic from '@anthropic-ai/sdk';",
],
[
'voice/extraction/voice-extraction.service.ts',
"import Anthropic from '@anthropic-ai/sdk';",
],
[
'voice/extraction/voice-prompt.ts',
"const m = require('@anthropic-ai/sdk/resources');",
],
[
'voice/voice.handlers.ts',
"import { APIError } from '@anthropic-ai/sdk';",
],
[
'jobs/voice-transcribe.job.ts',
"const s = await import('@anthropic-ai/sdk');",
],
])('flags %s: %s', (path, source) => {
expect(violations([{ path, source }])).not.toEqual([]);
});
it('flags a handler or job importing a module that reaches the network, even without fetch of its own', () => {
const client = {
path: 'weather/weather.service.ts',
source: 'await fetch(url);',
};
for (const path of [
'colonies/colonies.handlers.ts',
'jobs/daily-evaluation-steps.ts',
]) {
const importer = {
path,
source:
"import { WeatherService } from '../weather/weather.service';",
};
expect(violations([client, importer])).toEqual([
`${path}: handler or job imports a module that reaches the network`,
]);
}
});
it('flags a handler or job importing the voice adapter', () => {
const adapter = {
path: ANTHROPIC_ADAPTER,
source: "import Anthropic from '@anthropic-ai/sdk';",
};
const importers = [
{
path: 'voice/voice.handlers.ts',
source:
"import { AnthropicStructuredModel } from './extraction/anthropic-structured-model';",
},
{
path: 'jobs/voice-transcribe.job.ts',
source:
"import { AnthropicStructuredModel } from '../voice/extraction/anthropic-structured-model';",
},
];
expect(violations([adapter, ...importers])).toEqual(
importers.flatMap(({ path }) => [
`${path}: reaches the Anthropic adapter outside the worker wiring`,
`${path}: handler or job imports a module that reaches the network`,
]),
);
});
it('flags a job reaching the adapter through the worker wiring', () => {
const files = [
{
path: ANTHROPIC_ADAPTER,
source: "import Anthropic from '@anthropic-ai/sdk';",
},
{
path: 'voice/voice-worker.module.ts',
source:
"import { AnthropicStructuredModel } from './extraction/anthropic-structured-model';",
},
{
path: 'jobs/voice-transcribe.job.ts',
source:
"import { VoiceWorkerModule } from '../voice/voice-worker.module';",
},
];
expect(violations(files)).toEqual([
'jobs/voice-transcribe.job.ts: reaches the Anthropic adapter outside the worker wiring',
'jobs/voice-transcribe.job.ts: handler or job imports a module that reaches the network',
]);
});
it('flags the API side reaching the adapter, but not the worker wiring', () => {
const files = [
{
path: ANTHROPIC_ADAPTER,
source: "import Anthropic from '@anthropic-ai/sdk';",
},
{
path: 'voice/voice-worker.module.ts',
source:
"import { AnthropicStructuredModel } from './extraction/anthropic-structured-model';",
},
{
path: 'worker/worker-runtime.module.ts',
source:
"import { VoiceWorkerModule } from '../voice/voice-worker.module';",
},
{
path: 'voice/voice.module.ts',
source: "import { VoiceWorkerModule } from './voice-worker.module';",
},
{
path: 'app.module.ts',
source: "import { VoiceModule } from './voice/voice.module';",
},
];
expect(violations(files)).toEqual([
'voice/voice.module.ts: reaches the Anthropic adapter outside the worker wiring',
'app.module.ts: reaches the Anthropic adapter outside the worker wiring',
]);
});
it('flags a handler reaching the network over several imports', () => {
const files = [
{ path: 'weather/weather.service.ts', source: 'await fetch(url);' },
{
path: 'colonies/colony-weather.ts',
source:
"import { WeatherService } from '../weather/weather.service';",
},
{
path: 'colonies/colony-facts.ts',
source: "export * from './colony-weather';",
},
{
path: 'colonies/colonies.handlers.ts',
source: "import { facts } from './colony-facts';",
},
];
expect(violations(files)).toEqual([
'colonies/colonies.handlers.ts: handler or job imports a module that reaches the network',
]);
});
it('reads import specifiers in backticks', () => {
const files = [
{ path: 'weather/weather.service.ts', source: 'await fetch(url);' },
{
path: 'jobs/daily-evaluation.job.ts',
source: 'const w = await import(`../weather/weather.service`);',
},
{
path: 'colonies/colonies.service.ts',
source: 'const h = require(`node:https`);',
},
];
expect(violations(files)).toEqual([
'jobs/daily-evaluation.job.ts: handler or job imports a module that reaches the network',
'colonies/colonies.service.ts: network package import outside the allowed places',
]);
});
it('excuses a listed handler only for an indirect path', () => {
const graph = {
path: 'calendar-sync/graph-client.service.ts',
source: 'await fetch(url);',
};
const service = {
path: 'calendar-sync/calendar-sync.service.ts',
source: "import { GraphClient } from './graph-client.service';",
};
const indirect = {
path: 'calendar-sync/calendar-sync.handlers.ts',
source:
"import { CalendarSyncService } from './calendar-sync.service';",
};
const direct = {
...indirect,
source: "import { GraphClient } from './graph-client.service';",
};
expect(violations([graph, service, indirect])).toEqual([]);
expect(violations([graph, service, direct])).toEqual([
'calendar-sync/calendar-sync.handlers.ts: handler or job imports a module that reaches the network',
]);
});
it.each([
[ANTHROPIC_ADAPTER, "import Anthropic from '@anthropic-ai/sdk';"],
[
'voice/extraction/voice-extraction.service.ts',
"import { STRUCTURED_MODEL } from './structured-model.port';",
],
[
'weather/weather.service.ts',
"await fetch('https://api.open-meteo.com/v1');",
],
[
'telegram/telegram-client.ts',
"await fetch('https://api.telegram.org/bot');",
],
['calendar-sync/graph-client.service.ts', 'await fetch(url);'],
['webhooks/webhook-delivery.ts', "import * as https from 'node:https';"],
['colonies/colonies.service.ts', 'const prefetched = refetch + fetched;'],
['colonies/colonies.service.ts', '// fetch the data later\nconst a = 1;'],
['colonies/colonies.service.ts', "const scheme = 'https';"],
['colonies/colonies.service.ts', 'const o = { fetch: 1 };'],
['jobs/job-names.ts', "const a = ['--phase=fetch', '--phase=ingest'];"],
])('lets %s pass: %s', (path, source) => {
expect(violations([{ path, source }])).toEqual([]);
});
});
});
@@ -0,0 +1,165 @@
import { Prisma } from '../../../generated/prisma';
import { deferClaim, settleSkipped, settleSuccess } from './outbox-claims';
function fakeDb(count = 1) {
return { $executeRaw: jest.fn().mockResolvedValue(count) };
}
function sqlOf(db: ReturnType<typeof fakeDb>): Prisma.Sql {
return (db.$executeRaw.mock.calls as [Prisma.Sql][])[0][0];
}
describe('NotificationDelivery outbox helpers', () => {
const claimedAt = new Date('2026-10-15T10:00:00.000Z');
it('settleSuccess settles NotificationDelivery as DELIVERED into deliveredAt', async () => {
const db = fakeDb();
await expect(
settleSuccess(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
'DELIVERED',
),
).resolves.toBe(true);
const query = sqlOf(db);
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
expect(query.sql).toContain('"deliveredAt" = now()');
expect(query.sql).toContain('"NotificationDeliveryStatus"');
});
it('settleSuccess refuses DONE for NotificationDelivery', async () => {
await expect(
settleSuccess(
fakeDb() as never,
'NotificationDelivery',
'd1',
claimedAt,
'DONE',
),
).rejects.toThrow('NotificationDelivery settles as DELIVERED, not DONE');
});
it('settleSkipped is compare-and-set on id and claimedAt and keeps attempts', async () => {
const db = fakeDb();
await expect(
settleSkipped(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
'chat gone',
),
).resolves.toBe(true);
const query = sqlOf(db);
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
expect(query.sql).toContain(`CAST(? AS "NotificationDeliveryStatus")`);
expect(query.sql).toContain(
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
);
expect(query.sql).not.toContain('"attempts"');
expect(query.values).toEqual(['SKIPPED', 'chat gone', 'd1', claimedAt]);
});
it('settleSkipped reports a lost claim', async () => {
await expect(
settleSkipped(
fakeDb(0) as never,
'NotificationDelivery',
'd1',
claimedAt,
'x',
),
).resolves.toBe(false);
});
it('deferClaim puts the entry back to PENDING after the delay by the database clock, attempts unchanged, compare-and-set', async () => {
const db = fakeDb();
await expect(
deferClaim(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
15 * 60_000,
),
).resolves.toBe(true);
const query = sqlOf(db);
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
expect(query.sql).toContain(`"status" = 'PENDING'`);
expect(query.sql).toContain(
`"availableAt" = now() + (? * interval '1 millisecond')`,
);
expect(query.sql).toContain('"claimedAt" = NULL');
expect(query.sql).not.toContain('"attempts"');
expect(query.sql).toContain(
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
);
expect(query.values).toEqual([900_000, 'd1', claimedAt]);
});
it('deferClaim reports a lost claim and rejects a negative or non-integer delay', async () => {
await expect(
deferClaim(
fakeDb(0) as never,
'NotificationDelivery',
'd1',
claimedAt,
1,
),
).resolves.toBe(false);
await expect(
deferClaim(
fakeDb() as never,
'NotificationDelivery',
'd1',
claimedAt,
-1,
),
).rejects.toThrow(RangeError);
await expect(
deferClaim(
fakeDb() as never,
'NotificationDelivery',
'd1',
claimedAt,
1.5,
),
).rejects.toThrow(RangeError);
});
it('deferClaim caps the delay at 24 hours', async () => {
const db = fakeDb();
await deferClaim(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
30 * 24 * 3_600_000,
);
expect(sqlOf(db).values[0]).toBe(24 * 3_600_000);
});
it('settleSkipped truncates the reason to 200 characters', async () => {
const db = fakeDb();
await settleSkipped(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
'r'.repeat(500),
);
expect(sqlOf(db).values[1]).toBe('r'.repeat(200));
});
});
@@ -11,7 +11,8 @@ import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
* comparison and calculation runs in SQL against now() - the database * comparison and calculation runs in SQL against now() - the database
* clock - never against a JS Date. * clock - never against a JS Date.
*/ */
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery'; export type OutboxTable =
'DomainEvent' | 'WebhookDelivery' | 'NotificationDelivery';
export type OutboxOrderColumn = 'occurredAt' | 'createdAt'; export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
@@ -29,6 +30,9 @@ interface OutboxTableSql {
successStatus: 'DONE' | 'DELIVERED'; successStatus: 'DONE' | 'DELIVERED';
} }
/** Outbox tables whose entries can be put back or skipped by the sender. */
export type DeliveryOutboxTable = 'NotificationDelivery';
/** /**
* Table, enum and column names cannot be bound as parameters. They come * Table, enum and column names cannot be bound as parameters. They come
* only from these closed maps - never from caller input - which is what * only from these closed maps - never from caller input - which is what
@@ -47,6 +51,12 @@ const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
settledAtColumn: Prisma.raw('"deliveredAt"'), settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED', successStatus: 'DELIVERED',
}, },
NotificationDelivery: {
table: Prisma.raw('"NotificationDelivery"'),
statusType: Prisma.raw('"NotificationDeliveryStatus"'),
settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED',
},
}; };
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = { const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
@@ -54,6 +64,10 @@ const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
createdAt: Prisma.raw('"createdAt"'), createdAt: Prisma.raw('"createdAt"'),
}; };
/** Longest wait deferClaim allows: a day. */
const MAX_DEFER_MS = 24 * 60 * 60 * 1000;
const MAX_SKIP_REASON_CHARS = 200;
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)'; const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
function outboxTable(table: OutboxTable): OutboxTableSql { function outboxTable(table: OutboxTable): OutboxTableSql {
@@ -206,3 +220,56 @@ export async function settleFailure(
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`); WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return outcome; return outcome;
} }
/**
* Puts a claimed entry back to PENDING, due after `delayMs` by the database
* clock, without counting an attempt: the entry did not fail, it has to wait
* (rate limit from the receiver, quiet period, paused bot); the delay is
* capped at 24 hours. Compare-and-set
* on id + claimedAt; false if the claim was lost.
*/
export async function deferClaim(
db: DbClient,
table: DeliveryOutboxTable,
id: string,
claimedAt: Date,
delayMs: number,
): Promise<boolean> {
if (!Number.isInteger(delayMs) || delayMs < 0) {
throw new RangeError(
`Defer delay must be a non-negative integer, got ${delayMs}`,
);
}
const { table: name } = outboxTable(table);
const cappedDelayMs = Math.min(delayMs, MAX_DEFER_MS);
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = 'PENDING',
"claimedAt" = NULL,
"availableAt" = now() + (${cappedDelayMs} * interval '1 millisecond')
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}
/**
* Settles a claimed entry as SKIPPED: it will never be sent (the recipient
* unlinked, the message went stale). `reason` lands in lastError and must
* not contain personal data or secrets; it is cut to 200 characters. Compare-and-set on id + claimedAt;
* false if the claim was lost.
*/
export async function settleSkipped(
db: DbClient,
table: DeliveryOutboxTable,
id: string,
claimedAt: Date,
reason: string,
): Promise<boolean> {
const { table: name, statusType } = outboxTable(table);
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(${'SKIPPED'} AS ${statusType}),
"claimedAt" = NULL,
"lastError" = ${reason.slice(0, MAX_SKIP_REASON_CHARS)}
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}
@@ -0,0 +1,114 @@
import type { WaterContentConfig } from '../../automation/rule-config/rule-config.types';
import { latestWaterContent, waterContentWarnings } from './water-content';
const config: WaterContentConfig = {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: {},
};
const refs = [{ type: 'HoneyBatch', id: 'hb1' }];
describe('latestWaterContent', () => {
it('the newest measurement wins over the batch value', () => {
expect(
latestWaterContent({ waterContent: 17 }, [
{ waterContent: 19, measuredAt: new Date('2026-08-01') },
{ waterContent: 21, measuredAt: new Date('2026-09-01') },
{ waterContent: 18, measuredAt: new Date('2026-07-01') },
]),
).toBe(21);
});
it('falls back to the batch value without measurements', () => {
expect(latestWaterContent({ waterContent: 17.5 }, [])).toBe(17.5);
});
it('is null without any value', () => {
expect(latestWaterContent({ waterContent: null }, [])).toBeNull();
});
});
describe('waterContentWarnings', () => {
it('stays silent at the warning limit and below', () => {
expect(waterContentWarnings(18, 'Blütenhonig', config, refs)).toEqual([]);
expect(waterContentWarnings(12, null, config, refs)).toEqual([]);
});
it('warns above 18 %', () => {
const [warning, ...rest] = waterContentWarnings(19, null, config, refs);
expect(rest).toEqual([]);
expect(warning).toMatchObject({
code: 'WATER_CONTENT_HIGH',
severity: 'WARNUNG',
refs,
});
});
it('asks for a confirmation above 20 %', () => {
const [warning, ...rest] = waterContentWarnings(20.5, null, config, refs);
expect(rest).toEqual([]);
expect(warning).toMatchObject({
code: 'WATER_CONTENT_ABOVE_LEGAL',
severity: 'BESTAETIGEN',
});
});
it('20.0 % itself only warns', () => {
expect(
waterContentWarnings(20, null, config, refs).map((w) => w.code),
).toEqual(['WATER_CONTENT_HIGH']);
});
it('hints at a missing value', () => {
expect(
waterContentWarnings(null, null, config, refs).map((w) => [
w.code,
w.severity,
]),
).toEqual([['WATER_CONTENT_MISSING', 'HINWEIS']]);
});
it('a configured heather honey confirms only above 23 %', () => {
const heather: WaterContentConfig = {
...config,
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
};
expect(
waterContentWarnings(22, 'heidehonig', heather, refs).map((w) => w.code),
).toEqual(['WATER_CONTENT_HIGH']);
expect(
waterContentWarnings(23.5, 'HEIDEHONIG', heather, refs).map(
(w) => w.code,
),
).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
// Another type keeps the general limits; a near name is not a match.
expect(
waterContentWarnings(22, 'Heidehonig-Mix', heather, refs).map(
(w) => w.code,
),
).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
});
it('a type entry with a warning limit only keeps the general confirm limit', () => {
const custom: WaterContentConfig = {
...config,
byHoneyType: { Raps: { warnAbovePercent: 17 } },
};
expect(
waterContentWarnings(17.5, 'Raps', custom, refs).map((w) => w.code),
).toEqual(['WATER_CONTENT_HIGH']);
});
});
describe('water content messages', () => {
it('shows two decimals when one decimal would hide the difference to the limit', () => {
const [warning] = waterContentWarnings(20.04, null, config, refs);
expect(warning.message).toContain('(20,04 %)');
expect(warning.message).toContain('Grenze von 20 %');
});
it('keeps one decimal otherwise', () => {
const [warning] = waterContentWarnings(20.5, null, config, refs);
expect(warning.message).toContain('(20,5 %)');
});
});
@@ -0,0 +1,152 @@
import type { HoneyBatch, HoneyMeasurement } from '../../../generated/prisma';
import type { WaterContentConfig } from '../../automation/rule-config/rule-config.types';
import { readRule } from '../../automation/rule-config/rule-config.reader';
import { formatNumberDe } from '../../suggestions/texts/format-number';
import type { DbClient } from '../../prisma/db-client';
import {
WARNING_SEVERITY,
type PlausibilityWarning,
} from '../warnings/plausibility-warning';
type Ref = { type: string; id: string };
/**
* The water content that counts (spec 7.8): the newest measurement by
* `measuredAt`, else the value on the batch itself.
*/
export function latestWaterContent(
batch: Pick<HoneyBatch, 'waterContent'>,
measurements: readonly Pick<
HoneyMeasurement,
'waterContent' | 'measuredAt'
>[],
): number | null {
let newest: Pick<HoneyMeasurement, 'waterContent' | 'measuredAt'> | null =
null;
for (const measurement of measurements) {
if (newest === null || measurement.measuredAt > newest.measuredAt) {
newest = measurement;
}
}
return newest === null ? batch.waterContent : newest.waterContent;
}
/** The limits for a honey type: its own entry (any case, exact name) first. */
function limitsFor(
honeyType: string | null,
config: WaterContentConfig,
): { warn: number; confirm: number } {
const wanted = honeyType?.trim().toLowerCase();
const own =
wanted === undefined || wanted === ''
? undefined
: Object.entries(config.byHoneyType).find(
([name]) => name.toLowerCase() === wanted,
)?.[1];
return {
warn: own?.warnAbovePercent ?? config.warnAbovePercent,
confirm: own?.confirmAbovePercent ?? config.confirmAbovePercent,
};
}
/**
* A percentage with one decimal; with more when one decimal would round the
* value onto the limit and hide that it is above it (20.04 % is "20,04 %").
*/
export function percentText(value: number, limit?: number): string {
const maxDigits = 3;
let digits = 1;
while (
limit !== undefined &&
value !== limit &&
Number(value.toFixed(digits)) === limit &&
digits < maxDigits
) {
digits += 1;
}
return `${formatNumberDe(value, digits)} %`;
}
/**
* Water content against the limits of the rule HONEY_WATER_CONTENT. Above
* the confirm limit only WATER_CONTENT_ABOVE_LEGAL is reported (the stronger
* finding); a warning never blocks, the confirmation is asked for by
* POST /bottling-batches only.
*/
export function waterContentWarnings(
value: number | null,
honeyType: string | null,
config: WaterContentConfig,
refs: Ref[],
): PlausibilityWarning[] {
if (value === null) {
return [
{
code: 'WATER_CONTENT_MISSING',
severity: WARNING_SEVERITY.WATER_CONTENT_MISSING,
message:
'Für diese Honigcharge ist kein Wassergehalt erfasst. Bitte vor dem Abfüllen messen.',
refs,
},
];
}
const { warn, confirm } = limitsFor(honeyType, config);
if (value > confirm) {
return [
{
code: 'WATER_CONTENT_ABOVE_LEGAL',
severity: WARNING_SEVERITY.WATER_CONTENT_ABOVE_LEGAL,
message: `Der Wassergehalt (${percentText(value, confirm)}) liegt über der gesetzlichen Grenze von ${percentText(confirm)}. Abfüllen nur mit Bestätigung.`,
refs,
},
];
}
if (value > warn) {
return [
{
code: 'WATER_CONTENT_HIGH',
severity: WARNING_SEVERITY.WATER_CONTENT_HIGH,
message: `Der Wassergehalt (${percentText(value, warn)}) liegt über ${percentText(warn)}. Das Warenzeichen „Echter Deutscher Honig“ verlangt höchstens 18 %.`,
refs,
},
];
}
return [];
}
/**
* The warnings for a honey batch as it stands now: its newest water content
* against the rule in force. Read only; null when the batch does not exist.
*/
export async function waterContentWarningsOfBatch(
db: DbClient,
honeyBatchId: string,
): Promise<{
waterContent: number | null;
warnings: PlausibilityWarning[];
} | null> {
const batch = await db.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { id: true, honeyType: true, waterContent: true },
});
if (!batch) {
return null;
}
const [measurements, rule] = await Promise.all([
db.honeyMeasurement.findMany({
where: { honeyBatchId },
select: { waterContent: true, measuredAt: true },
}),
readRule(db, 'HONEY_WATER_CONTENT'),
]);
const waterContent = latestWaterContent(batch, measurements);
return {
waterContent,
warnings: waterContentWarnings(
waterContent,
batch.honeyType ?? null,
rule.config,
[{ type: 'HoneyBatch', id: batch.id }],
),
};
}
@@ -0,0 +1,27 @@
import { roundToThousandths } from './quantity';
describe('roundToThousandths', () => {
it('rounds to thousandths', () => {
expect(roundToThousandths(0.0004)).toBe(0);
});
it('keeps 1.2345 as 1.235 (half up)', () => {
expect(roundToThousandths(1.2345)).toBe(1.235);
});
it('rounds ties towards +Infinity and never returns -0', () => {
expect(roundToThousandths(-0.0005)).toBe(0);
expect(Object.is(roundToThousandths(-0.0005), 0)).toBe(true);
expect(Object.is(roundToThousandths(-0.0004), 0)).toBe(true);
expect(roundToThousandths(1.2345)).toBe(1.235);
expect(roundToThousandths(-1.2345)).toBe(-1.234);
});
it('cleans float noise of a sum', () => {
expect(roundToThousandths(0.1 + 0.2)).toBe(0.3);
});
it('keeps negative values', () => {
expect(roundToThousandths(-3.3333)).toBe(-3.333);
});
});
+8
View File
@@ -0,0 +1,8 @@
/**
* Quantities (kilograms of honey, millilitres of a medicine, ...) are kept on
* a grid of thousandths, so sums of floats compare and print cleanly.
*/
export function roundToThousandths(value: number): number {
// `+ 0` turns -0 (a tiny negative value) into 0.
return Math.round(value * 1000) / 1000 + 0;
}
@@ -1,7 +1,9 @@
import { import {
DEFAULT_RETRY_POLICY,
MAX_ATTEMPTS, MAX_ATTEMPTS,
RETRY_DELAYS_MS, RETRY_DELAYS_MS,
retryDelayMs, retryDelayMs,
retryDelayMsFor,
STALE_CLAIM_MS, STALE_CLAIM_MS,
} from './retry-policy'; } from './retry-policy';
@@ -26,4 +28,29 @@ describe('retry policy', () => {
expect(() => retryDelayMs(attempts)).toThrow(RangeError); expect(() => retryDelayMs(attempts)).toThrow(RangeError);
}, },
); );
it('keeps retryDelayMs identical to the default policy', () => {
expect(DEFAULT_RETRY_POLICY).toEqual({
delaysMs: RETRY_DELAYS_MS,
maxAttempts: MAX_ATTEMPTS,
staleClaimMs: STALE_CLAIM_MS,
});
for (const attempts of [1, 2, 3, 4, 5, 6, 9]) {
expect(retryDelayMsFor(DEFAULT_RETRY_POLICY, attempts)).toBe(
retryDelayMs(attempts),
);
}
});
it("retryDelayMsFor uses the policy's delays and repeats the last one", () => {
const policy = {
delaysMs: [1_000, 5_000],
maxAttempts: 4,
staleClaimMs: 60_000,
};
expect([1, 2, 3].map((n) => retryDelayMsFor(policy, n))).toEqual([
1_000, 5_000, 5_000,
]);
expect(() => retryDelayMsFor(policy, 0)).toThrow(RangeError);
});
}); });
Loaded 100 of 1505 files, more files were not shown because too many files have changed in this diff. Show more