Compare commits

...

198 Commits

Author SHA1 Message Date
2befe53df9 feat(stammbaum): Desktop-Ansicht passt den ganzen Baum ein + höhere Zeichenfläche
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m3s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m30s
Auf dem Desktop wurde der Baum bei festem Zoom 1 rechts abgeschnitten (man sah
nur 1–2 Generationen, der Rest lag außerhalb) und viel Fläche blieb leer. Die
Standard-Ansicht passt jetzt den gesamten geladenen Baum ein — gedeckelt auf
Zoom 1, damit Karten bei kleinen Bäumen nicht vergrößert/unscharf werden. Mobil
bleibt es bei „Wurzel links + Pan" (sonst würden die Karten winzig). Zeichen-
fläche von 62dvh auf 72dvh erhöht (füllt die Desktop-Höhe besser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:26:50 +02:00
cf672c0933 feat(provenance): Datenherkunft als chronologischer, datei-attribuierter Verlauf
Die Herkunft liest sich jetzt wie eine History: pro Feld wird genannt, aus
welcher Quelldatei der Wert kam, plus Merge-/Entscheidungs-/Wurfchronik-Schritte.
Beispiel: „In ‚X.xlsx' gefunden." → „Geburtsdatum (…) aus ‚X.xlsx'." → „Auch in
‚Y.docx' gefunden → zusammengeführt." → „Eltern über Position erkannt (Quelle …)."

Import: build_entity_provenance() um history[] erweitert; ein field_source-
Tracking im Gerbil-Dedup hält fest, welcher Datensatz/welche Datei jedes Feld
(DOB/Genotyp/Geschlecht/Farbschlag/Sterbedatum) lieferte — auch über Empty-Fill
und Mehrheitsentscheid hinweg. Kontakte und Würfe erhalten analoge, datei-
attribuierte Verläufe. Kein DB-Migration nötig (history liegt im Provenance-JSON).

Frontend: ProvenanceDialog rendert den Verlauf als nummerierte Timeline.
Tests: history wird erzeugt, nennt Dateien, überlebt Ingest (dotnet 212,
vitest 129, playwright 17, python merge/extract grün).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:21:24 +02:00
a31fea17fa feat(stammbaum): Kontextmenü per Long-Press auf Touch-Geräten öffnen
Das Rechtsklick-Kontextmenü (ID kopieren / Fehler melden) war auf Mobil nicht
erreichbar (onContextMenu feuert auf Touch nicht zuverlässig). Jetzt öffnet ein
langer Druck (~500 ms ohne Verschieben) auf eine Ahnenkarte dasselbe Menü; der
darauf folgende synthetische Klick (Umwurzeln) wird unterdrückt. Bewegung >10 px
bricht den Long-Press ab (kollidiert nicht mit Pan/Zoom). Legenden-Hinweis
ergänzt. e2e: Long-Press öffnet das Menü (desktop; phone-Projekt übersprungen,
da synthetischer Touch dort instabil).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:09:47 +02:00
438c816820 feat: Fehler-melden + Datenherkunft auf Kontakte & Würfe erweitern
- Kontakt-Detailseite: „Fehler melden"- und „Datenherkunft"-Button.
- Wurf-Ansicht: „Datenherkunft"-Button (Feedback war bereits vorhanden).
- Feedback-Entity um loses, nullable ContactId erweitert (kein FK → übersteht
  Ingest-Wipe); Migration AddFeedbackContactId.
- Contact.Provenance + Litter.Provenance (nullable text); Migration
  AddContactLitterProvenance; im Ingest gemappt und in den DTOs zurückgegeben.
- Import: build_entity_provenance() generalisiert; Kontakte (sourceFiles,
  Züchter/Abnehmer-Hinweise) und Würfe (Wurfchronik vs. Diagramm-rekonstruiert,
  Geschwister-Merge) erhalten Herkunftsdaten in resolved_import.json.
- Frontend: ProvenanceDialog generalisiert (EntityProvenance + entityLabel).

Tests erweitert (Ingest-Round-trip Kontakt/Wurf, contact-scoped Feedback
übersteht Wipe). dotnet(212)/vitest(129)/playwright(36)/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:02:11 +02:00
9c98c37d2a feat(rennmausakte): Datenherkunft/Nachverfolgung pro Rennmaus
Neuer „Datenherkunft"-Button in der Rennmausakte öffnet einen Dialog, der zeigt,
aus welchen Quellen der Eintrag erzeugt wurde: Quelldateien (Stammbäume +
Wurfchronik), Anzahl zusammengeführter Datensätze, Eltern-Herleitung
(Methode/Konfidenz) und Hinweise (z. B. „per manueller Entscheidung zugeordnet",
„Konflikt gelöst", „aus N Datensätzen zusammengeführt").

Import: build_provenance() in merge_and_resolve.py sammelt die Herkunft über alle
deduplizierten Datensätze und schreibt sie als Provenance-JSON je Tier in
resolved_import.json. Backend: Gerbil.Provenance (nullable text) + Migration
AddGerbilProvenance, gemappt im Ingest und im GerbilDto zurückgegeben. Frontend:
ProvenanceDialog + Typen + Strings.

Tests: Ingest-Round-trip (vorhanden/abwesend), e2e provenance.spec.ts.
dotnet(212)/vitest(129)/playwright/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:45:15 +02:00
b9e5b031c4 feat(feedback): Fehler-melden-Dialog + ID-kopieren (Stammbaum/Akte/Wurf)
Rechtsklick auf eine Maus im Stammbaum öffnet ein Kontextmenü mit „ID kopieren"
(Clipboard + Toast) und „Fehler melden". Zusätzlich „Fehler melden"-Buttons in
der Rennmausakte und der Wurf-Ansicht. Der Dialog erfasst eine Beschreibung und
sendet sie samt Debug-Kontext (Ansicht, Tier-/Wurf-ID + Name, URL, Zeitstempel,
User-Agent) an POST /feedback; gespeichert in einer neuen Feedback-Tabelle.

Backend: Feedback-Entity (lose nullable GerbilId/LitterId ohne FK), Endpoints
POST/GET /feedback, EF-Migration AddFeedback. Die Tabelle wird vom Import-Ingest
NICHT geleert — Feedback überlebt Re-Ingests (Test deckt das ab).

Tests: FeedbackEndpointTests (persistiert, 400 bei leer, übersteht Ingest-Wipe);
e2e feedback.spec.ts. tsc/eslint/vitest(129)/playwright(6)/dotnet(212) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:30:42 +02:00
1845d37476 fix(import): Eltern-Ref-Auswahl geschlechts-bewusst machen (Regression behoben)
Die in 03ad9e6 eingeführte DOB-Bevorzugung war zu grob: ein datierter, aber
geschlechts-falscher Ref (z. B. weibliche „Danielle") gewann den Vater-Slot
gegen einen undatieren männlichen/unbekannten (z. B. „Hagrid Rubeus") — wodurch
Mollys korrekte Mutter Arya Stark in der Rollen-Normalisierung verloren ging.

pick_parent_ref wertet jetzt zuerst das (aus den Tierdaten ermittelte) Geschlecht
für die Rolle: ein klar falsches Geschlecht wird stark abgewertet, erst danach
zählt plausibles DOB > kein DOB > unplausibles DOB. Fixt Molly (Hagrid × Arya)
UND behält Solice (Lui × Molly = Geschwister von Silvain). Keine Abdeckungs-
Regression (0 Selbst-Verpaarungen / 0 Gender-Fehler / 0 Alter-Verletzungen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:11:53 +02:00
2868aad369 feat(stammbaum): gleiches Geburtsdatum der Eltern als Geschwister-Indiz werten
Ein identisches Geburtsdatum bei Vater und Mutter ist ein starkes Indiz für eine
Vollgeschwister-Verpaarung — ein gemeinsamer Wurf ist praktisch der einzige
Grund dafür, und die Eltern-Würfe sind in den importierten Daten nicht immer
verknüpft (gleiche litterId fehlt dann).

Die Geschwister-Erkennung (Diagramm-Zusammenführung in build.ts + Akte-Chip in
GerbilDetailPage) greift jetzt bei gleicher litterId ODER gleichem, gesetztem
Geburtsdatum beider Eltern (distinkte Tiere vorausgesetzt).

Tests: gleiches DOB bei fehlender/verschiedener litterId → erkannt; unterschied-
liches DOB → nicht erkannt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:02:19 +02:00
03ad9e6e81 fix(import): Eltern-Alter-Sanity-Check + plausiblere Eltern-Auswahl aus Chart-Refs
Eine Rennmaus lebt max ~6 Jahre, kann also nicht Elternteil eines Tiers sein,
das nach ihrem Tod (oder vor ihrer Geburt) geboren wurde. Bisher fehlte diese
Prüfung: z. B. war Jayjay (*2013) als Vater von Solice (*2022) eingetragen, und
Solice/Silvain (Vollgeschwister, beide *27.03.2022) landeten in getrennten
Würfen mit verschiedenen Eltern.

- parent_age_plausible(): Elternteil muss vor dem Kind UND innerhalb der
  Lebensspanne (≤6 J.) geboren sein. Genutzt im Namens-Resolver (Kandidaten-
  filter) und als finaler Sanity-Pass, der unmögliche FatherId/MotherId verwirft
  und im Log auflistet.
- pick_parent_ref(): bei mehreren widersprüchlichen Chart-parentRefs wird nicht
  mehr blind der erste genommen, sondern ein alters-plausibler bevorzugt und das
  Tier der jeweils anderen Elternrolle gemieden (Vollgeschwister-Charts nennen
  denselben Namen in beiden Slots).

Ergebnis: Solice = Lui × Molly = Geschwister von Silvain (gemeinsamer Wurf);
0 Selbst-Verpaarungen, 0 Gender-Rollen-Fehler, 0 Eltern-Alter-Verletzungen über
alle 977 Würfe. Tests in test_merge_resolve.py ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:59:34 +02:00
18efab6996 fix(import): Foto-Generations-Shift beim Stammbaum-Import beheben
Fotos liegen in den Stammbaum-xlsx je nach Datei links, rechts oder auf der
Namenszelle. Die alte Seiten-Erkennung hing an festen Spalten (col 1 / col 4):
Blätter ohne solche Anker wurden als Rechts-Layout fehlgedeutet, wodurch jedes
Foto eine Generation zum Probanden hin verrutschte. Folge: Kazuya trug das Foto
seines Vaters (Wilbur), Wilbur das seines Großvaters (Elay), Elay hatte keins.

Fix (extract._attach_photos): beide Interpretationen (links/rechts) durchrechnen
und die wählen, die jedes Foto am nächsten an die Namensspalte seines Tiers legt
(minimale horizontale Fehlausrichtung). Diagnose über alle 42 Dateien: keine ist
echt rechts-seitig; ~21 waren fehlklassifiziert. Selbstjustierend statt fester
Spalten-Annahme.

Regressionstest in test_extract.py: Kazuya ohne Foto, Wilbur/Elay mit ihren
eigenen (gated auf vorhandene Quelldatei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:49:38 +02:00
070a896072 feat(rennmausakte): Hinweis-Chip für Geschwisterverpaarung in der Tierakte
Stammt ein Tier aus einer Vollgeschwister-Verpaarung (Vater und Mutter teilen
dieselbe litterId), zeigt die Rennmausakte jetzt einen Chip
"⚭ Geschwisterverpaarung" in der Fakten-Zeile — neben Geschlecht, Geburtsdatum
und Farbschlag, in Akzentfarbe hervorgehoben (zuchtrelevant).

e2e-Test: Chip erscheint für 'Inzucht Kind' (Eltern aus demselben Wurf),
nicht für 'Krümel' (Eltern aus verschiedenen Würfen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:36 +02:00
3715e32303 fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen
Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.

Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
  bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
  unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
  vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
  auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
  entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
  Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
  extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.

Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
  teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
  Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
  Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:02:45 +02:00
cb2ee03a14 fix: Naho Eltern + Foto-Layout-Erkennung + Eltern-Fallback-Lookup
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m34s
CI / Docker Build & Push (push) Failing after 9s
2026-06-21 22:55:50 +02:00
41ff973f9a feat(import): merge animals with different DOBs if their parent names match
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Failing after 12s
2026-06-21 22:23:59 +02:00
d2993418b6 fix(import): remap Kazuya DOB and correct parents using Stammbaum von Kazuya.xlsx
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-21 22:18:04 +02:00
4cb39cb180 fix(import): align child-parent pedigree mapping by using name cell row for vertical alignment
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m9s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-21 22:11:53 +02:00
b53b8cf2ff fix: show nameless fallback (ohne Namen) for nameless parents in litters list and detail pages 2026-06-21 22:06:10 +02:00
e460cd5905 feat(import): resolve color variety ID mappings mismatch, support unnamed parents in virtual litters, and implement SaleContract updates 2026-06-21 21:57:40 +02:00
b83e96f552 feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll
Detailseite (Rennmausakte):
- Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen,
  Charakter als Chips, getabte Fotos/Gesundheit/Gewicht.
- Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status
  Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen).
- Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular).

Listen:
- Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge,
  Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile
  Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button.
- Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header.

Zucht-Nachname:
- Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt
  (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix".
- „Eigene Zucht" ist die Standard-Herkunft neuer Tiere.

Weiteres:
- Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend
  EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage).
- Toast-Rückmeldungen für alle Speichern-Aktionen.
- Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:35:30 +02:00
bddbaf59d3 chore(import): document import workflow and apply Round 2 contact deduplications
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m53s
CI / Docker Build & Push (push) Has been skipped
2026-06-13 09:27:13 +02:00
3021149ddd chore(import): apply user contact deduplication decisions 2026-06-13 09:22:04 +02:00
46f987422b FIX: Combine Birgitta Büskens and Birgitta Müller-Büskens contacts 2026-06-13 08:35:15 +02:00
5d2316d670 FIX: Combine Barbara R / Barbara Riegler contacts 2026-06-13 08:22:54 +02:00
c6968b9968 FEAT: Merge Anja Sch. into Anja Schaumburg, and keep infinite scroll layout pages 2026-06-13 08:14:31 +02:00
f3f4534392 FEAT: Normalize Anke B./Anke Busch contact, and save other infinite scroll layout updates 2026-06-13 08:11:17 +02:00
d189da954f FEAT: Wipe Contacts table on ingest to prevent duplicates, and commit infinite scroll contacts list 2026-06-13 08:06:56 +02:00
5b32c09076 FIX: Keep Ag(o)uti('s sind) net(t) as a valid breeder contact 2026-06-13 07:59:52 +02:00
ea760fba06 FEAT: Apply contact normalization and misclassification discarding mappings during import 2026-06-13 01:45:45 +02:00
396d8f05d8 FEAT: Implement deceased/givenaway enclosure visibility rules, preserve external clan name, and hide receiver fields for deceased gerbils 2026-06-13 01:40:19 +02:00
5732398e60 feat(rennmaeuse): Tiere-Liste neu im Rennmaus-Filter-Design
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m54s
CI / Docker Build & Push (push) Has been skipped
Filter-UX aus dem Design:
- Quick-Chip-Kategorien (Status/Geschlecht/Farbschlag/Herkunft) öffnen ein
  Bottom-Sheet mit Mehrfachauswahl (suchbar bei Farbschlag/Herkunft); aktive
  Filter als entfernbare Chips; Sortierung + "Externe Ahnen" als gleich große
  Chips (Sortierung öffnet dasselbe Drawer).
- Neue zweizeilige Tierkarte (Name + Geschlechts-Kreis + Status-Badge, Meta-Zeile),
  cardIn/Chip/Sheet-Animationen, Titel blendet beim Scrollen aus.

App-weit:
- Design-Palette (Creme/Braun/Terrakotta), Hanken-Grotesk-Schrift und gemeinsamer
  --shadow-Token; obere "Rennmaus-Manager"-Titelleiste entfernt (mobil bleibt die
  untere Aktionsleiste); fixe Shell-Höhe, nur der Hauptbereich scrollt.

Funktion:
- Infinite Scroll via useInfiniteList (seitenweise) + IntersectionObserver-Sentinel.
- Namensgenerator: Option männlich/weiblich/egal.
- Mehrfach-OR-Filter über gridify orGroup; e2e an die neue UI angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:05:44 +02:00
0185446bfe feat(migration): implement offline-resolved database-ready ingestion, fix circular FKs, duplicate contacts and parser scanning bugs 2026-06-08 21:57:01 +02:00
04d189bd2f FEAT: genotype parsing resilience and displayGenotypeSafe format helper 2026-06-08 21:19:11 +02:00
7d8166c555 Merge feature/gerbil-icon-redesign: GerbilIcon SVG neu gezeichnet
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Julian fand die alte Silhouette bescheiden. Neue Rennmaus-Silhouette
(sitzende Pose, layered ears, Pfoten, geschwungener Schwanz + Tuft).
Komponenten-API unveraendert (Props/viewBox/role/aria-label/style) ->
Consumer + e2e Selektoren intakt. QA: tsc+vite build green, vitest 122/122.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:35:18 +02:00
d5c7b77e7e FEAT: redesign GerbilIcon with a cute alert sitting silhouette and dynamic tufted tail 2026-06-08 00:33:40 +02:00
bff25b4683 FEAT-8d: add wipe-import-data.sql utility
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Utility script to clean up import data from the database after testing
/import/dry-run. Useful for re-running the import process during development
and QA. Deletes all animals and litters created by the import (identified
by ImportSource='docx-export' and ImportSource='derived-litter'), resets
sequences, and leaves the database ready for fresh import execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:11 +02:00
aa1d22cec7 FEAT-8d: add node_modules/ to .gitignore
Never commit node_modules — always installed locally via npm install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:10 +02:00
f7e97ad7a2 FEAT-8d: fix StatusModel migration table name SaleContractAnimals->SaleContractAnimal
During import execution, the migration 20260607012436_StatusModel failed because it
referenced a non-existent table "SaleContractAnimals" (plural). The actual table name
is "SaleContractAnimal" (singular). Fixed the reference in the SQL raw string to use
the correct table name so the migration can apply cleanly on fresh deployments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:10 +02:00
7d552d33cd D7-PREP: Batch-Merge feature/d7-conflicts -> main (all 7 D7 conflict animals resolved)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
Merged commits:
- 5ce35af (Skarlett): dateOfDeath 01.01.2017 (year-only, born 14.07.2013)
- 62b0759 (Max): dateOfDeath 01.01.2014 (year-only, born 01.02.2013) + genotype
- 4d7333b (Dakota + Banjo): Dakota genotype + Banjo E-locus correction
- Base file (Isa/Jack II/Sunny): Already correct, verified

All D7 conflict decisions now on main. Feature branch can be retained or deleted per god preference.
Status: Ready for FEAT-8d re-import (currently on HOLD pending Julian GO).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:32:00 +02:00
4d7333b714 D7-PREP: Dakota of sweet little mouse + Banjo of Fiomi (E-locus correction)
Dakota of sweet little mouse (DOB 30.01.2015):
- Genotype: Aa CC Dd Ee Gg pp Spsp
- A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp confirmed by Julian

Banjo of Fiomi (DOB 06.07.2015, Dakota's son):
- E-Locus correction: E- → ee (Julian: "Goldfuchs Starkschecke, nicht Gold Starkschecke")
- Corrected genotype: AA CC DD ee Gg pp Spsp
- ColorVariety: Goldfuchs Starkschecke

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:27:53 +02:00
62b07597f5 D7-PREP: Max von Privat death date corrected to 01.01.2014 (year-only convention, birth 01.02.2013 + deathYear 2014)
Genotype confirmed: aa c[chm]c[chm] D- EE GG PP spsp
Reject 4 on-file variants: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:26:10 +02:00
5ce35af813 D7-PREP: Skarlett death date corrected to 01.01.2017 (year-only convention, birth 2013 + 4y)
Julian's final answer: 'She became 4 years old, no exact date.' Birth: 14.07.2013.
Calculated: 2013 + 4 = 2017. Applied year-only death convention: 01.01.2017.
Previous entry (17.04.2016) from HUMANQUESTION D6 was incorrect.
2026-06-07 16:40:55 +02:00
23b6dad303 chore: retire HUMANQUESTION.md -> HUMANQUESTION.md.migrated
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Human-feedback workflow moved into the hive task system (humanQA arrays on blocked cards in tasks.json). File is no longer the source of truth and must not be written to again. Open asks migrated to HQ-A3-GMAIL / HQ-A4-HOSTNAME / HQ-A5B2-REGISTRY / HQ-C7-RENNER-WISHES, plus D7-PREP (5 open conflict animals) and FEAT-8d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:17:37 +02:00
e9eef5cce6 Merge JUVENILE-AVATARS (feature/juvenile-avatars @299c038) — Kelly
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
CI / Backend Tests (.NET) (push) Successful in 1m3s
Foto-Avatare pro Jungtier im Wurf-Detail (36px Kreis-Avatar + GerbilIcon-Fallback),
reuse profilePhotoUrl + failedUrl-Fehler-Tracking. StammbaumPage auf gleiches
failedUrl-Muster umgestellt (set-state-in-effect Lint-Fix). FE-only, keine Migration.
god-QA: vitest 122/122, build+tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:24:49 +02:00
299c038449 feat(JUVENILE-AVATARS): Foto-Avatare pro Jungtier im Wurf-Detail
- JuvenileCard-Komponente in WurfDetailPage mit 36px-Kreis-Avatar links
- Fallback auf GerbilIcon (SVG-Silhouette) wenn kein profilePhotoUrl
- URL-basiertes Fehler-Tracking (failedUrl) statt useEffect+imgError
- wuerfe.css: Flex-Layout fuer Card+Avatar, overflow:hidden/object-fit:cover
- StammbaumPage: gleiche Lint-Verletzung (react-hooks/set-state-in-effect)
  behoben (failedUrl-Pattern, kein useEffect mehr fuer Bild-Reset)
- E2E: profilePhotoUrl auf Pippa (pup-abgabe), neuer Avatar-Test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:20:19 +02:00
ecd7ad2658 Merge feature/status-model-fe (STATUS-MODEL-FE): Dropdown Zucht/Liebhaber/Abzugeben; Verstorben/Abgegeben read-only abgeleitet; Labels+Filter
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Failing after 9s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
2026-06-07 03:37:48 +02:00
6de56ab112 Merge feature/litter-mortality-fe (LITTER-MORTALITY-FE): deathsWithin8Weeks Feld im Wurf-Formular+Detail mit >totalBorn-Warnung 2026-06-07 03:37:48 +02:00
ddc343a96b Merge feature/d7-conflicts (D7-PREP): 5 Tiere (Milon/Max/Isa/Jack II/Sunny) in conflict-decisions.json + Jahr-only-Tod deathYear-Vorschlag; dry-run Konflikte 7->2 (KEIN execute)
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:37:20 +02:00
a26bc4d75e Merge feature/litter-mortality (LITTER-MORTALITY-BE): Litter.DeathsWithin8Weeks int? + additive Migration + DTO + Validierung <=TotalBorn 2026-06-07 03:36:57 +02:00
d4dfe6c3ac Merge feature/status-model (STATUS-MODEL-BE P0): enum Active->Breeding(default)+Pet/ForSale; Deceased/GivenAway serverseitig abgeleitet (GerbilStatusService.Derive + Praezedenz + >7J presumed); Migration+Backfill+Startup-Sweep 2026-06-07 03:36:32 +02:00
40f3ce250d STATUS-MODEL-FE: Zucht/Liebhaber/Abzugeben wählbar; Verstorben+Abgegeben abgeleitet (gated auf Pam BE) 2026-06-07 03:36:10 +02:00
ffbbc0dfad Merge feature/stammbaum-avatar (STAMMBAUM-AVATAR): Stammbaum-Knoten konsumieren profilePhotoUrl (Foto-Avatar mit imgError-Fallback auf GerbilIcon)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:33:54 +02:00
05cb1af39e Merge feature/gerbil-profilephoto (PROFILEPHOTO-BE): berechnetes profilePhotoUrl aufs Gerbil-DTO (Shared-Dep fuer Avatare, keine Migration, kein N+1)
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
2026-06-07 03:33:01 +02:00
78f87f0d44 LITTER-MORTALITY: DeathsWithin8Weeks field (Frühsterblichkeit)
Litter.DeathsWithin8Weeks (int?, nullable). LitterDto + LitterInput extended.
Validation: negative → 400, > TotalBorn → 400 (skipped when TotalBorn null).
Migration AddLitterMortality (additive, nullable). 5 new tests. 194/194 green.
Contract: camelCase field deathsWithin8Weeks in LitterDto/LitterInput.
2026-06-07 03:31:51 +02:00
7f6855be62 D7: 5 Züchterin-Daten in conflict-decisions.json kodiert
Max von Privat (01.02.2013): genotype aa c[chm]c[chm] D- EE GG PP spsp.
  Tod-Jahr 2014 (Jahr-only) als _deathYear_pending kommentiert — Konvention offen.
Isa of Golden Lights (24.12.2014): dateOfDeath 21.07.2018.
Jack II von den Kleinen Chaoten (14.02.2016): dateOfDeath 06.10.2019.
Milon von den Kleinen Chaoten (27.11.2014): genotype Aa Cc[chm] D- ee[f] gg Pp spsp
  (A-Locus war der einzige Konflikt; Züchterin löst auf Aa).
Sunny von PZ Karl (10.04.2014): dateOfDeath 30.04.2018.

Dry-run delta: Konflikte 7→2 (resolved 15→20). Verbleibend: Skarlett + Dakota.
2026-06-07 03:29:19 +02:00
29e9c4cd64 LITTER-MORTALITY-FE: deathsWithin8Weeks Feld im Wurf-Formular und -Detail (gated auf Pam BE) 2026-06-07 03:28:11 +02:00
8eddb83c99 STAMMBAUM-AVATAR: Tier-Foto als Avatar in Pedigree-Karte, GerbilIcon-Fallback (profilePhotoUrl-Contract) 2026-06-07 03:27:24 +02:00
1a5f3218fb STATUS-MODEL: Active→Breeding + Pet enum + derived status logic (P0)
GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED.
GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1),
ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4).
All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply().
Startup sweep flips >7y gerbils to Deceased at next app restart.
Migration StatusModel: Active→Breeding rename + backfill derived statuses.
10 new tests; 200/200 green; has-pending=No.

Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale.
FE contract: status field values updated (see Done-Report).
2026-06-07 03:27:11 +02:00
2d54cb901f Merge feature/litter-juvenile-details (LITTER-JUVENILE-DETAILS): pro Jungtier Farbschlag/Abgabedatum/Abnehmer/Todestag/Ursache im Wurf-Detail
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m16s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:25:53 +02:00
03f8e4de90 LITTER-JUVENILE-DETAILS: Farbschlag/Abgabe/Abnehmer/Todesdaten pro Jungtier im Wurf-Detail 2026-06-07 03:23:22 +02:00
70803efa1f PROFILEPHOTO: profilePhotoUrl computed field on GerbilDto
GET /gerbils/{id}: loads first photo by sortOrder (2 queries, no N+1).
GET /gerbils list: batch-loads first photos for the page in one IN query.
profilePhotoUrl = /photos/files/{fileName} or null. No migration.
3 new tests: no-photo=null, sortOrder priority, list batch.
2026-06-07 03:19:00 +02:00
25af10a736 Merge feature/farbschlag-genotype-sync (FARBSCHLAG-GENOTYPE-SYNC): Farbschlag↔Gencode bidirektionale Sync im Tier-Formular
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:16:55 +02:00
ade40eef5e FARBSCHLAG-GENOTYPE-SYNC: Farbschlag↔Gencode bidirektionale Sync im Tier-Formular 2026-06-07 03:16:02 +02:00
c1b215d06f Merge feature/stammbaum-fulldate (STAMMBAUM): volles Geburtsdatum + Gencode-Chip + GerbilIcon (🐭→Rennmaus app-weit)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 03:03:45 +02:00
7525970ffc STAMMBAUM-FULLDATE: GerbilIcon-SVG + Gencode-Chip + Gerbil-Icon ersetzt 🐭 app-weit 2026-06-07 02:53:31 +02:00
bfc90f8581 STAMMBAUM-FULLDATE: volles Geburtsdatum auf Knoten-Karten (formatDate statt slice 4) + e2e 2026-06-07 02:47:12 +02:00
c45bcc99f0 Merge feature/ops-2 (OPS-2): TrueNAS Goldeye deploy config (registry git.rismer.de + JailStorage paths) + ops runbook
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 8s
# Conflicts:
#	deploy/truenas/compose.yaml
2026-06-07 02:28:00 +02:00
c4a661b5b6 Merge feature/search-1 (SEARCH-1): regression tests for separator-insensitive nameSearch + OriginBreeder filter 2026-06-07 02:27:21 +02:00
30b2fff775 Merge feature/web-2 (WEB-2): self-host public site — POST /api/publish (atomic swap) + publicsite nginx + vhost + docs
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 10s
CI / Backend Tests (.NET) (push) Successful in 59s
2026-06-07 01:45:30 +02:00
c0ecf2022d WEB-2: Self-hosted public site (publicsite-nginx + POST /api/publish, atomic swap)
POST /api/publish: rendert SiteSnapshot->HTML in _staging_new/, atomic swap ->
live/ (rename, ein Syscall). publicsite-nginx:alpine serviert live/ read-only
auf Port 8081. Shared Volume api(rw)/publicsite(ro). Manager bleibt LAN-only.
5 neue Tests (atomic swap, UTF-8, mehrfach), 184/184 gruen. compose config OK.
Vhost-Snippet + web-deploy.md (Deutsch) beigelegt; <DOMAIN> wartet auf Julian.
2026-06-07 01:44:28 +02:00
2198c33898 OPS-2: TrueNAS Goldeye 25.10.2.1 Deploy finalisiert
Registry truenas:13000 -> git.rismer.de/gulum (alle 6 Stellen).
Dataset-Pfade auf /mnt/JailStorage/DockerVolumes/gerbilmanager/ gesetzt.
docs/ops.md: vollstaendiges Goldeye-Runbook (Ordner + UID-999-Perms,
Registry-Login, Port-80-Fallback, Restore-Drill-Pflichtschritt, ZFS-Pfade).
docker compose config: OK.
2026-06-07 01:38:25 +02:00
04971bf3bb Merge feature/stammbaum-litters (STAMMBAUM-LITTERS): Würfe des Wurzeltiers links im Viewer + Link
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:38:10 +02:00
08500757d7 STAMMBAUM-LITTERS: Wuerfe des Wurzeltiers links im Stammbaum-Viewer
Layout: stammbaum-layout (flex-row Desktop / flex-column Mobil) wraps
[Wuerfe-Panel | Canvas]. Panel 148px breit, border-right Trenner;
auf Mobil (<=520px) horizontaler Scroll-Streifen ueber dem Canvas.

Daten: useApi(listLitters fatherId=id|motherId=id) reagiert automatisch
auf Umwurzeln (id-Param). Kein Panel wenn Wurzeltier keine Wuerfe hat.

Pro Wurf: Wurfname (fett) + N Junge + Link zu /wuerfe/{id}.
CSS: flex 0 0 auto Mobil-Override sichert Canvas-Hoehe im column-Mode.
de.ts: littersTitle, littersJunge (Stammbaum-Sektion, disjunkt).

Gate: vitest 122/122 e2e 20/20 Stammbaum (4 neue Tests x 2 Viewports)
build+tsc+eslint clean.
2026-06-07 01:37:08 +02:00
05ef3d9567 Merge feature/namegen-2-fe (NAMEGEN-2-FE): 10 Namens-Kategorien UI (disney/pokemon/EN+HR-Städte/US-Staaten)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:35:40 +02:00
7c12a65938 Merge feature/namegen-2-be (NAMEGEN-2-BE): 5 Themen-Kategorien disney/pokemon/encities/hrcities/usstates für /names/suggest
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:34:52 +02:00
5361a292f1 NAMEGEN-2-BE: 5 neue Themen-Kategorien (disney/pokemon/encities/hrcities/usstates)
UsageMap bildet alle Codes auf deutsche Beschreibungen ab; thematische Kategorien
landen unter "Themen-Kategorien" im Prompt (kein Etymologie-Zwang, origin=Kategoriename).
22 neue Tests, 179/179 gruen.
2026-06-07 01:32:56 +02:00
cb5acd2005 Merge feature/genotype-display-rex (GEN-4d + UI-FIX): Rex-Wildtyp 'rere' in Anzeige ausblenden (7 Loci) + doppelte Wurf-Überschrift entfernt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:31:19 +02:00
bda5479b93 UI-FIX: doppelte Überschrift 'Erwartete Farbschläge' auf Wurf-Detailseite entfernt 2026-06-07 01:29:49 +02:00
d5c155953b GEN-4d: rere (Re-Wildtyp) aus Anzeige ausblenden (Julian)
- genotype.ts: toDisplayString filtert Re aus wenn re/re (Wildtyp) —
  analog zur bestehenden Sls-Regel; Rere/ReRe bleiben sichtbar.
  Wildtyp-Anzeige jetzt 7 Loci: AA CC DD EE GG PP spsp (ohne rere).
- StammbaumPage.tsx: Druck-Ahnentafel nutzt toDisplayString(fromDisplayString(g.genotype))
  statt rohem g.genotype-String (Audit: einzige Display-Stelle ausserhalb toDisplayString).
  (GerbilDetailPage/BreedingResultView gehen bereits via toDisplayString.)
- genetics.test.ts: Alle Display-String-Assertions auf 7-Loci-Format aktualisiert;
  Rere-Nachweis zum Sls/Re-Test ergaenzt; Katalog-Regex auf {6,8}.
- colorVarietySeed.generated.json regeneriert (kein rere in canonicalGenotype).
- backend.json unveraendert (Pam: DB-Speicherung bleibt volles 8-Loki-Format).
2026-06-07 01:28:06 +02:00
36795bd974 Merge feature/seed-rew (SEED-REW): Backend ColorVariety 'Pink Eyed White (PEW)' → 'REW' rename-in-place (FK-safe)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 01:19:26 +02:00
ae60b47ad3 D7: Quelldatei(en) je offenem Konflikt-Tier angereichert (Dakota/Max + alle 6 offenen)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:15:04 +02:00
adb9e93cdb SEED-REW: rename ColorVariety 'Pink Eyed White (PEW)' to 'REW'
Single UpdateData migration for ID 00000000-...-0001 (sortOrder 0).
Name-only rename, genotype/ID/FK unchanged — no drift risk.
Matches colorVarietySeed.backend.json after GEN-4c (main 3aa9ac9).

165/165 tests, ef has-pending=No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 01:12:55 +02:00
3aa9ac9811 Merge feature/gen-4c (GEN-4c): PEW→REW konsolidiert (Julian REW-1) + REW-2 A-unabhängig verifiziert + aa-Test
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
2026-06-07 01:10:18 +02:00
b72e0a9d9a GEN-4c: PEW=REW konsolidiert (Julian) + REW-2-Verifikation
- catalog.ts: 'Pink Eyed White (PEW)' -> 'REW' (frozen rename; chch+pp
  engine-computes 'REW' per Julian; entry now round-trips correctly)
- genetics.test.ts: REW_SHADOWED-Ausnahme entfernt (kein Bedarf mehr);
  CATALOG[0]-Assertion auf 'REW'; aa cchmcchm+pp-Test als REW-2-Nachweis
  (Julian: A-Locus egal fuer REW-Bedingung)
- chipColors.ts: 'REW'-Chip ergaenzt (gleiche Farbe wie PEW; additive;
  PEW-Eintrag bleibt fuer Rueckwaertskompatibilitaet waehrend DB-Migration)
- Seed-Artefakte regeneriert (70 Reihen, REW statt PEW an Position 0)
- FLAGGED to god: 'Altweiss (REW)' hat volles C (C:'C') -> faellt NICHT
  unter REW-Bedingung; warte auf Julian-Entscheid vor Aenderung
2026-06-07 01:08:23 +02:00
12d85d9796 WEB-Hosting-Pivot: Cloudflare → Self-Host auf TrueNAS (Strato/DynDNS + nginx-Reverse-Proxy); A4 umgestellt
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:07:30 +02:00
76bdde4649 B2 Firewall erledigt — Handy-WLAN-Zugriff (Ports 5173/5179) offen
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:04:00 +02:00
025bca1840 REW-1/REW-2 beantwortet: PEW=REW (zusammenführen, GEN-4c) + REW A-unabhängig (schon implementiert)
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
2026-06-07 01:03:05 +02:00
58dc811574 E1/E2 Charakterbogen als ERLEDIGT markiert (CHARAKTERBOGEN-2: 4 Kategorien, +10 Traits, Warnsignale) — war stale
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:59:37 +02:00
367304940f D7: Osamu/Percy/Iwana Konfliktentscheidungen (Züchterin) + Eragon=Elieus C=CC bestätigt → conflict-decisions.json + HUMANQUESTION (7/13 erledigt)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:58:04 +02:00
7e1acdb5e4 A5 beantwortet: TrueNAS Goldeye 25.10 + eigener Postgres + Backups unter /mnt/JailStorage/DockerVolumes — OPS-2 entsperrt
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 1m2s
2026-06-07 00:54:26 +02:00
5ee6a4edf6 Merge feature/stammbaum-expand (STAMMBAUM-EXPAND): prominenter +-Vorfahren-Button (40px) + Bottom-Mini-Legende (Name→Akte)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:47:05 +02:00
ea527c7ec6 CI: Registry auf externes HTTPS git.rismer.de (statt 192.168.2.115:13000 HTTP) — keine insecure-registry-Konfig nötig
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:45:39 +02:00
c80adcf060 Merge feature/fix-8d-retry (FIX-8D-RETRY P0): wrap docx import tx in CreateExecutionStrategy.ExecuteAsync (Npgsql retrying strategy) + regression
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:40:11 +02:00
a42966ad8e STAMMBAUM-EXPAND: +-Button prominenter + Mini-Legende (Stammbaum-UX)
Punkt 1 - Expand-Button sichtbar/zuverlaessig:
- 40px solid-fill (war 32px outline), box-shadow, flex-centered;
  deutlich sichtbar auf 190px Karte, touch-tauglich (>=40px Tap-Target).
  Hover/focus-visible: opacity + outline.
- Edge-case (Litter mit null-Eltern): +-Button bleibt sichtbar --
  unbekannt-Karten sind korrektes Feedback fuer quarantaenierte Eltern.

Punkt 2 - Bottom-Hint Mini-Legende:
- stammbaum-hint -> stammbaum-hints ul (flex-wrap, 3 Items):
  Umwurzeln / Name=Akte / +=Nachladen. Gut lesbar auf 390px.
  de.ts: hintName + hintExpand (neue Keys; tapHint bleibt fuer card title).

e2e: +2 Specs x 2 Viewports = 4 neue Tests (Legende + Expand-Klick).
Emil (Gen 4, litterId w-emil) liefert Testanker im Mock-Stammbaum.

Gate: vitest 122/122 e2e 166+68skipped build+tsc+eslint clean.
2026-06-07 00:38:46 +02:00
886a6e3aae D7: Kazumi/Filou/Sokrates Konfliktentscheidungen (Züchterin) → conflict-decisions.json + HUMANQUESTION gestrichen
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:38:05 +02:00
88e00b3718 FIX-8D-RETRY: wrap docx execute transaction in CreateExecutionStrategy
NpgsqlRetryingExecutionStrategy rejects user-initiated transactions: SaveChanges
inside a BeginTransactionAsync block triggers OnFirstExecution which throws
InvalidOperationException. Fix: CreateExecutionStrategy().ExecuteAsync wraps the
entire tx block; mutable state (counters, contactByNorm, change tracker) reset at
lambda top for idempotent retry. Logic extracted to RunLoopAsync local function
shared by dry-run and execute paths.

Regression test (Test 7): FakeRetryingStrategy with MaxRetryCount=1 reproduces
the OnFirstExecution check in CI without a live Npgsql instance.

165/165 tests, ef has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 00:34:26 +02:00
3214855989 A5b2: konkrete Schritte für Registry-Secrets + insecure-registry-Hinweis
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:33:40 +02:00
76e635a122 c
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 00:02:20 +02:00
00886b1c6e Merge feature/fix-8d-dedup (FIX-8D-DEDUP P0): collision-free idempotent ExternalRef + single transaction + litter ±5d unique-date link
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 8s
2026-06-06 23:33:06 +02:00
d5e8946d7d FIX-8D-DEDUP: P0 duplicate ExternalRef + WsCode→litter mismatch
FIX-1 DEDUP (P0): Two same-name siblings in one litter produced identical
ExternalRefs ('docx-{ws}-{name}-{dob}'), crashing execute on IX_Gerbils_ExternalRef.
Fix: ordinal counter per base-ref within the batch → first occurrence keeps the
base ref, subsequent ones get -2, -3 … suffix. Deterministic (JSON-order) → idempotent
re-runs find existing rows via ExternalRef path (PATH 1). HashSet guard added as a
belt-and-suspenders check.

FIX-2 TRANSACTION: execute now opens a single BeginTransactionAsync before the loop
and commits after the final SaveChangesAsync. Eager contact saves (within the tx) and
the gerbil batch save are fully atomic — crash → full rollback, no partial state.

FIX-3 LITTER LOOKUP: docx WsCode is a litter-size fraction ('4/4', '/5') — NOT a
PairingCode like 'G01/ZdkC'. The previous WsCode→PairingCode lookup was always a
no-op (hence litterLinked=0 in every dry-run). Fix: look up DB litters by LitterDob
(±5 days); link only when exactly ONE candidate exists (unambiguous, no false links).

P0 REGRESSION TEST (Test 6): SQLite + EnsureCreated → unique index enforced.
Two animals same name+litter → execute succeeds, both created, distinct ExternalRefs,
re-run = 0 new. This test would have caught the live crash.
Gate: 164/164 tests, has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 23:31:46 +02:00
e367657254 Merge feature/feat-8d-create (FEAT-8d-CREATE): docx importer CREATE path for ~194 abgegebene Jungtiere
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-06 23:15:00 +02:00
8c43a8dc03 FEAT-8d-CREATE: docx importer CREATE path for ~194 abgegebene Jungtiere
ImportDocxService gains a three-way match-or-create loop:
  Path 1 (ExternalRef 'docx-…'): previously-created docx animals → enrich fill-NULL-only (idempotency)
  Path 2 (name+dob): existing main-import animals → enrich fill-NULL-only (as before)
  Path 3 (no match): CREATE new Gerbil — abgegebene Jungtiere not in pedigree charts
                      Status=GivenAway (or Deceased), IsResident=false,
                      OriginBreeder='Zucht der Kleinen Chaoten', ImportSource='docx',
                      ExternalRef='docx-{ws}-{normname}-{litterDob}' (stable idempotent key),
                      Gender/ColorVariety/LitterId/ReceiverContact/GoHomeDate/DateOfDeath all set.

ImportDocxReport gains Created field; contact-creation eagerly saved before gerbil batch.
5 SQLite-backed tests: dry-run counts, create+FK, idempotency, enrich-not-duplicate, Deceased status.
Gate: 163/163 tests, has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 23:13:21 +02:00
08732d8348 Merge feature/fix-import-cycle (FIX-IMPORT-CYCLE P0): break Gerbil<->Litter EF circular dependency on execute
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
Synth (chart-derived) litters now saved with FatherId/MotherId=null, then a deferred FK-update pass after all gerbils persist -> single-directional Gerbil->Litter dependency in the main save, no cycle. FK guard preserved. Regression: A-mother-is-B + B-mother-is-A cycle (SQLite, FK-enforced) asserts no throw + correct deferred FK. Gate: 158/158 (25/25 import), has-pending=No.

Unblocks the live WIPE+REIMPORT-3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:48:52 +02:00
c6edb2e9d0 FIX-IMPORT-CYCLE: break Gerbil↔Litter circular FK dependency in ImportService
Root cause: synth litters (from chart parentRefs) were added with FatherId/MotherId pointing
to gerbils that were also [Added] in the same SaveChanges batch. With 337 derived litters from
41 pedigrees, multi-generational references created a true dependency cycle:
  Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added]
EF's topo-sort threw InvalidOperationException; smaller datasets (REIMPORT-1/2) didn't
trigger it because they had fewer chart-derived litters.

Fix: add synth litters with FatherId=null/MotherId=null (deferred), track intended FKs
in synthLitterPendingParents, apply them in a separate SaveChanges after all gerbils are
persisted. Single-direction Gerbil→Litter dependency in the main save; no cycle possible.
No schema change, no migration.

Regression test: artificial A←B←A cycle in SQLite (FK-enforced) must not throw.
Also verifies deferred FK update sets litter parent correctly after execute.

GATE: 158/158 C# tests, has-pending-model-changes=No

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 22:47:18 +02:00
8e475dff6a Merge feature/charakterbogen-2 (CHARAKTERBOGEN-2 / E1): Trait-Kategorien + rennmaus-spezifische Eigenschaften + Warnsignale
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
de.character.traits flach -> 4 Kategorien (Sozialverhalten/Eignung&Umgang/Hobbys&Eigenarten/Wesen&Temperament). 15 bestehende Keys UNVERAENDERT (Live-Daten), 10 neue additiv. warn-Flag (schwer-vergesellschaftbar, territorial) visuell abgesetzt fuer ehrliche Inserate. Gate: vitest 122/122, tsc/build/eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:43:13 +02:00
54ffb809c4 CHARAKTERBOGEN-2 (E1): Trait-Katalog in 4 Kategorien + 10 neue Traits + Warnsignale
- de.ts: traits-Array -> traitCategories (4 Kategorien: Sozialverhalten / Eignung &
  Umgang / Hobbys & Eigenarten / Wesen & Temperament); alle 15 bestehenden Keys
  erhalten (stored auf Live-Tieren); 10 neue Keys additiv; warn:true auf
  schwer-vergesellschaftbar + territorial.
- format/traits.ts: TraitEntry/TraitCategory-Interface, TRAIT_CATEGORIES-Export,
  ALL_TRAITS via flatMap, neuer isWarnTrait()-Helper.
- Charakterbogen.tsx: grouped-Rendering nach Kategorie (section + h4); warn-Chips
  mit trait-chip--warn-Klasse + trait-warn-badge.
- charakterbogen.css: .trait-category__heading + .trait-chip--warn (amber) +
  .trait-warn-badge.
- format/__tests__/traits.test.ts: 14 neue Vitest-Tests (Kategorien, Keys, warn,
  traitLabel/traitLabels).
- Gate: vitest 122/122, tsc clean, build clean, eslint clean.
2026-06-06 22:32:16 +02:00
4403574f3d Merge feature/catalog-reseed (CATALOG-RESEED): Backend-ColorVariety auf finalen GEN-4/4b-Katalog (66->70)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
ReseedColorVarietiesAR5: 8 Dilute-Praefix-Renames + 4 neue Dilute-Fuchs (IDs 67-70), SortOrder von ID entkoppelt, append-only (alle 66 ID->Name-Bindings erhalten, kein Live-FK-Drift). Quelle: frozen colorVarietySeed.backend.json. Gate: dotnet test 157/157, has-pending=No (Merge-Ergebnis verifiziert).

Letztes Gate vor WIPE+REIMPORT-3 (Julian-GO erteilt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:31:46 +02:00
bcdea8011b CATALOG-RESEED: backend ColorVariety seed 66→70 from colorVarietySeed.backend.json (AR-5)
Source: gerbil-manager-web/src/genetics/colorVarietySeed.backend.json (70 entries, frozen symbols).
- 8 names updated to "Dilute" prefix (dd Gold→Dilute Gold, Agouti dd→Dilute Agouti, etc.)
- SortOrder decoupled from ID (CP-*-Hell interleaved per backend.json sortOrder values)
- 4 new entries appended at end (IDs 67-70): Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs
- All 66 existing ID→Name bindings preserved (append-only, no FK drift)
- Migration ReseedColorVarietiesAR5: UpdateData (renames+SortOrders) + InsertData (4 new)
- Tests updated: SeedGen3g_* asserts 70 entries + IDs 67-70 verified
- GATE: 157/157 C# tests, has-pending-model-changes=No

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 22:29:47 +02:00
643f1a6831 Merge feature/catalog-generator (AR-5): committed Katalog-Generator (gen:catalog) + frozen colorVarietySeed.backend.json + Drift-Guard
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
Frontend-only (Generator .mts + Seed-Artefakte + catalog-drift test). Gate: vitest 109/109.
Backend-Artefakt (70 Eintraege, GEN-4/4b-final) = Quelle fuer die Backend-ColorVariety-Re-Seed-Migration (Pam) vor dem WIPE+REIMPORT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:20:00 +02:00
27a3eb802c Merge feature/feat-8d-docx (FEAT-8d Phase 2): Wurfchronik-docx-Importer (Abnehmer/GoHomeDate/Tod)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
extract_docx.py (stdlib, 93 Wuerfe/227 Tiere) + ImportDocxService (fill-NULL-only: WS-Code→LitterId,
Abnehmer→ReceiverContactId, ABD→GoHomeDate, Tod→DateOfDeath+Ursache) + /import/docx/dry-run|execute.
157/157 C# + 28/28 python, has-pending=No. Dormant bis execute (NACHZUG nach Re-Import).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:05:35 +02:00
13a887608c docs(HUMANQUESTION): D7 — 13 neue Konflikt-Tiere aus den 41 Stammbaeumen (Sterbedatum + Gencode-Picks)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:04:25 +02:00
f74af537e2 FEAT-8d: docx-Importer fuer Wurfchronik-Detail (extract_docx.py + ImportDocxService)
tools/import/extract_docx.py (stdlib-Python, kein pip):
  Parst 'Wurfchronik der Kleinen Chaoten im Detail.docx' (Word/XML via zipfile).
  93 Wuerfe + 227 benannte Tiere aus Tabellen extrahiert.
  Felder pro Tier: WS-Code, Wurfgeburtsdatum, Name, Farbschlag, Geschlecht (Stern-
  Suffix), Abnehmer, Abgabedatum, Tod-Datum + Ursache, Partnername + DOB.
  Sonderwerte (ZT/BLEIBT/FREI/VG:) werden herausgefiltert.
  Edge-Cases: Doppel-Datum (16./17.03.2021, 31.05/*01.06.2023), WS ohne Zaehler
  (/5), fehlende Leerzeichen vor WS:, mehrere Abnehmer (1.) ... 2.) ...).
  Output: output/docx_litters.json + output/docx_animals.json.

tools/import/test_extract_docx.py:
  Unit-Tests fuer Regex-Logik + Live-Tests gegen die echte docx (skip wenn fehlt).
  28/28 Tests gruen.

GerbilManagerWebAPI/Import/ImportDocxService.cs:
  Idempotenter NACHZUG-Loader (fill-NULL-only, nie ueberschreiben):
  - WS-Code + Wurfgeburtsdatum -> PairingCode -> Gerbil.LitterId
  - Abnehmer -> Contact lookup-or-create -> Gerbil.ReceiverContactId
  - Abgabedatum -> Gerbil.GoHomeDate
  - Tod-Datum + Ursache -> Gerbil.DateOfDeath + CauseOfDeath
  Dry-Run zaehlt geplante Aenderungen, Execute schreibt.

GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs:
  POST /import/docx/dry-run + /import/docx/execute (analog ImportEndpoints).

GATE: 157/157 C#, 28/28 Python-docx-Tests, ef has-pending=No.
NACHZUG: laueft NACH dem finalen WIPE+REIMPORT-3 (kein Impact auf aktuellen Pipeline).
2026-06-06 22:04:09 +02:00
bb832b7f95 docs(HUMANQUESTION): aufgeraeumt — nur noch offene Punkte (A3/A4/A5/B2 + REW-Feinschliff) + Erledigt-Archiv
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Alle C/D-Genetik+Konflikt-Fragen + A1/A2/B1/B3 erledigt → ins kompakte Archiv. Offen: Gmail/Domain/NAS-Zugaenge, Firewall, optionaler Feinschliff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:01:54 +02:00
3a75005f43 docs(HUMANQUESTION): C6 erledigt — alle 32 Konflikt-Tiere entschieden (D6 komplett)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:59:39 +02:00
f2a64ad66b D6: Hanami Sterbedatum = 12.12.2019 (Julian 2026-06-07) — LETZTER D6-Konflikt, D6 KOMPLETT
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
conflict-decisions.json jetzt 16 Eintraege, alle D-Konflikte entschieden. Re-Import nicht mehr auf D6 gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:59:02 +02:00
3706ba3ecd Merge feature/stammbaum-open (STAMMBAUM-OPEN): Klick auf Namen oeffnet Tier (/rennmaeuse/{id})
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
PedigreeCard-Name = <Link> mit stopPropagation; Re-Root bleibt auf Karten-Flaeche. Unbekannt ohne Link.
+e2e (href + Keyboard/Accessibility). vitest 108, e2e 162. Frontend-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:56:31 +02:00
c8ab6bf072 STAMMBAUM-OPEN: Name in Ahnen-Karte ist Link zur Tier-Detailseite
- PedigreeCard: <span className=nametext> → <Link to=/rennmaeuse/{id}> mit e.stopPropagation() (Re-Root bleibt auf Karten-Bereich außerhalb des Namens)
- stammbaum.css: .pedigree-card__nametext color:inherit + text-decoration:none; hover→underline
- Kein Link im Drucklayout (PrintPedigree unverändert)
- e2e: href=/rennmaeuse/fridolin verifiziert + Keyboard-Navigation (fokus+Enter) öffnet Detailseite

Gate: vitest 108/108, e2e 162/162, tsc clean
2026-06-06 21:54:33 +02:00
30ee7b6198 AR-5: Seed-Artefakte auf finalem Katalog regeneriert (70 Eintraege, GEN-4/4b)
npm run gen:catalog lief auf rebased HEAD (post-GEN-4b, main 7fe538d).
Beide Artefakte aktualisiert:
- colorVarietySeed.generated.json: 70 Zeilen, Klammer-Notation (Display)
  Neue Namen: Dilute Gold/Platin/Agouti/Silberagouti/Kohlfuchs/Anthrazit/
  Topas/Blaufuchs + Dilute Fuchs-Varianten (Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs)
- colorVarietySeed.backend.json: 70 Zeilen, frozen symbols (ef/cchm/ch)
  Quelle fuer Pams Backend-Re-Seed-Migration (Guardrail: nie Klammern)
Drift-Guard (catalog-drift.test.ts) laeuft gruen.
Gate: build ✓  eslint ✓  vitest 109/109 ✓
2026-06-06 21:52:44 +02:00
29207e41da AR-5: Committed Katalog-Generator + Drift-Guard + Backend-Artefakt
- gen-seed.mts: deterministischer Generator aus catalog.ts (Single Source);
  emittiert BEIDE Artefakte: generated.json (Klammer-Notation, Display) +
  backend.json (frozen ef/cchm/ch, für Pam EF-Migrationen). Kein magisches
  Artefakt mehr.
- package.json: npm run gen:catalog (npx tsx gen-seed.mts)
- catalog-drift.test.ts: Drift-Guard — liest generated.json von Disk via
  import.meta.url + readFileSync, vergleicht mit live CATALOG; Fail-Meldung
  zeigt 'npm run gen:catalog'. 5 Test-Files, 93 Tests grün.
- colorVarietySeed.backend.json: 66 Zeilen frozen symbols (ef/cchm/ch),
  kein sofortiger Backend-Eingriff (AR-5 Guardrail; Pam konsumiert bei
  nächster Reseed-Migration).
- README.md: Katalog-Generator-Doku (wann laufen, was erzeugt wird).
Gate: build ✓  eslint ✓  vitest 93/93 ✓
2026-06-06 21:51:55 +02:00
7fe538dd5d Merge feature/gen-4 (GEN-4b): 7 Farbarten als Engine-Guard (keine Farbschläge) + Schimmel-Naming-Fix
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
ff42341: CATEGORY_NAMES-Guard (Standard/Colourpoint/Dilute/Fuchs/Fuchsschimmel/Schimmel/CP-Dilute nie als Ergebnis, FK=0);
locusToken E ef/e→'ef' (het-Schimmel matchen konkrete Katalog-Eintraege statt Family-Fallback 'Fuchsschimmel');
baseColourFor Family-Fallback→null; Orangeschimmel A-Restriktion (aa→Kohlfuchsschimmel). Frontend-only. vitest 96, e2e 148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:49:20 +02:00
c452b69dd6 NAMEGEN-2-FE: 5 neue Namenskategorien (Disney, Pokémon, Englische/Kroatische Städte, US-Bundesstaaten)
- NAMEGEN_USAGES: +disney, +pokemon, +encities, +hrcities, +usstates (jetzt 10 Einträge)
- names.test.ts: Count-Test 5→10, neue toContain-Checks für alle 5 Codes

Gate: vitest 107/107, e2e 160/160, tsc clean
2026-06-06 21:48:08 +02:00
ff42341c47 GEN-4 Addendum: 7 Farbarten-Kategorien blockiert + locusToken ef/e-Fix
catalog.ts:
- locusToken E: ef/e -> 'ef' (phenotypisch ausgedrücktes Allel; enables
  catalog-Match fuer het Schimmel-Tiere wie Kohlfuchsschimmel/Orangeschimmel).
  Vorher: 'eef' matchte keinen Katalogeintrag -> Family-Fallback 'Fuchsschimmel'.
- baseColourFor: Family-Fallback entfernt (null statt family-Name) — Familie
  ist nur noch Katalog-Suchfilter, nie direkter Ausgabewert.
- CATEGORY_NAMES Guard in farbschlagFor: Fuchs/Fuchsschimmel/Schimmel/Standard/
  Colourpoint/Dilute/Colourpoint Dilute -> Unbekannter Farbschlag wenn Engine
  diese Namen zurueckgeben wuerde.
- Orangeschimmel: A:'A' hinzugefuegt (Agouti-Einschraenkung), damit aa-ef-Tiere
  durch zu Kohlfuchsschimmel fallen.
- FK-Check: Keine der 7 Kategorie-Namen sind in BASE_COLORS -> 0 FK-Risiko.

genetics.test.ts:
- 'eef with unknown other loci': erwartet jetzt 'Kohlfuchsschimmel' (korrekt!)
- GEN-4 Farbarten-Fixture: Kategorie-Guard-Test + FK-Check-Assert.
Gate: build ✓  eslint ✓  vitest 96/96 ✓  e2e 148/148 ✓
2026-06-06 21:42:15 +02:00
90f998d189 Merge feature/animal-litters (ANIMAL-LITTERS): 'Wuerfe als Elternteil' auf Tier-Detailseite
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilDetailPage: Sektion listet Wuerfe wo fatherId|motherId==Tier (Gridify OR-Filter),
je Link /wuerfe/{id} + Vater/Mutter-Badge + Datum + totalBorn; Leer-Zustand. Rueckweg Tier→Wuerfe.
Frontend-only. vitest 104, e2e 160.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:42:04 +02:00
d5f4be76be ANIMAL-LITTERS: Tier-Detailseite zeigt Würfe dieses Tiers als Elternteil
- GerbilDetailPage: listLittersPaged({filter: fatherId={id}|motherId={id}}) + neue Sektion 'Würfe als Elternteil' vor den Tabs; je Wurf: Link /wuerfe/{id}, Vater/Mutter-Badge, Datum, Wurfstärke; Leer-Zustand wenn keine Würfe
- de.ts: parentLittersTitle, parentLittersEmpty, parentLittersRoleVater/Mutter (append-only in detail-Sektion)
- e2e: +2 Tests (Fridolin hat Wurf K als Vater; Krümel hat Leer-Zustand)

Gate: vitest 104/104, e2e 160/160, tsc clean
2026-06-06 21:39:14 +02:00
433f9ee592 Merge feature/gen-4 (GEN-4): Farbschlag-Benennung (Dilute-Praefix, REW, Fuchs spezifisch) + BreedingView
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
- dd → 'Dilute '-Praefix (Agouti dd→Dilute Agouti etc.); 4 Dilute-Fuchs-Eintraege gegen nackten 'Fuchs'
- REW = beide C-Allele reduziert (kein volles C) + pp (alle 3 Kombis, A-unabhaengig)
- CP-Fuchs in berechneter Verteilung unterdrueckt
- BreedingResultView: Gencode (Klammer) pro Karte + Karten-Layout-Fix (overflow/responsive)
Frontend-only (engine+catalog.ts+BreedingResultView+index.css). vitest 95, e2e 148.
OFFEN als Follow-up: 7 Kategorie-Eintraege entfernen, PEW=REW-Konsolidierung, Backend-Re-Seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:34:17 +02:00
5c327cd8d8 GEN-4 REW erweitert: alle 3 C-Kombi + pp = REW (Julian bestätigt)
- cReduced(c) = cchm || ch; REW wenn cReduced(c0) && cReduced(c1) && pp
  (cchm/cchm, cchm/ch, ch/ch alle REW; mind. ein volles C = NICHT REW)
- Counterproof-Test: C/cchm + pp und C/ch + pp = nicht REW
- Frozen-Round-Trip-Test: PEW (ch/ch+pp) explizit ausgenommen (intentional
  shadow durch REW-Check per Julian-Regel; PEW bleibt Dropdown-Name)
Gate: build ✓  eslint ✓  vitest 95/95 ✓  e2e 148/148 ✓
2026-06-06 21:32:20 +02:00
b12b5d720d GEN-4: Dilute-Präfix, REW-Erkennung, CP-Fuchs-Fix, BreedingResultView Gencode+Layout
Katalog (catalog.ts):
- 8 'X dd'/'dd X' Einträge → 'Dilute X' (Agouti/Silberagouti/Kohlfuchs/Anthrazit/
  Topas/Blaufuchs dd + dd Gold/Platin → Dilute Gold/Platin etc.)
- 4 neue Dilute-Fuchs-Basiseinträge (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs)
  → verhindert naked 'Fuchs' Fallback für agouti+dilute+fox Genotypen. 66→70 Einträge.
- colourpointName: 'Dilute X' Base → 'Dilute CP-X' statt 'CP-Dilute X' (Präfix-Reihenfolge)
  → AA cchmcchm dd ee GG PP = 'Dilute CP-Algierfuchs' statt 'CP-Fuchs'.

Engine (farbschlagFor):
- REW-Check: c0==cchm && c1==cchm && p0==p && p1==p → 'REW' (Rotaugenweiß).
  Unabhängig von A/D/E/G. Flagged to god zur Bestätigung.

BreedingResultView:
- Gencode (bracket notation) pro Farbschlag-Karte (erster/wahrscheinlichster Genotyp).
- Layout-Fix: flexbox body (name+gencode gestapelt), min-width:0 gegen Overflow,
  word-break, grid min 15rem, prob flex-shrink:0 rechtsbündig.

Tests: GEN-4 describe (Dilute/REW/no-bare-Fuchs Fixtures); CATALOG_SIZE 66→70;
  CP-Fuchs/CP-Fuchs-Hell → Dilute CP-Algierfuchs(-Hell) in bestehenden Tests.
Gate: build ✓  eslint ✓  vitest 95/95 ✓  e2e 148/148 ✓
2026-06-06 21:27:32 +02:00
92dd614269 Merge feature/namegen-frontend (FEAT-NAMEGEN-FE UC-1): 'Name vorschlagen'-Button (Gemini, live)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
NameSuggestPanel + names.ts + GerbilForm-Button (Anfangsbuchstabe/Geschlecht/Kulturen → /names/suggest),
Vorschlag-Klick befuellt Namensfeld; 503 NamesKeyMissing-Hinweis. vitest 104, e2e 156. Frontend-only.
Namensgenerator UC-1 LIVE (Gemini-Key free-tier aktiv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:19:29 +02:00
097a04cbd8 FEAT-NAMEGEN UC-1: 'Name vorschlagen'-Panel auf GerbilFormPage
- api/names.ts: suggestNames() + buildSuggestPath() + NAMEGEN_USAGES (norn/japa/mythg/ger/arb)
- de.ts: namegen-Sektion (button, panelTitle, letter/usages/load/states)
- NameSuggestPanel.tsx: inline-Panel mit Buchstabe/Herkunftskultur-Multiselect, Vorschlagsliste; 503 NamesKeyMissing → freundlicher Hinweis
- GerbilFormPage: 'Name vorschlagen'-Button neben Namensfeld (htmlFor-Pattern), Panel schließt+befüllt bei Auswahl
- vitest: 11 Tests (buildSuggestPath URL-Logik + NAMEGEN_USAGES-Struktur)
- e2e: mock /names/suggest + namesConfigured-Flag; 4 Specs (Panel öffnen, Klick befüllt, Buchstabe-Filter, 503-Fallback)

Gate: vitest 104/104, e2e 156/156, tsc clean
2026-06-06 21:17:42 +02:00
b7ebfd3057 Merge feature/namegen-backend (FEAT-NAMEGEN-BE): GET /names/suggest via Gemini + LitterLetter
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 57s
NameSuggestionService (bestehender OpenAiChatClient/Gemini, Fence-Strip, [{name,meaning,origin}]),
NamesEndpoints (503 NamesKeyMissing / 502 NamesUpstreamError), Litter.LitterLetter + Migration AddLitterLetter.
157/157, drift clean (Dwight). has-pending via CI (lokaler Build durch laufende App gesperrt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:16:40 +02:00
d268bbc122 D6: Kazu E=ee[f] G=GG P=PP (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 57s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Aa Cc[chm] DD ee[f] GG PP Spsp (UwUw-Quelle = GG). D6 nur noch Hanami offen (1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:59:29 +02:00
a638cf2c52 FEAT-NAMEGEN: GET /names/suggest backend (Gemini, 503 NamesKeyMissing, LitterLetter)
- NameSuggestionService: Gemini via existing OpenAiChatClient, builds
  letter/gender/usages/count prompt, strips markdown fences from response,
  deserialises [{name,meaning,origin}] array; NotConfigured -> 503.
- GET /names/suggest?letter=&gender=&usages=&count= -> Ok<List<NameSuggestion>>
  | 503 {code:NamesKeyMissing} | 502 {code:NamesUpstreamError}.
- Litter.LitterLetter (string?, nullable) + AddLitterLetter migration.
- 17 new tests (prompt assembly, fence strip, parse edge cases,
  503-not-configured, upstream-error); total 157/157 green.
- No Behind-the-Name dependency — Gemini path only (Julian's decision).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:56:33 +02:00
efbf24ca39 Merge feature/ci-backend-fix: dotnet-ef auf $GITHUB_PATH (Backend-CI-Job gruen) [god, via god-qa-Worktree]
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
2026-06-06 20:54:47 +02:00
22e033b26a CI-BACKEND-FIX: dotnet-ef PATH nach global tool install exportieren
dotnet tool update --global installiert dotnet-ef nach ~/.dotnet/tools,
aber der Runner-Container hat dieses Verzeichnis nicht im PATH. Jeder
run:-Step startet eine neue Shell -> der naechste Step (dotnet ef ...)
findet 'dotnet-ef' nicht (exitcode 1, Job rot seit DB-3).

Fix: 'echo ... >> $GITHUB_PATH' nach dem tool install; Gitea/GitHub Actions
liest diese Datei zwischen Steps und persistiert den PATH-Eintrag fuer alle
Folge-Steps in diesem Job.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:51:10 +02:00
ee7f0aeab4 docs(HUMANQUESTION): A1+A2 erledigt — Gemini-Key live, KOSTENLOS via gemini-flash-latest
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 49s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
KI-Verkaufstexte + Charakterbogen live verifiziert (echter Text generiert). Kein Billing noetig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:49:53 +02:00
454f6e0d60 fix(ai): Default-Modell gemini-2.0-flash -> gemini-flash-latest (Free-Tier-fähig)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
gemini-2.0-flash hat auf Julians Konto KEIN freies Kontingent (429), gemini-flash-latest schon
(direkt verifiziert). Julian bleibt kostenlos. compose/.env.example/docs angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:48:05 +02:00
c652afbbc7 D6: Skarlett Sterbedatum = 17.04.2016 (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2018 war falsch (leakte als '/ +2018' in den Gencode; in IMPORT-POLISH gefixt). D6 noch offen: Hanami, Kazu (2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:45:30 +02:00
545db4132f D6: Vance Jr. Sp-Locus = spsp (ungescheckt, Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Failing after 52s
aa Cc[chm] Dd Ee gg P- spsp (c[hm]→c[chm] normalisiert). D6 noch offen: Hanami, Kazu, Skarlett (3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:44:24 +02:00
ddc4676f87 D6: Big Ben P-Locus = Pp (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION gestrichen
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Aa Cc[chm] DD Ee Gg Pp Spsp. Noch offen in D6: Hanami, Vance Jr., Kazu, Skarlett (4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:42:49 +02:00
083ffac47a chore(api): UserSecretsId fuer AI-Config (Gemini-Key via user-secrets, nie committed)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 49s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Nur die UserSecretsId-GUID — der Schluessel selbst liegt in %APPDATA% user-secrets, nicht im Repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:40:16 +02:00
b6178b630b fix(ci): dotnet-ef Tool-Install idempotent (tool update) — self-hosted Runner persistiert --global
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
'tool install --global' schlug ab dem 2. Lauf mit 'already installed' fehl (Tools persistieren auf
dem self-hosted Runner) → Backend-Job rot seit DB-3, noch VOR dem Drift-Check. 'tool update' ist
installiert-oder-aktualisiert = idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:03:18 +02:00
55d08a5dc3 fix(ci): DB-3 Drift-Check — -c Release war --context Release (DbContext-Name!), nicht Build-Config
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
dotnet ef interpretiert -c als --context, daher 'No DbContext named Release was found' →
Backend-Tests-Job rot seit DB-3. Korrekt: --configuration Release. Lokal verifiziert:
Release-Build + --no-build --configuration Release → 'No changes'. Tests 140/140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:58:52 +02:00
532cbf3bcf Merge feature/qa-smoke-2: Live-Smoke der heutigen Merges (18/18, keine Findings)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Bestätigt live gegen echte Daten: GEN-3h Bracket-Notation (ee[f]/c[chm]), Herkunft+Gehoerlos editierbar,
CR-2 Partial-PUT behaelt Felder, namenlose Platzhalter, Gehege-Redirect. Finale Pre-Uebergabe-Bestaetigung.
e2e-only (skipUnlessLive). B3-Korpus-IDs vollstaendig dokumentiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:53:12 +02:00
d1baba686f QA-SMOKE-2: Live-Smoke GEN-3h/FORM-FIELDS/CR-2/UI-POLISH/GEHEGE (18/18 gruen) 2026-06-06 19:51:45 +02:00
8b867b9058 Merge feature/review-fixes-2: DB-4 deutsche ICU-Collation + DB-5 Litter.ExternalRef
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Failing after 51s
- DB-4: UseCollation('de-x-icu') auf Gerbil.Name/NameSearch/OriginBreeder, ColorVariety.Name, Contact.Name
  (Npgsql-konditional; SQLite-Testhost lehnt unbekannte Collation ab). Migration AddGermanCollation.
- DB-5: Litter.ExternalRef + filtered unique index + Importer-Idempotenz (ExternalRef primaer, Name+Date Fallback).
  Migration AddLitterExternalRef. +1 Regressionstest.
Gate 140/140, has-pending=No.
[god-QA: backend-only, disjunkt; 2 Migrationen]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:50:44 +02:00
ea3596ca0d REVIEW-FIXES-2: DB-4 (Deutsche Collation ä/ö/ü) + DB-5 (Litter.ExternalRef)
DB-4 (medium): ICU German collation de-x-icu auf deutschen Textspalten
  Gerbil.Name, Gerbil.NameSearch, Gerbil.OriginBreeder, ColorVariety.Name,
  Contact.Name. Korrekte ä/ö/ü-Sortierung + locale-aware lower() in Postgres.
  Nur bei Npgsql (Database.ProviderName check) — SQLite-Testhost kennt keine
  eigenen Collation-Namen und wuerde EnsureCreated fail lassen.
  Migration AddGermanCollation (ALTER COLUMN ... TYPE text COLLATE de-x-icu).

DB-5 (low): Litter.ExternalRef + filtered unique index + Importer-Anbindung
  Litter.ExternalRef (string?, nullable) = stable import source id (sl.Id).
  Filtered unique index WHERE ExternalRef IS NOT NULL (wie DB-1 fuer Gerbil).
  Migration AddLitterExternalRef.
  Importer (ImportService.cs): setzt ExternalRef = sl.Id beim Litter-Insert;
  prueft existingLitterExtRefSet als PRIMAEREN Idempotenz-Key (ExternalRef),
  Name+Date-Key als Fallback fuer Wuerfe ohne ExternalRef aus frueheren Laeufen.
  +Test DB5_Litter_ExternalRef_set_and_used_for_idempotency (mutiert den Namen
  nach erstem Import, prueft dass Re-Import via ExternalRef erkennt).

GATE: 140/140 C#, has-pending=No.
2026-06-06 19:49:50 +02:00
e6b513ed5d Merge feature/genotype-notation (GEN-3h): Genotyp-Anzeige in Zuechterin-Schreibweise
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
CI / Backend Tests (.NET) (push) Failing after 49s
- Bracket-Notation im Display: e[f]/c[chm]/c[h] (Storage bleibt frozen symbols)
- E-Locus-Reihenfolge: e VOR e[f] ('ee[f]' nicht 'efe')
- CR-1a: ee[-] (Silvain) Parser-Fix (lookbehind)
- C zeigt jetzt 'aa C- D- ee[f] Gg Pp spsp rere'
vitest 92/92 (inkl. ee[-] + C/Zuleika/Milka-Orakel), e2e 148, build/eslint. Frontend-only (src/genetics/).
HINWEIS: generated.json canonicalGenotype jetzt Klammer-Notation = DISPLAY; Backend-Seed bleibt frozen (Matching).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:27:45 +02:00
52303b90b2 CR-1a: ee[-] Parser-Fix (Silvain) — lookbehind trennt e[-] vom führenden Allel
normalizeToken: lookbehind-Regex /(?<=[A-Za-z])e\[-\]/→'?' greift wenn e[-]
von einem Buchstaben (erstes Allel) gefolgt wird (ee[-] → e?); standalone e[-]
fällt durch auf die generische \[-\]→? Regel (→ e?). Beide Pfade liefern
splitToken 2 Allele [e,?]. Vorher: ee[-]→ee?→ 3 Allele → Fehler.
Fixture: Silvain 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp' → E=[e,?] ✓.
Gate: build ✓  eslint ✓  vitest 92/92 ✓  e2e 126/126 ✓
2026-06-06 18:24:06 +02:00
4b6a07544d GEN-3h: Breeder bracket-notation display + E-locus e-vor-e[f] Sortierung
- genotype.ts: toDisplayString zeigt ef→e[f], cchm→c[chm], ch→c[h] (Display-only;
  Storage-Contract unverändert). E-Locus Display-Rank E>e>ef: {ef,e} Paar
  rendert als ee[f] statt e[f]e (Züchterin-Konvention, Julian-Feedback).
- genotype.ts: normalizeToken akzeptiert Klammer-Eingabe (e[f], c[chm], c[h],
  [-]) → interne Symbole; vollständiger Round-Trip Display→Parse.
- genetics.test.ts: Katalog-Regex auf [A-Za-z[\]?-]+ erweitert; GEN-3h
  Notation-Fixtures (C/Zuleika/Milka-Orakel, Klammer-Round-Trip, E-Sortierung).
- colorVarietySeed.generated.json: neu generiert (66 Zeilen mit Klammer-Notation).
Gate: build ✓  eslint ✓  vitest 91/91 ✓  e2e 120/120 ✓
2026-06-06 18:24:06 +02:00
8f90821081 Merge feature/ci-drift-check (DB-3): CI EF-Migrations-Drift-Check
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
.gitea/workflows/ci.yml: nach Build zwei Steps (dotnet-ef install + has-pending-model-changes)
→ faengt Snapshot/Migrations-Drift im Runner (EnsureCreated-SQLite-Testhost sieht das nie).
Automatisiert die manuelle MERGE-GATE-Regel. Nur CI-Config, kein Code/Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:06:07 +02:00
9dda78b28a DB-3: EF Migrations Drift-Check in CI (has-pending-model-changes)
Nach dem Build-Schritt in test-backend: dotnet-ef 10.0.* global installieren,
dann `dotnet ef migrations has-pending-model-changes` ausführen. Schlaegt fehl
wenn Code-Änderungen an Entities/OnModelCreating keine passende Migration haben.
Fängt genau den Drift, den der SQLite-EnsureCreated-Testhost nicht sieht.
2026-06-06 18:05:15 +02:00
865b3831c8 Merge feature/ops-fixes: DataProtection-Key-Persistenz (AR-3) + prod-compose AI-Env (AR-4)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- AR-3: AddDataProtection().PersistKeysToFileSystem + compose keys-Volume → Gmail-App-Passwort
  ueberlebt Redeploy (war ephemer → Inbox waere still gebrochen). Dev-Fallback .data-protection-keys.
- AR-4: prod compose.yaml ANTHROPIC_API_KEY → AI__BaseUrl/ApiKey/Model + .env.example aktualisiert.
139/139, has-pending=No, kein Schema-Change. [god-QA: config/Program.cs only, disjunkt]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:03:20 +02:00
1b0d3286db OPS-FIXES-1: AR-3 Data Protection Key-Persistenz + AR-4 AI-Env-Korrekte
AR-3 (P1): PersistKeysToFileSystem + persistentes Volume
  Program.cs: AddDataProtection().PersistKeysToFileSystem(keyRingPath).SetApplicationName(GerbilManager)
  Pfad konfigurierbar via DataProtection:KeyRingPath (env DataProtection__KeyRingPath);
  Fallback = ContentRoot/.data-protection-keys (Aspire-Dev-ephemeral, ok).
  compose.yaml: DataProtection__KeyRingPath: /data/keys + Volume-Mount keys:/data/keys.
  Volumes: neues 'keys' Volume (Bind-Mount auf NAS-Dataset KEYS_PATH=/mnt/SSD/gerbil/keys).
  .gitignore: .data-protection-keys/ ignoriert (Dev-only ephemeral keys).
  Verhindert: Gmail-App-Passwort wird nach Image-Redeploy unlesbar (bisher stilles inbox-fail).

AR-4 (P1): compose.yaml + .env.example: AI__* statt ANTHROPIC_API_KEY
  compose.yaml: ANTHROPIC_API_KEY entfernt (Code liest es nicht). Korrekte Vars:
    AI__BaseUrl: ${AI__BaseUrl:-}
    AI__ApiKey: ${AI__ApiKey:-}
    AI__Model: ${AI__Model:-gemini-2.0-flash}
  .env.example: AI__BaseUrl/ApiKey/Model + KEYS_PATH hinzugefuegt; ANTHROPIC_API_KEY entfernt.
  Quelle: docs/ai-provider.md (war korrekt, compose war falsch).
  Verhindert: alle 4 KI-Features (Verkaufstext, Inbox-Entwurf) blieben in prod stumm.

GATE: 139/139 C#-Tests, build gruen (using Microsoft.AspNetCore.DataProtection; framework-included).
2026-06-06 18:01:57 +02:00
de1d9460b3 Merge feature/review-fixes-backend: CR-9/CR-10/CR-11 + DB-1 (ExternalRef unique)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 56s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- CR-9: Import-Lookup TryGetValue + ExternalRef-Fallback (kein throw bei name/DOB-Drift)
- CR-11: Farbschlag aus Genotyp ableiten (fill-NULL-only, nur vollstaendige Genotypen ≥8/kein ?? → keine False-Positives); Plan-Loop + Post-Sweep fuer Bestandstiere
- CR-10: conflict-decision validiert Genotyp-Override (ungueltig → skip+Warnung, Konflikt trotzdem geloest)
- DB-1: filtered unique index ExternalRef IS NOT NULL + Migration UniqueExternalRef
Gate 139/139 C# + python all pass, ef has-pending=No. KEIN Farbschlag-Overwrite (fill-NULL).
[god-QA: disjunkt zu frontend-Merges]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:55:55 +02:00
259dd96c0e Merge feature/ui-polish-2: '(ohne Namen)'-Platzhalter in Stammbaum + VertragWizard + WurfForm
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Namenlose Stub-Tiere zeigen jetzt ueberall '(ohne Namen)' statt leer: Flagship-Stammbaum
(Karten+Druck+Header), Vertrags-Assistent, Wurf-Formular. +2 e2e (namenloser Ahne im Stammbaum).
vitest 82, e2e 148, build clean. Kein EF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:54:33 +02:00
615abfa510 REVIEW-FIXES-BACKEND: CR-9 + CR-11 + CR-10 + DB-1
CR-9 (major): gidByNameDob TryGetValue + ExternalRef-Fallback
  Verhindert KeyNotFoundException wenn Name/DOB zwischen zwei Laeufen driftet
  (z.B. correctDob-Remap oder UI-Umbenennung). Fallback: ExternalRef-Dict-Lookup;
  bei Miss: sauberes Ueberspringen + Note statt 500. +Test CR9_NameDOB_drift.

CR-11 (major): Farbschlag aus Genotyp ableiten (fill-NULL-only)
  Deep-Band-Tiere (gen>=2, kein Farbschlag-Feld) landen nicht laenger mit null
  ColorVariety. GenotypePotentiallyMatches() vergleicht locus-pair-weise (??=wildcard,
  case-insensitive). Nur vollstaendige Genotypen (8 Loci, kein ??) loesen Ableitung aus.
  Plan-Loop: fuellt colorVarietyId bei null + vollstaendigem Genotyp.
  Post-Sweep: bestehende DB-Tiere mit null ColorVarietyId werden nachgefuellt.
  AnimalSummary.FarbschlagDerivedFromGenotype = Zaehler. +Test CR11_ColorVariety_from_geno.

CR-10 (major, Python): malformed Override-Genotyp wird nicht angewendet
  apply_conflict_decisions validiert mapped8locus nach gt.parse(). Leeres Ergebnis
  = Genotyp unveraendert + decisionWarning statt stillem Blanken. Konflikt wird
  trotzdem aufgeloest (Entscheidung gilt, nur Genotyp-Override ausgelassen).
  +5 Python-Tests (CR-10-Block in test_extract.py).

DB-1 (high): filtered unique index auf Gerbil.ExternalRef
  WHERE ExternalRef IS NOT NULL — verhindert doppelten Import bei Race-Conditions
  oder Lauf-Ueberschneidungen. Migration UniqueExternalRef. SQLite-Testhost:
  HasFilter() wird via EnsureCreated appliziert (SQLite unterstuetzt Partial-Indexes).
  PartialUpdateTests externalRef-Assertion auf NotNull geaendert (name-hash unique).

GATE: 139/139 C# + Python ALL PASS, ef has-pending=No.
2026-06-06 17:54:06 +02:00
69ffe58d6b UI-POLISH-2: '(ohne Namen)' Platzhalter in Stammbaum, Vertrag-Wizard, Wurf-Formular
- StammbaumPage: PedigreeCard-Name + PrintCell-Name + beide titleFor()-Aufrufe → g.name || nameless
- VertragWizardPage: wizard-pick__name + join(', ')-Tierliste → g.name || nameless
- WurfFormPage: fatherName/motherName in Prefill (nameById.get || nameless) + Picker-Auswahl (g.name || nameless)

e2e: neuer Test stammbaum.spec — namenloser Ahne zeigt Platzhalter in Karte + Titel

Gate: vitest 82/82, e2e 148/148, tsc clean
2026-06-06 17:53:24 +02:00
d7c0698c44 Merge feature/form-fields: Herkunft (originBreeder) + 'Gehoerlos' (isDeaf) im Tier-Formular editierbar
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 54s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilFormPage: originBreeder-Freitextfeld + isDeaf-Tristate-Select (Ja/Nein/Unbekannt),
round-trip laden/speichern. types.ts isDeaf, de.ts Labels. +4 e2e round-trip. Komponiert
mit CR-2 PATCH-Semantik (ausgelassene Felder bleiben). vitest 82, e2e 146, build clean. Kein EF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:47:57 +02:00
db49150206 FORM-FIELDS-1: originBreeder + isDeaf in GerbilFormPage
- types.ts: isDeaf?: boolean|null in Gerbil + CreateGerbil (UpdateGerbil erbt via Partial)
- de.ts: fields.originBreeder, fields.isDeaf; form.isDeafUnknown/Yes/No
- GerbilFormPage: originBreeder Freitext-Input (neben originContactId-Dropdown); isDeaf Tristate-Select (Unbekannt/Ja/Nein); round-trip load→display→save

e2e: 2 neue Tests (originBreeder Freitext + isDeaf Tristate round-trip)

Gate: vitest 82/82, e2e 146/146, tsc clean
Rebase auf Pams CR-2 folgt nach deren Merge.
2026-06-06 17:47:25 +02:00
f6a4025d71 Merge feature/fix-cr2-partial-update: P0 BLOCKER — PUT /gerbils PATCH-Semantik (kein Datenverlust mehr)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CR-2: Apply() behaelt ausgelassene Felder (?? g.X fuer alle); Name/Gender nullable;
ForSale-Toggle + Charakterbogen-Save + Edit-Form nullen keine anderen Felder mehr.
+4 Regressionstests (PartialUpdateTests.cs). Gate 137/137, ef has-pending=No, kein Schema-Change.
[god-QA: backend-only, disjunkt zu ui-polish]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:41:32 +02:00
8175b150f7 CR-2 FIX: PUT /gerbils PATCH-Semantik — partielle Updates zerstoeren keine Felder mehr
GerbilInput.Name + Gender auf nullable geaendert. Apply() wendet fuer alle Felder
?? g.X an (omittierte/null Felder behalten den DB-Wert). g.Name aus dem PUT-Handler
in Apply() verschoben. Verhindert stillen Datenverlust bei partiellen Frontend-Calls
(ForSale-Toggle schickt nur {status}, Charakterbogen-Save schickt nur {traits,note}).

+4 Regressionstests (PartialUpdateTests): ForSale-Toggle, Charakterbogen-Save,
EditForm-ohne-Import-Felder, minimaler POST — alle grueen (137/137 gesamt).
2026-06-06 17:40:15 +02:00
319aca7608 Merge feature/ui-polish: namenlose Tiere Platzhalter '(ohne Namen)' + Leerwert-'—'-Konsistenz
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 56s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- de.ts nameless + GerbilsPage/GerbilDetailPage/AnimalPicker zeigen '(ohne Namen)' statt leer
- causeOfDeath/notes via || null → Rows zeigen '—' konsistent (Herkunft war schon korrekt)
- +4 e2e (Platzhalter Liste+Detail x2 Viewports). vitest 82, e2e 130, build clean. Kein EF.
[god-QA: disjunkt zu qa-regression+import-backfill-Merges]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:15 +02:00
4d9e901139 Merge feature/import-backfill: toleranter KC-Matcher + Herkunft-Backfill (fill-NULL) + Farbschlag-Zaehler
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- extract.py: norm_zucht-Regex-Fix (trailing \b nach '.'), is_clan_zucht() — alle KC-Schreibvarianten → canon 'kleinechaote'
- ImportService: Herkunft-Backfill (OriginBreeder NULL+resident → aus Eltern, sonst 'Zucht der Kleinen Chaoten'); fixt C bei naechstem Import-execute; ueberschreibt nie non-null
- FarbschlagWouldRebackfill no-op-Zaehler (Overwrite-Pfad gated auf Julian A/B/C)
- Keine Schema-Aenderung. Gate 133/133 C# + 57/57 python, ef has-pending=No
[god-QA: disjunkt zu qa-regression-Merge; Herkunft-Backfill laeuft nur bei Import-execute = REIMPORT-3 supervised]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:58 +02:00
01781f23d6 Merge feature/qa-regression: e2e-Regressionsnetz fuer gehege/wurf-link/herkunft
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 53s
gehege-regression.spec.ts (12 Tests, 6x2 Viewports): /becken→/gehege Redirect (auch /neu),
Nav zeigt Gehege/kein Becken/kein Start, '/' → /rennmaeuse, 404-Link landet auf Tierliste.
WURF-LINK + Herkunft waren bereits in tiere.spec abgedeckt. e2e-only, kein EF.
[god-QA: 0 behind main, single new file]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:32:28 +02:00
55c298ad1f UI-POLISH-1: namenlose Tiere + Leerwert-Konsistenz
- de.ts: nameless = '(ohne Namen)' (neuer Platzhalter-String)
- GerbilsPage: g.name || t.nameless in Karten-Name + Checkbox-Aria
- GerbilDetailPage: g.name || nameless im h2-Header; g.causeOfDeath/notes via || null (leer-string-sicher wie Herkunft)
- AnimalPicker: g.name || nameless im Picker-Button

e2e: +1 nameless-stub in mock-data; 2 neue Tests (Liste + Detail-Header)

Gate: vitest 82/82, e2e 130/130, tsc clean
2026-06-06 17:32:26 +02:00
e1e088f041 QA-REGRESSION-1: GEHEGE-RENAME Regressions-Netz (Redirect /becken→/gehege, Nav-Negativ, Landing /rennmaeuse) 2026-06-06 17:30:31 +02:00
358e30db1f IMPORT-BACKFILL: toleranter KC-Matcher + Herkunft-Backfill + FarbschlagWouldRebackfill-Zaehler
1) TOLERANTER KC-MATCHER (extract.py norm_zucht): trailing \b nach '.' greift nicht
   zwischen two non-word-chars (z.B. nach '.' vor ' '). Fix: r\bv\.\s?d\. (kein \b
   am Ende). Alle Schreibvarianten ('Zucht der Kleinen Chaoten', 'kleinen Chaoten',
   'v.d. Kleinen Chaoten', '[ZdkC]') -> canon 'kleinechaote'. +is_clan_zucht() helper.
   +11 Python-Tests (Regression + neue Varianten). FIX-1-Entscheidungs-Matching
   unberuehrt (canon_pair loest v.d. bereits via split_name_zucht).

2) HERKUNFT-BACKFILL (ImportService.cs, fill-NULL-only): POST-Sweep ueber alle
   DB-Tiere mit OriginBreeder==null && IsResident==true. Leitet OriginBreeder aus
   dem Elterntier (FatherId/MotherId -> OriginBreeder) ab; Fallback 'Zucht der
   Kleinen Chaoten'. NIEMALS ueberschreibt nicht-leeren OriginBreeder. Trocken-Lauf
   zaehlt HerkunftBackfilled; Execute schreibt. Fixt Tier 'C' (OriginBreeder null).

3) FARBSCHLAG-WOULD-REBACKFILL (no-op Zaehler): zaehlt bereits-importierte Tiere,
   bei denen der aktuelle Extraktor einen anderen Farbschlag liefert als in der DB.
   Kein Overwrite (Sicherheitsmechanismus ausstehend auf god/Julian-Freigabe);
   Optionen A/B/C an god gemeldet, Empfehlung: Option B (Timestamp-basiert).

4) ResidencySummary um HerkunftBackfilled + FarbschlagWouldRebackfill erweitert.
   ImportReport-Notizen fuer beide neuen Zaehler.

GATE: 133/133 C#-Tests, 57 Python-Tests, ef has-pending=No.
2026-06-06 17:29:02 +02:00
0b9b03eb2d Merge feature/wurf-link: Wurf auf Tier-Detailseite als Hyperlink
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 9s
GerbilDetailPage: Wurf-Feld → <Link to=/wuerfe/{litterId}> wenn litterId+Name aufloesbar,
sonst plain '—' (kein toter Link). +e2e tiere.spec (Link klickbar, navigiert zur Wurf-Detail).
Frontend-only, kein EF. [god-QA: disjunkt zu gehege-rename]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:01:26 +02:00
d4c9be23c8 Merge feature/gehege-rename: Becken→Gehege + Terrarium-Icon + Start-Nav raus, /rennmaeuse = Landing
- UI-Begriff 'Becken' → 'Gehege' (de.ts, Becken-Pages, Abgabe/Hilfe), Route /becken→/gehege+Redirect
- Nav-Icon Badewanne → Terrarium; 'Start'-Nav-Eintrag entfernt
- Index-Route '/' → Navigate /rennmaeuse (Landing Page), HomePage.tsx geloescht
- e2e navigation/becken-kontakte angepasst
Frontend-only, kein EF. [god-QA: disjunkt zu main-Commits, Home-Assertions nur in navigation.spec (selbst gefixt), live-data.spec referenziert kein '/']

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:01:09 +02:00
f6bfc0ebab WURF-LINK-Addendum: Herkunft-Zeile zeigt originBreeder + Kontaktlink
- originContactId gesetzt → Link /kontakte/{id} mit Kontaktname (Priorität)
- Sonst originBreeder (Freitext) gesetzt → plain text
- Sonst '—' (bisheriges Fallback)
Behebt: 74 Tiere mit originBreeder sahen '—' obwohl Daten vorhanden

e2e: 2 neue Tests (originBreeder-Text + Kontaktlink)

Gate: vitest 82/82, e2e 126/126, build+tsc clean
2026-06-06 17:00:32 +02:00
3160b66697 WURF-LINK: Wurf-Feld auf Tier-Detailseite als Link zur Wurf-Detailseite (/wuerfe/{id})
- GerbilDetailPage: Wurf-Feld zeigt Link wenn litterId + Name auflösbar; sonst bisheriger '—'-Fallback (kein toter Link)
- e2e: neuer Test verifiziert Link sichtbar + Navigation zu Wurf-Detail

Gate: vitest 82/82, e2e 122/122, build+tsc clean
2026-06-06 16:56:54 +02:00
39767cef0f Merge feature/qa-livedata: QA-LIVE-DATA Live-Test-Suite (50/50 grün)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 51s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
live-data.spec.ts (neu, 44 Tests: 22 Phone 390px + 22 Desktop) — tiefer QA-Lauf
gegen echte 325 Tiere/865 Wuerfe; 0 JS-Errors, 0 Page-Overflow, app uebergabebereit.
M-1+M-2 Fix: deterministischer Wurf-ID-Link statt nth, fixtures mainNavigation strict.
Test-only (e2e), kein App-/Backend-/EF-Code. [god-QA: Branch direkt vor main, M-2-aria-label verifiziert]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:50:45 +02:00
162a0fafb1 GEHEGE-RENAME Addendum: /rennmaeuse = Landing Page, 'Start'-Nav entfernt
- AppShell.tsx: Start-Eintrag (/) aus PRIMARY entfernt; /rennmaeuse ist
  nun erste Kachel und Landing Page
- App.tsx: index-Route → <Navigate to="/rennmaeuse" replace>;
  HomePage-Import entfernt
- HomePage.tsx: gelöscht (nicht mehr referenziert, via git wiederherstellbar)
- de.ts: nav.home + pages.home entfernt (nur von AppShell/HomePage genutzt)
- e2e/navigation.spec.ts: HomePage-Assertions → de.pages.gerbils.title
Gate: build ✓  eslint ✓  vitest 82/82 ✓  e2e 120/120 ✓
2026-06-06 16:49:21 +02:00
c26ac5c6dd QA-LIVE-DATA: Fix M-1+M-2 — fixtures.ts mainNavigation strict, live.spec.ts deterministischer Wurf-ID-Link 2026-06-06 16:46:12 +02:00
2bbdfe2b71 GEHEGE-RENAME: UI-Begriff Becken → Gehege + Route /gehege + Icon 🏜️
- de.ts: alle nutzer-sichtbaren 'Becken'-Strings → 'Gehege' (nav, fields,
  titles, buttons, validation, delete, hilfe sections)
- AppShell.tsx: route /becken → /gehege, icon 🛁🏜️ (Wüsten-Habitat)
- App.tsx: <Route path="gehege"> + <Route path="becken/*"> redirect (bewahrt
  Bookmarks; BeckenRedirect leitet Unterseiten 1:1 weiter)
- BeckenPage/DetailPage/FormPage: interne Links /becken → /gehege
- HilfePage.tsx: hartcodierte 'Becken'-Prosa → 'Gehege'
- e2e/becken-kontakte.spec.ts: goto('/becken') → goto('/gehege')
Gate: build ✓  eslint ✓  vitest 82/82 ✓  e2e 120/120 ✓
2026-06-06 16:45:29 +02:00
2c3f342905 QA-LIVE-DATA: Playwright live-data suite (44 Tests, 22 Phone + 22 Desktop, alle gruen) 2026-06-06 16:38:51 +02:00
b165916dfb docs(HUMANQUESTION): Zahlen auf Re-Import #2.5 aktualisiert (325 Tiere/865 Wuerfe/138 Fotos); Enya/Ella/Zac geladen -> nur noch D6 (5 Konflikte) offen
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
CI / Backend Tests (.NET) (push) Successful in 54s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:19:40 +02:00
797b38df34 docs(HUMANQUESTION): A5b erledigt - Gitea Actions LIVE (Runner aktiv, Tests gruen); neu A5b2: REGISTRY_USER/REGISTRY_TOKEN Secrets fuer Docker-Push-Job
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
2026-06-06 16:05:03 +02:00
c34d63e0fd Merge feature/web-6a: Oeffentliche Webseite launch-ready (mobile-first Design, Abgabetiere-Gruppenkarten, Grundseiten-Seeds) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 16:01:27 +02:00
a2bdf9626c Merge feature/ux-mobile-2: Genotyp-Tabellen horizontal scrollbar (overflow-x-Wrapper + nowrap) + app-main overflow-x:hidden gegen Page-Overflow [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 54s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 10s
2026-06-06 15:43:43 +02:00
0d4b560df8 Merge feature/seed-hell: Backend-Seed 61->66 append-only (IDs 62-66, bestehende ID->Name-Bindung unveraendert, Drift-Regressionstest) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:42:36 +02:00
d116b8d8f1 UX-MOBILE-2: Genotyp-Tabellen-Overflow behoben (scrollbar statt page-overflow)
- BreedingResultView: <table class="genotype-table"> in <div class="genotype-table-scroll"> gewrapped.
- index.css: .genotype-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch }.
  .genotype-table td { white-space: nowrap } → Tabelle scrollt statt Zellen umzubrechen.
  width: 100% entfernt (Tabelle auto-sized to content).
- index.css: .app-main { overflow-x: hidden } → verhindert, dass breite Inhalte
  den Page-Body horizontal überlaufen lassen (document.scrollWidth == clientWidth).
- e2e/genotype-table.spec.ts (4 Tests): Wrapper sichtbar, Wrapper selbst scrollbar
  (scrollWidth > clientWidth), keine Page-Overflow auf 390px. Beide Viewports.
- Vitest 78 / e2e 106 grün.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:41:59 +02:00
8dfad32918 SEED-HELL v2: 5 CP-*-Hell ans Ende haengen (IDs 62-66, kein ID-Name-Drift)
Erster Ansatz (Rename-in-Place-Shift) wuerde 17 Hand-zugewiesene
Gerbil.ColorVarietyId-FKs still verfaelschen (1 Tier auf CP-Fuchs live
bestaetigt). Stattdessen: 5 neue Eintraege ans ENDE gehaengt (IDs 62-66),
die 61 bestehenden Eintraege unveraendert.

Migration ReseedColorVarietiesGen3g: NUR 5 InsertData (IDs 62-66),
kein UpdateData, kein DeleteData, keine FK-Drift moeglich.

Neuer Test SeedGen3g_existing_varieties_preserve_id_name_binding:
asserts CP-Fuchs (ID 58) bleibt CP-Fuchs, alle 5 neuen IDs 62-66
haben korrekte Namen, Gesamtzahl = 66.

Gate: 127/127 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:40:57 +02:00
1ee176a24a SEED-HELL: Backend-ColorVariety-Seed 61->66 (GEN-3g CP-*-Hell)
Kevin GEN-3g fuegt 5 CP-*-Hell-Varianten ein (hell = cchmch):
CP-Agouti-Hell, CP-Silberagouti-Hell, CP-Algierfuchs-Hell,
CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.

Eintraege 0-53 (PEW..CP-Agouti) unveraendert. Eintraege 54-61 werden
durch UpdateData in den bestehenden IDs 55-61 umgeschrieben (EF-Rename-
in-Place-Shift): CP-Silberagouti/Algierfuchs/Polarfuchs/Fuchs/Fuchs-Hell/
Blaufuchs/Orangeschimmel ruecken um +1 bis +4 Positionen. 5 echte Neu-
Eintraege als InsertData in IDs 62-66.

Migration: 7 UpdateData + 5 InsertData, keine DeleteData, kein Datenverlust
ausser theoretischer Gerbil.ColorVarietyId-FK-Drift fuer die CP-Varianten
(ON DELETE SET NULL + Re-Import re-matcht per Name).

Gate: 126/126 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:36:16 +02:00
a2f5e848d1 Merge feature/qa-2: Verbatim-few-shots (POLISH-2), CI-Flake-Fix, skipUnlessLive+Live-Smoke, Abgabe-Wizard-e2e [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:34:38 +02:00
de293904bb Merge feature/ux-mobile-1: FilterPanel (mobil: Suche sichtbar, Filter im Drawer mit Aktiv-Badge; Desktop inline) auf rennmaeuse/wuerfe/anfragen + e2e phone-coverage [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:31:24 +02:00
68447896ee QA-2: Fix smart-quote raw-string delimiters in SaleAdPromptBuilder (POLISH-2 BOUNCE) 2026-06-06 15:30:46 +02:00
5f26594a12 Merge feature/gen-3g: 'hell'-Regel (cchm/ch = -Hell-Variante, cchm/cchm = Basis) + CP-Fuchs-Sammelbegriff ratifiziert, Katalog 61->66 (5 -Hell-Restaurationen), Seed regeneriert [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:29:26 +02:00
6eb82da90c UX-MOBILE-1: FilterPanel — einklappbare Filter auf Mobil (390px)
Neues FilterPanel-Muster (src/components/FilterPanel.tsx + filterPanel.css):
- Mobil (<768px): Suchfeld immer sichtbar; alle weiteren Filter hinter einem
  'Filter (N)'-Button eingeklappt (Badge = Anzahl aktiver Nicht-Default-Filter).
  Tippen öffnet/schließt den Drawer. 'Filter zurücksetzen' im Drawer.
- Desktop (>=768px): Toggle versteckt, Drawer als display:contents → alle Controls
  inline in der bestehenden .filters-Flexzeile, wie bisher.
Auf GerbilsPage, WuerfeListPage und AnfragenPage ausgerollt.
BeckenPage/KontaktePage nur ein Suchfeld → kein Panel nötig.

e2e: filter-panel.spec.ts (8 Tests, phone+desktop); bestand/tiere/anfragen-Specs
erhalten openFilterPanel-Aufruf vor Drawer-Controls; fixtures.ts +openFilterPanel().
Vitest 78 / e2e 102 grün.

Mobil-Pass (390px, Durchsicht): Hauptproblem behoben. Beobachtete Restpunkte
für god (nicht selbst gefixed):
- .genotype-table in Genetik/Probeverpaarung: Genotyp-Strings können eng werden
- Stammbaum (react-d3-tree SVG): kein explizites mobile Viewport-Handling
- BreedingResultView Genotyp-Detail-Tabelle: ggf. horizontal scrollbar-los

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:28:13 +02:00
0a1e63bec6 Merge feature/import-counter: 31 undatierte Wurfchronik-Eintraege ehrlich als WithoutDate gezaehlt statt als Phantom-created (+latente litterIdMap-FK-Falle entschaerft) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:25:51 +02:00
5b30407257 IMPORT-COUNTER-BUG: undatierte Wuerfe als WithoutDate zaehlen, nicht als Created
31 undatierte Wurfchronik-Eintraege (leeres Datumsfeld) wurden bei jedem Lauf
als littersCreated++ gezaehlt, weil ihr Idempotenz-Key (kein Datum) nie in
existingLitterKeySet stand -- aber kein DB-Insert folgte, da date is DateOnly d
false war. Das verfaelschte Arithmetik und Julians Bericht (752 vs 723).

Fix: undatierte Eintraege werden am Schleifenanfang uebersprungen
(littersWithoutDate++, continue) bevor sie in litterIdMap oder den
Created-Zaehler einfliessen. Execute-Block ohne redundante date-Pruefung.
Neues Feld LitterSummary.WithoutDate; Report-Notiz wenn WithoutDate > 0.

Gate: 126/126 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:24:55 +02:00
c357a18418 QA-2: Fix Abgabe-e2e Selektoren + ForSale in enc-leer (no Becken-Konflikt) + charakter scoped 2026-06-06 15:22:34 +02:00
791e9f91cc QA-2: Abgabe-Wizard e2e (ForSale-Seed, sale-ad+contracts Mock, abgabe.spec.ts) 2026-06-06 15:16:38 +02:00
efce79b3fa GEN-3g: CP colourpoint het-naming rule — '-Hell' == cchm/ch (breeder C9 answer)
- colourpointName(): A- cchm/ch now yields 'CP-<base>-Hell' (was 'CP-<base>');
  A- cchm/cchm unchanged. Non-agouti branch (Marder/Siam/Zobel/Zobel-Hell)
  unchanged — already used the het token.
- Added 5 new catalog entries: CP-Agouti-Hell, CP-Silberagouti-Hell,
  CP-Algierfuchs-Hell, CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.
  CP-Fuchs-Hell was already present. Catalog 61 -> 66.
- Regenerated colorVarietySeed.generated.json (66 rows for Pam DATA-2 re-seed).
- Tests: 82 -> 82 (reworded het case) + 1 new GEN-3g describe block. All pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:14:23 +02:00
868c5f5cd7 QA-2: skipUnlessLive() + Live-Smoke (Liste>=10, Suche Treffer, Eltern-Links) 2026-06-06 15:11:22 +02:00
ddfa3614a4 QA-2: CI-Flake fix — explizite Heading-Waits nach gotoSection in 'Kontakt anlegen' 2026-06-06 15:09:15 +02:00
13da8f2502 QA-2: POLISH-2 — verbatim few-shots von kleine-chaoten.jimdofree.com (Gruppe 1+4) 2026-06-06 15:08:56 +02:00
13eb17b453 docs(HUMANQUESTION): C6-Status praezisiert - 27 von 32 Konflikten geklaert, nur D6 (5 Tiere) offen; review-report nach IMPORT-POLISH-Re-Extract (Konflikte 5)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 51s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-06 15:06:20 +02:00
f20a42a94b SEARCH-1: tests — Normalize unit + separator-insensitive nameSearch filter + OriginBreeder/breeders endpoint (SQLite in-memory round-trip) 2026-06-06 09:04:20 +02:00
232 changed files with 49660 additions and 1688 deletions

View File

@@ -9,10 +9,10 @@
# 2. npm test + npm run build (Frontend)
# 3. Docker-Images bauen und in die Gitea-Registry pushen
#
# Registry: truenas:13000 (internes Gitea Container Registry)
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
# Images:
# truenas:13000/gulum/gerbilmanager-api:latest
# truenas:13000/gulum/gerbilmanager-frontend:latest
# git.rismer.de/gulum/gerbilmanager-api:latest
# git.rismer.de/gulum/gerbilmanager-frontend:latest
name: CI
@@ -25,7 +25,7 @@ on:
- main
env:
REGISTRY: truenas:13000
REGISTRY: git.rismer.de
REGISTRY_OWNER: gulum
DOTNET_VERSION: "10.0.x"
NODE_VERSION: "22"
@@ -48,6 +48,26 @@ jobs:
- name: Build
run: dotnet build GerbilManager.slnx --no-restore -c Release
- name: dotnet-ef Tool installieren
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
run: |
dotnet tool update --global dotnet-ef --version 10.0.*
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: DB-3 EF Migrations Drift-Check
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
# entities/OnModelCreating without generating a matching migration). Catches exactly
# the class of drift the SQLite/EnsureCreated test host is blind to.
run: >
dotnet ef migrations has-pending-model-changes
--project GerbilManagerWebAPI
--startup-project GerbilManagerWebAPI
--no-build --configuration Release
- name: Tests ausfuehren
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"

12
.gitignore vendored
View File

@@ -131,6 +131,12 @@ $RECYCLE.BIN/
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
# Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/
# Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
GerbilManagerWebAPI/.data-protection-keys/
# node_modules — never commit, installed locally per npm install
node_modules/

View File

@@ -0,0 +1,103 @@
using GerbilManagerWebAPI.Endpoints;
namespace GerbilManager.Tests;
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
public class CmsPublishTests
{
private static string TempRoot() =>
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
[Fact]
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
{
var root = TempRoot();
try
{
var files = new Dictionary<string, string>
{
["index.html"] = "<html>start</html>",
["kontakt/index.html"] = "<html>kontakt</html>",
["assets/site.css"] = "body {}",
};
await CmsEndpoints.PublishToDirectoryAsync(files, root);
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
Assert.Equal("<html>start</html>",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
Assert.Equal("version-2", content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "x" }, root);
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_mehrfach_ohne_Fehler()
{
var root = TempRoot();
try
{
for (int i = 1; i <= 3; i++)
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
}
Assert.Equal("v3",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
{
var root = TempRoot();
try
{
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = german }, root);
var content = await File.ReadAllTextAsync(
Path.Combine(root, "live", "index.html"),
System.Text.Encoding.UTF8);
Assert.Equal(german, content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}

View File

@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
// a non-ForSale gerbil must NOT appear
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()

View File

@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
Id = Guid.NewGuid(),
Name = "Krümel",
Gender = Gender.female,
Status = GerbilStatus.Active,
Status = GerbilStatus.Breeding,
DateOfBirth = new DateOnly(2025, 3, 12),
LitterId = litter.Id,
EnclosureId = enclosure.Id,
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
var row = lines[1];
Assert.Contains("Krümel", row);
Assert.Contains("Weiblich", row);
Assert.Contains("Aktiv", row);
Assert.Contains("Zucht", row);
Assert.Contains("12.03.2025", row);
Assert.Contains("Großbecken", row);
Assert.Contains("Wurf K", row);

View File

@@ -0,0 +1,181 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests;
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
/// - Validation: an empty message is rejected with 400.
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
/// </summary>
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Post_feedback_persists_row_with_debug_context()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var resp = await client.PostAsJsonAsync("/feedback", new
{
message = "Der Stammbaum zeigt den falschen Vater.",
context = "stammbaum",
gerbilId,
litterId = (Guid?)null,
entityName = "Krümel",
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
clientTimestamp = "2026-06-22T12:00:00Z",
});
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
// GET returns it (newest first)
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("entityName").GetString() == "Krümel"
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater.");
}
[Fact]
public async Task Post_feedback_rejects_empty_message()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Feedback_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
},
Litters = new[]
{
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
},
Gerbils = new[]
{
Animal(fatherId, "Papa", "male", contactId),
Animal(motherId, "Mama", "female", contactId),
},
GerbilPhotos = Array.Empty<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// A feedback report referencing the gerbil + litter that the wipe will delete.
var feedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = feedbackId,
Message = "Bitte prüfen.",
Context = "gerbil-detail",
GerbilId = fatherId,
LitterId = litterId,
EntityName = "Papa",
Url = "http://localhost/rennmaeuse/papa",
CreatedAt = DateTimeOffset.UtcNow,
});
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
// so it must survive the contact-table wipe just like gerbil/litter ids.
var contactFeedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = contactFeedbackId,
Message = "Adresse stimmt nicht.",
Context = "contact-detail",
ContactId = contactId,
EntityName = "Test Breeder",
Url = "http://localhost/kontakte/test-breeder",
CreatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
.Build();
// Run the ingest wipe + reload.
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
Assert.Equal(litterId, survivor.LitterId);
Assert.Equal("Papa", survivor.EntityName);
// The contact-scoped report also survives the contacts wipe (loose ContactId).
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
Assert.Equal(contactId, contactSurvivor.ContactId);
Assert.Equal("contact-detail", contactSurvivor.Context);
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
Assert.Equal(2, await db.Feedback.CountAsync());
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
{
Id = id,
Name = name,
Gender = gender,
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-" + name,
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = name.ToLowerInvariant(),
CharacterTraits = Array.Empty<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}

View File

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using Microsoft.Extensions.DependencyInjection;
namespace GerbilManager.Tests;
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
private readonly ApiFactory _factory;
public GerbilProfilePhotoTests(ApiFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
private static Guid ExtractId(string json) =>
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
private static string? GetStr(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
private async Task<Guid> CreateGerbil(string name)
{
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync());
}
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
foreach (var (fn, so) in photos)
db.GerbilPhotos.Add(new GerbilPhoto
{
Id = Guid.NewGuid(), GerbilId = gerbilId,
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
});
db.SaveChanges();
}
[Fact]
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
{
var id = await CreateGerbil("NoPhoto");
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
{
var id = await CreateGerbil("PhotoGerbil");
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
}
[Fact]
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
{
var id = await CreateGerbil("ListPhoto");
InsertPhotos(id, ("list-photo.jpg", 0));
var json = await _client.GetStringAsync("/gerbils");
var root = JsonDocument.Parse(json).RootElement;
var items = root.GetProperty("items").EnumerateArray().ToList();
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
Assert.NotEqual(default, gerbil);
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
}
}

View File

@@ -0,0 +1,58 @@
using System.Net;
using System.Net.Http.Json;
using GerbilManagerWebAPI.Models;
namespace GerbilManager.Tests;
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
public class GerbilSearchTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
JsonContent.Create(new
{
name,
gender = "female",
originBreeder,
});
[Theory]
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
Assert.Equal(expected, GerbilSearch.Normalize(input));
[Fact]
public async Task NameSearch_filter_matches_across_separators()
{
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
// The client normalizes the user's term the same way the column is normalized.
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
Assert.Contains("Clan-Kleine-Chaoten", json);
// a term that doesn't normalize-match must NOT return it
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
}
[Fact]
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
{
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
// distinct-values dropdown source
var breeders = await _client.GetStringAsync("/gerbils/breeders");
Assert.Contains("Zucht der kleinen Chaoten", breeders);
// Gridify filter on the field
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
Assert.Contains("Herkunftstier", filtered);
}
}

View File

@@ -0,0 +1,122 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
namespace GerbilManager.Tests;
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
public class GerbilStatusTests
{
private static readonly DateOnly Today = new(2026, 6, 7);
// 1) DateOfDeath → Deceased, always overrides user input
[Fact]
public void DateOfDeath_set_returns_Deceased()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// DateOfDeath wins even when also Abgegeben
[Fact]
public void DateOfDeath_wins_over_Abgegeben()
{
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// 2) Abgegeben without death date → GivenAway
[Fact]
public void ReceiverContact_set_returns_GivenAway()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
[Fact]
public void OlderThan6y_without_death_returns_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
[Fact]
public void ExactlyMaxAge_is_not_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
// (threshold = "older than" so >= means Deceased)
Assert.Equal(GerbilStatus.Deceased, status);
}
// One day before threshold is alive
[Fact]
public void OneDayBeforeMaxAge_is_alive()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
[Fact]
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
{
var dob = Today.AddYears(-7);
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 4) Default → Breeding
[Fact]
public void No_special_condition_returns_Breeding()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 4a) User sets Pet → kept
[Fact]
public void User_Pet_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Pet, status);
}
// 4b) User sets ForSale → kept
[Fact]
public void User_ForSale_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.ForSale, status);
}
// DateOfBirth null → age rule does not fire
[Fact]
public void Null_DateOfBirth_does_not_trigger_age_rule()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
}

View File

@@ -0,0 +1,343 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
/// </summary>
public class ImportDocxServiceTests : IDisposable
{
private readonly string _dir;
public ImportDocxServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
// One open connection keeps the :memory: SQLite DB alive across calls.
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
{
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
return (db, conn);
}
private void WriteLitters(object litters) =>
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
System.Text.Json.JsonSerializer.Serialize(litters));
private void WriteAnimals(object animals) =>
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
System.Text.Json.JsonSerializer.Serialize(animals));
// ── Test 1: dry-run shows correct counts without writing ─────────────────
[Fact]
public async Task DryRun_counts_new_animal_without_writing()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
Assert.False(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
Assert.Equal(1, report.GoHomeFilled);
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
}
}
// ── Test 2: execute creates animal with all fields + FK constraints ───────
[Fact]
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, report.ContactsCreated);
var gerbil = await db.Gerbils.SingleAsync();
Assert.Equal("Pepper", gerbil.Name);
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
Assert.Equal(Gender.female, gerbil.Gender);
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
Assert.False(gerbil.IsResident);
Assert.Equal("docx", gerbil.ImportSource);
Assert.StartsWith("docx-", gerbil.ExternalRef);
var contact = await db.Contacts.SingleAsync();
Assert.Equal("Max Mustermann", contact.Name);
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
}
}
// ── Test 3: idempotency — second run creates zero ────────────────────────
[Fact]
public async Task Execute_is_idempotent_second_run_creates_zero()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
owner = "", abgabeDate = "01.05.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(1, first.Created);
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
}
}
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
[Fact]
public async Task Execute_enriches_existing_animal_does_not_duplicate()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
var existing = new Gerbil
{
Id = Guid.NewGuid(),
Name = "Eddie",
DateOfBirth = new DateOnly(2022, 8, 10),
Gender = Gender.male,
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
IsResident = false,
};
db.Gerbils.Add(existing);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
// Same name+dob → enrich, not create
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, report.Created);
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
// GoHomeDate was enriched
var updated = await db.Gerbils.SingleAsync();
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
}
}
// ── Test 5: Deceased status when deathDate set ───────────────────────────
[Fact]
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
owner = "", abgabeDate = "",
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
});
await new ImportDocxService(db, _dir).RunAsync(execute: true);
var gerbil = await db.Gerbils.SingleAsync();
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
Assert.Equal("Tumor", gerbil.CauseOfDeath);
}
}
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
[Fact]
public async Task Execute_works_under_retrying_execution_strategy()
{
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
// OnFirstExecution() at the start of ExecuteAsync, which throws
// InvalidOperationException when it detects a user-initiated transaction
// that was NOT opened through the strategy. This test wires the same check
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
// without a live Npgsql instance.
//
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
// → OnFirstExecution sees active user tx → InvalidOperationException
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
// → OnFirstExecution: no tx yet → OK
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseSqlite(conn)
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
owner = "Retry Adopter", abgabeDate = "01.03.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.Contacts.CountAsync());
}
}
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
[Fact]
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
{
// P0 regression: two animals in the same litter with the same normalized name
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
// this test an authoritative regression gate.
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Eva Müller", abgabeDate = "10.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
// Identical name+wsCode+litterDob → base ExternalRef collision
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Lena Braun", abgabeDate = "11.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
});
// Must NOT throw unique-key violation
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(2, report.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
// Both start with the docx- prefix
Assert.All(refs, r => Assert.StartsWith("docx-", r));
// Must be distinct (unique index enforces this in SQLite)
Assert.Equal(2, refs.Distinct().Count());
// Second occurrence gets the -2 suffix
Assert.Single(refs, r => r.EndsWith("-2"));
// Idempotent re-run: zero new, still 2 in DB
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
}
}
}
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
/// <summary>
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
/// OnFirstExecution() throws when it detects a user-initiated transaction
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
/// </summary>
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
{
protected override bool ShouldRetryOn(Exception exception) => false;
}
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
: IExecutionStrategyFactory
{
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
}
}

View File

@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
Assert.Equal(2, await db.Litters.CountAsync());
}
[Fact]
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
{
// DB-5: litters must have ExternalRef set to the source litter id on first import,
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
using var db = NewDb();
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
// ExternalRef is set on created litters
var litters = await db.Litters.ToListAsync();
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
Assert.Contains(litters, l => l.ExternalRef == "L1");
Assert.Contains(litters, l => l.ExternalRef == "L2");
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
foreach (var l in litters) l.Name = "Geänderter Name";
await db.SaveChangesAsync();
// Re-import: litters detected as existing via ExternalRef even though Name changed
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Litters.Created);
Assert.Equal(2, await db.Litters.CountAsync());
}
[Fact]
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
{
@@ -236,6 +261,54 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
{
// Regression for "Unable to save changes because a circular dependency was detected":
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
// Triggered when a gerbil is both parent (in one synth litter) and offspring
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
// must NOT throw InvalidOperationException (circular dependency)
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, await db.Gerbils.CountAsync());
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
// both should be litter-linked
Assert.NotNull(alpha.LitterId);
Assert.NotNull(beta.LitterId);
// deferred FK update must have set the litter parents correctly
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
Assert.Equal(beta.Id, alphaLitter.MotherId);
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
Assert.Equal(alpha.Id, betaLitter.MotherId);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
{
@@ -507,6 +580,75 @@ namespace GerbilManager.Tests
}
}
[Fact]
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
{
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
// One dated litter, one undated litter (blank date field)
File.WriteAllText(Path.Combine(dir, "litters.json"), """
[
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
]
""");
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
using var db = NewDb();
// First run
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, r1.Litters.InSource);
Assert.Equal(1, r1.Litters.Created); // only the dated one
Assert.Equal(0, r1.Litters.AlreadyImported);
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
// Second run (re-import): dated litter is now existing, undated still WithoutDate
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
Assert.Equal(1, r2.Litters.AlreadyImported);
Assert.Equal(1, r2.Litters.WithoutDate);
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
{
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
Assert.NotNull(cpFuchs);
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
// Existing CP-*-Hell entries (IDs 62-66) unchanged
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
// 4 new Dilute Fuchs varieties appended (IDs 67-70)
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
// Total count is exactly 70
Assert.Equal(70, db.ColorVarieties.Count());
}
[Theory]
[InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)]
@@ -516,6 +658,74 @@ namespace GerbilManager.Tests
Assert.Equal(new DateOnly(y, m, d), date);
}
[Fact]
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
{
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
// stable ExternalRef without throwing.
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
// Run 1: load the animal normally
await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, await db.Gerbils.CountAsync());
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
g.Name = "Umbenannt Tier";
await db.SaveChangesAsync();
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
{
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
// Exact Agouti genotype, no explicit Farbschlag name
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
// ColorVarietyId must be set even though no explicit Farbschlag name was given
Assert.NotNull(tier.ColorVarietyId);
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
Assert.Equal("Agouti", variety!.Name);
// Report counter should reflect the genotype derivation
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
// ---- fixtures ----
private const string LittersJson = """
[

View File

@@ -0,0 +1,204 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
namespace GerbilManager.Tests
{
public class IngestResolvedServiceTests : IDisposable
{
private readonly string _dir;
private readonly string _resolvedJsonPath;
public IngestResolvedServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var photoId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new
{
Id = contactId,
Name = "Test Breeder",
ZuchtName = "Test Zucht",
City = "Test City",
Email = "test@example.com",
Homepage = "",
Phone = "",
Address = "",
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
}
},
Litters = new[]
{
new
{
Id = litterId,
Name = "Wurf A",
Date = "2026-01-01",
TotalBorn = 5,
DeathsWithin8Weeks = 0,
FatherId = fatherId,
MotherId = motherId,
ExpectedGoHomeDate = (string?)null,
Notes = "Test litter notes",
PairingCode = "PC01",
ExternalRef = "ext-litter-1",
LitterLetter = "A",
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
}
},
Gerbils = new[]
{
new
{
Id = fatherId,
Name = "Papa",
Gender = "male",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-papa",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "papa",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
},
new
{
Id = motherId,
Name = "Mama",
Gender = "female",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-mama",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "mama",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
Provenance = (string?)null
}
},
GerbilPhotos = new[]
{
new
{
Id = photoId,
GerbilId = fatherId,
FileName = "photo1.jpg",
SortOrder = 0
}
}
};
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
return db;
}
[Fact]
public async Task IngestResolved_loads_data_and_resolves_all_relations()
{
using var db = NewDb();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Import:SourcePath", _dir }
})
.Build();
var service = new IngestResolvedService(db, config, null!);
var result = await service.RunAsync();
Assert.Contains("Ingestion successful!", result);
// Verify counts
Assert.Equal(1, await db.Contacts.CountAsync());
Assert.Equal(1, await db.Litters.CountAsync());
Assert.Equal(2, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
// Verify relations
var litter = await db.Litters.SingleAsync();
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
var photo = await db.GerbilPhotos.SingleAsync();
Assert.Equal(father.Id, litter.FatherId);
Assert.Equal(mother.Id, litter.MotherId);
Assert.Equal(father.Id, photo.GerbilId);
Assert.Equal(father.OriginContactId, mother.OriginContactId);
// Provenance round-trips verbatim through ingest (and stays null when absent).
Assert.NotNull(father.Provenance);
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
Assert.Contains("mergedRecordCount", father.Provenance);
// The chronological, file-attributed history survives ingest verbatim
// (stored as opaque JSON on the text column — no schema for it).
Assert.Contains("history", father.Provenance);
Assert.Contains("Genotyp aus", father.Provenance);
Assert.Null(mother.Provenance);
// Contact + litter provenance also round-trips through the ingest.
var contact = await db.Contacts.SingleAsync();
Assert.NotNull(contact.Provenance);
Assert.Contains("als Züchter erkannt", contact.Provenance);
Assert.NotNull(litter.Provenance);
Assert.Contains("aus Wurfchronik", litter.Provenance);
Assert.Contains("fromWurfchronik", litter.Provenance);
}
}
}

View File

@@ -0,0 +1,88 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
public class LitterMortalityTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
private static int? GetInt(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32() : null;
private async Task<JsonElement> CreateAndGet(object body)
{
var post = await _client.PostAsJsonAsync("/litters", body);
post.EnsureSuccessStatusCode();
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
.GetProperty("id").GetString()!;
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
}
[Fact]
public async Task DeathsWithin8Weeks_round_trips()
{
var el = await CreateAndGet(new
{
name = "Wurf Mortalitätstest",
date = "2026-05-01",
totalBorn = 6,
deathsWithin8Weeks = 2,
});
Assert.Equal(6, GetInt(el, "totalBorn"));
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
}
[Fact]
public async Task DeathsWithin8Weeks_defaults_null()
{
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Ungültig",
date = "2026-05-03",
totalBorn = 4,
deathsWithin8Weeks = 5,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("TotalBorn", body);
}
[Fact]
public async Task Negative_DeathsWithin8Weeks_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Negativ",
date = "2026-05-04",
deathsWithin8Weeks = -1,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("negativ", body);
}
[Fact]
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
{
var el = await CreateAndGet(new
{
name = "Kein TotalBorn",
date = "2026-05-05",
deathsWithin8Weeks = 3,
});
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
}
}

View File

@@ -0,0 +1,280 @@
using System.Net;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Names;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
/// </summary>
public class NameSuggestionTests
{
// ── Prompt assembly ───────────────────────────────────────────────────
[Fact]
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("reinen JSON-Array", prompt);
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
Assert.Contains("name", prompt);
Assert.Contains("meaning", prompt);
Assert.Contains("origin", prompt);
}
[Fact]
public void SystemPrompt_erklärt_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("thematischen Kategorien", prompt);
Assert.Contains("Erfinde KEINE Etymologie", prompt);
}
[Fact]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt);
// codes are mapped to German descriptions
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Griechische Mythologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
}
[Fact]
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
Assert.Contains("5", prompt);
Assert.DoesNotContain("Buchstaben", prompt);
Assert.DoesNotContain("weibliche", prompt);
Assert.DoesNotContain("männliche", prompt);
}
[Fact]
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
Assert.DoesNotContain("weibliche", prompt);
Assert.DoesNotContain("männliche", prompt);
}
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
{
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
Assert.Equal(expectedDescription, entry.Description);
Assert.True(entry.Thematic);
}
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
Assert.Contains(expectedDescription, prompt);
Assert.Contains("Themen-Kategorien", prompt);
Assert.DoesNotContain("Kulturkreisen", prompt);
}
[Fact]
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
Assert.Contains("Disney-Charaktere", prompt);
Assert.Contains("Themen-Kategorien", prompt);
}
[Fact]
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
}
[Fact]
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
{
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync("D", null, "disney", 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal("Dumbo", result.Suggestions![0].Name);
Assert.Equal("Disney", result.Suggestions[0].Origin);
}
// ── JSON parsing ──────────────────────────────────────────────────────
[Fact]
public void ParseSuggestions_verarbeitet_reines_JSON()
{
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
var result = NameSuggestionService.ParseSuggestions(json);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Astrid", result[0].Name);
Assert.Equal("göttliche Stärke", result[0].Meaning);
Assert.Equal("Altnordisch", result[0].Origin);
}
[Fact]
public void ParseSuggestions_strippt_json_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Aiko", result![0].Name);
}
[Fact]
public void ParseSuggestions_strippt_generische_Markdown_Fence()
{
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Luna", result![0].Name);
}
[Fact]
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
{
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
var result = NameSuggestionService.ParseSuggestions(messy);
Assert.NotNull(result);
Assert.Equal("Sol", result![0].Name);
}
[Fact]
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
{
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
Assert.Null(NameSuggestionService.ParseSuggestions(""));
}
// ── 503: nicht konfiguriert ───────────────────────────────────────────
[Theory]
[InlineData(null, "key", "model")]
[InlineData("https://api.example.com/v1", null, "model")]
[InlineData("https://api.example.com/v1", "key", null)]
[InlineData(null, null, null)]
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
string? baseUrl, string? apiKey, string? model)
{
var service = CreateService(baseUrl, apiKey, model,
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
var result = await service.SuggestAsync(null, null, null, 5);
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
Assert.Null(result.Suggestions);
}
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
[Fact]
public async Task SuggestAsync_parst_valide_JSON_Antwort()
{
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal(2, result.Suggestions!.Count);
Assert.Equal("Astrid", result.Suggestions[0].Name);
}
[Fact]
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var handler = new StubHandler(_ => Canned(fenced));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.Equal("Luna", result.Suggestions![0].Name);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
{
var handler = new StubHandler(_ => Canned("das ist kein json"));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
Assert.Null(result.Suggestions);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
}
// ── Helfer ────────────────────────────────────────────────────────────
private static NameSuggestionService CreateService(
string? baseUrl, string? apiKey, string? model, StubHandler handler)
{
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
return new NameSuggestionService(new HttpClient(handler), options);
}
private static HttpResponseMessage Canned(string content)
{
var completion = new
{
choices = new[] { new { message = new { role = "assistant", content } } },
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(completion),
Encoding.UTF8, "application/json"),
};
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
}
}

View File

@@ -0,0 +1,145 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GerbilManager.Tests;
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
/// send partial bodies — all other fields must be preserved unchanged.</summary>
public class PartialUpdateTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
// ------------------------------------------------------------------ helpers
private static Guid ExtractId(string json) =>
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
private static string? GetStr(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
private static string? GetNested(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
/// <summary>Create a fully-populated gerbil and return its id.</summary>
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
{
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
// when multiple tests in the same fixture share the SQLite connection.
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
{
name,
gender = "female",
genotype = "aa CC DD ee GG PP spsp rere",
originBreeder = "Zucht der kleinen Chaoten",
isDeaf = false,
characterTraits = new[] { "neugierig", "zutraulich" },
characterNote = "Liebling der Familie",
notes = "Eine Notiz",
externalRef = $"ext-{name.GetHashCode():X8}",
}));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync());
}
// ------------------------------------------------------------------ tests
[Fact]
public async Task ForSale_toggle_does_not_wipe_other_fields()
{
// Arrange: create a fully-populated gerbil
var id = await CreateFullGerbil("ForSale-Tier");
// Act: send only { status: 'ForSale' } — exactly what the detail page does
var put = await _client.PutAsync($"/gerbils/{id}",
JsonContent.Create(new { status = "ForSale" }));
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
// Assert: re-read and check all fields survive
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("ForSale", GetStr(json, "status"));
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
}
[Fact]
public async Task CharacterSave_does_not_wipe_other_fields()
{
// Arrange
var id = await CreateFullGerbil("Charakter-Tier");
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
{
characterTraits = new[] { "handzahm" },
characterNote = "Neue Notiz",
}));
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
// Assert: name / genotype / originBreeder / status / externalRef all intact
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("Breeding", GetStr(json, "status"));
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
}
[Fact]
public async Task EditForm_omitting_import_fields_keeps_them()
{
// Arrange: gerbil with import-set fields
var id = await CreateFullGerbil("EditForm-Tier");
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
{
name = "EditForm-Tier (umbenannt)",
gender = "female",
genotype = "aa CC DD ee GG PP spsp rere",
notes = "Aktualisierte Notiz",
}));
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
// Assert: name updated, import fields survive
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
}
[Fact]
public async Task Create_with_minimal_body_uses_safe_defaults()
{
// A minimal POST (only name) must still produce a valid gerbil
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Minimal", GetStr(json, "name"));
Assert.Equal("Breeding", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
}
}

View File

@@ -67,9 +67,9 @@ namespace GerbilManager.Tests
Assert.Contains("Zucht der kleinen Chaoten", prompt);
Assert.Contains("NIEMALS Preise", prompt);
Assert.Contains("KEINE Fakten erfinden", prompt);
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
// few-shot: verbatim listings from kleine-chaoten.jimdofree.com
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
Assert.Contains("Status: FREI", prompt);
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
}

View File

@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
private readonly HttpClient _client;
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
private async Task Create(string name, string? originBreeder = null, string status = "Active")
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
{
var resp = await _client.PostAsJsonAsync("/gerbils",
new { name, gender = "female", status, originBreeder });
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
public async Task Combined_status_and_name_filter()
{
await Create("KombiVerkauf", status: "ForSale");
await Create("KombiAktiv", status: "Active");
await Create("KombiAktiv", status: "Breeding");
var hits = await Names("status==ForSale,nameSearch=*kombi");
Assert.Contains("KombiVerkauf", hits);
Assert.DoesNotContain("KombiAktiv", hits);

View File

@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
@@ -23,6 +24,7 @@ public class ApplicationContext : DbContext
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
@@ -49,6 +51,13 @@ public class ApplicationContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// DB-4: ICU German collation on user-visible / searched text columns.
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
// support custom collation names and would fail EnsureCreated on the test host.
const string DeIcu = "de-x-icu";
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
modelBuilder.Entity<Gerbil>(e =>
{
// Enums persisted as their string names (readable, Gridify-friendly).
@@ -58,6 +67,14 @@ public class ApplicationContext : DbContext
// Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true);
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
if (isNpgsql)
{
e.Property(g => g.Name).UseCollation(DeIcu);
e.Property(g => g.NameSearch).UseCollation(DeIcu);
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
}
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
@@ -81,6 +98,12 @@ public class ApplicationContext : DbContext
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.ColorVariety).WithMany()
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
e.HasIndex(g => g.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
});
modelBuilder.Entity<Litter>(e =>
@@ -89,6 +112,12 @@ public class ApplicationContext : DbContext
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(l => l.Mother).WithMany()
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
// DB-5: ExternalRef = source litter id from extract.py.
// Unique (filtered, nulls allowed for manually-entered litters).
e.HasIndex(l => l.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
});
modelBuilder.Entity<HealthRecord>(e =>
@@ -106,6 +135,10 @@ public class ApplicationContext : DbContext
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
modelBuilder.Entity<EnclosurePhoto>(e =>
e.HasOne<Enclosure>().WithMany()
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
// FEAT-13: Abgabeverträge + Zuchtprofil.
modelBuilder.Entity<SaleContract>(e =>
{
@@ -170,6 +203,24 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
// Guid columns (no navigation properties → EF creates NO foreign key), so the
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
// feedback rows. They survive re-ingest, which is the whole point.
modelBuilder.Entity<Feedback>(e =>
{
e.HasIndex(f => f.CreatedAt);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{
modelBuilder.Entity<ColorVariety>()
.Property(v => v.Name).UseCollation(DeIcu);
modelBuilder.Entity<Contact>()
.Property(c => c.Name).UseCollation(DeIcu);
}
SeedColorVarieties(modelBuilder);
}
@@ -246,69 +297,97 @@ public class ApplicationContext : DbContext
/// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder)
{
(string Name, string Genotype)[] catalog =
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
// ID = array_index + 1 (stable, never changes — live FKs safe).
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
(string Name, string Genotype, int SortOrder)[] catalog =
{
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
("Hermelin", "aa chch DD EE GG PP spsp rere"),
("Himalaya", "AA chch DD EE GG PP spsp rere"),
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
("Agouti", "AA CC DD EE GG PP spsp rere"),
("Schwarz", "aa CC DD EE GG PP spsp rere"),
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
("Blau", "aa CC dd EE GG PP spsp rere"),
("Gold", "AA CC DD EE GG pp spsp rere"),
("Platin", "aa CC DD EE GG pp spsp rere"),
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
("dd Gold", "AA CC dd EE GG pp spsp rere"),
("dd Platin", "aa CC dd EE GG pp spsp rere"),
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
("C-Separator", "aa CC DD ee gg pp spsp rere"),
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere"),
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
("Topas", "AA CC DD EE GG pp spsp rere"),
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Topas dd", "AA CC dd EE GG pp spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam", "aa cchmch DD EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
// --- C-locus white / partial albino (IDs 1-3) ---
("REW", "AA chch DD EE GG pp spsp rere", 0),
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
// --- Zobel / colourpoint dark (ID 4) ---
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
// --- Schimmel family (IDs 5) ---
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
// --- Standard colours (IDs 6-15) ---
("Agouti", "AA CC DD EE GG PP spsp rere", 5),
("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
("Blau", "aa CC dd EE GG PP spsp rere", 10),
("Gold", "AA CC DD EE GG pp spsp rere", 11),
("Platin", "aa CC DD EE GG pp spsp rere", 12),
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
// --- Dilute (dd) standard (IDs 16-17) ---
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
// --- REW / Apricot / misc C-locus (IDs 18-28) ---
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
("Topas", "AA CC DD EE GG pp spsp rere", 26),
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
// --- Dilute (dd) varieties (IDs 29-32) ---
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
// --- Hell variants (IDs 38-48) ---
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
// --- Dilute (dd) renamed variants (IDs 49-50) ---
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
// --- Marder / Siam / CP- series (IDs 51-66) ---
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
};
var rows = new ColorVariety[catalog.Length];
@@ -316,11 +395,11 @@ public class ApplicationContext : DbContext
{
rows[i] = new ColorVariety
{
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
Name = catalog[i].Name,
CanonicalGenotype = catalog[i].Genotype,
SortOrder = i,
SortOrder = catalog[i].SortOrder,
};
}
modelBuilder.Entity<ColorVariety>().HasData(rows);

View File

@@ -0,0 +1,16 @@
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// WEB-2: path where POST /api/publish writes the rendered static site.
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
/// In production this volume is shared with the publicsite-nginx container.
/// </summary>
public sealed class PublicSiteOptions
{
public const string SectionName = "PublicSite";
public string? RootPath { get; set; }
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
}
}

View File

@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
public sealed record ContractAnimal(
string Name,
string Geschlecht,
DateOnly? Geburtsdatum = null,
string? Farbschlag = null);
string? Farbschlag = null,
byte[]? Photo = null);
/// <summary>
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.

View File

@@ -0,0 +1,241 @@
using System.Globalization;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
/// 48 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
/// </summary>
public static class ContractPdfGenerator
{
static ContractPdfGenerator()
{
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
QuestPDF.Settings.License = LicenseType.Community;
}
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
private static string Price(decimal p) => p.ToString("N2", German) + " €";
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
private const string Accent = "#A85F2E";
private const string Ink = "#3D2E23";
private const string Line = "#D9CCB4";
public static byte[] Generate(ContractData data)
{
ArgumentNullException.ThrowIfNull(data);
return Document.Create(doc =>
{
doc.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(1.8f, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
page.Header().PaddingBottom(8).Column(h =>
{
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
});
page.Content().PaddingTop(6).Column(col =>
{
col.Spacing(12);
Parties(col, data);
Animals(col, data);
Subject(col, data);
Clauses(col);
Signatures(col, data);
});
page.Footer().AlignCenter().Text(t =>
{
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
t.Span("Seite ");
t.CurrentPageNumber();
t.Span(" / ");
t.TotalPages();
});
});
}).GeneratePdf();
}
private static void Heading(ColumnDescriptor col, string text) =>
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
private static void Kv(ColumnDescriptor col, string label, string value) =>
col.Item().Row(r =>
{
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
r.RelativeItem().Text(value).SemiBold();
});
private static void Bullet(ColumnDescriptor col, string text) =>
col.Item().Row(r =>
{
r.ConstantItem(14).Text("•").FontColor(Accent);
r.RelativeItem().Text(text);
});
private static void Parties(ColumnDescriptor col, ContractData data)
{
var s = data.Seller;
var b = data.Buyer;
Heading(col, "1. Vertragspartner");
col.Item().Text("Verkäufer / Züchter").SemiBold();
col.Item().Column(c =>
{
c.Spacing(2);
Kv(c, "Zuchtname:", Or(s.ZuchtName));
Kv(c, "Vor- und Nachname:", Or(s.Name));
Kv(c, "Adresse:", Or(s.Address));
Kv(c, "Telefon:", Or(s.Phone, ""));
Kv(c, "E-Mail:", Or(s.Email, ""));
Kv(c, "Homepage:", Or(s.Homepage, ""));
});
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
col.Item().Column(c =>
{
c.Spacing(2);
Kv(c, "Vor- und Nachname:", Or(b.Name));
Kv(c, "Adresse:", Or(b.Address));
Kv(c, "Telefon:", Or(b.Phone, ""));
Kv(c, "E-Mail:", Or(b.Email, ""));
});
}
private static void Animals(ColumnDescriptor col, ContractData data)
{
Heading(col, "2. Angaben zu den Tieren");
col.Item().Column(list =>
{
list.Spacing(10);
foreach (var a in data.Animals)
{
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
// wie im Mustervertrag (das große Rechteck links).
list.Item().Border(1).BorderColor(Line).Row(row =>
{
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
.Padding(4).Element(box =>
{
if (a.Photo is { Length: > 0 } bytes)
box.AlignMiddle().Image(bytes).FitArea();
else
box.AlignMiddle().AlignCenter().Text("Kein Foto")
.FontSize(8).FontColor("#B8AC97");
});
row.RelativeItem(3f).Column(kv =>
{
AnimalRow(kv, "Name:", Or(a.Name, ""));
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
});
});
}
});
}
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
{
var cell = col.Item();
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
{
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
r.RelativeItem().Text(value).SemiBold();
});
}
private static void Subject(ColumnDescriptor col, ContractData data)
{
Heading(col, "3. Gegenstand des Vertrages");
col.Item().Text(
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
$"von dem Züchter übergeben.");
}
private static void Clauses(ColumnDescriptor col)
{
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
col.Item().Text(
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
Heading(col, "5. Pflichten des Käufers");
col.Item().Text(
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
});
Heading(col, "6. Rechte des Käufers");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
});
Heading(col, "7. Haftung / Zuwiderhandlung");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
});
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
col.Item().Text(
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
}
private static void Signatures(ColumnDescriptor col, ContractData data)
{
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
col.Item().PaddingTop(28).Row(r =>
{
r.RelativeItem().Column(c =>
{
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
});
r.ConstantItem(40);
r.RelativeItem().Column(c =>
{
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
});
});
}
}

View File

@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? SpottingType,
string? Notes,
string? ImportSource,
string? ExternalRef,
@@ -29,19 +30,24 @@ namespace GerbilManagerWebAPI.Dtos
List<string> CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool IsResident);
bool IsResident,
string? ProfilePhotoUrl,
bool IsCastrated,
string? Provenance);
public record LitterDto(
Guid Id,
string Name,
DateOnly Date,
DateOnly? Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
string? Notes);
string? Notes,
string? Provenance);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
public record EnclosureDto(Guid Id, string Name, string? Notes);
@@ -59,8 +65,8 @@ namespace GerbilManagerWebAPI.Dtos
// Request DTOs -----------------------------------------------------------
public record GerbilInput(
string Name,
Gender Gender,
string? Name,
Gender? Gender,
GerbilStatus? Status,
Guid? LitterId,
Guid? OriginContactId,
@@ -72,6 +78,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? SpottingType,
string? Notes,
string? ImportSource,
string? ExternalRef,
@@ -79,18 +86,20 @@ namespace GerbilManagerWebAPI.Dtos
List<string>? CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool? IsResident);
bool? IsResident,
bool? IsCastrated);
public record LitterInput(
string Name,
DateOnly Date,
DateOnly? Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
public record EnclosureInput(string Name, string? Notes);

View File

@@ -19,7 +19,9 @@ namespace GerbilManagerWebAPI.Dtos
List<Guid> GerbilIds,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate);
DateOnly? ContractDate,
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
Dictionary<Guid, Guid>? AnimalPhotos = null);
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
public record BreederProfileDto(
@@ -29,5 +31,6 @@ namespace GerbilManagerWebAPI.Dtos
string Phone,
string Email,
string Homepage,
string City);
string City,
string NameSuffix);
}

View File

@@ -0,0 +1,27 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
public record FeedbackInput(
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp);
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
public record FeedbackDto(
Guid Id,
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp,
string? UserAgent,
DateTimeOffset CreatedAt);
}

View File

@@ -1,3 +1,4 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms;
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Endpoints
{
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
});
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
{
if (!opts.Value.IsConfigured)
return Results.Problem(
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
statusCode: 503,
title: "PublicSite nicht konfiguriert");
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
return Results.Ok(new { filesPublished = files.Count });
});
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
/// <summary>
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
/// </summary>
internal static async Task PublishToDirectoryAsync(
IReadOnlyDictionary<string, string> files, string rootPath)
{
var stagingDir = Path.Combine(rootPath, "_staging_new");
var liveDir = Path.Combine(rootPath, "live");
var oldDir = Path.Combine(rootPath, "_old");
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
Directory.CreateDirectory(stagingDir);
foreach (var (relativePath, content) in files)
{
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.Combine(stagingDir, normalPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
}
// Atomic swap: _staging_new → live
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
Directory.Move(stagingDir, liveDir);
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
catch { /* non-fatal — old dir gone on next publish */ }
}
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"

View File

@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
{
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
db.Contacts.Add(c);
await db.SaveChangesAsync();
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
}
}

View File

@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
@@ -22,6 +23,7 @@ namespace GerbilManagerWebAPI.Endpoints
{
private const string DocxContentType =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
private const string PdfContentType = "application/pdf";
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
{
@@ -49,6 +51,7 @@ namespace GerbilManagerWebAPI.Endpoints
var errors = new Dictionary<string, string[]>();
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
var photoChoice = input.AnimalPhotos ?? [];
if (gerbilIds.Count == 0)
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
if (input.Price < 0)
@@ -99,16 +102,21 @@ namespace GerbilManagerWebAPI.Endpoints
ContractDate = contractDate,
FileName = fileName,
CreatedAt = DateTimeOffset.UtcNow,
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
Animals = gerbilIds.Select(id => new SaleContractAnimal
{
GerbilId = id,
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
}).ToList(),
};
db.SaleContracts.Add(entity);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in gerbils)
{
g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate;
g.Status = GerbilStatus.GivenAway;
GerbilStatusService.Apply(g, today);
}
try
@@ -142,6 +150,49 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.PhysicalFile(path, DocxContentType, download);
});
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Contact)
.Include(x => x.Animals)
.FirstOrDefaultAsync(x => x.Id == id);
if (c is null || c.Contact is null) return TypedResults.NotFound();
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
var gerbils = await db.Gerbils.AsNoTracking()
.Include(g => g.ColorVariety)
.Where(g => gerbilIds.Contains(g.Id))
.ToListAsync();
var settings = await db.BreederSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
?? new BreederSettings();
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
// existieren — sonst bleibt das Bild leer statt zu scheitern).
var photoByGerbil = c.Animals
.Where(a => a.PhotoId is not null)
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
var data = new ContractData(
Seller: ToSeller(settings),
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
Animals: gerbils
.Select(g => new ContractAnimal(
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
photoBytes.GetValueOrDefault(g.Id)))
.ToList(),
Price: c.Price,
HandoverDate: c.HandoverDate,
ContractDate: c.ContractDate);
var pdf = ContractPdfGenerator.Generate(data);
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
return TypedResults.File(pdf, PdfContentType, download);
});
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
// (das wäre Magie — Status korrigiert man am Tier selbst).
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
@@ -164,6 +215,34 @@ namespace GerbilManagerWebAPI.Endpoints
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
{
var result = new Dictionary<Guid, byte[]>();
if (photoByGerbil.Count == 0) return result;
var photoIds = photoByGerbil.Values.ToList();
var photos = await db.GerbilPhotos.AsNoTracking()
.Where(p => photoIds.Contains(p.Id))
.ToListAsync();
foreach (var (gerbilId, photoId) in photoByGerbil)
{
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
if (photo is null) continue;
var path = Path.Combine(photoRoot, photo.FileName);
if (File.Exists(path))
result[gerbilId] = await File.ReadAllBytesAsync(path);
}
return result;
}
private static BreederProfile ToSeller(BreederSettings s) => new()
{
ZuchtName = s.ZuchtName,

View File

@@ -0,0 +1,63 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
/// GET /feedback -> list reports, newest first (for later review).
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
/// rows survive the import re-ingest wipe.
/// </summary>
public static class FeedbackEndpoints
{
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
FeedbackInput input, ApplicationContext db, HttpContext http) =>
{
if (string.IsNullOrWhiteSpace(input.Message))
return TypedResults.BadRequest("Message darf nicht leer sein.");
var entity = new Feedback
{
Id = Guid.NewGuid(),
Message = input.Message.Trim(),
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
GerbilId = input.GerbilId,
LitterId = input.LitterId,
ContactId = input.ContactId,
EntityName = input.EntityName,
Url = input.Url,
ClientTimestamp = input.ClientTimestamp,
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
CreatedAt = DateTimeOffset.UtcNow,
};
db.Feedback.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
});
group.MapGet("/", async (ApplicationContext db) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
var rows = await db.Feedback.AsNoTracking().ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt)
.Select(ToDto)
.ToList());
});
return app;
}
private static FeedbackDto ToDto(Feedback f) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
}
}

