Compare commits

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

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

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

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

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

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

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

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

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

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

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

Refs #32

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

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

Refs #28

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

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

Refs #32

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

Refs #26

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

Refs #26

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

Refs #27

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

Refs #26

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

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

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

Refs #27

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

Refs #27

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

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

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

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

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

Refs #27

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

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

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

Refs #32

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

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

Refs #31

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

Refs #9

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

Refs #26

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

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

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

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

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

Refs #27

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

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

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

Refs #27

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

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

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

Refs #27

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

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

Refs #27

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

Refs #27

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

Refs #27

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

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

Refs #27

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

Gates: Frontend lint, typecheck, 2886 Tests.

Refs #27

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

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

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

Refs #27

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

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

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

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

Refs #27

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

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

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

Refs #27

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

Refs #21

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

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

Refs #21

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

Refs #27

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

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

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

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

Refs #27 #28

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

Refs #27

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

Refs #27

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

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

Refs #21 #22

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

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

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

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

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

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

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

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

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

Refs #45

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

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

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

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

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

Refs #42

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 21:13:23 +02:00
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 13:42:32 +02:00
Nick Szaunig 7097fcd97c Merge remote-tracking branch 'origin/main'
Backend Image / build-and-push (push) Failing after 3m11s
Frontend Image / build-and-push (push) Successful in 7m5s
Manifests / validate (push) Successful in 17s
2026-10-14 11:44:21 +02:00
Nick SzaunigandClaude Opus 5.5 2d5c740f7f Merge feat/erp-inventar-export: read-only inventory mirror for ERPNext
Brings the ERPNext inventory export (T8) into main after its review:
strict seit format, settle window on the DB clock, BatchMovement
(createdAt, id) index, one scope per new API key and the failed-attempt
lockout on the shared throttler storage. Conflicts resolved in the
permission catalog (System and ERP-Anbindung groups) and ARCHITECTURE.md
(lockout is Annahme 30).

Gates on the merged state: backend lint, typecheck, 1909 unit, 471 e2e,
build, no schema drift; frontend 929 tests, lint, typecheck, build.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:44:03 +02:00
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00
Nick SzaunigandClaude Opus 5.5 25d95b0473 fix(erp-export): strict seit format, DB clock for settle window, movement index, one scope per key
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 23:24:05 +02:00
Nick SzaunigandClaude Opus 5.5 53fab102f2 Merge main checkpoint c01a388 into feat/erp-inventar-export
Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 22:49:53 +02:00
Nick SzaunigandClaude Opus 5.5 c01a38835a feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:57:54 +02:00
Nick.Rosenloecher b8abe16849 Merge pull request 'feat: Inventar-Export für ERPNext (erp-inventory:read)' (#1) from feat/erp-inventar-export into main
Backend Image / build-and-push (push) Successful in 4m55s
Frontend Image / build-and-push (push) Successful in 5m24s
Reviewed-on: #1
2026-10-13 05:38:40 +00:00
Nick Szaunig 9c332d4716 Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export
# Conflicts:
#	ARCHITECTURE.md
2026-10-13 05:58:30 +02:00
Nick SzaunigandClaude Opus 5.5 10cbe9bf2d fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s
Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 16:38:55 +02:00
Nick SzaunigandClaude Opus 5.5 6505411761 fix(ui): API-Schlüssel nur mit Rechten anbieten, die der Nutzer selbst hat
Der Server lehnt Rechte ab, die der Anlegende nicht selbst hat. Das UI
bietet jetzt auch nur diese an (z. B. kein shop-sync:write ohne eigenes
Recht) und erklärt, wenn gar keines übrig bleibt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 12:00:03 +02:00
Nick SzaunigandClaude Opus 5.5 fe7b61c3c1 fix(api-keys): gültige Schlüssel nie durch die Fehlversuchs-Sperre abweisen
Befunde aus dem Sicherheitsreview des Branches:

- MEDIUM: Die Sperre lief vor der Schlüsselsuche und zählte auch falsch
  aufgebaute Tokens. Wer die gemeinsame Adresse (Cluster-Ausgang, Proxy)
  nutzt oder per X-Forwarded-For vortäuscht, hätte ERPNext und den Shop
  15 Minuten aussperren können. Jetzt: erst Suche über den Hash; ein
  gültiger, nicht widerrufener, nicht abgelaufener Schlüssel kommt immer
  durch; 429 nur für unbekannte, widerrufene und abgelaufene Schlüssel;
  falsch aufgebaute Tokens sofort 401 ohne Zählung. Beim Start warnt
  ApiCore, wenn TRUST_PROXY in Produktion fehlt (im Cluster gesetzt).
- LOW: Bei vollem Speicher verdrängt der Limiter zuerst nicht gesperrte
  Einträge, gesperrte erst zuletzt.
- LOW: Bewegungs-Cursor mit Jahr außerhalb 2000-2100 ergab 500, jetzt 400.
- LOW/INFO: Vertrag legt fest, dass ERPNext Produkte und Chargen bei jedem
  Lauf vollständig neu liest (Voll-Spiegel) und Bewegungen inkrementell;
  remainingQuantity ist live, Bewegungen kommen 5 Minuten verzögert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 12:00:03 +02:00
Nick SzaunigandClaude Opus 5.5 e28cc8e0f3 fix(api-keys): Fehlversuche je Client-Netz zählen (IPv6 /64)
Wie der Throttler seit 886b4e7: die Sperre nach abgelehnten Schlüsseln
zählt über rateLimitSubject, damit wechselnde Adressen im eigenen
IPv6-/64 das Budget nicht zurücksetzen. Im Log steht weiter die Adresse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:47:05 +02:00
Nick Szaunig e357e8be9a Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export 2026-10-12 11:44:31 +02:00
Nick SzaunigandClaude Opus 5.5 c4cdb63414 feat(ui): Berechtigungen beim Anlegen eines API-Schlüssels wählbar
Bisher vergab die Oberfläche fest shop-sync:read. Jetzt sind alle
freigegebenen Rechte ankreuzbar (Shop lesen, Shop-Buchungen,
Inventar-Spiegel für ERPNext); vorausgewählt bleibt wie bisher nur
Shop lesen, ohne Haken lässt sich nichts anlegen. Die Liste zeigt, was
ein Schlüssel darf, und der Hinweis nach dem Anlegen nennt das Zielsystem.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:57 +02:00
Nick SzaunigandClaude Opus 5.5 68f81fabd4 feat(erp-export): nur lesender Inventar-Spiegel für ERPNext (T8)
Datenquelle für den nächtlichen Inventar-Abgleich in ERPNext (ERPNext-
Ausbau T8, Entscheidungen E2/E3). Neues Recht erp-inventory:read, auch als
API-Schlüssel-Scope; es öffnet nur diese vier GET-Endpunkte:

- /api/v1/export/inventar/produkte: Artikel (ohne Beschreibung)
- .../chargen: Abfüllungen und Herstellungschargen mit Losnummer, MHD,
  Ausgangsmenge, Rest aus dem Bewegungsbuch, Füllmenge g und Sorte
- .../bewegungen?seit=&nach=&limit=: Shop-Verkauf, Retoure, Entnahmen mit
  Grund (HOFLADEN = Vertrauensschrank, EIGENBEDARF, BRUCH, PROBE,
  SONSTIGES) und Inventur; Cursor über (createdAt, id), Bewegungen erst
  nach 5 Minuten Wartezeit, damit der Cursor keine offene Buchung überholt
- .../ernten?jahr=: Nettogewicht kg je Kalenderjahr, wie die Statistik

Alle Listen stabil sortiert, seitenweise (1-500), Parameter mit
class-validator geprüft, Cursor je Endpunkt streng validiert. Feld-
Allowlists: keine Notizen, Benutzer, Standorte, Rohstoffe. Die Gründe gab
es im Datenmodell schon - keine Migration; das Recht legt der Katalog-
Sync beim Start an und gibt es der Rolle Administrator.

Vertrag mit Feldern, Einheiten, Fehlercodes und Beispielen:
docs/erp-inventar-export.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:57 +02:00
Nick SzaunigandClaude Opus 5.5 1a1e70c94b feat(api-keys): Fehlversuchs-Sperre je IP und Log ohne Schlüssel
Nach 20 abgelehnten API-Schlüsseln (fehlerhaft, unbekannt, widerrufen,
abgelaufen) in 15 Minuten antwortet ApiCore der Absender-IP mit 429, auch
für richtige Schlüssel - vor jeder Datenbankabfrage. Gleiches Budget wie
der ERP-Export des Shops. Die Zählung liegt wie der Throttler im Speicher
des Pods und ist auf 10.000 Adressen begrenzt.

Abgelehnte Schlüssel werden mit Grund, öffentlichem Präfix (ack_xxxxxxxx)
und IP protokolliert, nie mit dem Schlüssel. Die Suche bleibt über den
SHA-256-Hash; neu sind Unit-Tests für authenticate (fehlend, unbekannt,
widerrufen, abgelaufen, Scope-Filter, IP-Allowlist, Sperre).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:56 +02:00
Nick SzaunigandClaude Opus 5.5 f5b1964223 fix(k8s): Ingress-Vorlagen auf Traefik statt nginx
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Successful in 3m48s
Die cloudflare-only-Annotation ist eine Traefik-Annotation und war mit
ingressClassName nginx wirkungslos. Produktion laeuft auf RKE2 mit Traefik
(Ingresses apicore-backend/-frontend), die Vorlagen nennen jetzt dieselbe
Klasse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:26:37 +02:00
Nick SzaunigandClaude Opus 5.5 e53e4fb1cf fix(k8s): Traefik-Middleware cloudflare-only an Ingress-Manifesten
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Canceled after 0s
Die live gesetzte Annotation traefik.ingress.kubernetes.io/router.middlewares
fehlte in den Manifesten (Backend und Frontend). Sie schuetzt vor
Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:46 +02:00
Nick SzaunigandClaude Opus 5.5 d9670dcda5 fix: TRUST_PROXY-Kommentar aktualisieren und Modus beim Start loggen
Die Kommentare beschrieben noch einen einzelnen Reverse-Proxy-Hop. Sie
beschreiben jetzt die Cloudflare/Traefik-Liste. Beim Start wird einmal
auf info-Level geloggt, ob Hop-Zahl, Liste oder der Default aktiv ist
(nur Anzahl, keine Adressen), damit eine fehlende Produktionseinstellung
auffaellt. Das Standardverhalten bleibt unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:41 +02:00
Nick SzaunigandClaude Opus 5.5 886b4e75e1 fix: Rate-Limit pro IPv6-/64 statt pro Einzeladresse
Der Standard-ThrottlerGuard trackt die volle req.ip. Ein IPv6-Client
kann innerhalb seines /64 beliebig Adressen rotieren und so die
Login-/Refresh-/SSO-Limits umgehen. Neuer ClientIpThrottlerGuard
liefert IPv4 unveraendert, IPv4-mapped als IPv4 und IPv6 als /64-Praefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:18:56 +02:00
Nick SzaunigandClaude Opus 5.5 d7f0d2c13b fix(security): TRUST_PROXY ohne uniquelocal, nur Pod-Netz und Cloudflare
Backend Image / build-and-push (push) Successful in 1m19s
Frontend Image / build-and-push (push) Successful in 30s
LAN-Adressen als vertrauenswuerdige Hops waren unnoetig weit: Traefik
verwirft X-Forwarded-For von LAN-Clients ohnehin, sie sind selbst Absender.
Vertraut werden nur noch 10.42.0.0/16 (Traefik-Pods) und die Cloudflare-Netze.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:57:09 +02:00
Nick SzaunigandClaude Opus 5.5 570f4f94e4 fix(security): TRUST_PROXY hinter Cloudflare auf Netzliste statt Hop-Zahl
Frontend Image / build-and-push (push) Successful in 3m26s
Backend Image / build-and-push (push) Successful in 3m33s
Mit TRUST_PROXY=1 (Standard) war req.ip die Cloudflare-Edge, weil Traefik
sie als letzten X-Forwarded-For-Eintrag anhaengt. Rate-Limits trafen damit
alle Clients derselben Edge gemeinsam, und IP-Freigaben von API-Schluesseln
sahen nie die echte Adresse.

Vorlage und .env.example nennen jetzt "loopback, uniquelocal" plus die
Cloudflare-Netze: Express nimmt den ersten Eintrag von rechts ausserhalb
dieser Netze, also den Client, den Cloudflare angehaengt hat. Vom Client
mitgeschickte Eintraege links davon erreicht die Auswertung nie.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:55:06 +02:00
Nick SzaunigandClaude Opus 5.5 04f9363e4b fix(k8s): persist documents and app backups, pin images, run hardened
Backend Image / build-and-push (push) Successful in 3m0s
Frontend Image / build-and-push (push) Successful in 3m12s
Documents, avatars and the encrypted app backups lived in ./uploads and
./backups inside the container and were lost on every restart. The
backend now mounts the apicore-data volume at /data (configmap paths
/data/documents and /data/backups).

Both deployments pin the image to a CI build (sha-<commit>) with
imagePullPolicy IfNotPresent instead of :latest, run as uid 1000 with
seccomp RuntimeDefault, no privilege escalation and all capabilities
dropped. The frontend gets a liveness probe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:25:45 +02:00
Nick SzaunigandClaude Opus 5.5 2359970f48 feat(k8s): daily pg_dump backup of the database
Backend Image / build-and-push (push) Successful in 2m43s
Frontend Image / build-and-push (push) Successful in 2m53s
CronJob apicore-db-backup dumps the database to the Storage Box
(StorageClass smb-backup, SMB 3.1.1 with seal). Keeps 14 daily and
8 weekly dumps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 02:04:28 +02:00
Nick SzaunigandClaude Opus 5.5 cde817845e chore(k8s): drop CPU and memory requests
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Canceled after 2m1s
Requests are set to an explicit 0 instead of being removed, because
Kubernetes defaults a missing request to the limit, which would reserve
even more. Limits stay as an upper bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:08:07 +02:00
Nick SzaunigandClaude Opus 5.5 910ba804a6 chore(k8s): pin postgres to 18.6-alpine
Backend Image / build-and-push (push) Successful in 3m12s
Frontend Image / build-and-push (push) Successful in 3m7s
The floating 18-alpine tag with IfNotPresent kept an old 18.x image
cached on the nodes, so minor releases never reached the cluster.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 01:55:03 +02:00
Nick SzaunigandClaude Opus 5.5 cfd3a187af feat(automation): separate lot number counters per raw material kind
Backend Image / build-and-push (push) Successful in 2m15s
Frontend Image / build-and-push (push) Successful in 2m3s
With {ART} in the raw material number format (default {ART}-{YYYY}-{SEQ:4}),
beeswax and propolis each count on their own: WA-2026-0001, PR-2026-0001.
Counters live in the new AutomationSequence table (one row per rule and
scope). Without {ART} the rule keeps its single shared counter, since
separate counters behind one prefix would hand out duplicate numbers.
Resetting a rule clears all of its counters.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 19:33:10 +02:00
Nick SzaunigandClaude Opus 5.5 5408711b2d feat(automation): number formats for raw material and production batches, raw material minimum
- RAW_MATERIAL_LOT_NUMBER_FORMAT / PRODUCTION_BATCH_NUMBER_FORMAT fill in the
  lot number when it is left empty, like honey batches and bottlings.
- RAW_MATERIAL_LOW: task when the total of a raw material kind drops below
  config.minGrams after consumption, withdrawal or inventory count.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:01:33 +02:00
Nick SzaunigandClaude Opus 5.5 fc887d8431 fix(ui): encode every id in API paths (client-side path traversal)
Backend Image / build-and-push (push) Successful in 1m9s
Frontend Image / build-and-push (push) Successful in 2m2s
Follow-up to a7609ff for the whole frontend: all 30 API helpers in
src/lib now pass path values through encodeURIComponent, so an id taken
from the URL can never leave its own path segment and make the page call
another endpoint with the viewer's token.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:40:25 +02:00
Nick SzaunigandClaude Opus 5.5 a7609ff3f8 fix(ui): encode ids in raw material, stock and production batch API paths
Security review finding (client-side path traversal): ids taken from the
URL were interpolated into API paths unencoded, so a crafted link like
/raw-materials/..%2Fusers made the page call another endpoint with the
viewer's token. Ids are now encodeURIComponent-ed; a regression test pins
"../users" to its own path segment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:34:07 +02:00
Nick SzaunigandClaude Opus 5.5 44971c23e6 docs(spec): raw materials and production addendum
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:38 +02:00
Nick SzaunigandClaude Opus 5.5 569a43d247 feat(ui): raw material lots and production inputs
- Pages for raw material lots (beeswax, propolis): list, create in grams
  with optional apiary, detail with stock card (harvested / used in
  production / withdrawn / remaining) and "used in" batches.
- Production batch form takes the raw material used (lot + grams) and the
  batch page shows what it was made from - traceability both ways.
- Sidebar entry "Rohstoffe".
- Removes backend files that were committed into ApiCore-Frontend/src by
  mistake in e52a605 (copies of ApiCore-Backend/src/raw-materials).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:07 +02:00
Nick SzaunigandClaude Opus 5.5 f9899d5621 fix(stock): require raw-material permission to consume, guard quantity edits
Security review findings on the raw material commit:
- Creating a production batch with inputs books raw material off other
  lots; production-batch:create alone no longer suffices, the user also
  needs raw-material:withdraw (403 otherwise).
- Correcting the quantity of a raw lot or production batch below what was
  already consumed, sold or withdrawn is refused (row lock + check in the
  same transaction) instead of silently driving the remaining stock
  negative; shortfalls go through an inventory correction.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:06 +02:00
Nick Szaunig e52a6057a2 Bestandsabgleich umgesetzt 2026-10-06 00:02:35 +02:00
Nick SzaunigandClaude Opus 5.5 e73c8b2cb4 feat(raw-materials): wax and propolis lots consumed by production batches
- RawMaterialLot (beeswax, propolis) in grams with origin (apiary,
  harvest); stock in the same movement ledger: withdrawals, inventory
  counts, and the new movement type CONSUMPTION.
- Production batches take inputs (raw lot + grams); batch and consumption
  are one transaction, never more than a lot has left. Deleting a
  mistaken batch gives the material back as its own movement.
- Traceability both ways: batch -> inputs, raw lot -> usedIn.
- Movement belongs to exactly one of bottling / production / raw lot
  (CHECK); raw material changes do not trigger the shop webhook.
- Permissions raw-material:*, granted to roles that maintain production
  batches.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 23:54:43 +02:00
Nick SzaunigandClaude Opus 5.5 41c2af7284 feat(ui): article stock overview and production batch editing
- New article page /products/[id]: remaining stock summed over all
  production batches (or bottlings for honey), each batch linked to its
  own page for withdrawals and counts; "Bestand ansehen" in the list.
- Production batches can be corrected (lot number, date, quantity,
  best-before date, note) via PATCH; the article stays fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 21:28:15 +02:00
Nick SzaunigandClaude Opus 5.5 f57db97578 feat(ui): stock card with withdraw and inventory count, production batches
- Bottling detail shows Abgefüllt / Webshop verkauft / Entnommen / Rest
  (negative rest flagged "Prüfen") and the full movement list; buttons
  "Entnehmen" (reason, note) and "Inventur korrigieren" (counted
  quantity, difference booked).
- Rest column in the bottling list.
- New pages for production batches (list, create, detail with the same
  stock card), sidebar entry with its own permission.
- Products get "Art" (honey vs. manufactured goods) and the webshop
  category; bottlings only offer honey articles.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 06:44:21 +02:00
Nick SzaunigandClaude Opus 5.5 04f93e954a feat(stock): batch movement ledger, production batches and shop write-back API
One remaining quantity per batch, kept in ApiCore and always derived:
initial quantity (jarCount / produced quantity) plus the signed movements
in the new BatchMovement ledger, which is never deleted.

- Production batches for MANUFACTURED articles (candles, propolis
  products, ...); articles get a kind and a shop category.
- Withdrawals (farm shop, own use, breakage, sample) and inventory
  corrections on bottlings and production batches, never more than the
  remaining stock; every write locks the bottling or the article row.
- Shop bookings via POST /shop-sync/movements (new scope
  shop-sync:write): idempotent per externalId, articles allocated oldest
  best-before first, returns mirrored onto the batches of the sale,
  invalid entries rejected one by one.
- GET /shop-sync/stock and remainingCount in the shop feed; webhook
  stock.changed after every change (ids only, receivers fetch numbers).
- Bottlings and production batches with movements cannot be deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 06:38:12 +02:00
Nick SzaunigandClaude Opus 5.5 85f05dc541 docs(spec): stock sync between ApiCore and the Wabenhain shop
One remaining quantity per batch, kept in ApiCore: movement ledger for
bottlings and new production batches, shop write-back of paid sales and
returns, withdrawals in ApiCore reflected in the shop via webhook plus a
10-minute reconcile.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 05:51:58 +02:00
Nick SzaunigandClaude Opus 5.5 e231fb95dc style(test): prettier formatting in export e2e spec
Frontend Image / build-and-push (push) Successful in 2m5s
Backend Image / build-and-push (push) Successful in 2m16s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:44:29 +02:00
Nick SzaunigandClaude Opus 5.5 39f00a105c docs: AuDHD adaptation of all pages - rules, building blocks, open points
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:44:20 +02:00
Nick SzaunigandClaude Opus 5.5 2656fb8328 fix(ui): number keyboard on phones for every weight, amount and percentage field
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:38:55 +02:00
Nick SzaunigandClaude Opus 5.5 c3dc596170 feat(ui): honey batch page with one main action, traceability errors, confirmed removal
- DetailHeader: "Abfüllung anlegen" as the main action, PDF export in
  "Mehr", state as icon + word, retry and way back on load errors.
- Traceability says what it shows and offers a retry on errors instead
  of "Lädt…" forever.
- Removing a container allocation asks first and says what changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:37:37 +02:00
Nick SzaunigandClaude Opus 5.5 86f9291ee3 feat(ui): harvest, extraction, bottling and equipment pages with heading and way back
Same DetailHeader as colonies/queens: an h1, a 44 px back link, state as
icon + word, the obvious next step as the one primary button (harvest:
"Schleuderung erfassen"); a failed load offers a retry and a way back
instead of a bare red line. Colonies of a harvest are tappable links;
changing equipment condition confirms with a toast.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:36:39 +02:00
Nick SzaunigandClaude Opus 5.5 d4b1addb7d fix(security): keep form drafts per user and delete them on logout
Drafts were stored in localStorage by form only, so on a shared phone
the next person was offered the previous user's unfinished entry
(colony observations, treatment and supplier details). Drafts are now
keyed by the signed-in user id and every draft is removed on logout,
like the offline cache already was.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:33:38 +02:00
Nick SzaunigandClaude Opus 5.5 031e47eaad feat(ui): detail pages for colonies, apiaries and queens with one clear main action
New DetailHeader: heading, back link, status as icon + word, ONE primary
action and a "Mehr" menu for exports, QR and the rest.

- Colony: "Durchsicht erfassen" is the main action; sub-cards show a
  load error with retry instead of "Noch keine …".
- Apiary: error states, weather with units.
- Queen: edit card in sections, evaluations as 1-5 tap scales.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:32:33 +02:00
Nick SzaunigandClaude Opus 5.5 2bc0db6f33 fix(statistics): feeding totals per unit instead of adding litres to kilograms
The feeding statistics summed every amount regardless of its unit, so
5 l syrup and 2 kg fondant showed as 7. The API now also returns byUnit
(units normalised: "L" = "l"), the old fields stay for compatibility,
and the statistics page shows one list per unit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:27:24 +02:00
Nick SzaunigandClaude Opus 5.5 ed0a000534 feat(ui): remaining capture forms with sections, tap choices and drafts
Varroa, swarm control, harvest, extraction, bottling, task and queen
forms use the form kit: required fields marked, inline errors with focus
on the first problem, rare fields folded, drafts, save in thumb reach.
Single choices (varroa method, swarm mood/action, priority, marking
colour) are large tap buttons; colonies and harvests are picked as
tiles. Plain hints: per-day mite fall vs. threshold, Brutto/Netto,
water content, MHD and lot number, marking colour by year.

Fixed on the way: the queen form suggested the marking colour from the
first digit typed ("2" -> Gelb for 2026); it now waits for a full year.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:25:24 +02:00
Nick SzaunigandClaude Opus 5.5 49f3db5b56 feat(ui): storage, map, search, wintering and statistics without dead ends
- Every query shows loading, an error with "Erneut versuchen", an empty
  state with a next step, then data; statistics no longer hang on
  "Lädt…" after an error, a failed search no longer reads as "no hits".
- Storage: one primary action, stock items as a clearly secondary
  section, low stock as icon + word.
- Wintering: "Gassen" explained, outcome as icon + word, touch-size
  edit buttons; map: tells how many apiaries have coordinates.
- Statistics: units and plain words instead of "Ø", a select instead
  of crowded tabs on phones.
- QR code buttons are touch size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:22:32 +02:00
Nick SzaunigandClaude Opus 5.5 0e46f666aa fix(ui): backup schedule shows a load error with retry, not "not configured"
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:17:56 +02:00
Nick SzaunigandClaude Opus 5.5 d57162e709 feat(ui): undo for deleted label elements, warn before leaving unsaved changes
The Delete key removed a label element for good; it now shows a toast
with "Rückgängig" that puts it back in place. Closing or reloading the
designer with unsaved changes asks first.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:17:36 +02:00
Nick SzaunigandClaude Opus 5.5 b4c8d80c33 fix(ui): number keyboard for intervals in recurring task dialogs
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:16:19 +02:00
Nick SzaunigandClaude Opus 5.5 549a75988e feat(ui): webhooks as cards with events in words, calendar sync without env jargon
- Webhooks: one card per connection, events by their German label
  instead of keys like "product.stock_changed", state as icon + word,
  a one-sentence explanation of what a webhook is for, retry on errors.
- Calendar sync: the group id field says where to find it; the
  "not configured" note says who sets it up instead of listing
  environment variable names.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:15:54 +02:00
Nick SzaunigandClaude Opus 5.5 a21b1665e7 feat(ui): colony edit dialogs with sections and tap ratings, confirmed merge
Inspection, treatment, feeding, varroa and swarm-control edit dialogs use
the form kit (required markers, inline errors, 1-5 as buttons, yes/no
tiles, number keyboards, save button pinned at the dialog bottom).
Moving, splitting and merging colonies report success; merging - which
cannot be undone - asks first and says what happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:14:07 +02:00
Nick SzaunigandClaude Opus 5.5 9944aabbc3 feat(ui): list pages with retry on errors, a next step when empty, one main action
Lists load through QueryState: a failed load says so and offers
"Erneut versuchen" instead of looking empty; empty lists link to the
create page; exports move into a "Mehr" menu so "Neu …" is the one
primary action.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:14:07 +02:00
Nick SzaunigandClaude Opus 5.5 62e78498bf feat(ui): capture forms for treatments, feedings, colonies, apiaries, products, equipment
Same structure as the inspection form: required fields marked, errors at
the field, numbered sections with rarely used fields folded, drafts kept
on the device, save button in thumb reach, plain hints for jargon
(Wirkstoff, Wartezeit, Code, SKU, units). The Bestandsbuch fields of a
treatment stay a visible section of their own. Apiaries can take the
coordinates from the phone's location. Offline queue and idempotency
are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:14:07 +02:00
Nick SzaunigandClaude Opus 5.5 832997f885 feat(ui): users and audit log as readable cards with names and retry
- Users: one card per person on every screen size, status and MFA as
  icon plus word, server reasons (e.g. "more rights than you") shown.
- Audit log: names instead of user ids, area as a choice instead of a
  free-text type, optional record id, filter reset, cards instead of a
  5-column table, empty and error states with a next step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:13:40 +02:00
Nick SzaunigandClaude Opus 5.5 bd2070436c feat(ui): calendar as a day list on phones, today named, every entry reachable
- Phones get a list of the days that have entries (44 px rows with type
  word, icon and title) instead of a 7-column grid with 0.7 rem chips.
- Month grid on wider screens: days are buttons, the chosen day lists
  all its entries below, so "+N weitere" is no longer a dead end.
- Today is marked with a filled circle and the word "Heute"; a "Heute"
  button jumps back. Month navigation buttons are touch size.
- Load errors offer a retry; an empty month says so.
- Entries are grouped by local day instead of UTC, so late-evening
  entries no longer land on the next day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:38:09 +02:00
Nick SzaunigandClaude Opus 5.5 dff6c364c8 feat(ui): automation settings start with everyday tabs and confirm risky actions
- Tabs ordered from everyday (suggestions, recurring tasks, labels) to
  technical (calendar sync, API keys, webhooks); the page opens on
  "Vorschläge" instead of Webhooks, and says in one sentence what it is.
- Resetting a number counter, deactivating a label template and ending a
  recurring task now ask first and say plainly what happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:36:28 +02:00
Nick SzaunigandClaude Opus 5.5 78325af1fe feat(ui): touch-size controls, scrollable dialogs, motion levels everywhere, load states
- Inputs and selects are 44 px on phones (36 px from sm up); default and
  small buttons get 40/36 px on phones.
- Dialogs scroll inside the viewport, so long edit forms keep their
  save button reachable on a phone.
- Dialog zoom, sheet slide, sidebar icon scale and stat-bar growth only
  move at the motion level "Voll" (motion-full), not at "Dezent".
- QueryState / ErrorState / LoadingState: loading, error with "Erneut
  versuchen", empty, data - a failed load can no longer look like an
  empty list. EmptyState takes an action for the obvious next step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:33:50 +02:00
Nick SzaunigandClaude Opus 5.5 32dfd8d8a7 feat(ui): calmer inspection form with sections, tap ratings and drafts
AuDHD-oriented rework of the most-used form, plus shared building blocks
for the remaining forms:

- Field: label, "Pflicht"/"optional", hint and the error right at the
  field instead of a vanishing toast.
- FormSection: numbered steps; optional parts start folded and say how
  many fields they hold.
- RatingScale (1-5 as five 44 px buttons with both ends named),
  ToggleChip (yes/no tiles with tick and colour), StickySubmit (save
  stays in thumb reach on phones).
- useFormDraft + DraftBanner: an unfinished entry is kept on the device
  and offered back explicitly ("Weitermachen" / "Verwerfen"), never
  applied silently; drafts expire after 3 days.

Inspection form: 21 flat fields became 5 steps, only colony and date are
required, observations are single taps, a hint appears when neither queen
nor eggs were seen, server messages show inline.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:32:20 +02:00
Nick SzaunigandClaude Opus 5.5 8b6f48b996 docs: API security model, audit results and operating notes
Auth flow and guard order as built now, new architecture decisions
(user-management escalation guard, SSO identity binding, sealed backups),
a section on API security with the remaining known gaps, and the audit
table with commits, new env variables and steps after the update.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:27:22 +02:00
Nick SzaunigandClaude Opus 5.5 45efb08369 fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica
- Every JWT carries a jti. The SSO completion token and the MFA challenge
  (once a code was accepted) are recorded as used in ConsumedToken; a
  replayed request or copied cookie cannot open a second session. Used
  ids are cleaned up after they expire.
- The token-validity check (active user, active role, password change)
  reads fresh on every request and refreshes the permission cache, so a
  deactivation or role change made on another replica applies to the very
  next request instead of up to 30 s later.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:24:28 +02:00
Nick SzaunigandClaude Opus 5.5 fc6af8737b style(public): drop a needless escape in the lot number pattern
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:20:55 +02:00
Nick SzaunigandClaude Opus 5.5 0899576262 fix(api): tighter public lot endpoint, SSO callback from config, mapped 4xx errors
- Public lot lookup: 30 requests/min per IP and only well-formed lot
  numbers (1-64 chars) reach the database.
- SSO callback URL is built from OIDC_REDIRECT_URI instead of the Host
  header; SSO cookies are cleared with the attributes they were set with.
- Logout is rate-limited like the other auth endpoints.
- Middleware errors with a 4xx status (body too large, malformed JSON)
  answer with that status instead of a 500; error log lines carry the
  path without query string.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:20:33 +02:00
Nick SzaunigandClaude Opus 5.5 eafe925d60 fix(search): only search record kinds the caller may read
search:read alone returned apiary names, colony and queen codes, task
titles and batch numbers to roles without the matching read permission.
Each kind of result is now searched only when apiaries:read,
colonies:read, queens:read, tasks:read or honey-batches:read is held.
The search term is validated (string, at most 100 characters).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:18:33 +02:00
Nick SzaunigandClaude Opus 5.5 0ba8996e3a feat(auth): 12-character minimum, 72-byte maximum and breach check for new passwords
- New passwords (create user, admin reset, own change) need at least 12
  characters (ASVS 2.1.1) and may not exceed 72 bytes, which bcrypt would
  silently cut off.
- They are checked against Have I Been Pwned with k-anonymity (only the
  first 5 SHA-1 characters leave the server, padded responses); an
  unreachable service is logged and does not block. PASSWORD_BREACH_CHECK
  =false disables it for offline deployments.
- Login accepts existing passwords as before, capped at 256 characters to
  bound anonymous bcrypt work.
- Password fields in the UI say the rule up front.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:47:09 +02:00
Nick SzaunigandClaude Opus 5.5 61e32474e4 fix(export): neutralize spreadsheet formulas in CSV exports
Typed-in values (colony codes, batch numbers, honey types) starting with
= + - @ TAB or CR were written as-is and ran as formulas (HYPERLINK, DDE)
when the export was opened in Excel or LibreOffice. Such text cells now
get a leading apostrophe; numbers keep their sign. Carriage returns are
quoted as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:43:44 +02:00
Nick SzaunigandClaude Opus 5.5 f1dc0de3e2 feat(api-keys): restrict keys to source IP addresses
A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain
server); a stolen key is useless from anywhere else. Requests from other
addresses get 403 "not allowed for this IP" and a log line. The admin UI
asks for the addresses when creating a key, warns when a key works from
anywhere, shows the last source address and can take it over into the
allowlist - changing the list needs no new key (PATCH /api-keys/:id).