View File

@@ -1,7 +1,9 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Gridify;
using Gridify.EntityFramework;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
{
var q = query.ToQuery();
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
var ids = paging.Data.Select(g => g.Id).ToList();
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
await db.GerbilPhotos.AsNoTracking()
.Where(p => ids.Contains(p.GerbilId))
.GroupBy(p => p.GerbilId)
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
var items = paging.Data
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
.ToList();
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
});
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) =>
@@ -30,7 +45,14 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
if (g is null) return TypedResults.NotFound();
var photoFileName = await db.GerbilPhotos.AsNoTracking()
.Where(p => p.GerbilId == id)
.OrderBy(p => p.SortOrder)
.Select(p => p.FileName)
.FirstOrDefaultAsync();
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
});
// POST /gerbils
@@ -39,19 +61,18 @@ namespace GerbilManagerWebAPI.Endpoints
if (string.IsNullOrWhiteSpace(input.Name))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
Apply(g, input, isCreate: true);
db.Gerbils.Add(g);
await db.SaveChangesAsync();
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
});
// PUT /gerbils/{id}
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
g.Name = input.Name;
Apply(g, input, isCreate: false);
await db.SaveChangesAsync();
return TypedResults.NoContent();
@@ -77,34 +98,46 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
// for fields the frontend omits. A non-null input value always wins (including explicit
// nulls — callers that want to clear a nullable field must send a full object; a
// dedicated PATCH endpoint can be added later if point-clear is needed).
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
{
g.Gender = i.Gender;
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
g.LitterId = i.LitterId;
g.OriginContactId = i.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId;
g.EnclosureId = i.EnclosureId;
g.ColorVarietyId = i.ColorVarietyId;
g.DateOfBirth = i.DateOfBirth;
g.DateOfDeath = i.DateOfDeath;
g.CauseOfDeath = i.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate;
g.Genotype = i.Genotype;
g.Notes = i.Notes;
g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef;
g.OriginBreeder = i.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote;
g.IsDeaf = i.IsDeaf;
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
// Status is applied as a user preference and then overridden by GerbilStatusService.
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
g.LitterId = i.LitterId ?? g.LitterId;
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
g.Genotype = i.Genotype ?? g.Genotype;
g.SpottingType = i.SpottingType ?? g.SpottingType;
g.Notes = i.Notes ?? g.Notes;
g.ImportSource = i.ImportSource ?? g.ImportSource;
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
}
internal static GerbilDto ToDto(Gerbil g) => new(
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
g.Provenance);
}
}

View File

@@ -0,0 +1,28 @@
using GerbilManagerWebAPI.Import;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GerbilManagerWebAPI.Endpoints
{
public static class ImportDocxEndpoints
{
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/import/docx").WithTags("Import");
// POST /import/docx/dry-run — analyse without writing
group.MapPost("/dry-run",
async Task<Ok<ImportDocxReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
group.MapPost("/execute",
async Task<Ok<ImportDocxReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
return app;
}
}
}

View File

@@ -29,6 +29,13 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(report);
});
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var result = await new IngestResolvedService(db, config, env).RunAsync();
return TypedResults.Ok(result);
});
return app;
}
}

View File

@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
});
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
{
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
Apply(l, input);
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
{
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound();
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
l.Name = input.Name;
l.Date = input.Date;
Apply(l, input);
@@ -76,14 +80,25 @@ namespace GerbilManagerWebAPI.Endpoints
private static void Apply(Litter l, LitterInput i)
{
l.TotalBorn = i.TotalBorn;
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
l.FatherId = i.FatherId;
l.MotherId = i.MotherId;
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
l.Notes = i.Notes;
}
private static string? ValidateMortality(int? deaths, int? totalBorn)
{
if (deaths is null) return null;
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
if (totalBorn is not null && deaths > totalBorn)
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
return null;
}
private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
}
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>

View File

@@ -0,0 +1,38 @@
using GerbilManagerWebAPI.Names;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
///
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
/// frontend maps exactly this code to its German disabled-state hint.
/// </summary>
public static class NamesEndpoints
{
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/names/suggest", async (
string? letter,
string? gender,
string? usages,
int count,
NameSuggestionService service,
CancellationToken ct) =>
{
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
return result.Status switch
{
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
NameSuggestionStatus.NotConfigured => Results.Json(
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
_ => Results.Json(
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
};
})
.WithTags("Names");
return app;
}
}
}