Also: a key can only carry scopes its creator holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:41:46 +02:00
Nick SzaunigandClaude Opus 5.5 9ae1d96a32 fix(api): throttle before auth, configurable proxy trust, no-store and a strict CSP
- ThrottlerGuard runs first, so requests with wrong tokens or API keys
  count against the rate limit before any database lookup.
- TRUST_PROXY (hop count or Express list) replaces the hard-coded 1.
- Every API answer except the public lot pages is Cache-Control: no-store
  without ETag; helmet sends default-src 'none' / frame-ancestors 'none'
  (Swagger keeps the page policy when enabled) and same-site CORP.
- Request log lines carry the path only - no query strings (the SSO
  callback's code and state no longer reach the logs).
- In production the server refuses to start without an https
  FRONTEND_ORIGIN or with COOKIE_SECURE=false.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:37:31 +02:00
Nick SzaunigandClaude Opus 5.5 2de93bc087 fix(auth): atomic refresh rotation and a 7-day absolute session limit
- Rotation claims the old token with a conditional update; of two
  concurrent refreshes only one gets a successor, so a stolen token cannot
  fork into two live sessions.
- A token rotated out less than 10 s ago (parallel tab) is refused without
  revoking every session; later reuse still revokes them all.
- Sessions end at most SESSION_MAX_AGE (default 7 days) after login: the
  login time is inherited by every rotated token and caps its expiry.
- The refresh cookie is cleared with the same attributes it was set with.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:30:32 +02:00
Nick SzaunigandClaude Opus 5.5 5c666fe49a fix(auth): lock accounts after failed attempts, one-time TOTP codes, stronger MFA changes
- Per-account lockout: five failed attempts (password, MFA code or a
  step-up confirmation) lock the account for 15 minutes, on top of the
  per-IP throttle - guesses spread over many addresses no longer help.
- A TOTP code is accepted once: the time step is stored and that step or
  any earlier one is refused (also between two concurrent requests).
- Login runs bcrypt on every path and answers unknown email, wrong
  password, deactivated and locked accounts with the same message; failed
  logins for unknown emails no longer write attacker-chosen text into the
  audit log.
- Turning MFA off needs password and a current code; turning it on needs
  the password. Both end all other sessions.
- New audit action ACCOUNT_LOCKED.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:27:47 +02:00
Nick SzaunigandClaude Opus 5.5 79d20ee64e fix(webhooks): close DNS rebinding, time out deliveries, sign with timestamp
- Deliveries connect through a lookup hook that checks the address
  actually dialled, so a host that resolves to a private address after
  registration (DNS rebinding) or a tampered row cannot reach internal
  services. Redirects are still not followed.
- More blocked ranges (CGNAT/100.64/10 incl. cloud metadata, multicast,
  reserved, benchmarking, NAT64, 6to4, site-local, IPv4-mapped IPv6);
  only ports 80/443, no credentials in the URL, https in production.
- 5 s timeout, response body discarded; dispatch no longer waits for
  receivers, so a hanging endpoint cannot stall the user's request.
- Signature covers "timestamp.deliveryId.body" with X-ApiCore-Timestamp
  and X-ApiCore-Delivery headers, so receivers can reject replays.
- Signing secrets are stored AES-256-GCM-encrypted; older plain rows still
  work.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:17:40 +02:00
Nick SzaunigandClaude Opus 5.5 ebeb499a18 fix(backup): keep GUI backup paths under BACKUP_ROOT and limit the schedule
backup:configure could point automatic backups at any writable directory
(e.g. a statically served one) and set a cron of every second; an invalid
expression deleted the running job before failing, silently ending
backups.

- A path entered in the UI is resolved under BACKUP_ROOT (default
  ./backups) and refused if it leaves it; env values stay unrestricted.
- The schedule must be a five-field cron that runs at most hourly.
- reload() validates before replacing the running job.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:14:18 +02:00
Nick SzaunigandClaude Opus 5.5 bd592adf66 fix(backup): seal backups and require step-up auth to restore
Backups carried every password hash, encrypted MFA secret and webhook
secret in plain JSON, and an import upserted arbitrary rows - including
users - so anyone holding backup:import, or tricking an admin into
importing a file, could overwrite the admin account with a known password.

- Export (download and scheduled) is AES-256-GCM over the gzipped bundle,
  key from BACKUP_ENCRYPTION_KEY or derived from JWT_ACCESS_SECRET via
  HKDF; header is authenticated, decompression is capped. Scheduled files
  are written 0600 in a 0700 directory; run-now returns only the file name.
- Import requires the current password (and TOTP when MFA is on).
- Only a sealed file restores users, roles, grants, webhooks and audit
  entries; an old unsealed file restores business data only and reports
  what it skipped.
- Audit entries are append-only on import.
- Imported documents: entityType checked against the enum, MIME type
  allowlisted and matched against the file signature, path kept under the
  storage root (no more directory traversal via entityType).
- Permission cache is cleared after an import.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:12:43 +02:00
Nick SzaunigandClaude Opus 5.5 a25265b88a fix(sso): bind Microsoft sign-in to the IdP identity, not the email claim
SSO picked the local account by the email/preferred_username claim, which
Entra does not verify. Anyone able to present a matching email (another
tenant with a multi-tenant issuer, or an editable attribute) could sign in
as that user without a password.

- The first SSO login links the account to "<iss>|<oid>" (new unique
  column User.ssoSubject); afterwards only that identity signs in, and a
  matching email from another identity is refused.
- Multi-tenant issuers (common/organizations/consumers) disable SSO.
- Emails are compared case-insensitively and stored lower-case, so
  [email protected] and [email protected] can no longer be two accounts; the migration
  lower-cases existing emails where that does not collide.
- An admin changing a user's email clears the link.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:20:49 +02:00
Nick SzaunigandClaude Opus 5.5 c4a11b4704 fix(users): never manage or hand out more than the caller holds
A role with users:update could reset an administrator's password or MFA,
change the admin's email to one it controls, or give a colluding account
the ADMIN role (with users:assign-role) - and so become administrator.

Every management action (update, deactivate, activate, reset password,
reset MFA) and every role assignment now requires the target role's grants
to be a subset of the caller's own, like role editing already did. The
target role is read from its grants, so a deactivated admin still counts
as an admin. Changing another user's email revokes their sessions.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:16:37 +02:00
Nick SzaunigandClaude Opus 5.5 e0bd7b2095 fix(auth): accept only access tokens as bearer credentials (MFA bypass)
All JWTs (access, MFA setup, MFA challenge, SSO completion) were signed
with the same secret and the access-token strategy checked only signature
and expiry. The MFA challenge token returned after the password step
therefore worked as a full access token: a password alone was enough to
use the API, skipping the second factor.

Every token now carries a pinned issuer, algorithm (HS256) and a
per-purpose audience; the strategy accepts only the access audience and
rejects any payload carrying a helper-token purpose. Existing access
tokens (no audience) stop working; clients renew them via the refresh
cookie.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:14:02 +02:00
Nick SzaunigandClaude Opus 5.5 fac66882fa feat: Ein-/Auswinterung und Winterverluste je Volk
- WinterCheck per colony, winter and season (autumn/spring), every field
  editable; loss causes limited to the COLOSS monitoring categories.
- Summary per winter and apiary: loss rate lost/wintered, marked
  provisional while colonies are not yet checked in spring - an unchecked
  colony never counts as survived.
- A loss marks the colony deceased only on explicit request, in the same
  transaction and audited.
- Permissions winter-checks:read/create/update granted by migration to
  beekeeper and staff roles; winter checks included in backups.
- Page "Überwinterung": next action first, one button per row until the
  colony is wintered, German number format.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 17:32:03 +02:00
Nick SzaunigandClaude Opus 5.5 0d23963f07 feat(export): Bestandsbuch für Tierarzneimittel nach Art. 108 VO (EU) 2019/6
- Treatment records supplier, purchase proof and prescribing vet, and
  keeps the apiary the colony stood at on the treatment day.
- Holder name and address in site settings (never in public output;
  regression test in public.service.spec).
- treatment-log.pdf: one sheet per apiary, identical treatments grouped,
  missing mandatory fields printed as FEHLT, uncertain locations marked.
- Shared PDF table helpers extracted from the record card.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 11:10:07 +02:00
Nick SzaunigandClaude Opus 5.5 f263ec79f2 feat(export): Stockkarte - a colony's complete record as PDF
New report colony-record-card (button "Stockkarte (PDF)" on the colony
page): master data, current queen and queen history, every inspection as
a table row (strength, eggs, open/capped brood, queen seen, queen cells,
gentleness, swarm drive, feed, measures, notes), treatments with batch
number and withdrawal period, feedings, varroa counts and harvests.
Landscape A4, table header repeated on each page. Whole history by
default, one calendar year with ?year=.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:27:55 +02:00
Nick SzaunigandClaude Opus 5.5 5cf4e87ca3 feat(automation): suggest follow-up checks after treatments and feedings
Two new rules, "Kontrolle nach Behandlung" and "Kontrolle nach
Fütterung", create a check task when a treatment or feeding is recorded.
The due date counts from the measure itself (end of treatment, else its
date; feeding date) plus the configured number of days, and the task
names the product and date. At most one open check per colony and rule.

Both rules ship switched off and without an interval: the right timing
depends on the product (package leaflet) and season, so the beekeeper
sets it. Every task-creating rule can now carry a "due after N days"
setting in the automation page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:23:13 +02:00
Nick SzaunigandClaude Opus 5.5 888edf7e1d feat(labels): cut marks in the sheet margin for trimming with a paper cutter
Label sheets printed on plain A4 are cut with a lever paper cutter. Each
template can now print small hairline cut marks in the sheet margin: one
at both ends of every cut line (left/right edge of each column, top/bottom
edge of each row), 1 mm away from the label and never closer than 2 mm to
the paper edge, so they print on normal printers and never touch a label.
Labels without a gap share one mark. Marks are drawn on every sheet for
the full grid, since the cutter cuts across the whole sheet.

On by default (new column cropMarks, default true); switch it off in the
template designer for pre-die-cut label sheets.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:11:03 +02:00
Nick SzaunigandClaude Opus 5.5 445fb3b94b feat: revocable read-only API keys for machine access
Integrations such as the Wabenhain shop sync no longer need a service
account with a password (and without MFA). An admin creates a key under
Verwaltung > Automatisierung > API-Schlüssel.

- Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is
  shown exactly once. Optional expiry, "last used" timestamp, revocation
  takes effect immediately; create/revoke are audit-logged without the key.
- A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard
  admits it only to routes whose permission is among its scopes and never
  to user-only routes. Scopes come from a short allowlist (shop-sync:read).
- Managing keys requires the new permission api-keys:manage.
- Keys are excluded from backups on purpose; a restore means issuing a new
  key.

Additive migration (new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 01:24:17 +02:00
Nick SzaunigandClaude Opus 5.5 ede86c062a feat: dedicated, versioned shop-sync feed for Wabenhain
GET /api/v1/shop-sync returns exactly the data ApiCore releases to the
shop: harvest facts, article master data and bottlings with their
harvest ids. Every query uses an explicit select, so notes, colonies,
treatments and fields added to the models later never leave ApiCore
unless deliberately added to the allowlist.

- New permission shop-sync:read (granted to ADMIN automatically, to no
  other role): the shop's service account needs nothing else, in
  particular no access to harvest or extraction records with notes.
- contractVersion 1; only breaking changes bump it.
- Always the full set: small volume, and a full comparison heals missed
  runs by itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:31:46 +02:00
Nick SzaunigandClaude Opus 5.5 3c7ff1e0b7 docs: document field ownership between ApiCore and Wabenhain
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:15:02 +02:00
Nick SzaunigandClaude Opus 5.5 2d4da96ad8 docs: record review findings, verification state and new architecture decisions
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 13:01:47 +02:00
Nick SzaunigandClaude Opus 5.5 f9374a135a feat: "Heute" start page with one next step, clear task states and motion levels
Start page (AuDHD-oriented, per the agreed "Heute zuerst" layout):
- One highlighted next step: started work first (easy re-entry after an
  interruption), then overdue, due today, then urgent undated tasks.
  Primary action "Erledigt", secondary "Beginnen", link to the related
  colony/apiary/queen.
- "Außerdem heute": at most five further tasks with a large honey-cell
  checkbox; undated automatic suggestions are included so a queenless
  colony does not sit unseen in the full list.
- Completing is the one authored motion moment: the cell fills, the tick
  draws, the row folds away and the next step arrives with a short focus
  pull. Every completion can be undone from the toast (10 s), restoring
  the previous status.
- Status is shown as icon plus word (never colour alone); tasks created by
  automation carry a "Vorschlag" badge, recurring and inspection tasks
  their own - a suggestion never looks like work already done.
- Friendly empty, loading and error states; key figures moved into a
  compact overview below; overdue tasks are no longer listed twice.

Motion: per-device setting Voll / Dezent / Aus in the profile (default
Voll); the OS reduce-motion setting always lowers Voll to Dezent.
New 44 px "touch" button size for gloves at the apiary; themed text
selection and caret.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 13:00:44 +02:00
Nick SzaunigandClaude Opus 5.5 5c2ea7bf1e fix: resend offline-queued entries with an idempotency key
When the server saved an inspection, harvest, treatment, feeding, varroa
measurement or swarm check but the answer was lost (dead zone at the
apiary), the form queued the entry and the sync later created it a second
time - including a second automation task. Each opened form now creates
one Idempotency-Key, sends it with the request and stores it with the
queued entry, so the retry returns the first record. Entries queued before
this change still sync without a key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:31:04 +02:00
Nick SzaunigandClaude Opus 5.5 5d11756a87 fix: keep quantities, colony/queen states and tasks consistent under retries
Data-integrity fixes found in a systematic review of ApiCore:

- Bottling booked the honey batch amount from a value read outside the
  transaction and wrote an absolute result back, so two concurrent
  bottlings (double tap, two devices) could both pass the check and one
  deduction was lost. Bookings now use a conditional UPDATE inside the
  transaction; corrections and deletions give amounts back atomically.
- Outgoing stock movements could drive stock below zero; the removal is
  now a conditional decrement that refuses when stock is insufficient.
- Weak, conspicuous and queenless colonies were treated as "not active"
  and could not be moved, split or merged - uniting a queenless colony
  is the textbook merge. Only terminal statuses lock a colony now; merging
  a colony into itself and dissolving twice are rejected.
- Moving a queen to another colony failed on the unique currentQueenId
  because the old colony kept pointing at her. The old colony now releases
  her (and becomes QUEENLESS); a queenless colony receiving a queen becomes
  ACTIVE; dissolved colonies cannot receive a queen.
- Completing a task twice moved completedAt and re-sent the webhook;
  reopening a task kept completedAt. Recurring templates created tasks
  without a due date and caught up missed weeks one stale task at a time.
- Automation rules created a new task and warning on every trigger (every
  stock removal below minimum). Tasks now record their source trigger and
  at most one open task per rule and object exists. The varroa threshold
  (mites per day) is only compared with natural-mite-fall rates, not with
  sample-based counts in another unit.
- New opt-in Idempotency-Key support for record-creating endpoints, so
  the offline queue can retry after a lost response without duplicates.
- Permission catalog sync crashed replicas booting concurrently on a
  fresh database (unique violation); it now uses createMany/skipDuplicates.
- Server-side validation for inspection rating scales (1-5), unique
  colony/harvest lists and net weight <= gross weight.
- Stale e2e expectations updated (permission count, trigger list, real PNG
  fixture for the file-signature check).

Migration is additive only (nullable Task column, new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:27:45 +02:00
Nick SzaunigandClaude Opus 5 786d929df9 feat: expose the remaining fields the edit forms were withholding
Backend Image / build-and-push (push) Successful in 2m11s
Frontend Image / build-and-push (push) Successful in 1m53s
The backend accepted every field of these records already; the forms simply
did not offer them, so from the app they were still frozen.

- Inventar: only the condition could be changed. A mistyped inventory number,
  a wrong purchase or maintenance date, a renamed item - all stuck for the life
  of the record. New edit dialog covering every field; the inline condition
  select stays as a shortcut.
- Produkt: the status was shown as a badge but could not be set anywhere, so a
  product could never be discontinued or brought back.
- Aufgabe: the status could only ever move to DONE through the "Erledigt"
  button. IN_PROGRESS and CANCELLED were unreachable, and an accidentally
  completed task could not be reopened.
- Aufgabenvorlage: priority, assignee and the active flag were missing.
  Deactivating was one-way, since only a deactivate endpoint was wired up -
  the active checkbox now reactivates through PATCH.
- Königinnen-Bewertung: notes and the evaluated colony were missing.

Verified by extracting the field set each form submits and comparing it with
the matching update DTO: every form now covers its DTO. The one gap left is
re-linking a document to a different record, which still has no UI.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 23:11:12 +02:00
Nick SzaunigandClaude Opus 5 5a5bd4a138 fix: print the jar size on labels in grams, not millilitres
renderLabelsPdf resolved the jarSize placeholder to "500 ml". Everywhere else
in the app jarSize is a fill weight in grams - the bottling forms label it
"Glasgröße (g)", the batch list and the public Herkunft page print "g", and
BottlingService divides by 1000 to get kilograms. Only the printed label said
millilitres, so every label produced so far declares the wrong net quantity for
a food sold by mass.

The placeholder mapping moved into an exported buildPlaceholderValues() so the
wording can be asserted directly. It could not be before: the render output is
a PDF buffer whose text a test cannot read, which is how the wrong unit shipped
unnoticed. Three tests now cover it, including one that rejects any volume unit
outright.

Labels already printed with "500 ml" have to be reprinted - the code change
does not correct paper.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 02:37:00 +02:00
Nick SzaunigandClaude Opus 5 46a2322814 fix: restore the e2e suite, which could not compile at all
Backend Image / build-and-push (push) Successful in 2m4s
Frontend Image / build-and-push (push) Successful in 1m51s
test/jest-e2e.json listed the ESM-only packages ts-jest must still transform,
but @nestjs/schedule (12.0.1, "type": "module") was not among them. Since
AppModule imports ScheduleModule for the backup scheduler, every e2e spec died
on "SyntaxError: Unexpected token 'export'" while loading AppModule - the whole
suite, not one test.

That silently disabled test/authorization-coverage.e2e-spec.ts, the check that
asserts every non-public route carries @RequirePermission or
@AnyAuthenticatedUser. It is exactly the check that would have caught the
displaced decorators fixed in the previous commit, and it had been dead since
the scheduler shipped.

With @nestjs/schedule and its cron dependencies added to the allowlist, the
authorization-coverage spec compiles and passes, confirming no route is left
undecorated. The remaining 36 e2e specs still need a Postgres instance on the
port .env.test points at; they fail on connection, not on code.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 02:22:09 +02:00
Nick SzaunigandClaude Opus 5 b8809eeaa2 fix: restore the authorization decorators three new routes displaced
Inserting the new PATCH handlers directly above an existing route put them
between that route's @RequirePermission decorator and its method, so the
decorator bound to the new handler instead:

- DELETE /documents/:id lost documents:delete
- POST /honey-batches/:id/measurements lost honey-batches:measure
- POST /queens/:id/evaluations lost queens:evaluate

and each new PATCH ended up carrying two @RequirePermission decorators, of
which only one takes effect.

This did not open anything up. PermissionsGuard fails closed by decorator
absence: a non-public route with neither @RequirePermission nor
@AnyAuthenticatedUser is denied outright, so the three routes would have
returned 403 for every caller including an administrator. The effect was a
broken endpoint, not a bypass.

Each decorator is back on the route it belongs to, and the duplicates are
gone. test/authorization-coverage.e2e-spec.ts asserts exactly this contract
across every route and is the check that should run before a route change
ships.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 02:12:56 +02:00
Nick SzaunigandClaude Opus 5 9a147ee123 feat: make webhooks and documents editable instead of delete-and-recreate
Both could only be created and deleted. For a webhook that is actively harmful:
recreating it issues a new signing secret, so the receiver that verifies the
X-ApiCore-Signature header against the old one starts rejecting every delivery
without anyone changing it on purpose.

Backend:
- New PATCH /webhooks/:id behind a new webhooks:update permission, covering the
  target URL, the event selection and the active flag - the last of which had
  no way to be set after creation at all, so a misbehaving receiver could only
  be deleted. The signing secret is deliberately left alone and stays out of
  the audit log.
- A changed webhook URL runs through the same assertPublicWebhookUrl SSRF check
  as a new one; without that the edit would have been a way around the guard
  create() applies. Covered by a test.
- New PATCH /documents/:id behind a new documents:update permission, for
  re-linking a document filed against the wrong record and correcting its
  display name. Re-linking validates the new target with the existing
  assertEntityExists; the stored file is untouched.

Frontend:
- New webhook edit dialog. The event labels and the toggle helper moved out of
  the create dialog into lib/webhooks/labels.ts so both use one copy.
- Documents can be renamed from the attachment card.

Re-linking a document to a different record has no UI yet: it needs a two-step
picker (colony, then inspection) that is not worth building unprompted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 02:07:48 +02:00
Nick SzaunigandClaude Opus 5 42ceb33844 feat: allow correcting honey measurements and queen evaluations
Backend Image / build-and-push (push) Successful in 2m3s
Frontend Image / build-and-push (push) Successful in 1m41s
Both records could be created but never changed. A water-content reading
decides whether a batch may be sold as honey at all, and queen ratings drive
breeding decisions - a slipped digit in either is exactly the kind of wrong
value that must not be left standing.

- New PATCH /honey-batches/measurements/:measurementId behind
  honey-batches:measure, and PATCH /queens/evaluations/:evaluationId behind
  queens:evaluate. Both are addressed by the child record's own id, so the
  routes take two path segments and do not collide with PATCH /:id.
- Both log before and after data, like every other correction.
- The measurement list on the honey batch page and the rating list on the queen
  page gained an edit dialog; the rating dialog reuses the existing criteria
  list rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:15:54 +02:00
Nick SzaunigandClaude Opus 5 b0e05daa42 feat: open the last locked fields for editing, keeping their history intact
Four fields were still frozen because changing them would have bypassed the
record that makes the change traceable, and a user's login data could not be
corrected at all. Locking a field is the wrong trade: a wrong value stays in
the system forever. The fix is to let the edit write the same history entry the
dedicated endpoint writes.

- A colony's apiary is editable through PATCH and writes the same
  ColonyApiaryHistory entry POST /colonies/:id/move writes, so the location
  history never skips a stand the colony was recorded on.
- A honey batch's storage container is editable through PATCH and writes the
  same HoneyBatchRelocation entry POST /honey-batches/:id/relocate writes.
- A stock item's quantity is editable and books the difference as an ADJUSTMENT
  movement, so the stored level stays the sum of its movements.
- A role's key is editable (system roles were already rejected outright by
  RolesService.update, which keeps the seed's ADMIN lookup intact).
- A user's e-mail - the login identity - is editable. A typo there previously
  locked the account out for good.
- New POST /users/:id/reset-password lets an administrator set someone else's
  password: there is no self-service reset flow, so a forgotten password was
  the one thing no edit could repair. It revokes every refresh token and clears
  the permission cache exactly as a self-service change does, refuses to act on
  your own account (that goes through /profile/password, which proves ownership
  by asking for the current one), and never writes the password to the log.

Frontend:
- Users had no edit UI at all, although PATCH /users/:id existed. New
  EditUserDialog covers name, e-mail, role and an optional new password.
- Colony, honey batch and stock item forms gained the newly editable fields,
  each with a note saying the correction is recorded as a move, a relocation
  or a stock adjustment.
- The role dialog gained the key field.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:13:00 +02:00
Nick SzaunigandClaude Opus 5 bee2199647 feat: make the parent relation of every record correctable after entry
An inspection, treatment, feeding, varroa measurement or swarm control check
filed against the wrong colony could not be repaired: the colony was frozen at
creation time, and with no delete endpoint either, the only remedy was leaving
a wrong record in the colony's history. The same held for a harvest booked
against the wrong apiary or with a colony forgotten, for an extraction linked
to the wrong harvests, and for a honey batch whose number or source extraction
was mistyped.

Backend:
- The five colony-scoped update DTOs now cover the full create DTO, colonyId
  included.
- Harvest update accepts apiaryId and colonyIds, extraction update accepts
  harvestIds. Both validate the ids first and replace the whole join-row set
  inside the existing transaction; the join rows carry no data of their own,
  so replacing beats diffing.
- Honey batch update accepts batchNumber, extractionId and status. Status was
  previously only ever set to BOTTLED by the bottling module, so SOLD and
  CONSUMED were unreachable through the API.
- Colony and storage container codes, and a task's relatedType/relatedId, are
  editable.
- apiaryId on a colony and storageContainerId on a honey batch stay out of
  PATCH on purpose: both have dedicated endpoints that also write the history
  entry (ColonyApiaryHistory, HoneyBatchRelocation) the traceability chain
  depends on.
- AllExceptionsFilter now also translates Prisma P2003 to a 400 and P2025 to a
  404. Services write straight against the foreign keys with no application
  level existence check, so before this a stale or mistyped relation id came
  back as a bare 500 and filled the log with fake server errors.

Frontend:
- New shared fields: ColonySelectField (used by all five colony-scoped edit
  dialogs), HarvestSelectField (both extraction dialogs) and TaskRelatedField.
- Re-filing a record invalidates the lists of both the colony it left and the
  one it joined, otherwise one of the two stays stale.
- The harvest dialog gained an apiary select and a colony checklist that keeps
  already-linked colonies visible even when they moved or were dissolved; the
  honey batch dialog gained batch number, source extraction and status.
- Extraction and harvest forms refuse to submit an empty relation set instead
  of letting the backend reject it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:01:05 +02:00
Nick SzaunigandClaude Opus 5 2b15795a60 feat: make every field of a bottling batch correctable and deletable
A bottling batch could only be corrected in four of its seven fields after
creation: honey batch, jar size and jar count were frozen at creation time,
an assigned product could not be cleared again, and there was no way to
remove a record entered by mistake at all. A mistyped jar count is a
data-entry error, not a fact of production, so it has to stay correctable.

Backend:
- UpdateBottlingBatchDto now covers every field. `productId: null`
  explicitly clears the product assignment; omitting it leaves it alone.
- BottlingService.update rebalances the affected honey batch amounts:
  the previously bottled amount is given back before the corrected amount
  is taken again, so a correction never double-charges the source batch.
  Moving a batch to a different honey batch credits the old one and debits
  the new one in the same transaction.
- New DELETE /bottling-batches/:id gives the bottled amount back to the
  honey batch and keeps the full record in the audit log (action DELETE,
  beforeData). The honey batch status is deliberately left untouched - the
  status it held before being bottled is not recoverable here.
- New permission bottling:delete, checked server-side. The catalog sync
  grants it to system roles on the next boot.

Frontend:
- The edit dialog exposes all fields, offers "Kein Produkt" to clear the
  assignment, keeps a discontinued product selectable so opening the dialog
  cannot silently drop it, and shows backend errors inline.
- The form is mounted only while the dialog is open, so a cancelled edit
  never leaks its values into the next one.
- New delete button behind a confirmation that names the amount credited
  back and warns that the public origin page for the lot number stops
  resolving.
- Edit and delete are additionally hidden without the matching permission.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 07:50:36 +02:00
Nick SzaunigandClaude Sonnet 5 961ca04a40 fix: translate duplicate-code errors into clean 409 responses
No code anywhere caught Prisma's P2002 (unique constraint violation).
Colony/apiary/queen codes, product SKUs, batch numbers, and equipment
inventory numbers are all @unique DB columns with no application-level
duplicate check ahead of the insert, relying on the constraint itself
- so a beekeeper reusing a code by mistake got a bare 500 "Internal
server error" with zero indication what went wrong, and the incident
polluted server logs as a fake error rather than a routine 4xx.

AllExceptionsFilter now recognizes P2002, returns 409 with a specific
German message naming the conflicting field when it's a single known
column (falling back to a generic duplicate message otherwise), and
logs it at warn level like any other client-side rejection.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:47:28 +02:00
Nick SzaunigandClaude Sonnet 5 dfb4de81f8 fix: surface remaining silent mutation failures
Four more mutations had no error feedback: equipment condition change,
task completion, and notification mark-as-read all failed silently.
More importantly, the offline sync badge computed a result.failed
count from syncPendingInspections() but never displayed it - an entry
rejected by the server (e.g. a validation rule) would sit in the
offline queue indefinitely with zero indication to the beekeeper why
it never syncs. Both the failed-count and outright sync-mutation
rejection now surface a toast.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:41:36 +02:00
Nick SzaunigandClaude Sonnet 5 997dadfaaa fix: surface specific business-rule error messages to beekeepers
Backend business-rule guards (inactive apiary blocking colony
create/move, active colonies blocking apiary deactivation, negative
quantity on IN/OUT stock movements, unavailable queen blocking colony
assignment, inactive colony blocking split/merge/move) threw English,
UUID-laden exception text that the frontend either discarded in favor
of a generic message or - in colony-actions.tsx's move/split/merge/
assign-queen dialogs - didn't handle at all, so the guard fired with
zero visible feedback. Translated the messages to plain German and
wired the affected mutations to display them, so a beekeeper sees the
actual reason a save was rejected instead of silence or "failed".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:38:35 +02:00
Nick SzaunigandClaude Sonnet 5 de5df72491 fix: handle document download and clipboard copy failures
Two unguarded async calls silently swallowed errors: document download
used a bare "void downloadDocument(...)" with no catch, so a failed
download (network error, expired session) did nothing visible; the
webhook secret copy button had no try/catch around
navigator.clipboard.writeText, which can throw on denied permission or
an insecure context. Both now show a toast on failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:31:15 +02:00
Nick SzaunigandClaude Sonnet 5 4ec1468eb4 fix: add error feedback to PDF/CSV export mutations
Export mutations (annual report PDFs, colony/honey-batch CSV exports)
had no onError handler, so a failed download (network error, expired
session) silently did nothing with zero user feedback. Same silent-
failure class as the earlier onError sweep on entity-save mutations,
here applied to the 5 remaining export/download call sites.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:29:01 +02:00
Nick SzaunigandClaude Sonnet 5 b4caa2ad5a fix: show an error toast when editing any entity fails
Nearly every *-edit-dialog.tsx and *-edit-card.tsx in the app - the
save mutation for editing a colony, queen, treatment, inspection,
feeding, varroa measurement, swarm control check, harvest, extraction
(both variants), honey batch, bottling batch, task, stock item,
storage container, or product - had an onSuccess handler but no
onError. A failed save (a validation error, including several added
earlier this session, a permission error, a network hiccup) produced
no feedback at all: no toast, no inline message, the dialog just sat
there having silently done nothing. The user's most likely read on
that is "it saved," when it didn't.

Added the same onError: () => toast.error("<Entity> konnte nicht
gespeichert werden") pattern already used elsewhere in the app (e.g.
apiary deactivate/reactivate) to every one of these mutations. Also
covers assign-colony-dialog.tsx, which is the exact spot where the
queen-availability guard added earlier this session would otherwise
have failed with zero visible feedback.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:22:11 +02:00
Nick SzaunigandClaude Sonnet 5 96207d0096 feat: mirror the new backend numeric bounds as HTML min/max on inputs
The previous two commits added @Min(0)/@Max(100) validation and a
sign-inversion guard on the backend, but the number inputs themselves
had no min/max attributes - a user could still type a negative weight,
quantity, or an out-of-range percentage and only find out via a
generic "failed" toast after submitting, with no indication of why.

Added min={0} (and max={100} on water-content percentages) to every
input for a field the backend now bounds: harvest weights/super
count, honey-batch amount/water content, honey measurements, feeding
amount, extraction amount/water content, stock item quantity/min
threshold, and varroa mite count/duration/calculated value - across
both the create and edit form for each.

The stock-movement quantity input is the one exception requiring
care: min is only applied when type !== "ADJUSTMENT", matching the
backend guard, since a correction movement must still be allowed to
go negative. Added a short explanatory line under that field too.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:12:24 +02:00
Nick SzaunigandClaude Sonnet 5 b208f52fd1 fix: reject negative weights/quantities and out-of-range percentages
None of these numeric business fields had a lower bound, so a
negative honey weight, jar count, feeding amount, mite count, or
stock quantity was silently accepted - each would corrupt whatever
total or statistic it later feeds into. Added @Min(0):

- Harvest: honeySuperCount, grossWeight, netWeight
- HoneyBatch: amount (+ @Max(100) on waterContent, a percentage)
- HoneyMeasurement: waterContent (+ @Max(100))
- Feeding: amount
- Extraction: amount (+ @Max(100) on waterContent)
- StockItem: quantity, minThreshold
- VarroaMeasurement: durationMinutes, miteCount, calculatedValue

Left temperature fields alone (extraction, inspection) - genuinely
can be negative (winter inspections, outdoor storage) and no
confident domain bound exists to apply instead. Update DTOs for these
entities all extend PartialType(PickType(Create...Dto, [...])) from
@nestjs/swagger, which preserves validation decorators, so no
separate change was needed there.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:05:13 +02:00
Nick SzaunigandClaude Sonnet 5 93d0f2b208 fix: reject a negative quantity on IN/OUT stock movements
addMovement() negates quantity internally for OUT (delta = -quantity)
and applies it directly for IN. Nothing stopped a negative quantity
from being submitted, so e.g. { type: 'OUT', quantity: -5 } would
compute delta = -(-5) = +5 - silently ADDING stock while being
recorded in the audit trail as a removal. The inverse held for IN.
ADJUSTMENT is the one type that must stay allowed to be negative,
since that's the only way to correct a stock count downward, so this
is a service-level guard (BadRequestException) rather than a DTO
validator - class-validator's @ValidateIf skips every validator on a
property when its condition is false, including @IsNumber, so it
can't express "always a number, but only sometimes positive" cleanly;
verified this empirically before choosing the service-level approach.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:01:45 +02:00
Nick SzaunigandClaude Sonnet 5 8bf9aa467d feat: add error boundaries so a crash never blanks the whole app
No error.tsx existed anywhere in the app - an uncaught render error on
any page (a beekeeper's most field-critical moment: mid-inspection,
no signal, nowhere to go) showed nothing recoverable, not even a
message, let alone a way back to a working page.

Two boundaries, matching Next.js App Router's error-boundary scoping:
- (app)/error.tsx covers every authenticated page. It only replaces
  the crashed page's content - the sidebar/header/bottom nav stay
  mounted and usable, so the user can navigate to a different page
  without a full reload.
- app/error.tsx is the outer fallback (login, the public Herkunft
  page, or a crash in (app)/layout.tsx itself, which only a strictly
  higher boundary can catch). Its "Zum Dashboard" action is a plain
  <a> reload rather than client-side navigation, since a crash this
  high may have left client state corrupted.

Both are in German, matching the app's existing card/honeycomb visual
language, and explicitly reassure the user that already-saved data is
unaffected.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 10:55:47 +02:00
Nick SzaunigandClaude Sonnet 5 2e6846285d fix: include Wirkstoff, Charge, and Wartezeit in the annual treatment report
The colony annual PDF report's treatment section only ever printed
date/product/amount - activeIngredient, batchNumber, and
withdrawalPeriodDays (added earlier this session specifically for
Wartezeit compliance) existed on the Treatment record but never made
it into the one document a beekeeper would actually hand to a
Veterinäramt inspector or keep as their Bestandsbuch-style compliance
record. Silently missing the withdrawal period from the printed
record undermines the whole point of tracking it.

Also adds the first tests for annual-reports.ts (previously untested).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 10:50:46 +02:00
Nick SzaunigandClaude Sonnet 5 fb4713e320 fix: cache backend API data so beekeepers can view it with no signal
The service worker only ever cached same-origin static assets/pages -
every real data call (colonies, apiaries, tasks, honey batches, ...)
was excluded, both because it targets a different origin (API_URL
points at a separate backend service, per .env.example) and because
of an explicit /api path exclusion. In practice this meant a
beekeeper opening the app at an apiary with no signal saw nothing at
all: not even data fetched minutes earlier on the same device, despite
the offline write-queue built earlier assuming the app itself stays
usable while offline.

The backend can't be matched by origin the way static assets are (the
service worker has no static knowledge of the runtime-configured
API_URL), so it's matched by path instead - every backend call goes
through /api/v1/. /api/runtime-config is explicitly excluded and
always fetched live, since a stale cached value would point the app
at the wrong backend. Error responses (4xx/5xx) are now never cached
either, closing a latent bug that could otherwise serve a stale 401
back after a token refresh.

Since the cache now holds real business data, it's cleared on logout
so it can't leak into the next person's session on a shared device.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:45:11 +02:00
Nick SzaunigandClaude Sonnet 5 3678a01319 fix: prevent new backend guards from ever surfacing as raw errors
The colony-apiary and queen-availability validation just added
backend-side had no matching frontend guard, so a user could still
pick an inactive apiary or open the assign-colony dialog on an
unavailable queen and only find out it fails after submitting:

- Colony create/move apiary pickers now fetch only ACTIVE apiaries.
- The queen "Volk zuweisen" button is disabled once her status is
  LOST/REPLACED/SOLD/DECEASED (QUEEN_UNAVAILABLE_STATUSES, mirroring
  the backend's QUEEN_NO_LONGER_AVAILABLE_STATUSES), instead of only
  failing after the form is filled out and submitted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:39:47 +02:00
Nick SzaunigandClaude Sonnet 5 3e179391b0 fix: block assigning a colony to a queen who is no longer available
assignColony() never checked the queen's own status, so a queen marked
LOST/DECEASED/REPLACED/SOLD - exactly the statuses the queen-loss fix
now treats as "not really available" and releases her from her old
colony for - could still be assigned to a brand new colony, which
makes no domain sense (you can't put a dead queen in a hive).

Rejects with ConflictException up front, before even looking up the
destination colony; correcting a mistaken status update first is the
intended fix path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:35:32 +02:00
Nick SzaunigandClaude Sonnet 5 5c5c7b756f fix: block creating or moving a colony into an inactive/missing apiary
Colony.apiaryId was never checked against the target apiary at all -
create() would hit a raw Prisma FK-constraint error for a nonexistent
apiary instead of a clean 404, and both create() and move() would
silently succeed against a deactivated apiary, orphaning the colony
the same way (just from the opposite direction) as the apiary-side gap
fixed in the previous commit.

Adds a shared assertApiaryActive() check, following the same
findUnique-then-status pattern already used for cross-entity checks
elsewhere (e.g. QueensService.assertMotherQueenExists), used by both
create() and move().

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:32:53 +02:00
Nick SzaunigandClaude Sonnet 5 961a237335 fix: block deactivating an apiary while it still has active colonies
Colony.apiaryId is a plain FK, never checked against the apiary's own
status. Without a guard, deactivating an apiary silently orphaned any
colonies still living there: they kept showing as ACTIVE, but their
apiary had disappeared from every "active apiaries" list, dashboard
count, and colony-creation picker - a confusing half-state with no
warning at all.

ApiariesService.deactivate() now throws ConflictException when active
colonies remain, so the beekeeper has to move or dissolve them first -
matching the same assertActive-style guard pattern already used
elsewhere (colonies, e.g. move()/merge()/split()).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:30:19 +02:00
Nick SzaunigandClaude Sonnet 5 20981ec1ce fix: release a colony's queen when it is dissolved or merged away
Colony.currentQueenId is unique across colonies. dissolve() and merge()
set status to DISSOLVED/MERGED but never cleared currentQueenId, so a
colony that still had a queen when it was dissolved or merged kept
permanently "owning" her - any later attempt to assign that queen to a
different colony (e.g. requeening another colony with her) would fail
with a unique-constraint conflict, since the dead colony's stale
reference was still occupying that slot.

Both operations now clear currentQueenId and close the open
QueenColonyHistory row for that queen, mirroring the bookkeeping
assignColony() and the queen-status-transition fix already do
elsewhere. The merge survivor keeps its own queen; a merged-away
colony's queen is released, not auto-transferred, since which queen
"wins" a merge is a beekeeper decision.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 20:58:53 +02:00
Nick SzaunigandClaude Sonnet 5 cc46fa4d9e fix: mark a colony QUEENLESS when its queen is lost, dies, or is replaced
A very common real-world event - a queen dying over winter, absconding,
being superseded, sold, or replaced - had no side effect on the colony
record at all. Colony.currentQueenId kept pointing at a queen who was
no longer really there, colony.status stayed whatever it was, and the
QUEENLESS dashboard alert could never fire from this path.

QueensService.update() now detects a transition into
LOST/DECEASED/REPLACED/SOLD and, inside the same transaction, clears
currentQueenId on whichever colony currently points at that queen,
flips its status to QUEENLESS (unless the colony is already in a
terminal lifecycle status like DISSOLVED, which is left alone), and
closes the open QueenColonyHistory row - mirroring the bookkeeping
assignColony() already does when a queen changes colonies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 13:30:18 +02:00
Nick SzaunigandClaude Sonnet 5 32f11e5b39 fix: clamp {SEQ:N} width in number-format to prevent resource exhaustion
formatGeneratedNumber() passed the admin-supplied {SEQ:N} width straight
into String.padStart() with no upper bound. A careless or malicious
format value (e.g. {SEQ:999999999}) would try to allocate a
correspondingly huge string - on the backend that runs on every single
honey-batch/bottling creation once such a format is saved, and in the
newly added frontend preview it would run on every keystroke while
typing the format. Caps the effective width at 20 digits, far beyond
any realistic sequence number, in both the backend implementation and
its frontend port so they stay identical.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:41:49 +02:00
Nick SzaunigandClaude Sonnet 5 1c16db114e feat: show a live example number for active number-format rules
Admins configuring the honey-batch/bottling auto-numbering format
(WH-{YYYY}-{SEQ:4}) had no way to see what it would actually produce
before saving - a typo'd placeholder or unexpected format silently
breaks every batch number from then on. When the rule is active, the
format card now renders the next number it would generate, live as
the format is edited, using the same formatGeneratedNumber logic the
backend uses (ported to the frontend, no shared package between the
two apps) and the real next sequence value (sequenceValue + 1).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:36:06 +02:00
Nick SzaunigandClaude Sonnet 5 1dddc387ba fix: expose every backend-supported field in all edit forms
Audit requested by the user after finding queens couldn't set
hatchYear: many edit dialogs (and some create forms) were missing
fields the backend DTOs already accept, so that data could never be
entered or corrected anywhere in the UI. Fixes per entity:

- Colony: status (WEAK/QUEENLESS/CONSPICUOUS) was never settable
  anywhere, so the COLONY_ATTENTION dashboard alert could never fire.
  Added as a dropdown restricted to the non-terminal statuses;
  DISSOLVED/SOLD/DECEASED/MERGED stay on their dedicated confirm
  actions, which also set deactivatedAt.
- Apiary: description was missing from the edit form.
- Inspection: 10 fields (weather, temperature, broodAmount,
  broodPattern, openBroodPresent, cappedBroodPresent,
  queenCellsPresent, swarmMood, gentleness, combBehavior,
  honeySuperStatus) existed in the schema/DTOs but nowhere in the UI -
  added to both the create and edit forms.
- Varroa measurement: durationMinutes was on the create form but
  missing from edit.
- Feeding: batchNumber missing from both create and edit.
- Treatment: startDate, endDate, activeIngredient, batchNumber
  missing from both create and edit.
- Task: assignedUserId had no UI anywhere; added to create/edit,
  degrading gracefully if the current user's role lacks users:read.
- Extraction (honey-batch traceability variant): amount was missing,
  while the other edit dialog for the same entity already had it.
- Bottling: productId missing from edit.
- Harvest: grossWeight missing from edit; grossWeight and
  honeySuperCount missing from create.
- Queen: breeder, matingType, matingStatus, inseminationData,
  motherLine, fatherLine added to the edit form (hatchYear/
  markingColor/markingNumber were already fixed in a prior commit).

Left alone deliberately: fields the backend DTO itself excludes via
PickType (e.g. HoneyBatch's jarSize/jarCount/totalAmount, Bottling's
quantities) since mutating those post-hoc would desync inventory
deduction, and Colony's terminal statuses/code which have dedicated
lifecycle actions.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:20:18 +02:00
Nick SzaunigandClaude Sonnet 5 792806b967 fix: compute mites/day for natural mite fall varroa measurements
VarroaMeasurement.calculatedValue was already used as "Milben/Tag" by
the VARROA_THRESHOLD automation (thresholdMitesPerDay) and the annual
PDF report, but no client ever actually computed it - the frontend
form only collects raw miteCount and durationMinutes. That meant the
threshold check was comparing a raw, un-normalized mite count against
a per-day threshold: e.g. 12 mites counted over 5 days (2.4/day,
healthy) would incorrectly trigger the same alert as 12 mites counted
over 1 day (a real problem).

VarroaService now derives calculatedValue = miteCount / duration in
days for NATURAL_MITE_FALL measurements, on create and (only when no
value exists yet) on update, without ever overwriting an explicitly
provided or already-stored value. Other methods (Puderzucker,
Waschmethode, Drohnenbrutkontrolle) use a different, non-time-based
metric and are left for manual entry rather than guessing an
unverified formula.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:01:02 +02:00
Nick SzaunigandClaude Sonnet 5 985ea09556 fix: allow entering a queen's hatch year and marking color/number
The backend has always accepted hatchYear/markingColor/markingNumber
(Queen DTOs, Prisma schema), and the queens list even already renders
a "Schlupfjahr" field - but neither the create nor the edit form ever
exposed inputs for them, so no user could actually set hatchYear
through the UI. That silently broke the OLD_QUEEN dashboard alert
just added: it can only ever fire for colonies whose queen has a
hatchYear, and there was no way to record one.

Both forms now include Schlupfjahr, Zeichenfarbe and Zeichennummer.
Marking color is auto-suggested from the hatch year using the
internationally standardized queen marking color convention (last
digit of the year -> white/yellow/red/green/blue), editable like any
other suggestion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 21:44:29 +02:00
Nick SzaunigandClaude Sonnet 5 edf13d3ae4 feat: warn on the dashboard when a queen is 3+ years old
Queen.hatchYear was already captured but never used anywhere - no
signal existed for "this colony's queen is overdue for evaluation or
replacement," despite that being one of the most common productivity
and swarming risk factors in beekeeping. Adds an OLD_QUEEN dashboard
alert alongside the existing overdue-task/swarm-check ones, flagging
any active colony whose current queen has completed 3+ seasons.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 18:43:37 +02:00
Nick SzaunigandClaude Sonnet 5 8c4ae64e76 docs: record the Wartezeit design assumption in ARCHITECTURE.md
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:51:15 +02:00
Nick SzaunigandClaude Sonnet 5 d42f8331f9 feat: warn against harvesting honey during a treatment's Wartezeit
Treatments (Ameisensäure, Oxalsäure, etc.) can require a legally
mandated withdrawal period before honey from that colony may be sold.
Nothing in the app tracked this before, so a beekeeper had no
safeguard against harvesting into an active withdrawal window.

Treatments now optionally record withdrawalPeriodDays (from the
product's Packungsbeilage). The new harvest form fetches each
candidate colony's active withdrawal window and shows an inline
warning naming the product and the date it clears, computed as
max(endDate ?? date) + withdrawalPeriodDays across a colony's
treatments, restricted to windows still in the future.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:50:34 +02:00
Nick SzaunigandClaude Sonnet 5 0881a38800 feat: warn on the dashboard when a swarm check is overdue
Schwarmkontrolle already captures nextCheckDate per check, but it was
only ever visible by opening that one colony's tab. During swarm
season a missed follow-up check can mean losing half the colony, so
this now surfaces as a dashboard alert (OVERDUE_SWARM_CHECK) like
overdue tasks already do - only the most recent check per colony is
considered, so a completed follow-up clears the warning.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:35:17 +02:00
Nick SzaunigandClaude Sonnet 5 acbf5f16ce fix: add apple-touch-icon for iOS "Add to Home Screen"
iOS Safari ignores the web manifest for home-screen icons entirely and
needs its own apple-touch-icon link, which Next.js only emits when
app/apple-icon.tsx (or a literal apple-icon.png) exists. Without it,
iOS falls back to a screenshot of the open page instead of the brand
mark. Mirrors the existing public/icon.svg design via next/og.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 18:06:22 +02:00
Nick SzaunigandClaude Sonnet 5 81986b3275 feat: seed a starter annual task-template calendar
TaskTemplate had zero seed data - a fresh deployment showed a
completely empty recurring-task list with no hint of what to even put
there. Seeds 12 YEARLY templates, one per month, covering the standard
German-beekeeping annual cycle (Frühjahrsdurchsicht, Schwarmkontrolle,
Einfütterung, Varroa-Restentmilbung/Oxalsäure, Wintersitz-Kontrolle,
etc.) so a new beekeeper gets a ready-made starting point instead of a
blank page. Runs once (guarded by a taskTemplate.count() check, same
idempotency shape as the existing admin-user seed) and only ever adds
rows - admins can edit or deactivate any of them afterward.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:58:28 +02:00
Nick SzaunigandClaude Sonnet 5 c4f970f071 feat: add wind speed and a same-day hive-inspection verdict to weather
Temperature and rain were already tracked, but wind matters just as
much for whether it's safe/sensible to open a hive - bees get more
defensive and frames are harder to handle in wind. Adds
wind_speed_10m_max to the Open-Meteo request and a derived
inspectionSuitable flag per day (>=15C, <=1mm rain, <=25km/h wind;
null when a value is missing rather than guessing). The apiary weather
card now shows a clear "good/not good for a Durchsicht today" banner
plus wind alongside each day's forecast.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:54:31 +02:00
Nick SzaunigandClaude Sonnet 5 1f67cd5548 feat: extend offline queueing to all quick-capture forms
Only Durchsicht had an offline fallback (queue to IndexedDB, sync on
reconnect); Behandlung, Fütterung, Varroamessung, Schwarmkontrolle and
Ernte just failed outright with no signal - exactly the situation a
beekeeper is in at the actual hive. Generalizes lib/offline/db.ts and
sync.ts to six queues (version-bumped IndexedDB schema, existing queued
inspections preserved), wires the same "server rejected it vs. no
network" fallback into all five remaining */new pages, and updates the
sync badge to count/drain all six.

Also: doc-hygiene pass (ARCHITECTURE.md/TASKS.md) to replace the stale
pg_dump/CronJob backup plan with the in-app BackupModule that actually
shipped earlier this session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:45:32 +02:00
Nick SzaunigandClaude Sonnet 5 ac45514356 fix: remove label cut lines and zero-pad printed dates
Backend Image / build-and-push (push) Successful in 2m26s
Frontend Image / build-and-push (push) Successful in 1m56s
The dashed rectangle drawn around every label slot (a cut/crop line)
wasn't needed and is gone, along with the width/height params that
existed only to draw it. Dates on the label (bottling date, MHD) now
use { day: '2-digit', month: '2-digit' } so single-digit values print
with a leading zero (05.03.2026, not 5.3.2026) - the default 'numeric'
formatting was dropping it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 16:44:25 +02:00
Nick SzaunigandClaude Sonnet 5 1054a845ef feat: allow a honey batch to be split across multiple storage containers
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Successful in 2m45s
A batch that doesn't fit in one container (e.g. 125 kg across 5 x 25 kg
buckets) had no way to be represented - HoneyBatch.storageContainerId
is a single nullable FK. Adds HoneyBatchContainerAllocation as an
additive join table (container + amount per batch), independent of the
existing single-container/relocate() flow which still covers the
simple one-container case unchanged.

- GET/PUT/DELETE /honey-batches/:id/containers[/:containerId], reusing
  the existing honey-batches:relocate permission.
- Assigning a container is an upsert (@@unique on batch+container), and
  the total assigned is validated against the batch's own amount when
  known.
- New "Aufteilung auf mehrere Behälter" card on the honey batch detail
  page, alongside (not replacing) the existing storage card.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 19:44:33 +02:00
Nick SzaunigandClaude Sonnet 5 c91dc9ebf1 fix: add missing Ernten/Schleuderungen list and detail views
The sidebar nav sent both entries straight to their "new" creation
form, with no list page to ever browse what had been created -
Schleuderung creation didn't even redirect to a detail page, just to
/colonies, so a saved extraction left no trace anywhere in the UI.

- New /harvests and /extractions list pages (mirrors the existing
  products/apiaries list pattern).
- New /extractions/[id] detail page + edit dialog, matching the
  harvests one that already existed but had nothing linking to it.
- Sidebar "Ernten"/"Schleuderungen" now point at the list pages instead
  of the creation forms.
- Extraction creation now redirects to its detail page instead of
  /colonies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 19:44:19 +02:00
Nick SzaunigandClaude Sonnet 5 e133a677ab refactor: reuse the SSO app registration for M365 calendar sync
One Entra enterprise application now covers both interactive login and
the calendar sync's client-credentials Graph access, instead of
requiring a second app registration. GraphClientService reads
OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (same as SSO) and
derives the tenant ID from the issuer URL's path, rather than three
dedicated M365_CALENDAR_* env vars. The app registration just needs the
"Calendars.ReadWrite" Application permission added alongside its
existing delegated login scopes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 14:35:18 +02:00
Nick SzaunigandClaude Sonnet 5 e7b157ec35 feat: sync calendar entries to a Microsoft 365 group calendar
Backend Image / build-and-push (push) Successful in 8m34s
Frontend Image / build-and-push (push) Successful in 8m41s
Adds a one-way push (ApiCore -> M365) of Aufgaben/Durchsichten/
Behandlungen/Fütterungen/Ernten/Königinnen-Zuordnungen into the calendar
of a Microsoft 365 group, via the Graph API.

- CalendarSyncModule (app-only client-credentials Graph auth, separate
  from the interactive SSO app registration; new env vars
  M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET).
- Outbox + minute-interval cron worker (CalendarSyncOutboxEntry) instead
  of a synchronous push, since Graph calls are too slow/flaky to block a
  domain create request on; failed syncs retry up to 5 times.
- Per-source-type toggle plus a group-ID field, configurable in
  Verwaltung > Automatisierung > Kalender-Sync (Verwaltung > Backup, an
  env var still wins on those fields where one applies to related config).
- Hooked into Tasks/Inspections/Treatments/Feedings/Harvests/Queens at
  their existing create (and Task update/complete) points; entries render
  as all-day Graph events to avoid timezone ambiguity.
- Documents the design decision in ARCHITECTURE.md (Annahme 9), since
  this is a net-new capability the lastenheft doesn't scope.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 09:23:35 +02:00
Nick SzaunigandClaude Sonnet 5 28ae0a2723 fix: lay out herkunft social links in a uniform count-aware grid
2 links center side-by-side, 3 sit in one row, and 4 form a centered
2x2 block - previously flex-wrap could leave an uneven number stranded
awkwardly on its own line.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 20:13:18 +02:00
Nick Szaunig 412d033d8d feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
Backend Image / build-and-push (push) Successful in 2m15s
Frontend Image / build-and-push (push) Successful in 2m17s
- SiteSettings gains websiteUrl (e.g. online shop link) alongside the
  existing social links, editable in Verwaltung > Automatisierung.
- /herkunft/[lot] now shows the apiary's Standort instead of its internal
  Name (so multiple apiaries at one location read as one origin), adds
  small icons per social link, and surfaces previously-fetched-but-unused
  data (Honigsorte, Abfülldatum, stat cards) to make the page a richer
  customer-facing info page.
- Backup schedule (path/cron/retention) is now also configurable via the
  GUI (Verwaltung > Backup), stored in a new BackupSettings singleton;
  an env var always wins over the GUI value per field, and the scheduler
  reloads its cron job live when GUI settings change.
- New Automatisierung rules let an admin define a format (e.g.
  "WH-{YYYY}-{SEQ:4}") that auto-generates HoneyBatch.batchNumber and
  BottlingBatch.lotNumber on creation, with an atomic per-rule sequence
  counter and a manual reset. Manual entry still works when the rule is
  inactive.
2026-09-03 13:37:09 +02:00
Nick Szaunig 7b23862a41 feat: expand backup to all data and add automatic scheduled backups
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 1m56s
Backup export/import now covers Role, User, RolePermission,
Notification, AuditLog and Document (with actual file content), not
just business data - matching "wirklich alle Daten". Role/User use
natural keys (role.key, resource+action) so a bundle restores cleanly
into a different database.

Adds BackupSchedulerService (@nestjs/schedule) for cron-based automatic
backups to a local path (BACKUP_STORAGE_PATH), with retention pruning
and a file-mtime guard against duplicate runs across replicas. New
GET /backup/schedule and POST /backup/schedule/run-now endpoints, plus
a status card and manual trigger in the frontend Backup settings page.
2026-09-03 11:33:48 +02:00
Nick SzaunigandClaude Sonnet 5 d998f611e2 feat: Verwaltungsmenü zu 3 Sammelseiten umgebaut - Benutzer & Rollen, Automatisierung, Backup
Backend Image / build-and-push (push) Successful in 2m17s
Frontend Image / build-and-push (push) Successful in 2m18s
Menü unter "Verwaltung" wie gewünscht neu strukturiert:

- "Benutzer & Rollen" (/settings/users-roles): Benutzerverwaltung und
  Rollen & Rechte als zwei Reiter auf einer Seite (vorher zwei
  getrennte Menüpunkte).
- "Automatisierung" (/settings/automation): Webhooks, Wiederkehrende
  Aufgaben, Etikettenvorlagen und Social Media Links als Reiter
  gebündelt - dazu (eigene Ergänzung, da sonst zwei fast gleich
  benannte Menüpunkte "Automatisierung"/"Automatisierungen"
  nebeneinander gestanden hätten) auch die bestehenden
  Automatisierungsregeln als fünfter Reiter "Regeln".
- "Backup" (/settings/backup): neuer voller Daten-Export/Import
  (Commit zuvor) plus der bisherige CSV-Import, beides auf einer Seite.

Aktiver Reiter wird als ?tab=... in der URL gehalten statt nur im
Client-State, damit z.B. "Speichern" im Etiketten-Designer wieder auf
den richtigen Reiter zurückführt.

Technisch: bestehende Seiteninhalte in Reiter-Komponenten extrahiert
(PageHeader entfernt, Rest unverändert übernommen), Dialog-Unterkomponenten
in die neuen Ordner verschoben (git mv, Historie bleibt erhalten). Die
Etiketten-Editor-Routen (/settings/label-templates/new, /[id]) bleiben
unverändert bestehen, nur der Rücksprung nach dem Speichern zeigt jetzt
auf den neuen Reiter. settings/layout.tsx's Berechtigungsliste um
label-templates:read, site-settings:read, backup:export/import ergänzt -
schließt nebenbei eine bestehende Lücke (eine Rolle mit ausschließlich
diesen Rechten wurde vorher faelschlich von der ganzen
Verwaltungssektion ausgesperrt.

Neuer Reiter-Baustein src/components/tab-switcher.tsx (generisch, nach
Vorbild von ActiveHistoryTabs). sidebar.tsx's NavItem.permission
unterstützt jetzt auch ein Array (irgendeine der Berechtigungen reicht),
fuer die neuen gebündelten Menüpunkte.

Lokal verifiziert: typecheck/lint/test/build fehlerfrei, Build zeigt
alle neuen Routen korrekt, Live-Test im Browser (ohne Login) zeigt
sauberen Redirect zu /login ohne Konsolenfehler.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 10:37:22 +02:00
Nick SzaunigandClaude Sonnet 5 2f223b6941 feat: Backend-Modul fuer vollstaendiges Daten-Backup (Export/Import)
Neues Modul src/backup/ fuer Backups und Datenuebertragung zwischen
Deployments: GET /backup/export liefert ein JSON-Bundle mit allen
fachlichen Daten (Bienenstaende bis Etikettenvorlagen, ~30 Tabellen),
POST /backup/import spielt so ein Bundle wieder ein.

Bewusst NICHT enthalten: User/Rollen/Rechte (werden ueber die
Benutzerverwaltung gepflegt, Permission-Zeilen werden ohnehin bei
jedem Start aus dem Code neu synchronisiert), Audit-Log (unveraenderliche
Historie), Notifications (fluechtig), Dokumente (nur die DB-Zeile waere
gesichert, nicht die tatsaechliche Datei auf der Festplatte).

Import laeuft in einer Transaktion, Tabellen in Abhaengigkeitsreihenfolge
(z.B. Bienenstand vor Volk, Volk vor Durchsicht). Selbstreferenzen
(Koenigin -> Mutterkoenigin) werden in zwei Durchgaengen aufgeloest,
damit die Reihenfolge im Export keine Rolle spielt. userId-Felder, die
im Zielsystem nicht existieren (z.B. Uebertragung auf ein anderes
Deployment), werden auf den ausfuehrenden Admin umgebogen statt die
Fremdschluessel-Pruefung scheitern zu lassen. AutomationRule wird ueber
triggerType statt id abgeglichen, da diese Zeilen code-fest sind.

Neue Berechtigungen backup:export/backup:import. 8 neue Unit-Tests
fuer die Sonderfaelle (Selbstreferenz, userId-Remapping,
AutomationRule-Abgleich, Audit-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 10:22:15 +02:00
2384 changed files with 320546 additions and 9430 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 -6
View File
@@ -22,7 +22,7 @@ jobs:
REGISTRY_PUSH: gitea:80
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Image-Namen in Kleinbuchstaben ermitteln
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
@@ -31,7 +31,7 @@ jobs:
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
@@ -40,7 +40,7 @@ jobs:
http = true
- name: Bei der Gitea-Container-Registry anmelden
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY_PUSH }}
username: ${{ gitea.actor }}
@@ -50,16 +50,15 @@ jobs:
- name: Metadaten (Tags/Labels) ermitteln
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
- name: Image bauen und pushen
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: ./ApiCore-Backend
push: true
+5 -6
View File
@@ -22,7 +22,7 @@ jobs:
REGISTRY_PUSH: gitea:80
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Image-Namen in Kleinbuchstaben ermitteln
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
@@ -31,7 +31,7 @@ jobs:
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
@@ -40,7 +40,7 @@ jobs:
http = true
- name: Bei der Gitea-Container-Registry anmelden
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY_PUSH }}
username: ${{ gitea.actor }}
@@ -50,16 +50,15 @@ jobs:
- name: Metadaten (Tags/Labels) ermitteln
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
- name: Image bauen und pushen
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: ./ApiCore-Frontend
push: true
+74
View File
@@ -0,0 +1,74 @@
name: Manifests
# Prueft deploy/k8s ohne Cluster-Zugriff: Overlay und jeden Rollout-Schritt
# rendern, gegen die Kubernetes-Schemas validieren und die Regeln testen
# (NetworkPolicies, Pod-Haertung, Ressourcen, CronJobs, Digests, Live-Selektoren).
#
# Bewusst kein pull_request-Trigger: der Runner laeuft im Cluster, und ein
# Fork-PR braechte eigenen Code (auch eine geaenderte Fassung dieser Datei,
# also ohne jeden if-Waechter) darauf zur Ausfuehrung. push setzt
# Schreibrechte am Repo voraus; jeder Branch-Push wird geprueft, damit auch
# Aenderungen vor dem Merge nach main durch die CI gehen.
on:
push:
paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml']
workflow_dispatch:
jobs:
validate:
runs-on: docker-builder
env:
# Werkzeuge mit festen Versionen; jede Datei wird gegen ihre SHA-256 von
# der offiziellen Release-Seite geprueft, bevor sie laeuft:
# kubectl: https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256
# kubeconform: CHECKSUMS im GitHub-Release yannh/kubeconform
# yq: checksums (Spalte SHA-256) im GitHub-Release mikefarah/yq
# Ein Update aendert Version und Pruefsumme zusammen.
KUBECTL_VERSION: v1.36.5
KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e
KUBECONFORM_VERSION: v0.8.0
KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
YQ_VERSION: v4.53.6
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Node einrichten
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: kubectl, kubeconform und yq laden
run: |
mkdir -p "$RUNNER_TEMP/bin"
cd "$RUNNER_TEMP"
curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
echo "${KUBECTL_SHA256} kubectl" | sha256sum -c -
curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz"
echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c -
tar -xzf kubeconform.tar.gz kubeconform
curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "${YQ_SHA256} yq" | sha256sum -c -
install -m 0755 kubectl kubeconform yq "$RUNNER_TEMP/bin/"
- name: Overlay rendern
run: |
"$RUNNER_TEMP/bin/kubectl" kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml"
"$RUNNER_TEMP/bin/yq" ea -o=json '[.]' "$RUNNER_TEMP/rendered.yaml" > "$RUNNER_TEMP/rendered.json"
- name: Rollout-Schritte rendern
# Runbook Schritte 1, 2, 3 und 5: Overlay mit den bis dahin
# freigegebenen Komponenten (siehe render-stages.mjs).
run: |
export KUBECTL="$RUNNER_TEMP/bin/kubectl" YQ="$RUNNER_TEMP/bin/yq"
node deploy/k8s/tests/render-stages.mjs "$RUNNER_TEMP/stages"
- name: Gegen Kubernetes-Schemas pruefen
run: |
"$RUNNER_TEMP/bin/kubeconform" -strict -summary "$RUNNER_TEMP/rendered.yaml" "$RUNNER_TEMP"/stages/*.yaml
- name: Regeln testen
run: |
export RENDERED_MANIFESTS="$RUNNER_TEMP/rendered.json" RENDERED_STAGES="$RUNNER_TEMP/stages/stages.json"
node --test deploy/k8s/tests/manifest-policy.test.mjs
+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/
+1046 -18
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,5 +1,8 @@
node_modules
dist
# A stale build info makes `nest build` emit only changed files into the
# freshly deleted dist (tsconfig.json: incremental).
*.tsbuildinfo
generated
coverage
.devdb
+115 -2
View File
@@ -2,16 +2,31 @@
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token.
# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten)
# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren.
# Erzeugen z.B. mit: openssl rand -hex 32
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
JWT_REFRESH_SECRET="change-me-refresh-secret"
JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
# Hard limit from login, however often the session is refreshed.
SESSION_MAX_AGE="7d"
# New passwords are checked against known breaches (Have I Been Pwned,
# k-anonymity: only 5 hash characters leave the server). "false" turns the
# check off for deployments without internet access.
# PASSWORD_BREACH_CHECK=true
PORT=3001
FRONTEND_ORIGIN="http://localhost:3000"
# Reverse-proxy hops in front of the backend whose X-Forwarded-For is
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
# (see deploy/k8s/base/backend/configmap.example.yaml); "1" would yield the
# Cloudflare edge IP that many clients share.
TRUST_PROXY=1
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
# local HTTP development - every real deployment must leave this unset (or
@@ -23,10 +38,72 @@ COOKIE_SECURE=false
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
DOCUMENTS_STORAGE_PATH="./uploads"
# Automatische Backups (Modul Backup). Alle drei Werte sind auch ueber die
# GUI (Verwaltung > Backup) konfigurierbar - eine hier gesetzte Env-Variable
# hat aber IMMER Vorrang vor dem GUI-Wert, pro Feld einzeln. Ohne Pfad
# (weder Env noch GUI) bleibt der Zeitplan deaktiviert (nur manueller
# Export/Import ueber die UI verfuegbar).
# Bei horizontaler Skalierung (>1 Replica) muss der Pfad auf ein geteiltes
# Volume zeigen (wie DOCUMENTS_STORAGE_PATH), sonst sieht jede Replica nur
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
# nicht.
BACKUP_STORAGE_PATH="./backups"
# Directory a backup path set in the admin UI must stay under (the env
# variable above is not restricted). Default "./backups".
# BACKUP_ROOT="./backups"
# Cron-Ausdruck (Standard: taeglich um 03:00 Uhr).
BACKUP_CRON_SCHEDULE="0 3 * * *"
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
# jedem Lauf geloescht (Standard: 14).
BACKUP_RETENTION_COUNT=14
# Obergrenze fuer eine einzuspielende Backup-Datei in Bytes (Standard 50 MB,
# hoechstens 256 MB, weil der Upload komplett im RAM verarbeitet wird).
# Export und Zeitplan warnen, wenn eine Sicherung groesser wird.
# BACKUP_MAX_IMPORT_BYTES=52428800
# Erster Administrator (prisma/seed.ts): wird angelegt, wenn es noch keinen
# Benutzer mit dieser Adresse gibt. Ohne Passwort erzeugt der Seed ein
# zufaelliges und gibt es einmalig im Log aus.
# [email protected]
# SEED_ADMIN_PASSWORD=
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
# production: at least 32 characters each, all three different from each
# other and from JWT_ACCESS_SECRET/JWT_REFRESH_SECRET, e.g.
# "openssl rand -hex 32". Outside production a missing key falls back to one
# derived from JWT_ACCESS_SECRET.
# Rotation: move the old value to <NAME>_PREVIOUS (still opens), set a new
# <NAME>, run the rekey job, then drop <NAME>_PREVIOUS. Backup files are not
# resealed: keep BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore.
# Set the same BACKUP_ENCRYPTION_KEY on another deployment to restore its
# backups there (MFA_/WEBHOOK_ENCRYPTION_KEY too, to keep MFA and webhook
# secrets). Keep the backup key outside the backup storage.
# MFA_ENCRYPTION_KEY=""
# WEBHOOK_ENCRYPTION_KEY=""
# BACKUP_ENCRYPTION_KEY=""
# Seals voice notes (audio, transcripts, suggestions); required in production.
# VOICE_ENCRYPTION_KEY=""
# MFA_ENCRYPTION_KEY_PREVIOUS=""
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
# VOICE_ENCRYPTION_KEY_PREVIOUS=""
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI
# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen.
#
# Dieselbe App-Registrierung wird auch fuer die M365-Kalender-Synchronisation
# (Modul Kalender) wiederverwendet - kein zweites Enterprise-App-Objekt noetig.
# Dafuer in der App-Registrierung zusaetzlich die Application-Permission
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig); die Tenant-ID wird
# aus OIDC_ISSUER_URL abgeleitet. Eintraege (Aufgaben, Durchsichten,
# Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) werden
# einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-Gruppe
# uebertragen. Ohne diese drei Werte bleibt die Synchronisation deaktiviert;
# zusaetzlich muessen die Synchronisation selbst UND die jeweilige Gruppen-ID
# in der GUI (Verwaltung > Automatisierung > Kalender-Sync) gesetzt werden.
OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
OIDC_CLIENT_ID="change-me-entra-client-id"
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
@@ -36,3 +113,39 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
ENABLE_SWAGGER=false
# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API +
# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert
# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job).
# In Produktion Pflicht und "all" ist dort verboten.
# APICORE_ROLE=api
# Port des Health-Endpunkts des Worker-Prozesses.
# WORKER_HEALTH_PORT=3002
# Wetterquelle fuer die Pflanzenentwicklung (Gradtagszahl) und die
# Wetteranzeige. Einzige Quelle ist die freie Open-Meteo-API (bewusst
# uebernommenes Lizenzrisiko, nicht-kommerzielle Bedingungen); der Adapter
# (src/weather/history) erlaubt einen Wechsel. Nur https:-URLs; ein
# unbekannter Quellenname oder eine http:-URL stoppt den Start. Der Schluessel
# ist optional, gehoert in ein Secret und steht nie in Logs oder Fehlermeldungen.
# WEATHER_HISTORY_SOURCE=open-meteo
# OPEN_METEO_FORECAST_URL=https://api.open-meteo.com/v1/forecast
# OPEN_METEO_ARCHIVE_URL=https://archive-api.open-meteo.com/v1/archive
# OPEN_METEO_API_KEY=
# Optional: Kontakt (E-Mail oder URL) fuer den User-Agent-Header der
# Wetteranfragen ("ApiCore/<Version> (+<Kontakt>)"); ohne Wert
# "ApiCore/<Version> (self-hosted beekeeping app)".
# WEATHER_USER_AGENT_CONTACT=
# Telegram-Erinnerungen (optional, T4c). TELEGRAM_BOT_USERNAME (ohne @, 5-32
# Zeichen: Buchstaben, Ziffern, Unterstrich) steht in API und Worker und
# erzeugt den Deep Link. TELEGRAM_BOT_TOKEN (BotFather) gehoert NUR dem Worker,
# im Cluster in das eigene Secret apicore-telegram; der API-Pod darf es nicht
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
# (/setjoingroups) und keinen Webhook setzen.
# Je Umgebung ein eigener Bot. Im Cluster steht der Name in der ConfigMap
# (TELEGRAM_BOT_USERNAME, leer = Telegram in der App nicht eingerichtet); den
# Zustand zeigt Verwaltung > System (Telegram-Karte, nur system:manage).
# TELEGRAM_BOT_USERNAME=
# TELEGRAM_BOT_TOKEN=
+3
View File
@@ -18,3 +18,6 @@ prisma/seed.d.ts
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
/uploads/
/test-uploads/
# T4 migration test harness: scratch copies of the migration folders
/.tmp-migrations/
+30 -10
View File
@@ -1,23 +1,33 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom
# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update
# ist ein bewusster Commit mit neuem Digest, ermittelt per
# docker buildx imagetools inspect node:22-alpine
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und
# Unit-Tests gruen sind. eslint laeuft hier ohne --fix (anders als
# `npm run lint`), damit ein Verstoss den Build abbricht statt still
# korrigiert zu werden. Die e2e-Tests brauchen eine Datenbank und laufen
# deshalb nicht im Image-Build.
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
RUN npm run build
FROM node:22-alpine AS runtime
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/generated ./generated
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
@@ -25,9 +35,19 @@ COPY --from=builder /app/generated ./generated
# fuehrte zu sporadischen OOM-Crashes beim Start.
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
EXPOSE 3001
# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle.
EXPOSE 3001 3002
USER node
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`,
# idempotent per Unique-Constraint auf User.email). Scheitert die Migration,
# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess
# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando
# (dist/worker.js, dist/cli.js <job>), die Rolle waehlt APICORE_ROLE.
# Dieses Image darf darum nur zusammen mit components/backend-roles live
# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in
# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment
# ohne die initContainer setzen: dann liefe keine Migration.
CMD ["node", "dist/main.js"]
+32 -86
View File
@@ -1,98 +1,44 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
# ApiCore-Backend
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
NestJS-API von ApiCore (TypeScript, Prisma, PostgreSQL). Alle fachlichen Funktionen liegen unter `/api/v1/...`. Überblick über das ganze Projekt: [`../README.md`](../README.md), Architektur: [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
## Einrichten
```bash
$ npm install
npm install
cp .env.example .env # DATABASE_URL, JWT- und Verschlüsselungs-Schlüssel setzen
npx prisma migrate dev # Schema anwenden (nur lokal; gegen Produktion nur migrate deploy)
npm run start:dev # http://localhost:3001
```
## Compile and run the project
Alle Variablen mit Bedeutung stehen in [`.env.example`](.env.example). Swagger unter `/api/v1/docs` ist nur mit `ENABLE_SWAGGER=true` erreichbar.
## Laufrollen
Ein Build, drei Einstiegspunkte (`ARCHITECTURE.md` Abschnitt 10):
| Befehl | Rolle | Zweck |
|---|---|---|
| `npm run start:dev` / `node dist/main.js` | `api` (lokal ohne `APICORE_ROLE`: `all`) | HTTP-API; mit `all` laufen API und Worker in einem Prozess, nur für Entwicklung und Tests |
| `npm run worker` (`node dist/worker.js`) | `worker` | Zustellung der Outbox `DomainEvent`, Webhooks, Kalender-Sync, Wetter, Telegram; Health auf Port 3002 |
| `npm run job -- <name>` (`node dist/cli.js <name>`) | `job` | `cleanup`, `daily-evaluation`, `task-templates`, `app-backup`, `rekey` (auch `rekey --dry-run`); im Cluster als CronJobs |
In Produktion ist `APICORE_ROLE` Pflicht und `all` verboten. Migrationen laufen im Cluster als initContainer `migrate` (`prisma migrate deploy`), der Seed als initContainer `seed` (`node dist/seed.js`).
## Prüfen
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
npm run lint # eslint mit --fix
npm run typecheck # tsc --noEmit
npm test # Jest-Unit-Tests
npm run test:e2e # Jest-e2e gegen die Datenbank aus .env.test (vorher migrieren)
npm run build # dist/main.js, dist/worker.js, dist/cli.js, dist/seed.js
```
## Run tests
Die e2e-Suites laufen parallel (`maxWorkers: 4` in `test/jest-e2e.json`) und scheitern dabei derzeit sporadisch; mit `npm run test:e2e -- --runInBand` sind sie grün ([Issue #32](https://git.encry.de/Nick.Rosenloecher/ApiCore/issues/32)). Der Image-Build (`Dockerfile`) führt Lint, Typecheck und Unit-Tests aus und bricht bei Fehlern ab; die e2e-Tests laufen dort nicht.
```bash
# unit tests
$ npm run test
## Aufbau
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
- `src/<modul>/`: ein NestJS-Modul je Fachbereich (z. B. `colonies`, `inspections`, `treatments`, `honey-batches`, `compliance`, `hive`), dazu Querschnitt in `common`, `prisma`, `domain-events`, `worker`, `jobs`, `runtime`.
- `prisma/schema.prisma` und `prisma/migrations/` sind die einzige Quelle des Schemas; `prisma db push` wird nirgends verwendet.
- `test/`: e2e-Specs und gemeinsame Fixtures (u. a. `test/fixtures/hive-ops/`, die auch das Frontend nutzt).
+122 -15
View File
@@ -9,6 +9,7 @@
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
"@anthropic-ai/sdk": "0.131.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
@@ -23,12 +24,14 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"cron": "^4.4.0",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"pg": "^8.23.1",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -48,6 +51,7 @@
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
@@ -210,6 +214,27 @@
"tslib": "^2.1.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.131.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -671,6 +696,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -3210,6 +3244,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -3583,6 +3623,12 @@
"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.7.6",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -5898,6 +5944,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6899,6 +6962,12 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
@@ -8754,6 +8823,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -9069,6 +9151,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -10038,14 +10129,14 @@
"license": "MIT"
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-connection-string": "^2.14.1",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-protocol": "^1.16.1",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -10053,7 +10144,7 @@
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
"pg-cloudflare": "^1.4.1"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -10065,16 +10156,16 @@
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
"license": "MIT"
},
"node_modules/pg-int8": {
@@ -10096,9 +10187,9 @@
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
"license": "MIT"
},
"node_modules/pg-types": {
@@ -11214,6 +11305,16 @@
"node": ">=8"
}
},
"node_modules/standardwebhooks": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11750,6 +11851,12 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+12 -3
View File
@@ -12,6 +12,8 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"worker": "node dist/worker.js",
"job": "node dist/cli.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"test": "jest",
@@ -20,9 +22,12 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"migrate:deploy": "prisma migrate deploy",
"seed": "node dist/seed.js"
"seed": "node dist/seed.js",
"export:voice-vocabulary": "ts-node scripts/export-voice-vocabulary.ts",
"eval:voice": "ts-node scripts/eval-voice.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "0.131.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
@@ -37,12 +42,14 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"cron": "^4.4.0",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"pg": "^8.23.1",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -62,6 +69,7 @@
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
@@ -91,12 +99,13 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)"
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"testTimeout": 20000
}
}
@@ -0,0 +1,9 @@
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_BATCH_NUMBER_FORMAT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'BOTTLING_LOT_NUMBER_FORMAT';
-- AlterTable
ALTER TABLE "AutomationRule" ADD COLUMN "sequenceValue" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "websiteUrl" TEXT;
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "BackupSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"storagePath" TEXT,
"cronSchedule" TEXT,
"retentionCount" INTEGER,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BackupSettings_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,42 @@
-- CreateEnum
CREATE TYPE "CalendarSyncSourceType" AS ENUM ('TASK', 'INSPECTION', 'TREATMENT', 'FEEDING', 'HARVEST', 'QUEEN_ASSIGNMENT');
-- CreateEnum
CREATE TYPE "CalendarSyncStatus" AS ENUM ('PENDING', 'SYNCED', 'FAILED', 'SKIPPED');
-- CreateTable
CREATE TABLE "CalendarSyncSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"groupId" TEXT,
"syncTask" BOOLEAN NOT NULL DEFAULT true,
"syncInspection" BOOLEAN NOT NULL DEFAULT true,
"syncTreatment" BOOLEAN NOT NULL DEFAULT true,
"syncFeeding" BOOLEAN NOT NULL DEFAULT true,
"syncHarvest" BOOLEAN NOT NULL DEFAULT true,
"syncQueenAssignment" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarSyncSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CalendarSyncOutboxEntry" (
"id" TEXT NOT NULL,
"sourceType" "CalendarSyncSourceType" NOT NULL,
"sourceId" TEXT NOT NULL,
"status" "CalendarSyncStatus" NOT NULL DEFAULT 'PENDING',
"graphEventId" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarSyncOutboxEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CalendarSyncOutboxEntry_status_idx" ON "CalendarSyncOutboxEntry"("status");
-- CreateIndex
CREATE UNIQUE INDEX "CalendarSyncOutboxEntry_sourceType_sourceId_key" ON "CalendarSyncOutboxEntry"("sourceType", "sourceId");
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "HoneyBatchContainerAllocation" (
"id" TEXT NOT NULL,
"honeyBatchId" TEXT NOT NULL,
"storageContainerId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HoneyBatchContainerAllocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "HoneyBatchContainerAllocation_storageContainerId_idx" ON "HoneyBatchContainerAllocation"("storageContainerId");
-- CreateIndex
CREATE UNIQUE INDEX "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key" ON "HoneyBatchContainerAllocation"("honeyBatchId", "storageContainerId");
-- AddForeignKey
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_storageContainerId_fkey" FOREIGN KEY ("storageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "IdempotencyStatus" AS ENUM ('PENDING', 'COMPLETED');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceAutomationTrigger" "AutomationTriggerType";
-- CreateTable
CREATE TABLE "IdempotencyRecord" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"status" "IdempotencyStatus" NOT NULL DEFAULT 'PENDING',
"statusCode" INTEGER,
"responseBody" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IdempotencyRecord_createdAt_idx" ON "IdempotencyRecord"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "IdempotencyRecord_userId_key_key" ON "IdempotencyRecord"("userId", "key");
-- CreateIndex
CREATE INDEX "Task_sourceAutomationTrigger_relatedType_relatedId_status_idx" ON "Task"("sourceAutomationTrigger", "relatedType", "relatedId", "status");
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"scopes" TEXT[],
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
-- CreateIndex
CREATE INDEX "ApiKey_revokedAt_idx" ON "ApiKey"("revokedAt");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AutomationTriggerType" ADD VALUE 'TREATMENT_FOLLOW_UP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'FEEDING_FOLLOW_UP';
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "holderAddress" TEXT,
ADD COLUMN "holderName" TEXT;
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "apiaryId" TEXT,
ADD COLUMN "prescribingVet" TEXT,
ADD COLUMN "purchaseProof" TEXT,
ADD COLUMN "supplier" TEXT;
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "WinterCheckSeason" AS ENUM ('AUTUMN', 'SPRING');
-- CreateEnum
CREATE TYPE "WinterOutcome" AS ENUM ('SURVIVED', 'WEAK', 'LOST');
-- CreateEnum
CREATE TYPE "ColonyLossCause" AS ENUM ('DEAD_OR_EMPTY', 'QUEEN_PROBLEM', 'NATURAL_DISASTER');
-- CreateTable
CREATE TABLE "WinterCheck" (
"id" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"winterYear" INTEGER NOT NULL,
"season" "WinterCheckSeason" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"occupiedSeams" INTEGER,
"feedStockKg" DOUBLE PRECISION,
"queenPresent" BOOLEAN,
"outcome" "WinterOutcome",
"lossCause" "ColonyLossCause",
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WinterCheck_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WinterCheck_winterYear_idx" ON "WinterCheck"("winterYear");
-- CreateIndex
CREATE UNIQUE INDEX "WinterCheck_colonyId_winterYear_season_key" ON "WinterCheck"("colonyId", "winterYear", "season");
-- AddForeignKey
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Permissions: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN). Winter checks are everyday beekeeping records, so the
-- other roles get the same access they have to inspections.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'winter-checks:read', 'winter-checks', 'read', 'Ansehen', 'Überwinterung', 850),
(gen_random_uuid()::text, 'winter-checks:create', 'winter-checks', 'create', 'Anlegen', 'Überwinterung', 851),
(gen_random_uuid()::text, 'winter-checks:update', 'winter-checks', 'update', 'Bearbeiten', 'Überwinterung', 852)
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 = 'winter-checks'
AND (
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
OR (r.key IN ('READONLY', 'API_CLIENT') AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,15 @@
-- Microsoft sign-in is bound to the IdP identity ("<iss>|<oid>"), set on the
-- first SSO login; the unverified email claim no longer picks the account.
ALTER TABLE "User" ADD COLUMN "ssoSubject" TEXT;
CREATE UNIQUE INDEX "User_ssoSubject_key" ON "User"("ssoSubject");
-- Emails are compared case-insensitively from now on. Store existing ones
-- lower-case, except where that would collide with another account - those
-- stay as they are and are still found by the case-insensitive lookup.
UPDATE "User" AS u
SET "email" = lower(u."email")
WHERE u."email" <> lower(u."email")
AND NOT EXISTS (
SELECT 1 FROM "User" AS o
WHERE o."id" <> u."id" AND lower(o."email") = lower(u."email")
);
@@ -0,0 +1,5 @@
-- TOTP replay protection and per-account lockout after failed attempts.
ALTER TABLE "User" ADD COLUMN "mfaLastTimeStep" INTEGER;
ALTER TABLE "User" ADD COLUMN "failedAuthAttempts" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "User" ADD COLUMN "authLockedUntil" TIMESTAMP(3);
ALTER TYPE "AuditAction" ADD VALUE 'ACCOUNT_LOCKED';
@@ -0,0 +1,3 @@
-- Absolute session lifetime: every rotated refresh token keeps the time
-- the session was first opened. Existing sessions count from now.
ALTER TABLE "RefreshToken" ADD COLUMN "sessionStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,3 @@
-- API keys can be limited to source addresses (IP or CIDR).
ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT;
@@ -0,0 +1,8 @@
-- One-time helper tokens (SSO completion, MFA challenge) are recorded once
-- used; a second use of the same jti is refused.
CREATE TABLE "ConsumedToken" (
"jti" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConsumedToken_pkey" PRIMARY KEY ("jti")
);
CREATE INDEX "ConsumedToken_expiresAt_idx" ON "ConsumedToken"("expiresAt");
@@ -0,0 +1,162 @@
-- Bestandsabgleich mit dem Webshop (docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
-- Bewegungsbuch je Charge, Herstellungschargen fuer Nicht-Honig-Artikel,
-- idempotente Buchungsanfragen des Shops. Rein additiv.
-- CreateEnum
CREATE TYPE "ProductKind" AS ENUM ('BOTTLED_HONEY', 'MANUFACTURED');
-- CreateEnum
CREATE TYPE "ShopCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "BatchMovementType" AS ENUM ('SHOP_SALE', 'SHOP_RETURN', 'WITHDRAWAL', 'CORRECTION');
-- CreateEnum
CREATE TYPE "WithdrawalReason" AS ENUM ('HOFLADEN', 'EIGENBEDARF', 'BRUCH', 'PROBE', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "ShopStockRequestKind" AS ENUM ('SALE', 'RETURN');
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "kind" "ProductKind" NOT NULL DEFAULT 'BOTTLED_HONEY',
ADD COLUMN "shopCategory" "ShopCategory" NOT NULL DEFAULT 'HONIG';
-- CreateTable
CREATE TABLE "ProductionBatch" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"lotNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quantity" INTEGER NOT NULL,
"bestBeforeDate" TIMESTAMP(3),
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductionBatch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BatchMovement" (
"id" TEXT NOT NULL,
"bottlingBatchId" TEXT,
"productionBatchId" TEXT,
"type" "BatchMovementType" NOT NULL,
"quantity" INTEGER NOT NULL,
"reason" "WithdrawalReason",
"notes" TEXT,
"shopRequestId" TEXT,
"userId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BatchMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ShopStockRequest" (
"id" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"kind" "ShopStockRequestKind" NOT NULL,
"bottlingBatchId" TEXT,
"productId" TEXT,
"quantity" INTEGER NOT NULL,
"reversesExternalId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ShopStockRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProductionBatch_lotNumber_key" ON "ProductionBatch"("lotNumber");
-- CreateIndex
CREATE INDEX "ProductionBatch_productId_idx" ON "ProductionBatch"("productId");
-- CreateIndex
CREATE INDEX "BatchMovement_bottlingBatchId_idx" ON "BatchMovement"("bottlingBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_productionBatchId_idx" ON "BatchMovement"("productionBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_shopRequestId_idx" ON "BatchMovement"("shopRequestId");
-- CreateIndex
CREATE UNIQUE INDEX "ShopStockRequest_externalId_key" ON "ShopStockRequest"("externalId");
-- CreateIndex
CREATE INDEX "ShopStockRequest_reversesExternalId_idx" ON "ShopStockRequest"("reversesExternalId");
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_bottlingBatchId_fkey" FOREIGN KEY ("bottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_shopRequestId_fkey" FOREIGN KEY ("shopRequestId") REFERENCES "ShopStockRequest"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Genau eine Charge je Bewegung, genau ein Ziel je Shop-Anfrage.
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (("bottlingBatchId" IS NULL) <> ("productionBatchId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_exactly_one_target"
CHECK (("bottlingBatchId" IS NULL) <> ("productId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_quantity_positive"
CHECK ("quantity" > 0);
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_quantity_positive"
CHECK ("quantity" > 0);
-- Permissions: the boot-time catalog sync only grants new permissions to
-- ADMIN; the other built-in roles get them here, matching their existing
-- bottling rights.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'bottling:withdraw', 'bottling', 'withdraw', 'Entnehmen und Inventur', 'Abfüllungen', 1604),
(gen_random_uuid()::text, 'production-batch:read', 'production-batch', 'read', 'Ansehen', 'Herstellungschargen', 1650),
(gen_random_uuid()::text, 'production-batch:create', 'production-batch', 'create', 'Anlegen', 'Herstellungschargen', 1651),
(gen_random_uuid()::text, 'production-batch:update', 'production-batch', 'update', 'Bearbeiten', 'Herstellungschargen', 1652),
(gen_random_uuid()::text, 'production-batch:delete', 'production-batch', 'delete', 'Löschen', 'Herstellungschargen', 1653),
(gen_random_uuid()::text, 'production-batch:withdraw', 'production-batch', 'withdraw', 'Entnehmen und Inventur', 'Herstellungschargen', 1654),
(gen_random_uuid()::text, 'shop-sync:write', 'shop-sync', 'write', 'Verkäufe und Rückbuchungen des Shops buchen', 'Shop-Synchronisation', 3301)
ON CONFLICT (key) DO NOTHING;
-- Wer Abfuellungen bearbeiten darf, darf kuenftig auch entnehmen und
-- Herstellungschargen pflegen; wer Abfuellungen sehen darf, sieht auch
-- Herstellungschargen.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:update'
JOIN "Permission" p ON p.key IN (
'bottling:withdraw', 'production-batch:create', 'production-batch:update',
'production-batch:delete', 'production-batch:withdraw'
)
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:read'
JOIN "Permission" p ON p.key = 'production-batch:read'
ON CONFLICT DO NOTHING;
-- Only ADMIN may hand out shop-sync:write to an API key (a key can only get
-- permissions its creator holds). Inserted here, the row is no longer "new"
-- for the boot-time catalog sync, so the grant has to happen here too.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'ADMIN' AND p.key = 'shop-sync:write'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,106 @@
-- Rohstoffe (Wachs, Propolis) mit eigenen Chargen und Bestand; Herstellungs-
-- chargen verbrauchen sie (ProductionInput + Bewegung CONSUMPTION). Damit
-- reicht die Rueckverfolgung vom Rohstoff ueber die Charge bis zur Kundin.
-- CreateEnum
CREATE TYPE "RawMaterialKind" AS ENUM ('BEESWAX', 'PROPOLIS');
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'CONSUMPTION';
-- AlterTable
ALTER TABLE "BatchMovement" ADD COLUMN "rawMaterialLotId" TEXT;
-- CreateTable
CREATE TABLE "RawMaterialLot" (
"id" TEXT NOT NULL,
"kind" "RawMaterialKind" NOT NULL,
"lotNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quantity" INTEGER NOT NULL,
"apiaryId" TEXT,
"harvestId" TEXT,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RawMaterialLot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductionInput" (
"id" TEXT NOT NULL,
"productionBatchId" TEXT NOT NULL,
"rawMaterialLotId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductionInput_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RawMaterialLot_lotNumber_key" ON "RawMaterialLot"("lotNumber");
-- CreateIndex
CREATE INDEX "RawMaterialLot_kind_idx" ON "RawMaterialLot"("kind");
-- CreateIndex
CREATE INDEX "ProductionInput_productionBatchId_idx" ON "ProductionInput"("productionBatchId");
-- CreateIndex
CREATE INDEX "ProductionInput_rawMaterialLotId_idx" ON "ProductionInput"("rawMaterialLotId");
-- CreateIndex
CREATE INDEX "BatchMovement_rawMaterialLotId_idx" ON "BatchMovement"("rawMaterialLotId");
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "Harvest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Eine Bewegung gehoert jetzt zu genau einer von drei Chargenarten.
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId") = 1);
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_quantity_positive" CHECK ("quantity" > 0);
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_quantity_positive" CHECK ("quantity" > 0);
-- Permissions (catalog sync grants only brand-new rows to ADMIN): roles that
-- may maintain production batches may maintain raw materials, readers read.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'raw-material:read', 'raw-material', 'read', 'Ansehen', 'Rohstoffe', 1630),
(gen_random_uuid()::text, 'raw-material:create', 'raw-material', 'create', 'Anlegen', 'Rohstoffe', 1631),
(gen_random_uuid()::text, 'raw-material:update', 'raw-material', 'update', 'Bearbeiten', 'Rohstoffe', 1632),
(gen_random_uuid()::text, 'raw-material:delete', 'raw-material', 'delete', 'Löschen', 'Rohstoffe', 1633),
(gen_random_uuid()::text, 'raw-material:withdraw', 'raw-material', 'withdraw', 'Entnehmen und Inventur', 'Rohstoffe', 1634)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:create'
JOIN "Permission" p ON p.key IN ('raw-material:create', 'raw-material:update', 'raw-material:delete', 'raw-material:withdraw')
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:read'
JOIN "Permission" p ON p.key = 'raw-material:read'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,4 @@
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOT_NUMBER_FORMAT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PRODUCTION_BATCH_NUMBER_FORMAT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOW';
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "AutomationSequence" (
"triggerType" "AutomationTriggerType" NOT NULL,
"scope" TEXT NOT NULL,
"value" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationSequence_pkey" PRIMARY KEY ("triggerType","scope")
);
-- AddForeignKey
ALTER TABLE "AutomationSequence" ADD CONSTRAINT "AutomationSequence_triggerType_fkey" FOREIGN KEY ("triggerType") REFERENCES "AutomationRule"("triggerType") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Outbox claims: one worker at a time writes an entry to M365, and a
-- revision counter lets it notice an enqueue that arrived during the sync.
-- AlterTable
ALTER TABLE "CalendarSyncOutboxEntry" ADD COLUMN "claimedAt" TIMESTAMP(3),
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,139 @@
-- Platform backbone: domain-event outbox (DomainEvent + per-handler
-- DomainEventDelivery), webhook delivery queue, cron job run history and
-- throttler counters, plus two small columns (Apiary.publicLocationLabel,
-- SiteSettings.originCountry).
-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only
-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE
-- (not a partial index) so the schema and the database cannot drift apart.
-- CreateEnum
CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED');
-- CreateEnum
CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
-- AlterTable
ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT;
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland';
-- CreateTable
CREATE TABLE "DomainEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"aggregateType" TEXT NOT NULL,
"aggregateId" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"actorUserId" TEXT,
"correlationId" TEXT NOT NULL,
"dedupeKey" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"processedAt" TIMESTAMP(3),
"lastError" TEXT,
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainEventDelivery" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"handler" TEXT NOT NULL,
"status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"body" TEXT NOT NULL,
"status" "WebhookDeliveryStatus" 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 "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JobRun" (
"id" TEXT NOT NULL,
"job" TEXT NOT NULL,
"status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"result" JSONB,
"error" TEXT,
CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE UNLOGGED TABLE "ThrottlerHit" (
"key" TEXT NOT NULL,
"throttlerName" TEXT NOT NULL,
"hits" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"blockedUntil" TIMESTAMP(3),
CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName")
);
-- CreateIndex
CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey");
-- CreateIndex
CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt");
-- CreateIndex
CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId");
-- CreateIndex
CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler");
-- CreateIndex
CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt");
-- CreateIndex
CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt");
-- CreateIndex
CREATE INDEX "JobRun_status_idx" ON "JobRun"("status");
-- CreateIndex
CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt");
-- AddForeignKey
ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;
@@ -0,0 +1,3 @@
-- ERP-Export /bewegungen sortiert und blättert nach (createdAt, id).
-- CreateIndex
CREATE INDEX "BatchMovement_createdAt_id_idx" ON "BatchMovement"("createdAt", "id");
@@ -0,0 +1,19 @@
-- T4 migration 1/2: new enum values only. PostgreSQL cannot use a value in
-- the transaction that adds it, so everything that uses them (the Suggestion
-- table, rule rows) comes in later migrations.
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'INSPECTION_INTERVAL';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_CELLS_SWARM_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_ACCEPTANCE_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SPLIT_SCHEDULE';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_ENDED_CLEANUP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'WITHDRAWAL_ENDED';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_WATER_CONTENT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SEASON_AUTOPILOT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PHENOLOGY';
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'BOTTLING';
-- AlterEnum
ALTER TYPE "CalendarSyncSourceType" ADD VALUE 'WITHDRAWAL';
@@ -0,0 +1,71 @@
-- T4 migration 2/2: suggestions (rule, chain and autopilot proposals the
-- beekeeper decides on). dedupeKey is nullable and unique: Postgres allows many
-- NULLs, so EXPIRED/RESOLVED rows release their key.
-- CreateEnum
CREATE TYPE "SuggestionStatus" AS ENUM ('OPEN', 'ACCEPTED', 'DISMISSED', 'RESOLVED', 'EXPIRED', 'OBSOLETE');
-- CreateEnum
CREATE TYPE "SuggestionMode" AS ENUM ('PLAN', 'DO');
-- CreateEnum
CREATE TYPE "SuggestionSource" AS ENUM ('RULE', 'CHAIN', 'AUTOPILOT');
-- CreateEnum
CREATE TYPE "SuggestionIntent" AS ENUM ('INSPECT', 'SWARM_CHECK', 'QUEEN_CHECK', 'QUEEN_ACCEPTANCE', 'QUEEN_REPLACE', 'SPLIT_PLAN', 'VARROA_MEASURE', 'VARROA_RECHECK', 'VARROA_TREAT', 'SUMMER_TREATMENT', 'FEED', 'HONEY_SUPER', 'WINTER_CHECK', 'HEALTH_CHECK', 'FOLLOW_UP', 'STOCK_REORDER', 'CLEANUP');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceSuggestionId" TEXT;
-- CreateTable
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"ruleKey" "AutomationTriggerType" NOT NULL,
"source" "SuggestionSource" NOT NULL,
"mode" "SuggestionMode" NOT NULL,
"intent" "SuggestionIntent" NOT NULL,
"status" "SuggestionStatus" NOT NULL DEFAULT 'OPEN',
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
"relatedType" "TaskRelatedType" NOT NULL,
"relatedId" TEXT,
"colonyId" TEXT,
"reasonCode" TEXT NOT NULL,
"evidence" JSONB NOT NULL,
"requiredPermission" TEXT,
"dueDate" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"plan" JSONB,
"dedupeKey" TEXT,
"sourceEventId" TEXT,
"decidedAt" TIMESTAMP(3),
"decidedByUserId" TEXT,
"decisionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Suggestion_dedupeKey_key" ON "Suggestion"("dedupeKey");
-- CreateIndex
CREATE INDEX "Suggestion_status_colonyId_idx" ON "Suggestion"("status", "colonyId");
-- CreateIndex
CREATE INDEX "Suggestion_status_dueDate_idx" ON "Suggestion"("status", "dueDate");
-- CreateIndex
CREATE INDEX "Suggestion_ruleKey_relatedType_relatedId_status_idx" ON "Suggestion"("ruleKey", "relatedType", "relatedId", "status");
-- CreateIndex
CREATE INDEX "Task_sourceSuggestionId_idx" ON "Task"("sourceSuggestionId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceSuggestionId_fkey" FOREIGN KEY ("sourceSuggestionId") REFERENCES "Suggestion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_decidedByUserId_fkey" FOREIGN KEY ("decidedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- T4 migration 3/n: one vocabulary for the feed condition. Inspection.feedStatus
-- was free text; it becomes ColonyCondition, like the colony card. Known words
-- (ausreichend, niedrig, kritisch; trimmed of spaces, tabs and line breaks like
-- JS trim, any case) map to the enum, any other text moves to the end of
-- "notes" as 'Futter (alt): <text>'. Nothing is lost.
-- AlterTable
ALTER TABLE "Inspection" ADD COLUMN "feedCondition" "ColonyCondition";
-- Known words map to the enum.
UPDATE "Inspection"
SET "feedCondition" = CASE lower(btrim("feedStatus", E' \t\r\n'))
WHEN 'ausreichend' THEN 'GOOD'::"ColonyCondition"
WHEN 'niedrig' THEN 'WEAK'::"ColonyCondition"
WHEN 'kritisch' THEN 'CRITICAL'::"ColonyCondition"
END
WHERE "feedStatus" IS NOT NULL;
-- Every other non-empty text goes to the notes.
DO $$
DECLARE
moved integer;
BEGIN
UPDATE "Inspection"
SET "notes" = concat_ws(E'\n', NULLIF("notes", ''), 'Futter (alt): ' || btrim("feedStatus", E' \t\r\n'))
WHERE "feedCondition" IS NULL
AND btrim("feedStatus", E' \t\r\n') <> '';
GET DIAGNOSTICS moved = ROW_COUNT;
RAISE NOTICE 't4_feed_vocabulary: % free-text feed values moved into notes', moved;
END $$;
-- AlterTable
ALTER TABLE "Inspection" DROP COLUMN "feedStatus";
ALTER TABLE "Inspection" RENAME COLUMN "feedCondition" TO "feedStatus";
@@ -0,0 +1,8 @@
-- T4 migration 5/n: where the queen of a split colony comes from. Optional:
-- an observation is never filled in for old rows.
-- CreateEnum
CREATE TYPE "SplitQueenSource" AS ENUM ('RAISE_OWN', 'QUEEN_CELL', 'QUEEN');
-- AlterTable
ALTER TABLE "ColonyLineage" ADD COLUMN "queenSource" "SplitQueenSource";
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part sampleBees): number of bees in a powdered-sugar or
-- washing sample, so that the infestation percentage can be computed.
-- AlterTable
ALTER TABLE "VarroaMeasurement" ADD COLUMN "sampleBees" INTEGER;
@@ -0,0 +1,33 @@
-- T4 migration 8/n: every notification belongs to exactly one user. A
-- broadcast (userId IS NULL) is copied once per active user with its read
-- flag and creation time, then the original is deleted. After that userId is
-- NOT NULL; dedupeKey ("<prefix>:<userId>") makes repeated writes harmless.
-- Check afterwards: SELECT count(*) FROM "Notification" WHERE "userId" IS NULL;
-- must be 0 (the SET NOT NULL below fails otherwise).
-- DropForeignKey (was ON DELETE SET NULL, impossible for a NOT NULL column)
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "dedupeKey" TEXT;
-- Fan-out of the broadcasts to ALL active users (isActive only, no role or
-- permission filter): a broadcast was visible to every user before, so the
-- copies keep exactly that visibility. With no active user, broadcasts are
-- dropped. The copies get no dedupeKey and the default requiredPermissions.
INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "relatedId", "read", "createdAt")
SELECT gen_random_uuid()::text, u."id", n."priority", n."message", n."relatedType", n."relatedId", n."read", n."createdAt"
FROM "Notification" n
CROSS JOIN "User" u
WHERE n."userId" IS NULL AND u."isActive" = true;
DELETE FROM "Notification" WHERE "userId" IS NULL;
-- AlterTable
ALTER TABLE "Notification" ALTER COLUMN "userId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Notification_dedupeKey_key" ON "Notification"("dedupeKey");
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- T4 migration 9/n: switch for the withdrawal-period entries of the calendar
-- sync (on by default, like the other sources).
-- AlterTable
ALTER TABLE "CalendarSyncSettings" ADD COLUMN "syncWithdrawal" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,11 @@
-- T4 migration 10/n: Colony.occupiedFrames, the number of occupied frames
-- (0-40, same unit and bounds as Inspection.colonyStrength). Colony.strength
-- (scale 1-10) is left untouched, nothing is converted.
--
-- Check BEFORE the deploy (hits must be clarified with the owner, never cut):
-- SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40;
-- AlterTable
ALTER TABLE "Colony" ADD COLUMN "occupiedFrames" INTEGER;
ALTER TABLE "Colony" ADD CONSTRAINT "Colony_occupiedFrames_range" CHECK ("occupiedFrames" BETWEEN 0 AND 40);
@@ -0,0 +1,39 @@
-- T4 migration 11/n (part category, link, permissions, preferences). Runs
-- after migration 8. category of the old rows: job failures carry
-- relatedType 'JobRun' (and rows without relatedType are system messages) ->
-- SYSTEM, everything else -> SUGGESTION. Only then NOT NULL.
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('TASK_DUE', 'DIGEST', 'SUGGESTION', 'WITHDRAWAL', 'SEASON', 'SYSTEM');
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "category" "NotificationCategory",
ADD COLUMN "link" TEXT,
ADD COLUMN "requiredPermissions" TEXT[] DEFAULT ARRAY[]::TEXT[];
UPDATE "Notification"
SET "category" = CASE
WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM'::"NotificationCategory"
ELSE 'SUGGESTION'::"NotificationCategory"
END;
ALTER TABLE "Notification" ALTER COLUMN "category" SET NOT NULL;
-- CreateTable
CREATE TABLE "NotificationPreference" (
"userId" TEXT NOT NULL,
"taskDueEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestTime" TEXT NOT NULL DEFAULT '06:30',
"quietFrom" TEXT DEFAULT '21:00',
"quietUntil" TEXT DEFAULT '06:30',
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
"telegramCategories" "NotificationCategory"[] DEFAULT ARRAY['TASK_DUE', 'DIGEST']::"NotificationCategory"[],
"telegramDetail" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- T4 follow-up: notifications and their settings go with their user.
-- Users are deactivated, never hard-deleted, in production, so RESTRICT
-- protected nothing; but fan-out writes rows to users of other test files,
-- and deleting such a user must not fail on a foreign notification.
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- DropForeignKey
ALTER TABLE "NotificationPreference" DROP CONSTRAINT "NotificationPreference_userId_fkey";
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,105 @@
-- T4 migration 4/n: plant development (phenology) and the season segment.
-- PhenologyReport holds the beekeeper's own reports; ApiaryPhenology and
-- ApiaryWeatherDay are derived/cached state written only by the
-- daily-evaluation step and the weather loop.
-- CreateEnum
CREATE TYPE "PhenologyPhase" AS ENUM ('SALWEIDE', 'KIRSCHE', 'APFEL', 'HOLUNDER', 'LINDE', 'TRACHTENDE', 'HERBST', 'WINTER');
-- CreateEnum
CREATE TYPE "SeasonSegment" AS ENUM ('WINTERRUHE', 'FRUEHJAHR', 'SCHWARMZEIT', 'SOMMER', 'SPAETSOMMER', 'HERBST');
-- CreateEnum
CREATE TYPE "PhaseSource" AS ENUM ('REPORT', 'GTS', 'CALENDAR');
-- CreateTable
CREATE TABLE "PhenologyReport" (
"id" TEXT NOT NULL,
"apiaryId" TEXT,
"phase" "PhenologyPhase" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"userId" TEXT NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PhenologyReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiaryPhenology" (
"apiaryId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"gts" DOUBLE PRECISION,
"gtsThroughDay" TIMESTAMP(3),
"missingDays" INTEGER NOT NULL DEFAULT 0,
"estimatedPhase" "PhenologyPhase",
"reportedPhase" "PhenologyPhase",
"effectivePhase" "PhenologyPhase",
"phaseSource" "PhaseSource",
"segment" "SeasonSegment" NOT NULL,
"segmentSince" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiaryPhenology_pkey" PRIMARY KEY ("apiaryId","year")
);
-- CreateTable
CREATE TABLE "ApiaryWeatherDay" (
"apiaryId" TEXT NOT NULL,
"day" TIMESTAMP(3) NOT NULL,
"meanTempC" DOUBLE PRECISION,
"source" TEXT NOT NULL,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiaryWeatherDay_pkey" PRIMARY KEY ("apiaryId","day")
);
-- CreateIndex
CREATE INDEX "PhenologyReport_date_idx" ON "PhenologyReport"("date");
-- CreateIndex
CREATE INDEX "PhenologyReport_apiaryId_phase_date_idx" ON "PhenologyReport"("apiaryId", "phase", "date");
-- AddForeignKey
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryPhenology" ADD CONSTRAINT "ApiaryPhenology_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryWeatherDay" ADD CONSTRAINT "ApiaryWeatherDay_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Permissions: the boot-time catalog sync grants new permissions to system
-- roles only for rows it creates itself, and these rows exist before the
-- first boot. So system roles (ADMIN: full access) get all three here; this
-- mirrors what PermissionCatalogSyncService does for permissions it creates.
-- Phenology reports are everyday beekeeping records, so non-system roles get
-- them like winter-checks (assumption 16): read with colonies:read,
-- create/update with inspections:create.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'phenology:read', 'phenology', 'read', 'Ansehen', 'Pflanzenentwicklung', 860),
(gen_random_uuid()::text, 'phenology:create', 'phenology', 'create', 'Melden', 'Pflanzenentwicklung', 861),
(gen_random_uuid()::text, 'phenology:update', 'phenology', 'update', 'Bearbeiten', 'Pflanzenentwicklung', 862)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r."isSystem" = true AND p.resource = 'phenology'
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Role" r ON r.id = rp."roleId" AND r."isSystem" = false
JOIN "Permission" have ON have.id = rp."permissionId"
JOIN "Permission" p ON p.resource = 'phenology'
WHERE (have.key = 'colonies:read' AND p.action = 'read')
OR (have.key = 'inspections:create' AND p.action IN ('create', 'update'))
ON CONFLICT DO NOTHING;
@@ -0,0 +1,84 @@
-- T4 migration 6/n: ledger of the honey batch. HoneyBatch.amount (kg) stays
-- the running rest; initialAmount (kg) is the starting quantity, and
-- amount = initialAmount + sum(BatchMovement.quantity) / 1000
-- (quantity in grams, signed). Every bottling is booked as a BOTTLING
-- movement. Earlier hand corrections of amount are folded into initialAmount.
--
-- Invariant check afterwards (must return 0 rows):
-- SELECT hb."id" FROM "HoneyBatch" hb
-- LEFT JOIN (SELECT "honeyBatchId", sum("quantity") g FROM "BatchMovement"
-- WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
-- WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
-- AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
-- AlterTable
ALTER TABLE "BatchMovement" ADD COLUMN "honeyBatchId" TEXT,
ADD COLUMN "sourceBottlingBatchId" TEXT;
-- AlterTable
ALTER TABLE "HoneyBatch" ADD COLUMN "initialAmount" DOUBLE PRECISION;
-- CreateIndex
CREATE INDEX "BatchMovement_honeyBatchId_idx" ON "BatchMovement"("honeyBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_sourceBottlingBatchId_idx" ON "BatchMovement"("sourceBottlingBatchId");
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_sourceBottlingBatchId_fkey" FOREIGN KEY ("sourceBottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- A movement now belongs to exactly one of four batch kinds (name kept from
-- 20261005100000_raw_materials_production).
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId", "honeyBatchId") = 1);
-- Recalculation, only for batches with a recorded amount (a batch without
-- one is not booked, as before).
-- 1. initialAmount = amount + the bottlings rounded to the gram exactly like
-- the bookings below (kg), so the invariant holds without a rounding gap.
UPDATE "HoneyBatch" hb
SET "initialAmount" = hb."amount" + coalesce((
SELECT sum(floor(b."totalAmount" * 1000 + 0.5)) FROM "BottlingBatch" b WHERE b."honeyBatchId" = hb."id"
), 0) / 1000.0
WHERE hb."amount" IS NOT NULL;
-- 2. One BOTTLING movement per bottling, in grams, dated like the bottling.
-- floor(x + 0.5) rounds half up like the application (Math.round), whereas
-- round(double precision) may round half to even.
INSERT INTO "BatchMovement" ("id", "honeyBatchId", "sourceBottlingBatchId", "type", "quantity", "notes", "userId", "createdAt")
SELECT gen_random_uuid()::text, b."honeyBatchId", b."id", 'BOTTLING',
-(floor(b."totalAmount" * 1000 + 0.5))::integer,
'Übernahme aus Abfüllung (T4)', b."userId", b."createdAt"
FROM "BottlingBatch" b
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId"
WHERE hb."amount" IS NOT NULL;
-- Report and self-check: the counts go to the migration log; a broken
-- invariant aborts the whole migration (the file runs in one transaction).
DO $$
DECLARE
batches integer;
bookings integer;
skipped integer;
broken integer;
BEGIN
SELECT count(*) INTO batches FROM "HoneyBatch" WHERE "initialAmount" IS NOT NULL;
SELECT count(*) INTO bookings FROM "BatchMovement" WHERE "honeyBatchId" IS NOT NULL AND "type" = 'BOTTLING';
SELECT count(*) INTO skipped FROM "BottlingBatch" b
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId" WHERE hb."amount" IS NULL;
RAISE NOTICE 'honey ledger: % batches with initialAmount, % bookings created, % bottlings skipped (batch amount is NULL)',
batches, bookings, skipped;
SELECT count(*) INTO broken FROM "HoneyBatch" hb
LEFT JOIN (SELECT "honeyBatchId", sum("quantity") AS g FROM "BatchMovement"
WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
IF broken > 0 THEN
RAISE EXCEPTION 'honey ledger invariant broken for % batches', broken;
END IF;
END $$;
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part shelf life): Product.shelfLifeMonths, the shelf life
-- in months, used to pre-fill the best-before date. No default value.
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "shelfLifeMonths" INTEGER;
@@ -0,0 +1,111 @@
-- T4 migration 11/n (part Telegram and delivery): chat binding, single-use
-- link tokens, the delivery outbox and the receiver's poll state. All four
-- tables are excluded from the backup (MODELS_EXCLUDED_FROM_BACKUP).
-- NotificationDelivery carries either a notification for a user or a bot
-- system reply for a chat, never both (CHECK below), and at most one
-- delivery per notification and channel. TelegramPollState holds exactly one
-- row (CHECK below), created here.
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('TELEGRAM');
-- CreateEnum
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'SKIPPED', 'FAILED');
-- CreateTable
CREATE TABLE "TelegramLink" (
"userId" TEXT NOT NULL,
"chatId" BIGINT NOT NULL,
"telegramFirstName" TEXT,
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TelegramLink_pkey" PRIMARY KEY ("userId")
);
-- CreateTable
CREATE TABLE "TelegramLinkToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TelegramLinkToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationDelivery" (
"id" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"userId" TEXT,
"chatId" BIGINT,
"notificationId" TEXT,
"templateCode" TEXT,
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"lastStatusCode" INTEGER,
"lastError" TEXT,
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TelegramPollState" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"updateOffset" BIGINT NOT NULL DEFAULT 0,
"leaseOwner" TEXT,
"leaseUntil" TIMESTAMP(3),
"lastPollAt" TIMESTAMP(3),
"lastError" TEXT,
CONSTRAINT "TelegramPollState_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TelegramLink_chatId_key" ON "TelegramLink"("chatId");
-- CreateIndex
CREATE UNIQUE INDEX "TelegramLinkToken_tokenHash_key" ON "TelegramLinkToken"("tokenHash");
-- CreateIndex
CREATE INDEX "TelegramLinkToken_userId_idx" ON "TelegramLinkToken"("userId");
-- CreateIndex
CREATE INDEX "NotificationDelivery_status_availableAt_idx" ON "NotificationDelivery"("status", "availableAt");
-- CreateIndex
CREATE INDEX "NotificationDelivery_userId_createdAt_idx" ON "NotificationDelivery"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "NotificationDelivery_notificationId_idx" ON "NotificationDelivery"("notificationId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationDelivery_notificationId_channel_key" ON "NotificationDelivery"("notificationId", "channel");
-- AddForeignKey
ALTER TABLE "TelegramLink" ADD CONSTRAINT "TelegramLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TelegramLinkToken" ADD CONSTRAINT "TelegramLinkToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- A delivery is either a notification for a user (rendered at send time) or a
-- system reply for a chat (rendered from templateCode).
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_target_check" CHECK (
("notificationId" IS NOT NULL AND "userId" IS NOT NULL AND "templateCode" IS NULL AND "chatId" IS NULL)
OR ("notificationId" IS NULL AND "templateCode" IS NOT NULL AND "chatId" IS NOT NULL)
);
-- The receiver's single state row.
ALTER TABLE "TelegramPollState" ADD CONSTRAINT "TelegramPollState_singleton_check" CHECK ("id" = 'singleton');
INSERT INTO "TelegramPollState" ("id") VALUES ('singleton');
@@ -0,0 +1,146 @@
-- T6 migration 1/5: veterinary medicine catalogue, packs and the pack ledger.
-- A treatment may point at a catalogue entry and at the pack it was taken
-- from; the pack ledger is booked, never edited. Rows of the catalogue and
-- the packs are never deleted while something references them (RESTRICT).
-- CHECKs: a pack holds more than nothing; a booking moves something; only a
-- TREATMENT booking belongs to a treatment.
-- CreateEnum
CREATE TYPE "MedicineUnit" AS ENUM ('ML', 'G', 'PIECE');
-- CreateEnum
CREATE TYPE "VeterinaryMedicineMovementType" AS ENUM ('TREATMENT', 'CORRECTION', 'DISPOSAL');
-- AlterEnum
ALTER TYPE "DocumentEntityType" ADD VALUE 'MEDICINE_LOT';
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "doseQuantity" DOUBLE PRECISION,
ADD COLUMN "medicineId" TEXT,
ADD COLUMN "medicineLotId" TEXT;
-- CreateTable
CREATE TABLE "VeterinaryMedicine" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"activeIngredient" TEXT,
"authorisationNumber" TEXT,
"prescriptionOnly" BOOLEAN NOT NULL DEFAULT false,
"withdrawalPeriodDays" INTEGER,
"unit" "MedicineUnit" NOT NULL,
"dosePerColony" DOUBLE PRECISION,
"doseNote" TEXT,
"applicationMethod" TEXT,
"minTemperatureC" DOUBLE PRECISION,
"maxTemperatureC" DOUBLE PRECISION,
"defaultSupplier" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineLot" (
"id" TEXT NOT NULL,
"medicineId" TEXT NOT NULL,
"batchNumber" TEXT,
"purchasedOn" TIMESTAMP(3) NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"expiresOn" TIMESTAMP(3),
"supplier" TEXT,
"purchaseProof" TEXT,
"prescribingVet" TEXT,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicineLot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineMovement" (
"id" TEXT NOT NULL,
"lotId" TEXT NOT NULL,
"type" "VeterinaryMedicineMovementType" NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"treatmentId" TEXT,
"reason" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VeterinaryMedicineMovement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VeterinaryMedicine_active_idx" ON "VeterinaryMedicine"("active");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineLot_medicineId_expiresOn_idx" ON "VeterinaryMedicineLot"("medicineId", "expiresOn");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_lotId_idx" ON "VeterinaryMedicineMovement"("lotId");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_treatmentId_idx" ON "VeterinaryMedicineMovement"("treatmentId");
-- CreateIndex
CREATE INDEX "Treatment_medicineId_idx" ON "Treatment"("medicineId");
-- CreateIndex
CREATE INDEX "Treatment_medicineLotId_idx" ON "Treatment"("medicineLotId");
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineLotId_fkey" FOREIGN KEY ("medicineLotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "Treatment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "VeterinaryMedicineLot"
ADD CONSTRAINT "VeterinaryMedicineLot_quantity_positive" CHECK ("quantity" > 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_quantity_nonzero" CHECK ("quantity" <> 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_treatment_matches_type"
CHECK (("type" = 'TREATMENT') = ("treatmentId" IS NOT NULL));
-- Permissions: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN), so the grants are made here (same as winter_checks).
-- API_CLIENT gets nothing: receipts and invoice numbers are not for the shop.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'veterinary-medicines:read', 'veterinary-medicines', 'read', 'Ansehen', 'Tierarzneimittel', 3700),
(gen_random_uuid()::text, 'veterinary-medicines:create', 'veterinary-medicines', 'create', 'Anlegen', 'Tierarzneimittel', 3701),
(gen_random_uuid()::text, 'veterinary-medicines:update', 'veterinary-medicines', 'update', 'Bearbeiten', 'Tierarzneimittel', 3702)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'veterinary-medicines'
AND (
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
OR (r.key = 'READONLY' AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,21 @@
-- T6 migration 2/5: mandatory label particulars. The food business operator
-- (name and address) is separate from the bee keeper of the Bestandsbuch; a
-- product carries the legal name of the food; a label template carries the
-- minimum x-height (CHECK 0.5 to 5 mm) the print check warns about. Printing
-- a label is audited as PRINT.
-- AlterEnum
ALTER TYPE "AuditAction" ADD VALUE 'PRINT';
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "foodBusinessAddress" TEXT,
ADD COLUMN "foodBusinessName" TEXT;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "legalName" TEXT;
-- AlterTable
ALTER TABLE "LabelTemplate" ADD COLUMN "minXHeightMm" DOUBLE PRECISION NOT NULL DEFAULT 1.2;
ALTER TABLE "LabelTemplate"
ADD CONSTRAINT "LabelTemplate_minXHeightMm_range" CHECK ("minXHeightMm" BETWEEN 0.5 AND 5);
@@ -0,0 +1,140 @@
-- T6 migration 3/5: authority package and readiness. ComplianceSettings is a
-- singleton like SiteSettings; the default net-quantity digit rules are an
-- assumption the owner has to check (ARCHITECTURE). AuthorityNotice.openKey
-- is a nullable unique key: at most one open draft per kind and year, any
-- number of rows without a key. ApiaryRestriction ends on or after its start.
-- CreateEnum
CREATE TYPE "AuthorityNoticeKind" AS ENUM ('REGISTRATION', 'HERD_CHANGE', 'TSK_REPORT');
-- CreateEnum
CREATE TYPE "AuthorityNoticeStatus" AS ENUM ('DRAFT', 'SENT', 'CANCELLED');
-- AlterEnum
-- This migration adds more than one value to an enum. They are not used in
-- this migration (PostgreSQL forbids it inside the same transaction).
ALTER TYPE "DocumentEntityType" ADD VALUE 'AUTHORITY_NOTICE';
ALTER TYPE "DocumentEntityType" ADD VALUE 'APIARY_RESTRICTION';
-- CreateTable
CREATE TABLE "ComplianceSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"veterinaryOfficeName" TEXT,
"veterinaryOfficeAddress" TEXT,
"registrationNumber" TEXT,
"noticeOnColonyCountChange" BOOLEAN NOT NULL DEFAULT true,
"noticeOnApiaryChange" BOOLEAN NOT NULL DEFAULT true,
"tskReferenceDay" TEXT NOT NULL DEFAULT '01-01',
"tskReportDueDay" TEXT,
"tskMemberNumber" TEXT,
"tskExcludedColonyTypes" "ColonyType"[] DEFAULT ARRAY[]::"ColonyType"[],
"netQuantityDigitRules" JSONB NOT NULL DEFAULT '[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ComplianceSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthorityNotice" (
"id" TEXT NOT NULL,
"kind" "AuthorityNoticeKind" NOT NULL,
"status" "AuthorityNoticeStatus" NOT NULL DEFAULT 'DRAFT',
"year" INTEGER,
"referenceDate" TIMESTAMP(3) NOT NULL,
"countedAt" TIMESTAMP(3) NOT NULL,
"snapshot" JSONB NOT NULL,
"totalColonies" INTEGER NOT NULL,
"sentOn" TIMESTAMP(3),
"sentVia" TEXT,
"reference" TEXT,
"notes" TEXT,
"openKey" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AuthorityNotice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiaryRestriction" (
"id" TEXT NOT NULL,
"apiaryId" TEXT NOT NULL,
"disease" TEXT NOT NULL,
"authority" TEXT,
"reference" TEXT,
"startsOn" TIMESTAMP(3) NOT NULL,
"endsOn" TIMESTAMP(3),
"notes" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiaryRestriction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ComplianceAcknowledgement" (
"id" TEXT NOT NULL,
"itemKey" TEXT NOT NULL,
"subjectId" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"note" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"revokedByUserId" TEXT,
CONSTRAINT "ComplianceAcknowledgement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthorityNotice_openKey_key" ON "AuthorityNotice"("openKey");
-- CreateIndex
CREATE INDEX "AuthorityNotice_kind_status_idx" ON "AuthorityNotice"("kind", "status");
-- CreateIndex
CREATE INDEX "AuthorityNotice_year_idx" ON "AuthorityNotice"("year");
-- CreateIndex
CREATE INDEX "ApiaryRestriction_apiaryId_startsOn_idx" ON "ApiaryRestriction"("apiaryId", "startsOn");
-- CreateIndex
CREATE INDEX "ComplianceAcknowledgement_itemKey_subjectId_idx" ON "ComplianceAcknowledgement"("itemKey", "subjectId");
-- AddForeignKey
ALTER TABLE "AuthorityNotice" ADD CONSTRAINT "AuthorityNotice_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ComplianceAcknowledgement" ADD CONSTRAINT "ComplianceAcknowledgement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "ApiaryRestriction"
ADD CONSTRAINT "ApiaryRestriction_endsOn_after_startsOn"
CHECK ("endsOn" IS NULL OR "endsOn" >= "startsOn");
-- Permissions (see the winter_checks migration): read for everyone who
-- reads the records, manage (settings, notices, restrictions, readiness
-- sign-off) only for ADMIN and BEEKEEPER. API_CLIENT gets nothing.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'compliance:read', 'compliance', 'read', 'Ansehen', 'Prüfbereitschaft & Behörden', 3800),
(gen_random_uuid()::text, 'compliance:manage', 'compliance', 'manage', 'Verwalten', 'Prüfbereitschaft & Behörden', 3801)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'compliance'
AND (
r.key IN ('ADMIN', 'BEEKEEPER')
OR (r.key IN ('STAFF', 'READONLY') AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,7 @@
-- T6 migration 4/5: notification category for the authority hints ("Bestand
-- geaendert", "TSK-Stichtag"). The value is only added here; migration 5
-- uses it (PostgreSQL forbids using a new enum value in the same
-- transaction that adds it).
-- AlterEnum
ALTER TYPE "NotificationCategory" ADD VALUE 'COMPLIANCE';
@@ -0,0 +1,13 @@
-- T6 migration 5/5: COMPLIANCE is a Telegram category chosen by default. New
-- preferences start with it; every existing preference gets it appended
-- (the app shows all categories, the list only steers Telegram). A user can
-- still remove it in the profile: an empty list is a deliberate opt-out and
-- stays empty (only lists with at least one entry get the new category).
-- AlterTable
ALTER TABLE "NotificationPreference" ALTER COLUMN "telegramCategories" SET DEFAULT ARRAY['TASK_DUE', 'DIGEST', 'COMPLIANCE']::"NotificationCategory"[];
UPDATE "NotificationPreference"
SET "telegramCategories" = array_append("telegramCategories", 'COMPLIANCE'::"NotificationCategory")
WHERE cardinality("telegramCategories") > 0
AND NOT ('COMPLIANCE'::"NotificationCategory" = ANY("telegramCategories"));
@@ -0,0 +1,9 @@
-- T6 migration 6/6: the ComplianceSettings singleton row exists from the
-- start. The label check reads the net-quantity digit rules from it and fails
-- closed when the row is missing, so no legal value lives in the code. The
-- column defaults (digit rules, notice switches) fill the row; the owner has
-- to check them (ARCHITECTURE).
INSERT INTO "ComplianceSettings" ("id", "updatedAt")
VALUES ('singleton', now())
ON CONFLICT ("id") DO NOTHING;
@@ -0,0 +1,109 @@
-- Beute in 3D: hive systems, part types, per-colony setup and its change
-- history (spec 2026-10-15, section 5.1).
-- CreateEnum
CREATE TYPE "HivePartKind" AS ENUM ('FLOOR', 'BOX', 'DIVIDER_BOARD', 'QUEEN_EXCLUDER', 'FEEDER', 'COVER_SHEET', 'PROPOLIS_SCREEN', 'INNER_LID', 'INSULATION', 'ROOF', 'FLOOR_INSERT', 'EVAPORATOR');
-- CreateEnum
CREATE TYPE "HiveBoxDepth" AS ENUM ('FULL', 'SHALLOW');
-- CreateEnum
CREATE TYPE "HiveChangeSource" AS ENUM ('INIT', 'CARD', 'EDITOR', 'INSPECTION', 'AI_SUGGESTION');
-- CreateTable
CREATE TABLE "HiveSystem" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"frameWidthMm" INTEGER NOT NULL,
"frameHeightMm" INTEGER NOT NULL,
"footprintLMm" INTEGER NOT NULL,
"footprintWMm" INTEGER NOT NULL,
"template" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HiveSystem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HivePartType" (
"id" TEXT NOT NULL,
"systemId" TEXT NOT NULL,
"kind" "HivePartKind" NOT NULL,
"name" TEXT NOT NULL,
"articleRef" TEXT,
"lengthMm" INTEGER NOT NULL,
"widthMm" INTEGER NOT NULL,
"heightMm" INTEGER NOT NULL,
"boxDepth" "HiveBoxDepth",
"frameSlots" INTEGER,
"stateSchema" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HivePartType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HiveSetup" (
"colonyId" TEXT NOT NULL,
"systemId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"state" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HiveSetup_pkey" PRIMARY KEY ("colonyId")
);
-- CreateTable
CREATE TABLE "HiveChange" (
"id" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"occurredOn" TIMESTAMP(3) NOT NULL,
"source" "HiveChangeSource" NOT NULL,
"inspectionId" TEXT,
"userId" TEXT NOT NULL,
"ops" JSONB NOT NULL,
"stateAfter" JSONB NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HiveChange_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HiveSystem_name_key" ON "HiveSystem"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HivePartType_systemId_name_key" ON "HivePartType"("systemId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "HiveChange_inspectionId_key" ON "HiveChange"("inspectionId");
-- CreateIndex
CREATE INDEX "HiveChange_colonyId_occurredOn_idx" ON "HiveChange"("colonyId", "occurredOn");
-- CreateIndex
CREATE UNIQUE INDEX "HiveChange_colonyId_version_key" ON "HiveChange"("colonyId", "version");
-- AddForeignKey
ALTER TABLE "HivePartType" ADD CONSTRAINT "HivePartType_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "HiveSetup"("colonyId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "Inspection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- Beute in 3D: start catalog for the Liebig-Zander equipment (spec 2026-10-15,
-- sections 4.3 and 4.4) under fixed ids, so the system template has stable
-- references and a restore into a fresh install creates no duplicate names.
-- Dimensions are catalog data and editable in the settings.
-- Hive system
INSERT INTO "HiveSystem" ("id", "name", "frameWidthMm", "frameHeightMm", "footprintLMm", "footprintWMm", "template", "active", "updatedAt")
VALUES (
'hive-system-zander-liebig-10',
'Zander nach Dr. Liebig, 10 Rähmchen',
477,
220,
525,
430,
'{"schemaVersion":1,"stack":[{"partTypeId":"hive-part-zl10-floor","state":{}},{"partTypeId":"hive-part-zl10-box-10","state":{},"frames":[{"slot":1,"type":"FOUNDATION"},{"slot":2,"type":"FOUNDATION"},{"slot":3,"type":"FOUNDATION"},{"slot":4,"type":"FOUNDATION"},{"slot":5,"type":"FOUNDATION"},{"slot":6,"type":"FOUNDATION"},{"slot":7,"type":"FOUNDATION"},{"slot":8,"type":"FOUNDATION"},{"slot":9,"type":"FOUNDATION"},{"slot":10,"type":"FOUNDATION"}]},{"partTypeId":"hive-part-zl10-cover-abdeckfix","state":{}},{"partTypeId":"hive-part-zl10-inner-lid","state":{}},{"partTypeId":"hive-part-zl10-insulation","state":{}},{"partTypeId":"hive-part-zl10-roof","state":{}}]}'::jsonb,
true,
CURRENT_TIMESTAMP
);
-- Part types
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "stateSchema", "active", "updatedAt")
VALUES
('hive-part-zl10-floor', 'hive-system-zander-liebig-10', 'FLOOR', 'Liebig Wanderboden', 525, 430, 120, NULL, NULL, '[{"key":"entrance","label":"Flugloch","kind":"choice","options":["OPEN","NARROW","CLOSED"],"optionLabels":["offen","eng","zu"]},{"key":"varroaTray","label":"Varroa-Schublade","kind":"choice","options":["IN","OUT"],"optionLabels":["drin","draußen"]}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-floor-insert', 'hive-system-zander-liebig-10', 'FLOOR_INSERT', 'Liebig Bausperre', 475, 375, 5, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-box-10', 'hive-system-zander-liebig-10', 'BOX', 'Liebig Zander Ganzzarge, 10 Rähmchen', 525, 430, 230, 'FULL', 10, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-box-empty', 'hive-system-zander-liebig-10', 'BOX', 'Leerzarge (Liebig Zander Ganzzarge ohne Rähmchen)', 525, 430, 230, 'FULL', 0, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-divider-board', 'hive-system-zander-liebig-10', 'DIVIDER_BOARD', 'Liebig Zwischenboden', 525, 430, 50, NULL, NULL, '[{"key":"bungHoles","label":"Spundlöcher","kind":"choice","options":["OPEN","CLOSED","BEE_ESCAPE"],"optionLabels":["offen","zu","Bienenflucht"]}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-queen-excluder', 'hive-system-zander-liebig-10', 'QUEEN_EXCLUDER', 'Niro-Absperrgitter Zander Liebig', 478, 376, 2, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-feeder', 'hive-system-zander-liebig-10', 'FEEDER', 'Liebig Zweikammer-Futterzarge', 520, 420, 70, NULL, NULL, '[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":3,"step":0.5,"feedType":"SUGAR_WATER"},{"key":"fondantKg","label":"Futterteig","kind":"level","unit":"kg","max":2.5,"step":0.5,"feedType":"FEED_PASTE"}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-cover-abdeckfix', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Abdeck-Fix (Folie, klar, 0,4 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-cover-lotus', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Imgut Lotus-Abdeckung (0,5 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-propolis-screen', 'hive-system-zander-liebig-10', 'PROPOLIS_SCREEN', 'Imgut Propolisgitter', 478, 378, 3, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-inner-lid', 'hive-system-zander-liebig-10', 'INNER_LID', 'Liebig Holzdeckel', 525, 430, 35, NULL, NULL, '[{"key":"feedHole","label":"Futterloch","kind":"choice","options":["OPEN","CLOSED"],"optionLabels":["offen","zu"]}]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-insulation', 'hive-system-zander-liebig-10', 'INSULATION', 'Styropor-Einlage (selbst gebaut)', 465, 365, 20, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-roof', 'hive-system-zander-liebig-10', 'ROOF', 'Edelstahldeckel konisch Liebig', 525, 430, 60, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
('hive-part-zl10-evaporator', 'hive-system-zander-liebig-10', 'EVAPORATOR', 'Nassenheider Verdunster Professional', 200, 140, 120, NULL, NULL, '[{"key":"acidMl","label":"Ameisensäure","kind":"level","unit":"ml","max":290,"step":10}]'::jsonb, true, CURRENT_TIMESTAMP);
@@ -0,0 +1,10 @@
-- Beute in 3D: Futtertasche (FRAME_FEEDER, spec 2026-10-15 sections 4.1, 4.3,
-- 5.1). A frame feeder hangs in a box in place of frames and covers
-- `slotWidth` frame slots. The enum value cannot be used in the migration that
-- adds it, so the catalog row comes in 20261018100100.
-- AlterEnum
ALTER TYPE "HivePartKind" ADD VALUE 'FRAME_FEEDER';
-- AlterTable
ALTER TABLE "HivePartType" ADD COLUMN "slotWidth" INTEGER;
@@ -0,0 +1,20 @@
-- Beute in 3D: start catalog row for the Futtertasche (owner wish 2026-10-16,
-- spec sections 4.3 and 4.1). Fixed id like the other start data; dimensions and
-- the 2 l capacity are assumptions the owner confirms (catalog data, editable).
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "slotWidth", "stateSchema", "active", "updatedAt")
VALUES (
'hive-part-zl10-frame-feeder',
'hive-system-zander-liebig-10',
'FRAME_FEEDER',
'Holz-Futtertasche Zander, doppelte Wabenbreite',
477,
70,
220,
NULL,
NULL,
2,
'[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":2,"step":0.25,"feedType":"SUGAR_WATER"}]'::jsonb,
true,
CURRENT_TIMESTAMP
);
@@ -0,0 +1,96 @@
-- CreateEnum
CREATE TYPE "VoiceCaptureSource" AS ENUM ('AUDIO', 'TEXT');
-- CreateEnum
CREATE TYPE "VoiceCaptureStatus" AS ENUM ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY', 'CONFIRMED', 'DISCARDED', 'FAILED', 'EXPIRED');
-- CreateTable
CREATE TABLE "VoiceCapture" (
"id" TEXT NOT NULL,
"ownerUserId" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"inspectionDate" DATE NOT NULL,
"source" "VoiceCaptureSource" NOT NULL,
"status" "VoiceCaptureStatus" NOT NULL DEFAULT 'QUEUED',
"audioMime" TEXT,
"audioBytes" INTEGER,
"durationSec" INTEGER,
"transcriptEnc" TEXT,
"suggestionEnc" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"claimToken" TEXT,
"lastErrorCode" TEXT,
"model" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"inspectionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"readyAt" TIMESTAMP(3),
"contentExpiresAt" TIMESTAMP(3) NOT NULL,
"contentDeletedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoiceCapture_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoiceCaptureAudio" (
"captureId" TEXT NOT NULL,
"dataEnc" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VoiceCaptureAudio_pkey" PRIMARY KEY ("captureId")
);
-- CreateIndex
CREATE INDEX "VoiceCapture_status_availableAt_idx" ON "VoiceCapture"("status", "availableAt");
-- CreateIndex
CREATE INDEX "VoiceCapture_ownerUserId_status_idx" ON "VoiceCapture"("ownerUserId", "status");
-- CreateIndex
CREATE INDEX "VoiceCapture_contentExpiresAt_idx" ON "VoiceCapture"("contentExpiresAt");
-- AddForeignKey
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceCaptureAudio" ADD CONSTRAINT "VoiceCaptureAudio_captureId_fkey" FOREIGN KEY ("captureId") REFERENCES "VoiceCapture"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_audioBytes_range"
CHECK ("audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152);
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_durationSec_range"
CHECK ("durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240);
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_confirmed_matches_inspection"
CHECK (("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL));
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_audioMime_only_for_audio"
CHECK ("source" = 'AUDIO' OR "audioMime" IS NULL);
-- Permission: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN), so the grants are made here (same as T6). READONLY
-- and API_CLIENT get nothing: voice notes are for people who inspect.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'voice-captures:use', 'voice-captures', 'use', 'Benutzen', 'Sprache', 4000)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.key = 'voice-captures:use'
AND r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
ON CONFLICT DO NOTHING;
@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "VoiceExtractionOutcome" AS ENUM ('OK', 'REFUSED', 'TRUNCATED', 'INVALID', 'RETRY', 'BAD_REQUEST', 'AUTH', 'ABORTED');
-- CreateTable
CREATE TABLE "VoiceExtractionCall" (
"id" TEXT NOT NULL,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"model" TEXT NOT NULL,
"inputTokens" INTEGER NOT NULL DEFAULT 0,
"outputTokens" INTEGER NOT NULL DEFAULT 0,
"outcome" "VoiceExtractionOutcome" NOT NULL,
"captureId" TEXT,
CONSTRAINT "VoiceExtractionCall_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VoiceExtractionCall_at_idx" ON "VoiceExtractionCall"("at");
File diff suppressed because it is too large. Load diff
+219 -29
View File
@@ -16,34 +16,209 @@ function generateRandomPassword(): string {
}
// Only printed the moment the account is actually created (first deploy on
// an empty database), never on later restarts: by then the real admin may
// an empty user table), never on later restarts: by then the real admin may
// have changed the password via /profile, so repeating it would just be
// stale and would leak credentials into logs indefinitely for no reason.
function printLoginBanner(email: string, password: string): void {
// An operator-supplied password is never printed: the operator already has
// it, and the log would only be one more place it leaks to.
function printLoginBanner(
email: string,
password: string | null,
log: (line: string) => void,
): void {
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const line = '='.repeat(60);
console.log(line);
console.log('Seed: Admin-Zugang eingerichtet.');
console.log(` URL: ${url}`);
console.log(` Benutzer: ${email}`);
console.log(` Passwort: ${password}`);
console.log(line);
log(line);
log('Seed: Admin-Zugang eingerichtet.');
log(` URL: ${url}`);
log(` Benutzer: ${email}`);
log(
password === null
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
: ` Passwort: ${password}`,
);
log(line);
}
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// Duplicated from src/task-templates/recurrence.ts rather than imported:
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
// isolated compile, separate from the main Nest build), so a cross-rootDir
// import would fail to compile. Only the YEARLY case is needed here.
function nextYearlyOccurrenceUtc(
month: number,
dayOfMonth: number,
from: Date,
): Date {
const year = from.getUTCFullYear();
const clampedDay = (y: number): number =>
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
const today = new Date(
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
);
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
if (candidate.getTime() >= today.getTime()) {
return candidate;
}
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
}
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
console.log(`Seed: admin user ${email} already exists, skipping.`);
await prisma.$disconnect();
interface DefaultTaskTemplate {
title: string;
description: string;
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
month: number;
dayOfMonth: number;
}
// The standard German-beekeeping annual cycle, one flagship task per month -
// gives a new deployment a ready-made starter calendar instead of a
// completely empty task-template list. Admins can edit/deactivate any of
// these afterward; this only ever runs once (see the count() guard below).
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
{
title: 'Wintersitz kontrollieren',
description:
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
priority: 'NORMAL',
month: 1,
dayOfMonth: 15,
},
{
title: 'Gemülldiagnose',
description:
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
priority: 'NORMAL',
month: 2,
dayOfMonth: 15,
},
{
title: 'Frühjahrsdurchsicht',
description:
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
priority: 'HIGH',
month: 3,
dayOfMonth: 15,
},
{
title: 'Volk erweitern',
description:
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
priority: 'NORMAL',
month: 4,
dayOfMonth: 1,
},
{
title: 'Schwarmkontrolle beginnen',
description:
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
priority: 'HIGH',
month: 5,
dayOfMonth: 1,
},
{
title: 'Honigernte vorbereiten',
description:
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
priority: 'NORMAL',
month: 6,
dayOfMonth: 15,
},
{
title: 'Varroa-Kontrolle nach der Ernte',
description:
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
priority: 'HIGH',
month: 7,
dayOfMonth: 15,
},
{
title: 'Einfütterung beginnen',
description:
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
priority: 'HIGH',
month: 8,
dayOfMonth: 1,
},
{
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
description:
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
priority: 'HIGH',
month: 9,
dayOfMonth: 15,
},
{
title: 'Fütterung abschließen, Mäusegitter anbringen',
description:
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
priority: 'NORMAL',
month: 10,
dayOfMonth: 1,
},
{
title: 'Winterbehandlung (Oxalsäure)',
description:
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
priority: 'HIGH',
month: 11,
dayOfMonth: 15,
},
{
title: 'Jahresrückblick und Gerätewartung',
description:
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
priority: 'LOW',
month: 12,
dayOfMonth: 15,
},
];
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
const existingCount = await prisma.taskTemplate.count();
if (existingCount > 0) {
return;
}
const now = new Date();
await prisma.taskTemplate.createMany({
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
title: template.title,
description: template.description,
priority: template.priority,
recurrenceType: 'YEARLY',
recurrenceMonth: template.month,
recurrenceDayOfMonth: template.dayOfMonth,
nextDueDate: nextYearlyOccurrenceUtc(
template.month,
template.dayOfMonth,
now,
),
})),
});
console.log(
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
);
}
type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
/**
* Creates the first administrator, but only while the user table is still
* empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
* its password) on every start after the real admin changed their address.
*/
export async function seedAdmin(
prisma: AdminSeedClient,
env: NodeJS.ProcessEnv,
log: (line: string) => void,
): Promise<'created' | 'skipped'> {
if ((await prisma.user.count()) > 0) {
log('Seed: Es gibt bereits Benutzer, kein Admin angelegt.');
return 'skipped';
}
const email = env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const suppliedPassword = env.SEED_ADMIN_PASSWORD;
const password = suppliedPassword ?? generateRandomPassword();
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
@@ -57,27 +232,42 @@ async function main(): Promise<void> {
roleId: adminRole.id,
},
});
printLoginBanner(email, password);
} catch (error: unknown) {
// P2002 = unique constraint violation on email: another replica's
// startup won this race between the existence check above and this
// create() call. The user now exists either way, so this is a no-op,
// not a failure - container startup must not crash on it.
// startup won this race between the count above and this create()
// call. The user now exists either way, so this is a no-op, not a
// failure - container startup must not crash on it.
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
console.log(
`Seed: admin user ${email} was created concurrently, skipping.`,
);
} else {
log(`Seed: admin user ${email} was created concurrently, skipping.`);
return 'skipped';
}
throw error;
}
}
printLoginBanner(
email,
suppliedPassword === undefined ? password : null,
log,
);
return 'created';
}
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
await seedDefaultTaskTemplates(prisma);
await seedAdmin(prisma, process.env, (line) => console.log(line));
await prisma.$disconnect();
}
main().catch((error: unknown) => {
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
if (require.main === module) {
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
});
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Evaluates voice extraction against the gold set (test/fixtures/voice/gold).
*
* npm run eval:voice offline, replays the expected answers, no network, free
* npm run eval:voice -- --live --yes real Claude calls, costs money
* npm run eval:voice -- --live --yes --model=claude-haiku-5-5
*
* Live mode needs ANTHROPIC_API_KEY in the environment AND both --live and
* --yes; it is never the default and never runs in CI (no workflow calls
* this script). This is an operator tool, not a request path: it lives
* outside src/, so the network guard (src/common/no-network-outside-loops)
* does not scan it, and it talks to the adapter only through the
* StructuredModel port.
*
* Exit code: 0 pass, 1 below the thresholds (precision < 98 % or recall <
* 85 %, see EVAL_THRESHOLDS), 2 usage or setup error.
*/
import { join } from 'node:path';
import { anthropicApiKey } from '../src/voice/anthropic-config';
import { formatReport } from '../src/voice/eval/eval-report';
import { loadGoldCases } from '../src/voice/eval/gold-case';
import { estimateCostUsd } from '../src/voice/eval/voice-prices';
import {
offlineModelFor,
runEval,
summarize,
} from '../src/voice/eval/voice-eval';
import { AnthropicStructuredModel } from '../src/voice/extraction/anthropic-structured-model';
import { FAKE_USAGE } from '../src/voice/extraction/fake-structured-model';
import { DEFAULT_VOICE_MODEL, VOICE_MODELS } from '../src/voice/voice-config';
const GOLD_DIR = join(__dirname, '..', 'test', 'fixtures', 'voice', 'gold');
const EXIT_PASS = 0;
const EXIT_BELOW_THRESHOLD = 1;
const EXIT_USAGE = 2;
interface Options {
live: boolean;
yes: boolean;
model: string;
}
function parseArgs(args: readonly string[]): Options | string {
let live = false;
let yes = false;
let model: string = DEFAULT_VOICE_MODEL;
for (const arg of args) {
if (arg === '--live') {
live = true;
} else if (arg === '--yes') {
yes = true;
} else if (arg.startsWith('--model=')) {
model = arg.slice('--model='.length);
} else {
return `Unknown argument: ${arg}`;
}
}
if (!(VOICE_MODELS as readonly string[]).includes(model)) {
return `--model must be one of ${VOICE_MODELS.join(', ')}`;
}
return { live, yes, model };
}
function fail(message: string): never {
console.error(message);
process.exit(EXIT_USAGE);
}
async function main(): Promise<number> {
const options = parseArgs(process.argv.slice(2));
if (typeof options === 'string') {
return fail(options);
}
const cases = loadGoldCases(GOLD_DIR);
if (!options.live) {
const results = await runEval(
cases,
offlineModelFor(options.model),
options.model,
);
console.log(
formatReport(results, summarize(results), {
mode: 'offline',
model: options.model,
}),
);
return summarizeExit(results);
}
const key = anthropicApiKey(process.env);
if (key === null) {
return fail('--live needs a valid ANTHROPIC_API_KEY in the environment.');
}
const estimate = estimateCostUsd(options.model, {
input: FAKE_USAGE.input * cases.length,
output: FAKE_USAGE.output * cases.length,
cacheRead: 0,
cacheWrite: 0,
});
console.warn(
`WARNING: ${cases.length} real Claude calls on ${options.model}, roughly ${
estimate === null ? 'an unknown amount' : `$${estimate.toFixed(2)}`
} of API cost.`,
);
if (!options.yes) {
return fail('Add --yes to confirm and run the live evaluation.');
}
const live = new AnthropicStructuredModel(key);
const results = await runEval(cases, () => live, options.model);
console.log(
formatReport(results, summarize(results), {
mode: 'live',
model: options.model,
}),
);
return summarizeExit(results);
}
function summarizeExit(results: Parameters<typeof summarize>[0]): number {
return summarize(results).failures.length === 0
? EXIT_PASS
: EXIT_BELOW_THRESHOLD;
}
main().then(
(code) => process.exit(code),
(error: unknown) => {
console.error(error instanceof Error ? error.message : 'eval failed');
process.exit(EXIT_USAGE);
},
);
@@ -0,0 +1,15 @@
/**
* Writes the Whisper initial prompt to ApiCore-Whisper/vocabulary.txt, so the
* Python image uses the same word list as the backend (one source:
* src/voice/core/voice-vocabulary.ts). Run `npm run export:voice-vocabulary`
* after changing the list; voice-vocabulary-export.spec.ts fails until you do.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { whisperInitialPrompt } from '../src/voice/core/voice-vocabulary';
const target = join(__dirname, '..', '..', 'ApiCore-Whisper', 'vocabulary.txt');
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, `${whisperInitialPrompt()}\n`, 'utf8');
console.log(`Wrote ${target}`);
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AppModule } from './app.module';
import { WorkerRuntimeModule } from './worker/worker-runtime.module';
/**
* Role "all": the API plus the worker loops in one process, for local
* development and tests. Production refuses it (runtime/role.ts) and runs
* api and worker as separate pods. No worker health server here - the
* API's /health endpoints serve the process.
*/
@Module({
imports: [AppModule, WorkerRuntimeModule],
})
export class AllInOneModule {}
@@ -0,0 +1,42 @@
import { PermissionKey } from '../auth/permissions/permission-catalog';
/**
* Permissions an API key may carry. Deliberately a short allowlist, not the
* whole catalog: a key is a long-lived secret stored in another system, so it
* only ever gets what a machine integration needs. shop-sync:write lets the
* shop book its own sales and returns - nothing else - and every booking is
* idempotent and kept in the ledger, so a leaked key cannot erase history.
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
* batches with remaining stock, stock movements, harvest totals) - read
* only, no people, no notes. Removing an entry here also takes it away from
* existing keys (see ApiKeysService.authenticate).
*/
export const API_KEY_SCOPES = [
'shop-sync:read',
'shop-sync:write',
'erp-inventory:read',
] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
/**
* Scopes that book (change data). Every other scope only reads. A new key
* never mixes both kinds: a leaked read key cannot book, a leaked booking
* key cannot read (ARCHITECTURE.md, Annahme 14).
*/
export const API_KEY_WRITE_SCOPES = [
'shop-sync:write',
] as const satisfies readonly ApiKeyScope[];
export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value);
}
function isWriteScope(scope: string): boolean {
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
}
/** True if the scopes hold at least one reading and one booking right. */
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
}
@@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import {
ApiKeySummary,
ApiKeysService,
CreatedApiKey,
} from './api-keys.service';
@ApiTags('api-keys')
@ApiBearerAuth()
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeys: ApiKeysService) {}
@RequirePermission('api-keys:manage')
@Get()
findAll(): Promise<ApiKeySummary[]> {
return this.apiKeys.findAll();
}
@RequirePermission('api-keys:manage')
@Post()
create(
@Body() dto: CreateApiKeyDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<CreatedApiKey> {
return this.apiKeys.create(dto, actor.id);
}
@RequirePermission('api-keys:manage')
@Patch(':id')
updateAllowedIps(
@Param('id') id: string,
@Body() dto: UpdateApiKeyDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ApiKeySummary> {
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
}
@RequirePermission('api-keys:manage')
@Post(':id/revoke')
revoke(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ApiKeySummary> {
return this.apiKeys.revoke(id, actor.id);
}
}
@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
// ThrottlerStorageModule: the failure counters share the rate-limit table,
// so the limit holds across all api pods.
@Global()
@Module({
imports: [ThrottlerStorageModule],
controllers: [ApiKeysController],
providers: [ApiKeysService, FailedAttemptLimiter],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
@@ -0,0 +1,365 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
Logger,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
function storedKey(overrides: Record<string, unknown> = {}) {
return {
id: 'key-1',
name: 'ERPNext',
prefix: 'ack_abcd1234',
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
scopes: ['erp-inventory:read'],
createdByUserId: 'user-1',
createdAt: new Date('2026-10-01T00:00:00.000Z'),
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
...overrides,
};
}
/**
* Stand-in for the shared counter table with the same counting rule as
* PrismaThrottlerStorage within one window: count up to the limit, the hit
* after it blocks, a blocked source keeps its count. Time and the database
* itself are covered by the e2e tests.
*/
function inMemoryStorage(): PrismaThrottlerStorage {
const rows = new Map<string, { hits: number; blocked: boolean }>();
return {
increment: (
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
) => {
const id = `${throttlerName}|${key}`;
const row = rows.get(id) ?? { hits: 0, blocked: false };
const next = row.blocked
? row
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
rows.set(id, next);
return Promise.resolve({
totalHits: next.hits,
timeToExpire: ttl / 1000,
isBlocked: next.blocked,
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
});
},
} as unknown as PrismaThrottlerStorage;
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
let warn: jest.SpyInstance;
beforeEach(() => {
prisma = {
apiKey: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
},
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});
function loggedText(): string {
return JSON.stringify(warn.mock.calls);
}
it('sucht den Schlüssel nur über seinen Hash', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
});
});
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
null,
);
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
});
it('lehnt einen unbekannten Schlüssel ab', async () => {
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
});
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('revoked');
});
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('expired');
});
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date() }),
);
await service.authenticate(TOKEN, '203.0.113.7');
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
const text = loggedText();
expect(text).not.toContain(TOKEN);
expect(text).not.toContain(OTHER_TOKEN);
expect(text).not.toContain('A'.repeat(43));
expect(text).not.toContain('B'.repeat(43));
expect(text).toContain('ack_abcd1234');
});
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
);
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal?.scopes).toEqual(['erp-inventory:read']);
});
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ allowedIps: ['198.51.100.0/24'] }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toBeInstanceOf(ForbiddenException);
});
async function failFrom(ip: string, times: number): Promise<void> {
for (let i = 0; i < times; i++) {
// Unbekannter, aber richtig aufgebauter Schlüssel.
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
}
}
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
await expect(blocked).rejects.toBeInstanceOf(HttpException);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
expect(loggedText()).toContain('203.0.113.7');
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
// Andere Adressen sind nicht betroffen.
await expect(
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
).resolves.toBe(null);
});
it('meldet das Erreichen der Grenze einmal im Log', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
const limitMessages = warn.mock.calls.filter(([message]) =>
String(message).includes('get 429'),
);
expect(limitMessages).toHaveLength(1);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
id: 'key-1',
scopes: ['erp-inventory:read'],
});
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
null,
);
}
// Weder gesperrt noch eine Datenbankabfrage.
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).resolves.toBe(null);
});
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await service.authenticate(
OTHER_TOKEN,
`2001:db8:1:2::${i.toString(16)}`,
);
}
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
).rejects.toMatchObject({ status: 429 });
// Ein anderes /64 ist frei.
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
).resolves.toBe(null);
});
});
describe('ApiKeysService.create', () => {
let service: ApiKeysService;
let prisma: { apiKey: { create: jest.Mock } };
beforeEach(() => {
prisma = {
apiKey: {
create: jest.fn((args: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
),
},
};
const permissionResolver = {
getPermissions: jest
.fn()
.mockResolvedValue(
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
),
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
});
it('refuses a new key with both shop scopes', async () => {
await expect(
service.create(
{
name: 'Shop',
scopes: ['shop-sync:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses the read-only ERP scope together with booking', async () => {
await expect(
service.create(
{
name: 'ERPNext',
scopes: ['erp-inventory:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses a new key with more than one scope, even if all of them read', async () => {
await expect(
service.create(
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('treats the same scope given twice as one', async () => {
const created = await service.create(
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
'user-1',
);
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
});
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
'still creates a key with only %s',
async (scope) => {
const created = await service.create(
{ name: 'Shop', scopes: [scope] },
'user-1',
);
expect(created.apiKey.scopes).toEqual([scope]);
},
);
});
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
expect(extractApiKeyToken(undefined)).toBeNull();
});
});
@@ -0,0 +1,318 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomBytes, randomInt } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import {
ApiKeyScope,
combinesReadAndWrite,
isApiKeyScope,
} from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [
...new Set(
(entries ?? [])
.map(normalizeAllowlistEntry)
.filter((entry): entry is string => entry !== null),
),
];
}
// ack_<8 lowercase chars>_<32 random bytes, base64url>
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
// lastUsedAt is informational; writing it on every request would turn each
// read into a write for no benefit.
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// "ack_" plus the 8-character prefix: identifies a key in logs without
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal {
id: string;
scopes: ApiKeyScope[];
}
/** An API key as shown in the admin UI - never the hash or the key itself. */
export type ApiKeySummary = Omit<ApiKey, 'keyHash'>;
export interface CreatedApiKey {
apiKey: ApiKeySummary;
/** The only time the plain key exists outside the caller's system. */
token: string;
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function randomPrefix(): string {
let prefix = '';
for (let i = 0; i < 8; i++) {
prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)];
}
return `ack_${prefix}`;
}
function toSummary(apiKey: ApiKey): ApiKeySummary {
const { keyHash, ...summary } = apiKey;
void keyHash;
return summary;
}
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
// Counters shared by all api pods (ThrottlerHit), not process memory.
private readonly failures: FailedAttemptLimiter,
) {}
async findAll(): Promise<ApiKeySummary[]> {
const keys = await this.prisma.apiKey.findMany({
orderBy: { createdAt: 'desc' },
});
return keys.map(toSummary);
}
async create(
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// One scope per new key: a leaked read key cannot book, a leaked booking
// key cannot read, and each connected system gets its own key.
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
if (combinesReadAndWrite(dto.scopes)) {
throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
);
}
if (new Set(dto.scopes).size > 1) {
throw new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
if (missing.length > 0) {
throw new ForbiddenException(
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
);
}
const prefix = randomPrefix();
// 32 random bytes: a plain hash of it is safe to store without a slow
// KDF - there is nothing guessable to brute-force.
const token = `${prefix}_${randomBytes(32).toString('base64url')}`;
const expiresAt =
dto.expiresInDays !== undefined
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
const apiKey = await this.prisma.apiKey.create({
data: {
name: dto.name.trim(),
prefix,
keyHash: hashToken(token),
scopes: [...new Set(dto.scopes)],
allowedIps: normalizeAllowlist(dto.allowedIps),
expiresAt,
createdByUserId: actingUserId,
},
});
const summary = toSummary(apiKey);
await this.auditLog.record({
entityType: 'ApiKey',
entityId: apiKey.id,
action: 'CREATE',
userId: actingUserId,
afterData: summary,
});
return { apiKey: summary, token };
}
/** Changes where a key may be used from, without issuing a new one. */
async updateAllowedIps(
id: string,
dto: UpdateApiKeyDto,
actingUserId: string,
): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('API-Schlüssel nicht gefunden');
}
const updated = await this.prisma.apiKey.update({
where: { id },
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
});
await this.auditLog.record({
entityType: 'ApiKey',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: toSummary(before),
afterData: toSummary(updated),
});
return toSummary(updated);
}
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('API-Schlüssel nicht gefunden');
}
if (before.revokedAt) {
return toSummary(before);
}
const revoked = await this.prisma.apiKey.update({
where: { id },
data: { revokedAt: new Date() },
});
await this.auditLog.record({
entityType: 'ApiKey',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: toSummary(before),
afterData: toSummary(revoked),
});
return toSummary(revoked);
}
/**
* Resolves a presented key to what it may do, or null for anything that
* must not pass: malformed, unknown, revoked or expired.
*
* The key is found by the SHA-256 of the presented token through a unique
* index - never by comparing secrets byte by byte, so response timing says
* nothing about the stored key. Order matters: the lookup comes first, and
* only an invalid key counts against (or is stopped by) the failure limit
* of the client network. A malformed token is not a guess at a key and is
* refused without lookup and without counting.
*/
async authenticate(
token: string,
clientIp?: string,
): Promise<ApiKeyPrincipal | null> {
if (!TOKEN_PATTERN.test(token)) {
return null;
}
const apiKey = await this.prisma.apiKey.findUnique({
where: { keyHash: hashToken(token) },
});
const now = new Date();
const reason = rejectReason(apiKey, now);
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
await this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private async recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): Promise<void> {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
const verdict = await this.failures.recordFailure(subject);
if (verdict.blocked) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
message:
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Only the public prefix is ever logged, never the key itself.
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (verdict.limitReached) {
this.logger.warn(
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
);
}
}
private async accept(
apiKey: ApiKey,
clientIp: string | undefined,
now: Date,
): Promise<ApiKeyPrincipal> {
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a
// thief learns nothing new, they already hold the key.
this.logger.warn(
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
);
throw new ForbiddenException(
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
);
}
if (
!apiKey.lastUsedAt ||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
apiKey.lastUsedIp !== (clientIp ?? null)
) {
await this.prisma.apiKey.update({
where: { id: apiKey.id },
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
});
}
// Intersect with today's allowlist: narrowing it later also narrows
// keys issued before.
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
}
}
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
if (!apiKey) return 'unknown';
if (apiKey.revokedAt) return 'revoked';
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
return null;
}
/** The key from an `Authorization: Bearer ack_…` header, or null. */
export function extractApiKeyToken(
authorization: string | undefined,
): string | null {
if (!authorization?.startsWith('Bearer ack_')) {
return null;
}
return authorization.slice('Bearer '.length).trim();
}
@@ -0,0 +1,17 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { normalizeAllowlistEntry } from '../ip-allowlist';
@ValidatorConstraint({ name: 'allowedIp' })
export class AllowedIpConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
return typeof value === 'string' && normalizeAllowlistEntry(value) !== null;
}
defaultMessage(args: ValidationArguments): string {
return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`;
}
}
@@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
Validate,
} from 'class-validator';
import { API_KEY_SCOPES } from '../api-key-scopes';
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
import { AllowedIpConstraint } from './allowed-ips.validator';
export class CreateApiKeyDto {
@ApiProperty({
description: 'Wofür der Schlüssel ist, z. B. "Wabenhain-Shop"',
})
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
@IsArray()
@ArrayMinSize(1)
@ArrayUnique()
@IsIn(API_KEY_SCOPES, { each: true })
scopes!: string[];
@ApiPropertyOptional({
description:
'Gültigkeit in Tagen; ohne Angabe gilt der Schlüssel bis zum Widerruf',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(730)
expiresInDays?: number;
@ApiPropertyOptional({
description:
'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(MAX_ALLOWED_IPS)
@IsString({ each: true })
@Validate(AllowedIpConstraint, { each: true })
allowedIps?: string[];
}
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator';
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
import { AllowedIpConstraint } from './allowed-ips.validator';
export class UpdateApiKeyDto {
@ApiProperty({
description:
'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar',
type: [String],
})
@IsArray()
@ArrayMaxSize(MAX_ALLOWED_IPS)
@IsString({ each: true })
@Validate(AllowedIpConstraint, { each: true })
allowedIps!: string[];
}
@@ -0,0 +1,91 @@
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import {
API_KEY_FAILURE_LIMIT,
API_KEY_FAILURE_THROTTLER,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
const increment = jest.fn();
const limiter = new FailedAttemptLimiter({
increment,
} as unknown as PrismaThrottlerStorage);
function record(totalHits: number, isBlocked: boolean) {
return {
totalHits,
timeToExpire: 600,
isBlocked,
timeToBlockExpire: isBlocked ? 900 : 0,
};
}
beforeEach(() => {
increment.mockReset();
increment.mockResolvedValue(record(1, false));
});
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
await limiter.recordFailure('203.0.113.7');
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
expect(increment).toHaveBeenCalledTimes(1);
expect(increment).toHaveBeenCalledWith(
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_LIMIT.maxFailures,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_THROTTLER,
);
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
});
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
expect(API_KEY_FAILURE_LIMIT).toEqual({
maxFailures: 20,
windowMs: 15 * 60 * 1000,
});
});
it('sperrt nicht unterhalb der Grenze', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: false,
});
});
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: true,
});
});
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: true,
limitReached: false,
});
});
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
increment.mockRejectedValueOnce(new Error('db down'));
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
'db down',
);
});
});
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys - the same budget the ERP export
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
} as const;
/**
* Throttler name and key prefix of these counters in "ThrottlerHit", so
* they never mix with the request rate limits stored in the same table.
*/
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
export interface FailedAttemptVerdict {
/** The source is over the limit: refuse the request with 429. */
blocked: boolean;
/** This failure is the one that reached the limit (once per window). */
limitReached: boolean;
}
/**
* Counts invalid API keys per source (client network) in the shared,
* database-backed rate-limit storage, so the limit holds across all api
* pods. One upsert per failure, every time comparison on the database
* clock (PrismaThrottlerStorage). The window starts with the first failure;
* the failure after the limit blocks the source for one window length.
* A database error propagates: the request fails instead of passing.
*/
@Injectable()
export class FailedAttemptLimiter {
constructor(private readonly storage: PrismaThrottlerStorage) {}
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
const record = await this.storage.increment(
`${API_KEY_FAILURE_THROTTLER}:${source}`,
windowMs,
maxFailures,
windowMs,
API_KEY_FAILURE_THROTTLER,
);
return {
blocked: record.isBlocked,
limitReached: !record.isBlocked && record.totalHits === maxFailures,
};
}
}
@@ -0,0 +1,34 @@
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
describe('ip-allowlist', () => {
it('normalizes addresses and networks', () => {
expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7');
expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24');
expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32');
expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7');
});
it('rejects anything else', () => {
for (const entry of [
'',
'example.com',
'203.0.113.0/33',
'1.2.3.4/x',
'1.2.3.4/8/1',
]) {
expect(normalizeAllowlistEntry(entry)).toBeNull();
}
});
it('allows every address when the list is empty', () => {
expect(isIpAllowed('198.51.100.1', [])).toBe(true);
});
it('allows only listed addresses and networks', () => {
const list = ['203.0.113.7', '198.51.100.0/24'];
expect(isIpAllowed('203.0.113.7', list)).toBe(true);
expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true);
expect(isIpAllowed('203.0.113.8', list)).toBe(false);
expect(isIpAllowed(undefined, list)).toBe(false);
});
});
@@ -0,0 +1,66 @@
import * as net from 'node:net';
/** Max entries per key - an allowlist is a short list of servers. */
export const MAX_ALLOWED_IPS = 20;
function unmapIpv4(ip: string): string {
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
return mapped ? mapped[1] : ip;
}
/**
* "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or
* null if the entry is neither an address nor a network.
*/
export function normalizeAllowlistEntry(entry: string): string | null {
const trimmed = entry.trim();
const [address, prefix, ...rest] = trimmed.split('/');
if (rest.length > 0 || !address) {
return null;
}
const ip = unmapIpv4(address);
const version = net.isIP(ip);
if (version === 0) {
return null;
}
if (prefix === undefined) {
return ip;
}
if (!/^\d{1,3}$/.test(prefix)) {
return null;
}
const bits = Number(prefix);
if (bits > (version === 4 ? 32 : 128)) {
return null;
}
return `${ip}/${bits}`;
}
/** An empty list allows every address. */
export function isIpAllowed(
clientIp: string | undefined,
allowedIps: readonly string[],
): boolean {
if (allowedIps.length === 0) {
return true;
}
if (!clientIp) {
return false;
}
const ip = unmapIpv4(clientIp);
const version = net.isIP(ip);
if (version === 0) {
return false;
}
const list = new net.BlockList();
for (const entry of allowedIps) {
const [address, prefix] = entry.split('/');
const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
if (prefix === undefined) {
list.addAddress(address, family);
} else {
list.addSubnet(address, Number(prefix), family);
}
}
return list.check(ip, version === 6 ? 'ipv6' : 'ipv4');
}
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@ApiTags('apiaries')
@ApiBearerAuth()
@Controller('apiaries')
export class ApiariesController {
constructor(private readonly apiariesService: ApiariesService) {}
constructor(
private readonly apiariesService: ApiariesService,
private readonly apiaryFieldService: ApiaryFieldService,
) {}
@RequirePermission('apiaries:read')
@Get()
@@ -31,6 +35,24 @@ export class ApiariesController {
return this.apiariesService.findAll({ status, location });
}
@RequirePermission('colonies:read')
@Get(':id/colony-overview')
colonyOverview(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.colonyOverview(id, actor.id);
}
@RequirePermission('colonies:read')
@Get(':id/offline-pack')
offlinePack(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.offlinePack(id, actor.id);
}
@RequirePermission('apiaries:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -1,10 +1,15 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { HiveModule } from '../hive/hive.module';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@Module({
imports: [AutopilotModule, DomainEventsModule, HiveModule],
controllers: [ApiariesController],
providers: [ApiariesService],
providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService],
})
export class ApiariesModule {}
@@ -2,42 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApiariesService } from './apiaries.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
describe('ApiariesService', () => {
let service: ApiariesService;
let prisma: {
apiary: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
let order: string[];
let tx: {
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
colony: { count: jest.Mock };
};
let prisma: {
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
const existingApiary = {
id: 'apiary-1',
name: 'Heimstand',
status: 'ACTIVE',
deactivatedAt: null,
address: 'Weg 1',
location: 'Garten',
latitude: 48.1,
longitude: 11.5,
notes: null,
};
const changes = (): string[] =>
(
events.record.mock.calls as [unknown, { payload: { change: string } }][]
).map(([, event]) => event.payload.change);
beforeEach(() => {
prisma = {
order = [];
tx = {
$queryRaw: jest.fn().mockImplementation(() => {
order.push('lock');
return Promise.resolve([{ id: 'apiary-1' }]);
}),
apiary: {
findMany: jest.fn(),
findUnique: jest.fn(),
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(existingApiary);
}),
create: jest.fn(),
update: jest.fn(),
},
colony: { count: jest.fn().mockResolvedValue(0) },
};
prisma = {
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
);
});
// Whatever a test recorded, the herd payloads carry only ids.
afterEach(() => {
expectOnlyIdPayloads(events.record.mock.calls);
});
it('findById throws NotFoundException when missing', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -45,65 +81,152 @@ describe('ApiariesService', () => {
);
});
it('create persists the apiary and records an audit entry', async () => {
prisma.apiary.create.mockResolvedValue(existingApiary);
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
const result = await service.create({ name: 'Heimstand' }, 'user-1');
expect(result).toEqual(existingApiary);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Apiary',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: 'apiary-1',
actorUserId: 'user-1',
payload: { change: 'CREATED' },
});
});
it('a failing audit write rolls back the apiary change', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
auditLog.record.mockRejectedValue(new Error('audit down'));
await expect(
service.create({ name: 'Heimstand' }, 'user-1'),
).rejects.toThrow('audit down');
// Record, audit and event share one transaction callback: its rejection
// is what rolls the record back.
expect(events.record).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it('update reads before after locking the Apiary row', async () => {
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
expect(order).toEqual(['lock', 'read']);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: existingApiary,
}),
tx,
);
});
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue({
it('update of a missing apiary answers 404', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.apiary.update).not.toHaveBeenCalled();
});
it.each([
[{ address: 'Neuer Weg 2' }],
[{ location: 'Wiese' }],
[{ latitude: 50 }],
[{ longitude: 12 }],
])('update records LOCATION_CHANGED for %j', async (dto) => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
await service.update('apiary-1', dto, 'user-1');
expect(changes()).toEqual(['LOCATION_CHANGED']);
});
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
await service.update(
'apiary-1',
{ name: 'Anders', notes: 'neu' },
'user-1',
);
// The same value sent again is no change either.
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
expect(events.record).not.toHaveBeenCalled();
});
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
tx.apiary.update.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
const result = await service.deactivate('apiary-1', 'user-1');
expect(result.status).toBe('INACTIVE');
expect(order).toEqual(['lock', 'read']);
expect(tx.colony.count).toHaveBeenCalledWith({
where: {
apiaryId: 'apiary-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
tx,
);
expect(changes()).toEqual(['DEACTIVATED']);
});
it('deactivate throws ConflictException when active colonies remain', async () => {
tx.colony.count.mockResolvedValue(2);
await expect(
service.deactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.apiary.update).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect(
service.reactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
prisma.apiary.findUnique.mockResolvedValue({
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
tx.apiary.findUnique.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
prisma.apiary.update.mockResolvedValue({
...existingApiary,
status: 'ACTIVE',
deactivatedAt: null,
});
tx.apiary.update.mockResolvedValue(existingApiary);
const result = await service.reactivate('apiary-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.apiary.update).toHaveBeenCalledWith({
expect(tx.apiary.update).toHaveBeenCalledWith({
where: { id: 'apiary-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
tx,
);
expect(changes()).toEqual(['REACTIVATED']);
});
});
+100 -15
View File
@@ -5,7 +5,11 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Apiary, ApiaryStatus } from '../../generated/prisma';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { DomainEventPayloads } from '../domain-events/domain-event-types';
import { lockRowForUpdate } from '../prisma/row-lock';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
@@ -14,11 +18,22 @@ export interface ApiaryFilter {
location?: string;
}
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
// The fields a change of which counts as a new location of the apiary.
const LOCATION_FIELDS = [
'address',
'location',
'latitude',
'longitude',
] as const;
@Injectable()
export class ApiariesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
@@ -42,15 +57,21 @@ export class ApiariesService {
}
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
await this.auditLog.record({
return this.prisma.$transaction(async (tx) => {
const apiary = await tx.apiary.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: apiary.id,
action: 'CREATE',
userId: actingUserId,
afterData: apiary,
});
},
tx,
);
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
return apiary;
});
}
async update(
@@ -58,56 +79,120 @@ export class ApiariesService {
dto: UpdateApiaryDto,
actingUserId: string,
): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
const apiary = await tx.apiary.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record({
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
},
tx,
);
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
}
return apiary;
});
}
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
// Colony.apiaryId is a plain FK, not enforced against apiary status -
// without this guard a colony can silently keep pointing at an
// inactive apiary (still shows ACTIVE, still countable, but its
// "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead. Counted under the
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
// into it that is still running finishes first.
const activeColonyCount = await tx.colony.count({
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
});
if (activeColonyCount > 0) {
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({
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
},
tx,
);
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
return apiary;
});
}
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Apiary is already active');
}
const apiary = await this.prisma.apiary.update({
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.auditLog.record({
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
);
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
return apiary;
});
}
// Locks the apiary row and reads it afterwards: the result - not an earlier
// read - is "before" for the comparison, the audit and the event.
private async lockApiary(
tx: Prisma.TransactionClient,
id: string,
): Promise<Apiary> {
await lockRowForUpdate(tx, 'Apiary', id);
const apiary = await tx.apiary.findUnique({ where: { id } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
return apiary;
}
private recordChange(
tx: Prisma.TransactionClient,
apiaryId: string,
change: ApiaryChange,
actorUserId: string,
): Promise<void> {
return this.events.record(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: apiaryId,
actorUserId,
payload: { change },
});
}
}
@@ -0,0 +1,332 @@
import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveService } from '../hive/hive.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
describe('ApiaryFieldService', () => {
let service: ApiaryFieldService;
let prisma: {
apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
$queryRaw: jest.Mock;
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
let hives: { getMany: jest.Mock };
let hiveCatalogs: { listSystems: jest.Mock };
const stepsOf = (
colonyId: string,
overrides: Partial<ColonyNextSteps> = {},
): ColonyNextSteps => ({
colonyId,
segment: 'SCHWARMZEIT',
phase: null,
phaseSource: null,
inspectionDueOn: null,
withdrawalUntil: null,
steps: [],
...overrides,
});
const colonyRow = (id: string, code: string, queenId: string | null) => ({
id,
code,
apiaryId: 'apiary-1',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
hiveType: null,
strength: 5,
occupiedFrames: 12,
broodStatus: null,
feedStatus: null,
healthStatus: null,
currentQueenId: queenId,
notes: null,
});
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
beforeEach(() => {
prisma = {
apiary: {
findUnique: jest.fn().mockResolvedValue({
id: 'apiary-1',
name: 'Heimstand',
location: 'Garten',
status: 'ACTIVE',
}),
},
colony: {
findMany: jest
.fn()
.mockResolvedValue([
colonyRow('c10', 'V-10', 'q1'),
colonyRow('c2', 'V-2', null),
]),
},
inspection: {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]),
},
$queryRaw: jest.fn().mockResolvedValue([]),
queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
nextSteps = {
forColonies: jest.fn().mockResolvedValue(
new Map([
['c10', stepsOf('c10')],
['c2', stepsOf('c2')],
]),
),
warningCounts: jest.fn().mockResolvedValue(new Map()),
};
hives = { getMany: jest.fn().mockResolvedValue({}) };
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService,
hives as unknown as HiveService,
hiveCatalogs as unknown as HiveCatalogService,
);
});
it('throws NotFoundException for an unknown apiary', async () => {
grant('colonies:read');
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('sorts living colonies by code with numeric collation', async () => {
grant('colonies:read');
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
expect(prisma.colony.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
}),
);
});
it('leaves out inspections, queens and tasks without their read permission', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.inspections).toBeNull();
expect(pack.queens).toBeNull();
expect(pack.tasks).toBeNull();
expect(pack.overview.inspectionsVisible).toBe(false);
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
true,
);
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
expect(prisma.queen.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 () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z');
prisma.inspection.groupBy.mockResolvedValue([
{ colonyId: 'c2', _max: { date } },
]);
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.overview.inspectionsVisible).toBe(true);
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
expect(prisma.queen.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
);
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
expect(prisma.task.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
],
},
}),
);
});
it('keeps at most three inspections per colony, newest first', async () => {
grant('colonies:read', 'inspections:read');
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
where: { colonyId: 'c2' },
take: 3,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
});
expect(pack.inspections).toHaveLength(2);
});
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
grant('colonies:read', 'inspections:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(Object.keys(pack.apiary).sort()).toEqual([
'id',
'location',
'name',
'status',
]);
const apiarySelect = (
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
)[0].select;
expect(apiarySelect).not.toHaveProperty('latitude');
expect(apiarySelect).not.toHaveProperty('longitude');
expect(apiarySelect).not.toHaveProperty('address');
expect(apiarySelect).not.toHaveProperty('notes');
const inspectionArgs = (
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
)[0];
expect(inspectionArgs.omit).toEqual({ userId: true });
});
it('takes the warning counts from the open suggestions the caller may see', async () => {
grant('colonies:read', 'tasks:read');
nextSteps.warningCounts.mockResolvedValue(
new Map([['c2', { warnings: 2, critical: 1 }]]),
);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
['c2', 'c10'],
'u1',
new Set(['colonies:read', 'tasks:read']),
);
expect(overview.rows[0]).toMatchObject({
code: 'V-2',
openWarnings: 2,
openCritical: 1,
});
expect(overview.rows[1]).toMatchObject({
openWarnings: 0,
openCritical: 0,
});
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
grant('colonies:read', 'tasks:read');
nextSteps.forColonies.mockResolvedValue(
new Map([
[
'c2',
stepsOf('c2', {
segment: 'SOMMER',
inspectionDueOn: '2026-05-10',
steps: [
{
key: 'suggestion:s1',
kind: 'SUGGESTION',
intent: 'INSPECT',
title: 'Durchsicht mit Schwarmkontrolle',
reasons: [],
suggestionIds: ['s1'],
dueOn: '2026-05-10',
urgency: 'OVERDUE',
action: { type: 'ACCEPT', suggestionId: 's1' },
dismissible: false,
},
],
}),
],
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
]),
);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows[0]).toMatchObject({
inspectionDueOn: '2026-05-10',
segment: 'SOMMER',
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
});
expect(overview.rows[1]).toMatchObject({
inspectionDueOn: null,
segment: 'WINTERRUHE',
nextStepTitle: null,
});
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
});
it('leaves the segment null when the colony has no season state', async () => {
grant('colonies:read');
nextSteps.forColonies.mockResolvedValue(new Map());
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
});
it('adds the next steps per colony to the offline pack', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.version).toBe(1);
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,233 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
import { HiveService } from '../hive/hive.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import type {
ColonyOverview,
ColonyOverviewRow,
OfflinePackV1,
} from './apiary-field.types';
const INSPECTIONS_PER_COLONY = 3;
/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */
function activeOrUsed<S extends { id: string; active: boolean }>(
systems: S[],
hives: Record<string, { systemId: string }>,
): S[] {
const used = new Set(Object.values(hives).map((hive) => hive.systemId));
return systems.filter((system) => system.active || used.has(system.id));
}
type PackColony = OfflinePackV1['colonies'][number];
interface FieldData {
apiary: OfflinePackV1['apiary'];
colonies: PackColony[];
permissions: ReadonlySet<string>;
overview: ColonyOverview;
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
}
/**
* Read models for work at the apiary: the colony traffic-light overview and
* the offline pack the PWA caches before the beekeeper loses reception.
* Sections beyond the colonies are only included for callers who may read them.
*/
@Injectable()
export class ApiaryFieldService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
private readonly hives: HiveService,
private readonly hiveCatalogs: HiveCatalogService,
) {}
async colonyOverview(
apiaryId: string,
userId: string,
): Promise<ColonyOverview> {
return (await this.load(apiaryId, userId)).overview;
}
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview, nextSteps } =
await this.load(apiaryId, userId);
const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read')
? this.loadInspections(colonies)
: null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
this.hives.getMany(colonies.map((colony) => colony.id)),
this.hiveCatalogs.listSystems(),
]);
return {
version: 1,
generatedAt: new Date().toISOString(),
apiary,
overview,
colonies,
queens,
inspections,
tasks,
hives,
hiveCatalog: activeOrUsed(hiveCatalog, hives),
nextSteps: Object.fromEntries(
colonies.map((colony) => [
colony.id,
nextSteps.get(colony.id)?.steps ?? [],
]),
),
};
}
private async load(apiaryId: string, userId: string): Promise<FieldData> {
// Explicit select: coordinates, address and notes of an apiary are
// theft-sensitive and must never reach the field payloads.
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
select: { id: true, name: true, location: true, status: true },
});
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const permissions = await this.permissionResolver.getPermissions(userId);
const found = await this.prisma.colony.findMany({
where: { ...LIVING_COLONY_WHERE, apiaryId },
select: {
id: true,
code: true,
apiaryId: true,
status: true,
colonyType: true,
hiveType: true,
strength: true,
occupiedFrames: true,
broodStatus: true,
feedStatus: true,
healthStatus: true,
currentQueenId: true,
notes: true,
},
});
const colonies = [...found].sort((a, b) =>
a.code.localeCompare(b.code, 'de', { numeric: true }),
);
const inspectionsVisible = permissions.has('inspections:read');
const colonyIds = colonies.map((colony) => colony.id);
const [lastInspections, warnings, nextSteps] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.nextSteps.warningCounts(colonyIds, userId, permissions),
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
code: colony.code,
status: colony.status,
strength: colony.strength,
occupiedFrames: colony.occupiedFrames,
broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
segment: nextSteps.get(colony.id)?.segment ?? null,
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
openCritical: warnings.get(colony.id)?.critical ?? 0,
}));
return {
apiary,
colonies,
permissions,
overview: { apiaryId, inspectionsVisible, rows },
nextSteps,
};
}
private async lastInspectionDates(
colonies: PackColony[],
): Promise<Map<string, string>> {
const groups = await this.prisma.inspection.groupBy({
by: ['colonyId'],
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
_max: { date: true },
});
const dates = new Map<string, string>();
for (const group of groups) {
if (group._max.date) {
dates.set(group.colonyId, group._max.date.toISOString());
}
}
return dates;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],
);
return this.prisma.queen.findMany({
where: { id: { in: queenIds } },
select: {
id: true,
code: true,
hatchYear: true,
breed: true,
markingColor: true,
markingNumber: true,
status: true,
},
});
}
private async loadInspections(
colonies: PackColony[],
): Promise<OfflinePackV1['inspections']> {
const perColony = await Promise.all(
colonies.map((colony) =>
this.prisma.inspection.findMany({
where: { colonyId: colony.id },
take: INSPECTIONS_PER_COLONY,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
}),
),
);
return perColony.flat();
}
private loadTasks(
apiaryId: string,
colonies: PackColony[],
): Promise<OfflinePackV1['tasks']> {
return this.prisma.task.findMany({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: apiaryId },
{
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
},
],
},
select: {
id: true,
title: true,
description: true,
dueDate: true,
priority: true,
status: true,
relatedType: true,
relatedId: true,
},
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
});
}
}
@@ -0,0 +1,118 @@
import type {
ApiaryStatus,
Colony,
ColonyCondition,
ColonyStatus,
Inspection,
Queen,
Task,
} from '../../generated/prisma';
import type { HiveSystemDto } from '../hive/hive-catalog.service';
import type { HiveSetupView } from '../hive/hive.service';
import type { NextStep } from '../autopilot/next-step-ranking';
import type { SeasonSegmentKey } from '../season/season-types';
/** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow {
id: string;
code: string;
status: ColonyStatus;
strength: number | null;
occupiedFrames: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null;
/**
* The day the next inspection is due (YYYY-MM-DD); null in the winter
* rest, when the interval rule is off and without `inspections:read`.
*/
inspectionDueOn: string | null;
/** The season segment of the apiary today; null when it is unknown. */
segment: SeasonSegmentKey | null;
/** Title of the first next step; null without one or without `tasks:read`. */
nextStepTitle: string | null;
/** Open suggestions of priority HIGH the caller may see. */
openWarnings: number;
/** Open suggestions of priority URGENT the caller may see. */
openCritical: number;
}
export interface ColonyOverview {
apiaryId: string;
inspectionsVisible: boolean;
rows: ColonyOverviewRow[];
}
/**
* Everything the offline apiary mode needs. It deliberately carries no
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
* and no inspection userId. Sections the caller has no read permission for
* are null.
*/
export interface OfflinePackV1 {
version: 1;
generatedAt: string;
apiary: {
id: string;
name: string;
location: string | null;
status: ApiaryStatus;
};
overview: ColonyOverview;
colonies: Pick<
Colony,
| 'id'
| 'code'
| 'apiaryId'
| 'status'
| 'colonyType'
| 'hiveType'
| 'strength'
| 'occupiedFrames'
| 'broodStatus'
| 'feedStatus'
| 'healthStatus'
| 'currentQueenId'
| 'notes'
>[];
queens:
| Pick<
Queen,
| 'id'
| 'code'
| 'hatchYear'
| 'breed'
| 'markingColor'
| 'markingNumber'
| 'status'
>[]
| null;
inspections: Omit<Inspection, 'userId'>[] | null;
tasks:
| Pick<
Task,
| 'id'
| 'title'
| 'description'
| 'dueDate'
| 'priority'
| 'status'
| 'relatedType'
| 'relatedId'
>[]
| null;
/**
* The next steps per colony id (what `GET /colonies/:id/next-steps`
* returns as `steps`). Absent in packs cached before T4.
*/
nextSteps?: Record<string, NextStep[]>;
/**
* The hive setup per colony id, only for colonies of this apiary that have
* one. Absent in packs cached before the hive card.
*/
hives?: Record<string, HiveSetupView>;
/** The hive systems with their part types. Absent in older packs. */
hiveCatalog?: HiveSystemDto[];
}
@@ -1,5 +1,11 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
import {
IsLatitude,
IsLongitude,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
export class CreateApiaryDto {
@ApiProperty()
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description:
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
})
@IsOptional()
@IsString()
@MaxLength(100)
publicLocationLabel?: string;
}
@@ -1,4 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateApiaryDto } from './create-apiary.dto';
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
export class UpdateApiaryDto extends PartialType(CreateApiaryDto, {
skipNullProperties: false,
}) {}
@@ -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 },
) {}
+120
View File
@@ -0,0 +1,120 @@
import { Logger } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import {
configureApp,
describeTrustProxy,
trustProxySetting,
} from './app.config';
describe('trustProxySetting', () => {
const original = process.env.TRUST_PROXY;
afterEach(() => {
if (original === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = original;
});
it('defaults to one hop when unset or blank', () => {
delete process.env.TRUST_PROXY;
expect(trustProxySetting()).toBe(1);
process.env.TRUST_PROXY = ' ';
expect(trustProxySetting()).toBe(1);
});
it('parses a number as hop count and anything else as a list', () => {
process.env.TRUST_PROXY = '2';
expect(trustProxySetting()).toBe(2);
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16');
});
});
describe('describeTrustProxy', () => {
it('reports the hop count mode and the default fallback', () => {
expect(describeTrustProxy(1, false)).toBe(
'TRUST_PROXY not set, using default of 1 trusted hop',
);
expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)');
});
it('reports the list mode with entry count but not the entries', () => {
const text = describeTrustProxy(
'loopback, 10.42.0.0/16, 2400:cb00::/32',
true,
);
expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)');
});
});
describe('configureApp trust proxy logging', () => {
it('logs the active mode once at startup', () => {
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
expect(
log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')),
).toHaveLength(1);
log.mockRestore();
});
});
describe('configureApp trust proxy warning', () => {
const originalProxy = process.env.TRUST_PROXY;
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = originalProxy;
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
});
function configure(): jest.SpyInstance {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
return warn;
}
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
const calls = warn.mock.calls as unknown[][];
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
}
afterEach(() => jest.restoreAllMocks());
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
process.env.NODE_ENV = 'production';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(1);
});
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
process.env.NODE_ENV = 'production';
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
it('warnt nicht in der Entwicklung', () => {
process.env.NODE_ENV = 'development';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
});
Loaded 100 of 2384 files, more files were not shown because too many files have changed in this diff. Show more