View File

@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.NoContent();
}).WithTags("Photos");
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
app.MapGet("/enclosures/{id:guid}/photos",
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
{
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
var photos = await db.EnclosurePhotos.AsNoTracking()
.Where(p => p.EnclosureId == id)
.OrderBy(p => p.SortOrder)
.ToListAsync();
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
}).WithTags("Photos");
app.MapPost("/enclosures/{id:guid}/photos",
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
var ext = Path.GetExtension(file.FileName);
var fileName = $"{Guid.NewGuid():N}{ext}";
var root = PhotoRoot(config, env);
Directory.CreateDirectory(root);
await using (var stream = File.Create(Path.Combine(root, fileName)))
await file.CopyToAsync(stream);
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
var photo = new EnclosurePhoto
{
Id = Guid.NewGuid(),
EnclosureId = id,
FileName = fileName,
Caption = caption,
SortOrder = nextSort,
CreatedAt = DateTimeOffset.UtcNow,
};
db.EnclosurePhotos.Add(photo);
await db.SaveChangesAsync();
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
}).WithTags("Photos").DisableAntiforgery();
app.MapDelete("/enclosure-photos/{id:guid}",
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return TypedResults.NotFound();
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
if (File.Exists(path)) File.Delete(path);
db.EnclosurePhotos.Remove(photo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).WithTags("Photos");
app.MapGet("/photos/files/{fileName}",
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
{
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
private static PhotoDto ToDto(GerbilPhoto p) =>
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
}
}

View File

@@ -33,6 +33,7 @@ namespace GerbilManagerWebAPI.Endpoints
s.Email = input.Email ?? "";
s.Homepage = input.Homepage ?? "";
s.City = input.City ?? "";
s.NameSuffix = input.NameSuffix ?? "";
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
}
internal static BreederProfileDto ToDto(BreederSettings s) =>
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
}
}

View File

@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Breeding] = "Zucht",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
[GerbilStatus.ForSale] = "Abzugeben",
[GerbilStatus.Pet] = "Liebhaber",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()

View File

@@ -1,39 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="QuestPDF" Version="2026.6.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,437 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
/// <summary>
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
/// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
///
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
/// Match order per docx row:
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
///
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
/// -N ordinal suffix disambiguates same-name siblings in a litter).
///
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
///
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
/// to re-run on transient-failure retry.
/// Execute is gated by the endpoint; this service only acts when asked.
/// </summary>
public sealed class ImportDocxService
{
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
private readonly ApplicationContext _db;
private readonly string _sourceDir;
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
: this(db,
config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
{ }
public ImportDocxService(ApplicationContext db, string sourceDir)
{
_db = db;
_sourceDir = sourceDir;
}
public async Task<ImportDocxReport> RunAsync(bool execute)
{
var notes = new List<string>();
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
int docxLitCount = docxLitters.Count;
int docxAnimCount = docxAnimals.Count;
int pdfLitCount = pdfLitters.Count;
int pdfAnimCount = pdfAnimals.Count;
docxLitters.AddRange(pdfLitters);
docxAnimals.AddRange(pdfAnimals);
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
{
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
}
// Litter lookup by birth date (DayNumber) → list of matching DB litters.
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
// Date-only lookup with uniqueness guard avoids false links (only link when
// exactly one DB litter falls within ±5 days of the docx litter DOB).
var littersInDb = await _db.Litters
.Select(l => new { l.Id, l.Date })
.ToListAsync();
var littersByDayNumber = littersInDb
.Where(l => l.Date.HasValue)
.GroupBy(l => l.Date!.Value.DayNumber)
.ToDictionary(g => g.Key, g => g.ToList());
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
var gerbilsInDb = await _db.Gerbils
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
g.ExternalRef })
.ToListAsync();
var gerbilByKey = gerbilsInDb
.Where(g => g.DateOfBirth is not null)
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
.ToDictionary(g => g.Key, g => g.ToList());
// ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
var docxExternalRefs = gerbilsInDb
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
.ToDictionary(g => g.ExternalRef!,
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
// Contact lookup: normalized name → existing Contact.Id
var contactsInDb = await _db.Contacts
.Select(c => new { c.Id, c.Name })
.ToListAsync();
var contactByNorm = contactsInDb
.GroupBy(c => NormalizeName(c.Name))
.ToDictionary(g => g.Key, g => g.First().Id);
// Snapshot of DB contacts before any writes.
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
// but would remain in the in-memory dict without this reset).
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
var colorVarietyByName = (await _db.ColorVarieties
.Select(cv => new { cv.Id, cv.Name })
.ToListAsync())
.GroupBy(cv => NormalizeName(cv.Name))
.ToDictionary(g => g.Key, g => g.First().Id);
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
// Ordinal counter for collision-free ExternalRef within this batch.
var externalRefOrdinals = new Dictionary<string, int>();
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
var batchRefs = new HashSet<string>();
// Inner loop — shared by dry-run and execute paths.
// All local variables above are captured by reference (C# closure), so the strategy
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
async Task RunLoopAsync()
{
foreach (var da in docxAnimals)
{
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
// Collision-free ExternalRef: ordinal suffix for same-name siblings
var baseRef = DocxExternalRefBase(da);
externalRefOrdinals.TryGetValue(baseRef, out var ord);
ord++;
externalRefOrdinals[baseRef] = ord;
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
Guid? litterId = null;
if (!string.IsNullOrWhiteSpace(da.LitterDob))
{
var litterDob = ParseDate(da.LitterDob);
if (litterDob is not null)
{
var candidates = new List<Guid>();
for (int delta = -5; delta <= 5; delta++)
{
if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
candidates.AddRange(cl.Select(l => l.Id));
}
if (candidates.Count == 1)
litterId = candidates[0];
// If 0 or >1 candidates: no link (avoid false links)
}
}
// Animal DOB = litter birth date (docx has no per-animal DOB)
var animalDob = litterId is not null
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
: ParseDate(da.LitterDob);
if (animalDob is null) { skipped++; continue; }
var goHomeDate = ParseDate(da.AbgabeDate);
var deathDate = ParseDate(da.DeathDate);
// Resolve receiver contact (lookup-or-create; shared by all paths)
Guid? receiverId = null;
if (!string.IsNullOrWhiteSpace(da.Owner))
{
var normOwner = NormalizeName(da.Owner);
if (contactByNorm.TryGetValue(normOwner, out var existingContactId))
{
receiverId = existingContactId;
ownerLinked++;
}
else
{
ownerCreated++;
if (execute)
{
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
_db.Contacts.Add(newContact);
await _db.SaveChangesAsync(); // flush within the outer tx
receiverId = newContact.Id;
contactByNorm[normOwner] = receiverId.Value;
}
}
}
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap))
{
bool willLink = litterId is not null && prevSnap.LitterId is null;
bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null;
bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null;
if (willLink) litterLinked++;
if (willHome) goHomeFilled++;
if (willDeath) deathFilled++;
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
if (row is null) continue;
if (willLink) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
if (willHome) row.GoHomeDate = goHomeDate;
if (willDeath)
{
row.DateOfDeath = deathDate;
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
row.CauseOfDeath = da.DeathCause.Trim();
}
}
continue;
}
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
var nameKey = NameDobKey(da.Name, animalDob.Value);
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
{
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
?? gerbilCands.First();
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
if (willLink) litterLinked++;
if (willHome) goHomeFilled++;
if (willDeath) deathFilled++;
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
if (row is null) continue;
if (willLink) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
if (willHome) row.GoHomeDate = goHomeDate;
if (willDeath)
{
row.DateOfDeath = deathDate;
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
row.CauseOfDeath = da.DeathCause.Trim();
}
}
continue;
}
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
animalsCreated++;
if (litterId is not null) litterLinked++;
if (goHomeDate is not null) goHomeFilled++;
if (deathDate is not null) deathFilled++;
if (execute)
{
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
_db.Gerbils.Add(new Gerbil
{
Id = Guid.NewGuid(),
Name = da.Name.Trim(),
DateOfBirth = animalDob,
Gender = ParseGender(da.Gender),
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
LitterId = litterId,
ReceiverContactId = receiverId,
GoHomeDate = goHomeDate,
DateOfDeath = deathDate,
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
ColorVarietyId = cvId == default ? null : cvId,
OriginBreeder = "Zucht der kleinen Chaoten",
IsResident = false,
ImportSource = "docx",
ExternalRef = externalRef,
});
}
}
}
if (!execute)
{
// Dry-run: just count, no writes, no transaction needed.
await RunLoopAsync();
}
else
{
// Execute: wrap the entire transaction in the execution strategy so that providers
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
// The lambda resets all mutable state at the top so retries start clean.
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// Reset mutable state — idempotent on strategy retry
_db.ChangeTracker.Clear();
externalRefOrdinals.Clear();
batchRefs.Clear();
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
ownerLinked = 0; ownerCreated = 0; skipped = 0;
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
await using var tx = await _db.Database.BeginTransactionAsync();
await RunLoopAsync();
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
await _db.SaveChangesAsync();
await tx.CommitAsync();
});
}
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
}
private T? Load<T>(string file)
{
var path = Path.Combine(_sourceDir, file);
if (!File.Exists(path)) return default;
using var fs = File.OpenRead(path);
return JsonSerializer.Deserialize<T>(fs, Json);
}
private static DateOnly? ParseDate(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
var m = System.Text.RegularExpressions.Regex.Match(s,
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
if (!m.Success) return null;
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
int y = int.Parse(m.Groups[3].Value);
if (y < 100) y += 2000;
try { return new DateOnly(y, mo, d); } catch { return null; }
}
private static string NameDobKey(string name, DateOnly dob)
{
var n = System.Text.RegularExpressions.Regex.Replace(
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
return $"{n}|{dob:yyyy-MM-dd}";
}
private static string NormalizeName(string name)
{
var n = (name ?? "").ToLowerInvariant();
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
return n;
}
/// <summary>
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
/// </summary>
internal static string DocxExternalRefBase(DocxAnimal da)
{
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
var name = System.Text.RegularExpressions.Regex.Replace(
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
return $"docx-{ws}-{name}-{da.LitterDob}";
}
private static Gender ParseGender(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return Gender.unknown;
var lower = s.ToLowerInvariant();
if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male;
if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female;
return Gender.unknown;
}
}
// ---- Source shapes (from extract_docx.py output) ----
public sealed class DocxLitter
{
public string LitterId { get; set; } = "";
public string Dob { get; set; } = "";
public string MotherName { get; set; } = "";
public string FatherName { get; set; } = "";
public string WsCode { get; set; } = "";
public string Note { get; set; } = "";
}
public sealed class DocxAnimal
{
public string WsCode { get; set; } = "";
public string LitterDob { get; set; } = "";
public string Name { get; set; } = "";
public string Farbschlag { get; set; } = "";
public string Gender { get; set; } = "";
public string Owner { get; set; } = "";
public string AbgabeDate { get; set; } = "";
public string AbgabeWeight { get; set; } = "";
public string DeathDate { get; set; } = "";
public string DeathCause { get; set; } = "";
public string PartnerName { get; set; } = "";
public string PartnerDob { get; set; } = "";
}
// ---- Report ----
public sealed record ImportDocxReport(
bool Executed,
int Created,
int LitterLinked,
int OwnerLinked,
int GoHomeFilled,
int DeathFilled,
int ContactsCreated,
int Skipped,
IReadOnlyList<string> Notes);
}

View File

@@ -82,12 +82,16 @@ namespace GerbilManagerWebAPI.Import
ResidencySummary? Residency = null);
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
int ParentFksBackfilled = 0);
int ParentFksBackfilled = 0, int WithoutDate = 0);
public sealed record AnimalSummary(
int InSource,
@@ -98,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
int AlreadyImported,
QuarantineSummary Quarantined,
int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0);
int ConflictsResolvedByDecision = 0,
int FarbschlagDerivedFromGenotype = 0);
public sealed record QuarantineSummary(
int Conflicts,

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -71,17 +72,21 @@ namespace GerbilManagerWebAPI.Import
else if (conf == "niedrig") dateOnly++;
}
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
// existing rows (idempotency).
var existingGerbilExtRefs = await _db.Gerbils
.Where(g => g.ExternalRef != null)
.Select(g => g.ExternalRef!).ToListAsync();
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
var existingLitterKeys = await _db.Litters
.Select(l => new { l.Name, l.Date }).ToListAsync();
var existingLitterKeySet = existingLitterKeys
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
var existingLitterData = await _db.Litters
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
var existingLitterKeySet = existingLitterData
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
var existingLitterExtRefSet = existingLitterData
.Where(x => x.ExternalRef != null)
.Select(x => x.ExternalRef!).ToHashSet();
// colour-variety name -> id (case-insensitive)
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
@@ -94,31 +99,41 @@ namespace GerbilManagerWebAPI.Import
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
int littersCreated = 0, littersExisting = 0;
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
// so skip them early — they can never be created or linked to animals.
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters)
{
var date = ParseDate(sl.Date);
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}";
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
// for litters imported before ExternalRef existed (those have ExternalRef = null).
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
{ littersExisting++; continue; }
var id = Guid.NewGuid();
litterIdMap[sl.Id] = id;
littersCreated++;
if (execute && date is DateOnly d)
if (execute)
{
_db.Litters.Add(new Litter
{
Id = id,
Name = name,
Date = d,
Date = date.Value,
TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
});
}
if (samples.Count < 8 && date is not null)
if (samples.Count < 8)
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
}
if (execute) await _db.SaveChangesAsync();
@@ -131,31 +146,71 @@ namespace GerbilManagerWebAPI.Import
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
var existingRows = await _db.Gerbils
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
var gidByNameDob = new Dictionary<string, Guid>();
foreach (var g in existingRows)
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.Id);
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
var varietiesWithGeno = await _db.ColorVarieties
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
var plan = new List<AnimalPlan>();
int fbDerivedFromGenotype = 0;
foreach (var a in loadable)
{
bool exists = existingGerbilSet.Contains(a.Id);
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
Guid gid;
if (exists)
{
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
{
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
else
{
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
continue;
}
}
}
else gid = Guid.NewGuid();
Guid? wurfLitterId = null;
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
wurfLitterId = lid;
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
Guid? colorVarietyId = null;
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
.Where(s => !string.IsNullOrWhiteSpace(s));
foreach (var fb in fbCandidates)
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
{ colorVarietyId = vid; break; }
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
{
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
// would match any catalog entry via wildcards and produce false positives.
var composed = ComposeGenotype(a.Genotype);
if (!composed.Contains("??"))
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
}
var gender = InferGender(a, sireNames, damNames);
var norm = Normalize(StripZucht(a.Name));
@@ -163,7 +218,8 @@ namespace GerbilManagerWebAPI.Import
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
}
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
@@ -234,10 +290,15 @@ namespace GerbilManagerWebAPI.Import
litterParents[lid] = (f, m);
}
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
// before the animals — the offspring AND the parent gerbils are created in the loop
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
// which would cause EF's topo-sort to throw "circular dependency detected".
if (execute)
{
// reuse an existing litter with the same parents+date instead of duplicating.
@@ -246,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
var litterByParentsDate = new Dictionary<string, Guid>();
foreach (var l in existingLitterRows)
{
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
litterParents[l.Id] = (l.FatherId, l.MotherId);
}
foreach (var sl in synthLitters.Values.ToList())
{
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
{
// remap offspring to the existing litter; don't create a duplicate.
@@ -261,13 +322,17 @@ namespace GerbilManagerWebAPI.Import
derivedLitters--;
continue;
}
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
// in this same batch. Setting them now causes EF circular dependency
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
_db.Litters.Add(new Litter
{
Id = sl.Id,
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
Date = sl.Date ?? default,
FatherId = sl.Father,
MotherId = sl.Mother,
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
Date = sl.Date,
FatherId = null, // deferred — applied after gerbils SaveChanges
MotherId = null, // deferred — applied after gerbils SaveChanges
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
});
}
@@ -326,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
if (execute)
{
_db.Gerbils.Add(new Gerbil
var importedGerbil = new Gerbil
{
Id = p.Gid,
Name = p.A.Name,
Gender = p.Gender,
Status = GerbilStatus.Active,
Status = GerbilStatus.Breeding,
DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId,
@@ -355,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag,
}),
});
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
_db.Gerbils.Add(importedGerbil);
}
// photos
@@ -382,6 +449,20 @@ namespace GerbilManagerWebAPI.Import
}
if (execute) await _db.SaveChangesAsync();
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
if (execute && synthLitterPendingParents.Count > 0)
{
foreach (var (litId, (f, m)) in synthLitterPendingParents)
{
var row = await _db.Litters.FindAsync(litId);
if (row is null) continue;
if (f is not null) row.FatherId = f;
if (m is not null) row.MotherId = m;
}
await _db.SaveChangesAsync();
}
// ---- back-link Wurfchronik litter parents by name (best effort) ----
if (execute)
{
@@ -454,29 +535,110 @@ namespace GerbilManagerWebAPI.Import
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
}
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
// Counts already-imported animals where the current extract matched a DIFFERENT
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
int farbschlagWouldRebackfill = plan.Count(p =>
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
{
var noColor = await _db.Gerbils
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
.Select(g => new { g.Id, g.Genotype })
.ToListAsync();
foreach (var g in noColor)
{
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
Guid? derivedVid = null;
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
{ derivedVid = v.Id; break; }
if (derivedVid is null) continue;
fbDerivedFromGenotype++;
if (execute)
{
var row = await _db.Gerbils.FindAsync(g.Id);
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
}
}
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
}
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
int herkunftBackfilled = 0;
{
var needsHerkunft = await _db.Gerbils
.Where(g => g.OriginBreeder == null && g.IsResident)
.Select(g => new { g.Id, g.LitterId })
.ToListAsync();
foreach (var g in needsHerkunft)
{
herkunftBackfilled++;
if (execute)
{
// Prefer the OriginBreeder of an existing parent (father first, then mother).
string? derived = null;
if (g.LitterId is not null)
{
var parentIds = await _db.Litters
.Where(l => l.Id == g.LitterId)
.Select(l => new { l.FatherId, l.MotherId })
.FirstOrDefaultAsync();
if (parentIds?.FatherId is Guid fid)
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
if (derived is null && parentIds?.MotherId is Guid mid)
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
}
var row = await _db.Gerbils.FindAsync(g.Id);
if (row is not null && row.OriginBreeder is null)
row.OriginBreeder = derived ?? DefaultClanBreeder;
}
}
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
}
if (littersWithoutDate > 0)
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
if (parentFksBackfilled > 0)
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
if (herkunftBackfilled > 0)
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
if (farbschlagWouldRebackfill > 0)
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
if (conflictsResolvedByDecision > 0)
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
if (fbDerivedFromGenotype > 0)
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
return new ImportReport(
Executed: execute,
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
parentLinksAdded, conflictsResolvedByDecision),
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples,
Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
}
private T? Load<T>(string file)
@@ -509,6 +671,23 @@ namespace GerbilManagerWebAPI.Import
private static string StripCaret(string allele) => allele.Replace("^", "");
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
{
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (a.Length < 8 || c.Length < 8) return false;
for (int i = 0; i < 8; i++)
{
if (a[i] == "??" || c[i] == "??") continue;
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
}
return true;
}
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
{
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
@@ -566,7 +745,8 @@ namespace GerbilManagerWebAPI.Import
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
private sealed record AnimalPlan(
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
Guid? CurrentColorVarietyId = null);
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);

View File

@@ -0,0 +1,264 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using GerbilManagerWebAPI.Services;
namespace GerbilManagerWebAPI.Import
{
public sealed class IngestResolvedService
{
private readonly ApplicationContext _db;
private readonly string _resolvedJsonPath;
private readonly string _sourceDir;
private readonly string _photoRoot;
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
_sourceDir = config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
_photoRoot = config["Photos:RootPath"]
?? Path.Combine(contentRoot, "photo-storage");
}
public async Task<string> RunAsync()
{
if (!File.Exists(_resolvedJsonPath))
{
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
}
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
var jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
if (data == null)
{
return "Error: Failed to deserialize resolved_import.json.";
}
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
// Break circular references first
foreach (var l in _db.Litters)
{
l.FatherId = null;
l.MotherId = null;
}
foreach (var g in _db.Gerbils)
{
g.LitterId = null;
}
await _db.SaveChangesAsync();
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
_db.WeightRecords.RemoveRange(_db.WeightRecords);
_db.HealthRecords.RemoveRange(_db.HealthRecords);
_db.SaleContracts.RemoveRange(_db.SaleContracts);
_db.Gerbils.RemoveRange(_db.Gerbils);
_db.Litters.RemoveRange(_db.Litters);
_db.Contacts.RemoveRange(_db.Contacts);
await _db.SaveChangesAsync();
}
else
{
// Break circular references first using ExecuteUpdateAsync
await _db.Litters.ExecuteUpdateAsync(s => s
.SetProperty(l => l.FatherId, (Guid?)null)
.SetProperty(l => l.MotherId, (Guid?)null));
await _db.Gerbils.ExecuteUpdateAsync(s => s
.SetProperty(g => g.LitterId, (Guid?)null));
await _db.GerbilPhotos.ExecuteDeleteAsync();
await _db.WeightRecords.ExecuteDeleteAsync();
await _db.HealthRecords.ExecuteDeleteAsync();
await _db.SaleContracts.ExecuteDeleteAsync();
await _db.Gerbils.ExecuteDeleteAsync();
await _db.Litters.ExecuteDeleteAsync();
await _db.Contacts.ExecuteDeleteAsync();
}
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
var addedContactIds = new HashSet<Guid>();
int contactsAdded = 0;
int contactsUpdated = 0;
foreach (var c in data.Contacts)
{
if (existingContacts.TryGetValue(c.Id, out var existingContact))
{
existingContact.Name = c.Name;
existingContact.Email = c.Email;
existingContact.Phone = c.Phone;
existingContact.Address = c.Address;
existingContact.Notes = c.Notes;
existingContact.IsBreeder = c.IsBreeder;
existingContact.IsReceiver = c.IsReceiver;
existingContact.NameSuffix = c.NameSuffix;
existingContact.Provenance = c.Provenance;
contactsUpdated++;
}
else if (addedContactIds.Add(c.Id))
{
_db.Contacts.Add(c);
contactsAdded++;
}
}
await _db.SaveChangesAsync();
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
var littersToInsert = new List<Litter>();
foreach (var l in data.Litters)
{
littersToInsert.Add(new Litter
{
Id = l.Id,
Name = l.Name,
Date = l.Date,
TotalBorn = l.TotalBorn,
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
FatherId = null, // Set in Pass 2
MotherId = null, // Set in Pass 2
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
Notes = l.Notes,
PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter,
Provenance = l.Provenance
});
}
_db.Litters.AddRange(littersToInsert);
await _db.SaveChangesAsync();
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
var gerbilsToInsert = new List<Gerbil>();
foreach (var g in data.Gerbils)
{
var importedGerbil = new Gerbil
{
Id = g.Id,
Name = g.Name,
Gender = g.Gender,
Status = g.Status,
LitterId = null, // Set in Pass 2
OriginContactId = g.OriginContactId,
ReceiverContactId = g.ReceiverContactId,
EnclosureId = g.EnclosureId,
ColorVarietyId = g.ColorVarietyId,
DateOfBirth = g.DateOfBirth,
DateOfDeath = g.DateOfDeath,
CauseOfDeath = g.CauseOfDeath,
GoHomeDate = g.GoHomeDate,
Genotype = g.Genotype,
Notes = g.Notes,
ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData,
Provenance = g.Provenance,
OriginBreeder = g.OriginBreeder,
NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits,
CharacterNote = g.CharacterNote,
IsDeaf = g.IsDeaf,
IsResident = g.IsResident
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
gerbilsToInsert.Add(importedGerbil);
}
_db.Gerbils.AddRange(gerbilsToInsert);
await _db.SaveChangesAsync();
// 5. Pass 2: Set the actual foreign key relationships
// Update Litters with their parent animal IDs
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
foreach (var l in data.Litters)
{
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
{
dbLitter.FatherId = l.FatherId;
dbLitter.MotherId = l.MotherId;
}
}
// Update Gerbils with their actual LitterId
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
foreach (var g in data.Gerbils)
{
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
{
dbGerbil.LitterId = g.LitterId;
}
}
await _db.SaveChangesAsync();
// 6. Insert all Photos & copy physical files
Directory.CreateDirectory(_photoRoot);
foreach (var p in data.GerbilPhotos)
{
if (!string.IsNullOrEmpty(p.SourcePath))
{
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var src = Path.Combine(_sourceDir, relPath);
if (File.Exists(src))
{
var dest = Path.Combine(_photoRoot, p.FileName);
try
{
File.Copy(src, dest, overwrite: true);
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
}
}
else
{
Console.WriteLine($"Warning: Source photo file not found at {src}");
}
}
_db.GerbilPhotos.Add(new GerbilPhoto
{
Id = p.Id,
GerbilId = p.GerbilId,
FileName = p.FileName,
Caption = p.Caption,
SortOrder = p.SortOrder,
CreatedAt = DateTimeOffset.UtcNow
});
}
await _db.SaveChangesAsync();
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
}
}
public class ResolvedImportData
{
public List<Contact> Contacts { get; set; } = new();
public List<Litter> Litters { get; set; } = new();
public List<Gerbil> Gerbils { get; set; } = new();
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
}
public class ResolvedPhoto
{
public Guid Id { get; set; }
public Guid GerbilId { get; set; }
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
[JsonPropertyName("_source_path")]
public string? SourcePath { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesGen3g : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmch DD EE GG PP spsp rere", "CP-Agouti-Hell", 61 },
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmch DD EE gg PP spsp rere", "CP-Silberagouti-Hell", 62 },
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmch DD ee GG PP spsp rere", "CP-Algierfuchs-Hell", 63 },
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmch DD ee gg PP spsp rere", "CP-Polarfuchs-Hell", 64 },
{ new Guid("00000000-0000-0000-0000-000000000066"), "AA cchmch DD efef GG PP spsp rere", "CP-Orangeschimmel-Hell", 65 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class UniqueExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGermanCollation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalRef",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.CreateIndex(
name: "IX_Litters_ExternalRef",
table: "Litters",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Litters_ExternalRef",
table: "Litters");
migrationBuilder.DropColumn(
name: "ExternalRef",
table: "Litters");
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterLetter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LitterLetter",
table: "Litters",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LitterLetter",
table: "Litters");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,598 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesAR5 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
column: "Name",
value: "Dilute Gold");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
column: "Name",
value: "Dilute Platin");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
column: "Name",
value: "Dilute Agouti");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
column: "Name",
value: "Dilute Silberagouti");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
column: "Name",
value: "Dilute Kohlfuchs");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
column: "Name",
value: "Dilute Anthrazit");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
column: "SortOrder",
value: 36);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "SortOrder",
value: 37);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "SortOrder",
value: 38);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "SortOrder",
value: 39);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "SortOrder",
value: 40);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
column: "SortOrder",
value: 41);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
column: "SortOrder",
value: 42);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "SortOrder",
value: 43);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
column: "SortOrder",
value: 44);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
column: "SortOrder",
value: 45);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "SortOrder",
value: 46);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
column: "SortOrder",
value: 47);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "SortOrder",
value: 48);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
column: "SortOrder",
value: 49);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
column: "SortOrder",
value: 50);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "SortOrder",
value: 51);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Dilute Topas", 52 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Dilute Blaufuchs", 53 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "SortOrder",
value: 54);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
column: "SortOrder",
value: 55);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "SortOrder",
value: 56);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
column: "SortOrder",
value: 57);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
column: "SortOrder",
value: 59);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
column: "SortOrder",
value: 61);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
column: "SortOrder",
value: 63);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
column: "SortOrder",
value: 65);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
column: "SortOrder",
value: 66);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
column: "SortOrder",
value: 67);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
column: "SortOrder",
value: 68);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
column: "SortOrder",
value: 58);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
column: "SortOrder",
value: 60);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
column: "SortOrder",
value: 62);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
column: "SortOrder",
value: 69);
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA CC dd ee GG PP spsp rere", "Dilute Algierfuchs", 32 },
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC dd ee GG pp spsp rere", "Dilute Goldfuchs", 33 },
{ new Guid("00000000-0000-0000-0000-000000000069"), "aa CC dd ee GG pp spsp rere", "Dilute Rotfuchs", 34 },
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd ee gg PP spsp rere", "Dilute Polarfuchs", 35 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
column: "Name",
value: "dd Gold");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
column: "Name",
value: "dd Platin");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
column: "Name",
value: "Agouti dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
column: "Name",
value: "Silberagouti dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
column: "Name",
value: "Kohlfuchs dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
column: "Name",
value: "Anthrazit dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
column: "SortOrder",
value: 32);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "SortOrder",
value: 33);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "SortOrder",
value: 34);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "SortOrder",
value: 35);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "SortOrder",
value: 36);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
column: "SortOrder",
value: 37);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
column: "SortOrder",
value: 38);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "SortOrder",
value: 39);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
column: "SortOrder",
value: 40);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
column: "SortOrder",
value: 41);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "SortOrder",
value: 42);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
column: "SortOrder",
value: 43);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "SortOrder",
value: 44);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
column: "SortOrder",
value: 45);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
column: "SortOrder",
value: 46);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "SortOrder",
value: 47);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Topas dd", 48 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Blaufuchs dd", 49 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "SortOrder",
value: 50);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
column: "SortOrder",
value: 51);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "SortOrder",
value: 52);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
column: "SortOrder",
value: 53);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
column: "SortOrder",
value: 54);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
column: "SortOrder",
value: 55);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
column: "SortOrder",
value: 56);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
column: "SortOrder",
value: 57);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
column: "SortOrder",
value: 58);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
column: "SortOrder",
value: 59);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
column: "SortOrder",
value: 60);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
column: "SortOrder",
value: 61);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
column: "SortOrder",
value: 62);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
column: "SortOrder",
value: 63);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
column: "SortOrder",
value: 65);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class RenameREW : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "REW");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "Pink Eyed White (PEW)");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class StatusModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Rename string value 'Active' → 'Breeding'.
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
// 2) DateOfDeath set → Deceased (always takes precedence).
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
WHERE ""Status"" != 'Deceased'
AND (""ReceiverContactId"" IS NOT NULL
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
AND ""DateOfBirth"" IS NOT NULL
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterMortality : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DeathsWithin8Weeks",
table: "Litters",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeathsWithin8Weeks",
table: "Litters");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddSpottingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SpottingType",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SpottingType",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContactRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBreeder",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsReceiver",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBreeder",
table: "Contacts");
migrationBuilder.DropColumn(
name: "IsReceiver",
table: "Contacts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddIsCastrated : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCastrated",
table: "Gerbils",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCastrated",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddBreedingNameSuffix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NameSuffix",
table: "Contacts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NameSuffix",
table: "BreederSettings",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "BreederSettings",
keyColumn: "Id",
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
column: "NameSuffix",
value: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NameSuffix",
table: "Contacts");
migrationBuilder.DropColumn(
name: "NameSuffix",
table: "BreederSettings");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddEnclosurePhotos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EnclosurePhotos",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
EnclosureId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
Caption = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EnclosurePhotos", x => x.Id);
table.ForeignKey(
name: "FK_EnclosurePhotos_Enclosures_EnclosureId",
column: x => x.EnclosureId,
principalTable: "Enclosures",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EnclosurePhotos_EnclosureId",
table: "EnclosurePhotos",
column: "EnclosureId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EnclosurePhotos");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class MakeLitterDateNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateOnly>(
name: "Date",
table: "Litters",
type: "date",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateOnly>(
name: "Date",
table: "Litters",
type: "date",
nullable: false,
defaultValue: new DateOnly(1, 1, 1),
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContractAnimalPhoto : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "PhotoId",
table: "SaleContractAnimal",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PhotoId",
table: "SaleContractAnimal");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedback : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Feedback",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Context = table.Column<string>(type: "text", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
EntityName = table.Column<string>(type: "text", nullable: true),
Url = table.Column<string>(type: "text", nullable: true),
ClientTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
UserAgent = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Feedback", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Feedback_CreatedAt",
table: "Feedback",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGerbilProvenance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Provenance",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackContactId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ContactId",
table: "Feedback",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContactId",
table: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContactLitterProvenance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Contacts",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Provenance",
table: "Litters");
migrationBuilder.DropColumn(
name: "Provenance",
table: "Contacts");
}
}
}

View File

@@ -164,6 +164,10 @@ namespace GerbilManagerWebAPI.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("NameSuffix")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
@@ -185,6 +189,7 @@ namespace GerbilManagerWebAPI.Migrations
Email = "",
Homepage = "",
Name = "",
NameSuffix = "",
Phone = "",
ZuchtName = ""
});
@@ -201,7 +206,8 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<int>("SortOrder")
.HasColumnType("integer");
@@ -215,7 +221,7 @@ namespace GerbilManagerWebAPI.Migrations
{
Id = new Guid("00000000-0000-0000-0000-000000000001"),
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
Name = "Pink Eyed White (PEW)",
Name = "REW",
SortOrder = 0
},
new
@@ -320,14 +326,14 @@ namespace GerbilManagerWebAPI.Migrations
{
Id = new Guid("00000000-0000-0000-0000-000000000016"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "dd Gold",
Name = "Dilute Gold",
SortOrder = 15
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000017"),
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
Name = "dd Platin",
Name = "Dilute Platin",
SortOrder = 16
},
new
@@ -411,28 +417,28 @@ namespace GerbilManagerWebAPI.Migrations
{
Id = new Guid("00000000-0000-0000-0000-000000000029"),
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
Name = "Agouti dd",
Name = "Dilute Agouti",
SortOrder = 28
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000030"),
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
Name = "Silberagouti dd",
Name = "Dilute Silberagouti",
SortOrder = 29
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000031"),
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
Name = "Kohlfuchs dd",
Name = "Dilute Kohlfuchs",
SortOrder = 30
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000032"),
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
Name = "Anthrazit dd",
Name = "Dilute Anthrazit",
SortOrder = 31
},
new
@@ -440,203 +446,266 @@ namespace GerbilManagerWebAPI.Migrations
Id = new Guid("00000000-0000-0000-0000-000000000033"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Silberschimmel",
SortOrder = 32
SortOrder = 36
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Polarfuchsschimmel",
SortOrder = 33
SortOrder = 37
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Algierfuchsschimmel",
SortOrder = 34
SortOrder = 38
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel",
SortOrder = 35
SortOrder = 39
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000037"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
Name = "Blaufuchsschimmel",
SortOrder = 36
SortOrder = 40
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000038"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs, hell",
SortOrder = 37
SortOrder = 41
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000039"),
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
Name = "Goldfuchs, hell",
SortOrder = 38
SortOrder = 42
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
Name = "Goldfuchsschimmel",
SortOrder = 39
SortOrder = 43
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000041"),
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Gold-Hell",
SortOrder = 40
SortOrder = 44
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000042"),
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
Name = "Blaufuchs, hell",
SortOrder = 41
SortOrder = 45
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
Name = "Rotfuchsschimmel",
SortOrder = 42
SortOrder = 46
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000044"),
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
Name = "Polarfuchs, hell",
SortOrder = 43
SortOrder = 47
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000045"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel, hell",
SortOrder = 44
SortOrder = 48
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000046"),
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
Name = "Rotfuchs, hell",
SortOrder = 45
SortOrder = 49
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000047"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs-Hell",
SortOrder = 46
SortOrder = 50
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000048"),
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
Name = "Algierfuchs, hell",
SortOrder = 47
SortOrder = 51
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000049"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "Topas dd",
SortOrder = 48
Name = "Dilute Topas",
SortOrder = 52
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000050"),
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
Name = "Blaufuchs dd",
SortOrder = 49
Name = "Dilute Blaufuchs",
SortOrder = 53
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000051"),
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
Name = "Marder",
SortOrder = 50
SortOrder = 54
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000052"),
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
Name = "Siam",
SortOrder = 51
SortOrder = 55
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000053"),
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
Name = "Zobel-Hell",
SortOrder = 52
SortOrder = 56
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000054"),
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
Name = "CP-Agouti",
SortOrder = 53
SortOrder = 57
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000055"),
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
Name = "CP-Silberagouti",
SortOrder = 54
SortOrder = 59
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000056"),
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "CP-Algierfuchs",
SortOrder = 55
SortOrder = 61
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000057"),
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
Name = "CP-Polarfuchs",
SortOrder = 56
SortOrder = 63
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000058"),
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
Name = "CP-Fuchs",
SortOrder = 57
SortOrder = 65
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000059"),
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
Name = "CP-Fuchs-Hell",
SortOrder = 58
SortOrder = 66
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000060"),
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
Name = "CP-Blaufuchs",
SortOrder = 59
SortOrder = 67
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000061"),
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel",
SortOrder = 68
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
Name = "CP-Agouti-Hell",
SortOrder = 58
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
Name = "CP-Silberagouti-Hell",
SortOrder = 60
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
Name = "CP-Algierfuchs-Hell",
SortOrder = 62
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000065"),
CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere",
Name = "CP-Polarfuchs-Hell",
SortOrder = 64
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel-Hell",
SortOrder = 69
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000067"),
CanonicalGenotype = "AA CC dd ee GG PP spsp rere",
Name = "Dilute Algierfuchs",
SortOrder = 32
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000068"),
CanonicalGenotype = "AA CC dd ee GG pp spsp rere",
Name = "Dilute Goldfuchs",
SortOrder = 33
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000069"),
CanonicalGenotype = "aa CC dd ee GG pp spsp rere",
Name = "Dilute Rotfuchs",
SortOrder = 34
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000070"),
CanonicalGenotype = "AA CC dd ee gg PP spsp rere",
Name = "Dilute Polarfuchs",
SortOrder = 35
});
});
@@ -652,8 +721,18 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("IsBreeder")
.HasColumnType("boolean");
b.Property<bool>("IsReceiver")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("NameSuffix")
.HasColumnType("text");
b.Property<string>("Notes")
@@ -662,6 +741,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Phone")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Contacts");
@@ -685,6 +767,80 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Enclosures");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Caption")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EnclosureId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EnclosureId");
b.ToTable("EnclosurePhotos");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ContactId")
.HasColumnType("uuid");
b.Property<string>("Context")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName")
.HasColumnType("text");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.HasColumnType("text");
b.Property<string>("UserAgent")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("Feedback");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.Property<Guid>("Id")
@@ -729,6 +885,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("ImportSource")
.HasColumnType("text");
b.Property<bool>("IsCastrated")
.HasColumnType("boolean");
b.Property<bool?>("IsDeaf")
.HasColumnType("boolean");
@@ -742,26 +901,35 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("NameSearch")
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("OriginBreeder")
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<string>("RawImportData")
.HasColumnType("text");
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
b.Property<string>("SpottingType")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
@@ -772,6 +940,10 @@ namespace GerbilManagerWebAPI.Migrations
b.HasIndex("EnclosureId");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("LitterId");
b.HasIndex("OriginContactId");
@@ -849,15 +1021,24 @@ namespace GerbilManagerWebAPI.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
b.Property<DateOnly?>("Date")
.HasColumnType("date");
b.Property<int?>("DeathsWithin8Weeks")
.HasColumnType("integer");
b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date");
b.Property<string>("ExternalRef")
.HasColumnType("text");
b.Property<Guid?>("FatherId")
.HasColumnType("uuid");
b.Property<string>("LitterLetter")
.HasColumnType("text");
b.Property<Guid?>("MotherId")
.HasColumnType("uuid");
@@ -871,11 +1052,18 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("FatherId");
b.HasIndex("MotherId");
@@ -1137,6 +1325,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("PhotoId")
.HasColumnType("uuid");
b.HasKey("SaleContractId", "GerbilId");
b.HasIndex("GerbilId");
@@ -1205,6 +1396,15 @@ namespace GerbilManagerWebAPI.Migrations
.IsRequired();
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null)
.WithMany()
.HasForeignKey("EnclosureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")

View File

@@ -18,6 +18,13 @@ namespace GerbilManagerWebAPI.Models
public string ZuchtName { get; set; } = "";
/// <summary>
/// Namens-Anhängsel der eigenen Zucht (wie ein „Nachname"), z. B.
/// „von den kleinen Chaoten“. Wird an Namen eigener (selbst gezüchteter)
/// Tiere angehängt: „Danako von den kleinen Chaoten“.
/// </summary>
public string NameSuffix { get; set; } = "";
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
public string Name { get; set; } = "";

View File

@@ -15,5 +15,20 @@ namespace GerbilManagerWebAPI.Models
public string? Phone { get; set; }
public string? Address { get; set; }
public string? Notes { get; set; }
public bool IsBreeder { get; set; }
public bool IsReceiver { get; set; }
/// <summary>
/// Namens-Anhängsel dieser Zucht (wie ein „Nachname"), z. B.
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
/// </summary>
public string? NameSuffix { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this contact (sourceFiles, mergedRecordCount,
/// notes). Written by the Python merge_and_resolve step and surfaced read-only
/// ("Datenherkunft"). Null = manually-added contact / no import data.</summary>
public string? Provenance { get; set; }
}
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>A photo of an enclosure (Gehege). Mirrors <see cref="GerbilPhoto"/>:
/// the file lives under the shared photo-storage root; only the file name is in the DB.</summary>
public class EnclosurePhoto
{
[Key]
public Guid Id { get; set; }
public Guid EnclosureId { get; set; }
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the
/// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced
/// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete
/// gerbils/litters without deleting or breaking feedback rows. The captured EntityName
/// keeps the report human-readable even after the referenced animal is gone.
/// </summary>
public class Feedback
{
[Key]
public Guid Id { get; set; }
/// <summary>The user's free-text description of the problem.</summary>
public required string Message { get; set; }
/// <summary>Which view the report came from: stammbaum | gerbil-detail | litter-detail.</summary>
public required string Context { get; set; }
/// <summary>Loose reference (no FK) to the gerbil the report is about, if any.</summary>
public Guid? GerbilId { get; set; }
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
public Guid? LitterId { get; set; }
/// <summary>Loose reference (no FK) to the contact the report is about, if any.</summary>
public Guid? ContactId { get; set; }
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
public string? EntityName { get; set; }
/// <summary>The client URL/route the report was filed from.</summary>
public string? Url { get; set; }
/// <summary>Client-supplied timestamp (when the user submitted, in their browser).</summary>
public DateTimeOffset? ClientTimestamp { get; set; }
/// <summary>Optional browser user-agent for diagnostics.</summary>
public string? UserAgent { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
public Guid Id { get; set; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
// Birth litter (the litter this gerbil was born in).
public Guid? LitterId { get; set; }
@@ -41,12 +41,21 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
public string? Genotype { get; set; }
public string? SpottingType { get; set; }
public string? Notes { get; set; }
// Provenance (for the FEAT-8 spreadsheet import).
public string? ImportSource { get; set; }
public string? ExternalRef { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this entry (sourceFiles, mergedRecordCount,
/// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python
/// merge_and_resolve step and surfaced read-only in the Rennmausakte
/// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data.</summary>
public string? Provenance { get; set; }
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; }
@@ -80,6 +89,7 @@ namespace GerbilManagerWebAPI.Models
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
public bool IsResident { get; set; } = true;
public bool IsCastrated { get; set; }
}
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>

View File

@@ -1,12 +1,17 @@
namespace GerbilManagerWebAPI.Models
{
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
public enum GerbilStatus
{
Active = 0,
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
Breeding = 0,
Deceased = 1,
GivenAway = 2,
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
ForSale = 3
ForSale = 3,
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
Pet = 4,
}
}

View File

@@ -8,11 +8,15 @@ namespace GerbilManagerWebAPI.Models
[Key]
public Guid Id { get; set; }
public required string Name { get; set; }
public DateOnly Date { get; set; }
public DateOnly? Date { get; set; }
/// <summary>Total born count (was "Strength").</summary>
public int? TotalBorn { get; set; }
/// <summary>Frühsterblichkeit: Anzahl der in den ersten 8 Wochen verstorbenen Tiere.
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
public int? DeathsWithin8Weeks { get; set; }
public Guid? FatherId { get; set; }
public Gerbil? Father { get; set; }
public Guid? MotherId { get; set; }
@@ -25,5 +29,21 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
public string? PairingCode { get; set; }
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
/// Name+Date is the fallback for litters created before this column existed.</summary>
public string? ExternalRef { get; set; }
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this litter (sourceFiles, mergedRecordCount,
/// fromWurfchronik, notes — e.g. "aus Wurfchronik", "aus Stammbaum-Diagramm rekonstruiert").
/// Written by the Python merge_and_resolve step and surfaced read-only ("Datenherkunft").
/// Null = manually-added litter / no import data.</summary>
public string? Provenance { get; set; }
}
}

View File

@@ -41,5 +41,9 @@ namespace GerbilManagerWebAPI.Models
public Guid SaleContractId { get; set; }
public Guid GerbilId { get; set; }
public Gerbil? Gerbil { get; set; }
/// <summary>Optional gewähltes Tierfoto (<see cref="GerbilPhoto"/>) fürs Vertragsbild.
/// Nur die Id wird gespeichert; beim PDF-Rendern wird die Datei nachgeladen.</summary>
public Guid? PhotoId { get; set; }
}
}

View File

@@ -0,0 +1,5 @@
namespace GerbilManagerWebAPI.Names
{
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
}

View File

@@ -0,0 +1,162 @@
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Ai;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Names
{
/// <summary>
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
/// Returns NotConfigured when the AI section is missing — callers map to 503.
/// </summary>
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
{
private readonly OpenAiChatClient _client = new(http, options);
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
};
// Known usage codes → (German description, isThematic).
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
new(StringComparer.OrdinalIgnoreCase)
{
["norn"] = ("Nordische/Altnordische Etymologie", false),
["japa"] = ("Japanische Etymologie", false),
["mythg"] = ("Griechische Mythologie", false),
["ger"] = ("Germanische/Deutsche Etymologie", false),
["arb"] = ("Arabische Etymologie", false),
["disney"] = ("Disney-Charaktere", true),
["pokemon"] = ("Pokémon-Namen", true),
["encities"] = ("Namen englischer Städte", true),
["hrcities"] = ("Namen kroatischer Städte", true),
["usstates"] = ("Namen von US-Bundesstaaten", true),
};
public async Task<NameSuggestionResult> SuggestAsync(
string? letter, string? gender, string? usages, int count,
CancellationToken ct = default)
{
var aiResult = await _client.CompleteAsync(
BuildSystemPrompt(),
BuildUserPrompt(letter, gender, usages, count),
ct);
if (aiResult.Status == AiCallStatus.NotConfigured)
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
var suggestions = ParseSuggestions(aiResult.Text);
return suggestions is null
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
}
internal static string BuildSystemPrompt() =>
"Du bist ein Helfer für Rennmaus-Züchter. " +
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
"Erfinde KEINE Etymologie für thematische Kategorien.";
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
{
var sb = new StringBuilder();
sb.Append($"Schlage {count} Rennmaus-Namen vor");
if (!string.IsNullOrWhiteSpace(letter))
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
if (!string.IsNullOrWhiteSpace(gender) &&
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
bool hasThematic = false;
if (!string.IsNullOrWhiteSpace(usages))
{
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var etymDescriptions = new List<string>();
var thematicDescriptions = new List<string>();
foreach (var code in codes)
{
if (UsageMap.TryGetValue(code, out var entry))
{
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
else etymDescriptions.Add(entry.Description);
}
else
{
etymDescriptions.Add(code);
}
}
if (etymDescriptions.Count > 0)
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
if (thematicDescriptions.Count > 0)
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
hasThematic = thematicDescriptions.Count > 0;
}
sb.Append(". ");
if (hasThematic)
{
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
}
else
{
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). ");
}
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
return sb.ToString();
}
/// <summary>
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
/// its JSON output, then deserialises the array.
/// </summary>
internal static List<NameSuggestion>? ParseSuggestions(string raw)
{
var text = raw.Trim();
// Strip ```json ... ``` or ``` ... ``` fences.
if (text.StartsWith("```", StringComparison.Ordinal))
{
var firstNewline = text.IndexOf('\n');
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
if (text.EndsWith("```", StringComparison.Ordinal))
text = text[..^3].TrimEnd();
}
// Find the JSON array bounds defensively.
var start = text.IndexOf('[');
var end = text.LastIndexOf(']');
if (start < 0 || end <= start) return null;
text = text[start..(end + 1)];
try
{
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
}
catch (JsonException)
{
return null;
}
}
}
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
public sealed record NameSuggestionResult(
NameSuggestionStatus Status,
List<NameSuggestion>? Suggestions,
string? Error);
}

View File

@@ -1,5 +1,8 @@
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
@@ -44,9 +47,25 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
builder.Services.AddDataProtection();
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
// In prod the path is mounted to a persistent volume (compose DataProtection__KeyRingPath).
// In dev (Aspire) keys live in the content root — ephemeral, which is fine there.
{
var keyRingPath = builder.Configuration["DataProtection:KeyRingPath"]
?? Path.Combine(builder.Environment.ContentRootPath, ".data-protection-keys");
Directory.CreateDirectory(keyRingPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
.SetApplicationName("GerbilManager");
}
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
@@ -67,7 +86,23 @@ app.MapScalarApiReference();
if (!app.Environment.IsEnvironment("Testing"))
{
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
db.Database.Migrate();
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
// since the last write. No-op if all statuses are already current.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var candidates = await db.Gerbils
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
&& g.DateOfDeath == null && g.ReceiverContactId == null
&& g.DateOfBirth != null
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
.ToListAsync();
if (candidates.Count > 0)
{
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
await db.SaveChangesAsync();
}
}
app.UseCors(LanCorsPolicy);
@@ -84,11 +119,14 @@ app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.MapSaleAdEndpoints();
app.MapImportEndpoints();
app.MapImportDocxEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.Run();

View File

@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
namespace GerbilManagerWebAPI.SaleAd
@@ -13,37 +13,31 @@ namespace GerbilManagerWebAPI.SaleAd
public static class SaleAdPromptBuilder
{
/// <summary>
/// Two few-shot examples derived from the documented listing template.
/// NOTE: the research file documents the format and one real tagline;
/// these examples are synthesized to that template. Swap in verbatim
/// listings from kleine-chaoten.jimdofree.com when available.
/// Two verbatim listings from kleine-chaoten.jimdofree.com (retrieved 2026-06-06).
/// These are real listings in the breeder's own voice — the strongest grounding source.
/// </summary>
private const string ExampleListing1 = """
Status: FREI
Status: LOCKER RESERVIERT LEONIE
Großer und kleiner Bruder Dynamik Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
Großer und kleiner Bruder Dynamik Männliches Rennmaus Duo sucht ein liebevolles Zuhause
Balu CP-Agouti, geboren am 12.03.2024
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
Hier steht ein außergewöhnlich harmonisches Rennmaus-Duo zur Abgabe frei, das nicht nur durch seinen Charakter, sondern auch durch seine besondere Geschichte überzeugt. Die zwei verhalten sich als ob sie Brüder wären und sind unzertrennlich. Das Duo kann ab sofort ausziehen.
Benny Schwarz Schecke, geboren am 12.03.2024
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest getrennt werden die beiden deshalb nicht.
Der Ältere von beiden trägt die Farbe CP-Agouti und ist am 11.11.2025 geboren. Er ist ein neugieriger, aktiver und sozialer kleiner Kerl. Er liebt es, seine Umgebung zu erkunden, ist gerne in Bewegung und bringt dabei eine angenehme Ausgeglichenheit mit. Er wirkt sicher, aufmerksam und zeigt ein rundum unkompliziertes Wesen.
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
Sein, etwas jünger, Partner trägt die Farbe Schwarz Schecke und ist am 26.02.2026 geboren. Statt des in diesem Alter oft typischen Kräftemessens zeigt er ein erstaunlich sanftes, ruhiges und sehr soziales Verhalten. Man merkt ihm an, wie feinfühlig und verträglich er ist.
""";
private const string ExampleListing2 = """
Status: LOCKER RESERVIERT Anna
Status: FREI
Zwei Schwestern, ein Herz und ganz viel Neugier Weibliches Duo sucht seine Menschen
Unzertrennliches Mutter-Tochter Duo sucht gemeinsam neue Körnergeber
Frieda Gold, geboren am 28.06.2024
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
Die Mama ist am 13.08.2025 geboren und trägt die Farbe Kohlfuchs. Sie ist eine eher ruhige und vorsichtige Rennmaus, die neuen Situationen zunächst aufmerksam begegnet. Gibt man ihr etwas Zeit, zeigt sich schnell ihre neugierige Seite und sie kommt gerne schauen, was im Gehege passiert. Sie ist ein echtes Energiebündel, liebt es zu buddeln, zu rennen und alles an Beschäftigungsmaterial besonders Klorollen kreativ umzugestalten. Charakterlich zeigt sie sich sehr sozial und friedlich; besonders hervorzuheben ist ihre fürsorgliche Art als Mutter.
Fine Agouti, geboren am 28.06.2024
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
Die Tochter ist am 26.02.2026 geboren und trägt die Farbe Agouti-Schecke. Sie ist ihrer Mama sehr ähnlich, dabei aber schon ein kleines bisschen mutiger. Auch sie beobachtet erst gerne in Ruhe, bevor die Neugier siegt. Sie ist sehr anhänglich und orientiert sich stark an ihrer Mutter.
Die Schwestern ziehen selbstverständlich nur zusammen um.
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
""";
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>

View File

@@ -0,0 +1,53 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Services
{
/// <summary>
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
/// derived statuses (Deceased, GivenAway) are always consistent.
///
/// Precedence (highest wins):
/// 1) DateOfDeath set → Deceased (explicit, always)
/// 2) Abgabe (ReceiverContactId set) → GivenAway
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
///
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
/// a) Apply() on every write (catches the animal at write time)
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
/// </summary>
public static class GerbilStatusService
{
public const int MaxAgeYears = 6;
/// <summary>Derives and sets g.Status using the gerbil's current field values.
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
public static void Apply(Gerbil g, DateOnly today)
{
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
isAbgegeben: g.ReceiverContactId is not null, today);
}
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
public static GerbilStatus Derive(
GerbilStatus requested,
DateOnly? dateOfBirth,
DateOnly? dateOfDeath,
bool isAbgegeben,
DateOnly today)
{
if (dateOfDeath is not null) return GerbilStatus.Deceased;
if (isAbgegeben) return GerbilStatus.GivenAway;
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
return GerbilStatus.Deceased;
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
? requested : GerbilStatus.Breeding;
}
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
today >= dob.AddYears(years);
}
}

View File

@@ -1,168 +0,0 @@
# HUMANQUESTION.md — offene Fragen & benötigte Daten
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
> alles wartet geduldig, bis ihr antwortet.
_Stand: 2026-06-06._
Der Manager ist fertig und läuft. Die alten Daten sind importiert
(**723 Würfe + 247 Tiere + 99 Fotos** sind drin). Mehrere KI-Funktionen sind
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist.
Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
---
## A. Für Julian — Konten, Schlüssel, Technik
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|---|---|---|---|
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
| A5 | **TrueNAS / Gitea — 4 Antworten:** (a) TrueNAS SCALE-Version? (Electric Eel 24.10+?) (b) Gitea Actions aktiviert/aktivierbar? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Welcher Dataset-Pfad für Daten/Backups, und ist Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose + CI sind fertig vorbereitet) |
## B. Für Julian — kleine Aktionen (jederzeit)
| # | Aktion | Warum |
|---|---|---|
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
---
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
| # | Frage | Blockiert |
|---|---|---|
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef`**Orangeschimmel** · `efef pp`**Rotaugenschimmel** · `efef gg`**Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef`**CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
---
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|---|---|---|
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
| Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---|
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-****DD** | ✅ **D-** (DD war Tippfehler) — Julian |
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-****PP** | ✅ **PP** — Julian |
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-****DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-****Cc[h]** · E-Locus: **E-****EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee****ee** · P-Locus: **P-****Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee****ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`****ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
>
> Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
| Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---|
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-****DD** (WP gleich in beiden) | ✅ **DD** — Julian |
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]****ee[f]****Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
### D5 · **Sterbedatum** widersprüchlich
| Tier | Problem |
|---|---|
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
| Tier | Konkreter Konflikt — was stimmt? |
|---|---|
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019****14.01.2020** (= D5) |
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP****Pp** |
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp****spsp** (Schecke ja/nein) |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016****2018** |
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
---
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
sagen, was ergänzt oder gestrichen werden soll:**
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
---
## F. Was hängt woran (Kurzübersicht)
| Funktion | Status | Wartet auf |
|---|---|---|
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
| Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2 ✅) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
---
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
sie hier und meldet, was dadurch live geht.*

105
HUMANQUESTION.md.migrated Normal file
View File

@@ -0,0 +1,105 @@
# HUMANQUESTION.md — offene Fragen & benötigte Daten
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
> das, was **noch offen** ist. Nichts geht verloren.
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
Feinschliff** — siehe unten._
---
## A. Zugänge & Technik (für Julian)
| # | Was gebraucht wird | Schaltet frei |
|---|---|---|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
## B. Kleine Aktion (jederzeit)
| # | Aktion | Warum |
|---|---|---|
| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
## C. Genetik-Feinschliff (optional, blockiert nichts)
| # | Frage | Betrifft |
|---|---|---|
| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|---|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|---|---|---|
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
---
## F. Was hängt woran (Kurzübersicht)
| Funktion | Status | Wartet auf |
|---|---|---|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — |
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
---
## ✅ Erledigt (Archiv — nichts geht verloren)
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
- **C1C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
- **D1D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
---
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*

126
README.md
View File

@@ -1,31 +1,95 @@
# GerbilManager
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
# Requirements
Install ef:
dotnet tool install --global dotnet-ef
# Used cmd´s
## Start containers
docker compose up
## Create the initial database
dotnet ef database update
## Create an migration script
dotnet ef migrations add <name>
## Docker
### force compose rebuild
docker-compose build --no-cache
## Frontend (gerbil-manager-web)
cd gerbil-manager-web
npm install
npm run dev
TODO:
- Backup docker volume database
- run "dotnet ef database update" if no database exist.
# GerbilManager
A manager application for gerbil breeding data featuring a .NET Aspire backend and a React-based frontend.
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger under `/swagger`)
- `gerbil-manager-web/` — React Frontend (Vite + TypeScript), see `gerbil-manager-web/README.md`
- `tools/import/` — Python import pipeline scripts and database schemas
## Requirements
To manage database migrations, install the EF Core CLI tool globally:
```bash
dotnet tool install --global dotnet-ef
```
## Running the Application
To run the full stack (PostgreSQL database, WebAPI, and Frontend Vite app) in one command:
```bash
dotnet run --project GerbilManager.AppHost
```
---
## 📥 Data Import & Normalization Pipeline
The application features a Python-based import pipeline that compiles gerbil breeding records, lineage, photos, and contact information from source files (markdown logs, Excel spreadsheets, Word documents) into a unified JSON format, and ingests them into the PostgreSQL database.
### 1. Structure of the Pipeline
* **Source Data**: Located at `C:\Users\gulum\dev\Wurfchronik_Bilder` (contains markdown files, `.xlsx` pedigree trees, and detail logs).
* **Compilation**: [merge_and_resolve.py](file:///C:/Users/gulum/dev/GerbilManager/tools/import/merge_and_resolve.py) processes the source files, extracts breeding details, resolves lineage links, maps colors/genotypes, and normalizes contact names.
* **Normalization Mapping**: `merge_and_resolve.py` contains a `norm_map` dictionary of contact name normalizations. This maps various spelling variations and abbreviations to a single canonical name.
### 2. How to Run the Import
#### Automatic Way (All-in-One)
The PowerShell script in `tools/import` boots the Aspire stack, waits for the API to become ready, and automatically triggers the ingest:
```powershell
powershell -ExecutionPolicy Bypass -File tools/import/run_app_and_import.ps1
```
#### Manual Way
1. **Recompile the import data**:
Run the compiler script from the root directory to parse raw sources and output a fresh `resolved_import.json`:
```bash
python tools/import/merge_and_resolve.py
```
2. **Ingest into the running database**:
Ensure the application is running, then send an HTTP `POST` request to the API:
```bash
Invoke-RestMethod -Uri "http://localhost:5179/import/ingest-resolved" -Method Post
```
---
## 👥 Contact Deduplication Workflow
When the database is wiped and re-imported, you can detect and resolve duplicate contacts (e.g. spelling mistakes, initials vs full names) using the deduplication checklist workflow.
### 1. Search for Duplicate Contacts
You can run duplicate checks using two different similarity parameter models:
* **Round 1 (Clever matching on first 4 characters)**:
```bash
python tools/import/find_duplicates_v5.py
```
Creates: `tools/import/output/potential_duplicates_decision.md`
* **Round 2 (Broader matching on initials, matching last names, phones, or emails)**:
```bash
python tools/import/find_duplicates_round2.py
```
Creates: `tools/import/output/potential_duplicates_decision_round2.md`
### 2. Fill out the Checklist
Open the generated markdown checklist file and edit the checkboxes:
* **Combine/Merge**: Put an `x` in the checkbox `[x]` of the name you want to choose as the **canonical name**. Leave the checkboxes empty `[ ]` for any names you want to **merge into the canonical name**.
* **Standalone**: Put an `o` in the checkbox `[o]` of any name that should remain a separate, standalone contact.
* **New Name**: Put an `x` next to `Custom name:` and type your new desired name inside the backticks.
* **Keep all separate**: Leave all checkboxes in a Choice empty `[ ]`.
*Example:*
```markdown
- [x] Max Mustermann <-- canonical name
- [ ] Max M. <-- merges into Max Mustermann
- [o] Moritz Mustermann <-- remains a separate standalone contact
```
### 3. Apply the Decisions
Once you have saved your decisions in the checklist file, run the parser:
```bash
python tools/import/parse_decisions.py
```
This automatically parses your selections, resolves the merges, and updates the `norm_map` inside `merge_and_resolve.py` so they are **permanently saved** in the codebase.
Run the compilation pipeline again to produce the final deduplicated import JSON:
```bash
python tools/import/merge_and_resolve.py
```

View File

@@ -1,4 +1,4 @@
# GerbilManager Produktionskonfiguration
# GerbilManager Produktionskonfiguration
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
@@ -7,17 +7,28 @@ POSTGRES_PASSWORD=aendere_mich_bitte
# Externer Port fuer das Frontend (Standard: 80)
PORT=80
# Gitea Container Registry (Standard: truenas:13000/gulum)
REGISTRY=truenas:13000/gulum
# Container Registry (git.rismer.de/gulum)
REGISTRY=git.rismer.de/gulum
TAG=latest
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
ANTHROPIC_API_KEY=
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
PUBLICSITE_PORT=8081
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
AI__BaseUrl=
AI__ApiKey=
AI__Model=gemini-flash-latest

View File

@@ -1,4 +1,4 @@
# GerbilManager — TrueNAS Custom Application
# GerbilManager — TrueNAS Custom Application
# ==============================================
# Vor dem ersten Start:
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
@@ -29,7 +29,7 @@ services:
# --- .NET API (GerbilManagerWebAPI) ---
api:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
build:
context: ../..
dockerfile: GerbilManagerWebAPI/Dockerfile
@@ -40,10 +40,20 @@ services:
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
Photos__RootPath: /data/photos
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
# AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments)
DataProtection__KeyRingPath: /data/keys
# AR-4: KI-Funktionen (Verkaufstext + Posteingang-Entwurf, Sektion AI; beliebiger OpenAI-kompatibler Anbieter)
# Anbieter-Optionen und Schlüssel-Beispiele: docs/ai-provider.md
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-flash-latest}"
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
PublicSite__RootPath: /data/publicsite
volumes:
- photos:/data/photos
- keys:/data/keys
- publicsite:/data/publicsite
depends_on:
db:
condition: service_healthy
@@ -56,7 +66,7 @@ services:
# --- nginx Frontend (React SPA + API-Proxy) ---
frontend:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
build:
context: ../..
dockerfile: gerbil-manager-web/Dockerfile
@@ -67,6 +77,21 @@ services:
api:
condition: service_healthy
# --- nginx Public Site (WEB-2) ---
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
publicsite:
image: nginx:alpine
restart: unless-stopped
ports:
- "${PUBLICSITE_PORT:-8081}:80"
volumes:
- publicsite:/usr/share/nginx/html:ro
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
backup:
image: postgres:17-alpine
@@ -88,22 +113,37 @@ services:
volumes:
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
pgdata:
driver: local
driver_opts:
type: none
o: bind
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
photos:
driver: local
driver_opts:
type: none
o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys:
driver: local
driver_opts:
type: none
o: bind
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
backups:
driver: local
driver_opts:
type: none
o: bind
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
publicsite:
driver: local
driver_opts:
type: none
o: bind
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"

Some files were not shown because too many files have changed in this diff Show More