Compare commits

...

204 Commits

Author SHA1 Message Date
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
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
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
97acaf6b10 WEB-6a: Oeffentliche Webseite — Gestaltung & Grundseiten
- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav,
  site-footer-nav mit Impressum/Datenschutz-Links)
- AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung,
  Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen
  mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote)
- SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote
  erweitert
- Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als
  Platzhalter-Seiten (footer-only, kein navOrder-Eintrag)
- SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests)
- Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests
  (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier,
   Impressum/Datenschutz, Footer-Links)
Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
2026-06-06 15:15:54 +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
9ed68ba38a Merge feature/import-polish: decision-matching (name,zucht,dob), specific-wins dedup, parent-FK-backfill (DB-weit via allDbNormToGid), Skarlett-Parse-Fix [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:04:41 +02:00
dfcd296119 IMPORT-POLISH v2: 3 Korrekturen nach god-Review
FIX-1 ZUCHT: apply_conflict_decisions/apply_dob_remaps matchen jetzt
auf das VOLLE canon_pair-Tupel (nameCanon, zuchtCanon, dob) wenn die
Decision eine Zucht traegt; Fallback name-only wenn keine Zucht.
C3-Regel gewahrt: gleicher Name+DOB, andere Zucht -> kein Hit.
Neuer Regression-Test: Luna ZdkC-Decision trifft nur luna-kc,
nicht luna-bf (andere Zucht).

FIX-2 MERGE: dedup() waehlt das spezifischste Genotyp (fewest '?'
alleles) als sekundaeren Tiebreaker nach locus-count. CC schlaegt C-,
Gg schlaegt G- unabhaengig von der Reihenfolge. 3 neue Merge-Tests
(C- first/CC first/G-vsGg).

FIX-3 BACKFILL allDbNormToGid: ResolveParentForBackfill prueft jetzt
BEIDE Quellen: (a) createdAnimalByName (aktiver Lauf) und
(b) allDbNormToGid (alle DB-Tiere). Decktt den kritischen Fall:
Elterntier in fruehrem Lauf geladen, in diesem Lauf absent vom
Extract. Neuer 3-Lauf-SQLite-Test: Lauf 1 null-Vater, Lauf 2 laedt
Vater, Lauf 3 backfillt via allDbNormToGid.

Gate: 125/125 C#-Tests, Python ALL PASS, has-pending-model-changes=No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:03:17 +02:00
db85a6e0dc docs(HUMANQUESTION): C9 beantwortet - 'hell' im Farbschlag-Namen = c[h]-Allel (cchm/ch), ohne = cchm/cchm; CP-Fuchs ist Sammelbegriff (Phaenotyp schneeweiss/schwarzaeugig, Loci unbekannt) -> GEN-3g beauftragt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 10s
2026-06-06 14:53:39 +02:00
0c94cfcbf1 IMPORT-POLISH: 4 Importer-Fixes nach Re-Import #2
FIX-1 decision-matching: apply_conflict_decisions/apply_dob_remaps
nutzen jetzt canon_pair(name)[0] als Match-Key (Dedup-Identitaet:
call-name ohne Zucht, v.d.<->von den gefaltet). Workaround-Spelling
v.d. in Victoria Welbys Decision bleibt erhalten; beide Formen
matchen jetzt. Kommentar im decision-Eintrag aktualisiert.

FIX-2 specific-wins: _alleles_compatible aendert '? vs x = False'
-> '? vs x = True' (spezifischer Wert gewinnt). C- vs CC, G- vs Gg,
P? vs PP sind kein Konflikt mehr. Echte Wert-Widersprueche (DD vs Dd,
Ee vs ee, PP vs Pp) bleiben Konflikte. Loest Enya, Ella, Zac
automatisch (Konflikte 8->5 erwartet). 2 bestehende Tests angepasst,
7 neue Tests.

FIX-3 parent-FK backfill: nach dem Wurfchronik-Rueckverknuepfungs-
Block iteriert ImportService.RunAsync ueber bereits importierte
Wuerfe mit null Father/MotherId und setzt fehlende FKs wenn das
Elterntier jetzt ladbar ist. Trockenlauf zaehlt, Execute schreibt.
LitterSummary.ParentFksBackfilled + 2 neue C#-Tests (SQLite).

FIX-4 Skarlett-Artefakt: parse_detail() strippt trailing / +YEAR
aus dem Genotyp-Tail (re.sub). Sterbejahr bleibt als death-Date
erhalten -> Skarlett erscheint als reiner Sterbedatum-Konflikt.
2 neue Python-Tests.

Gate: 124/124 C#-Tests, Python test_extract/test_genotype ALL PASS,
has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:27 +02:00
5eadd89bb6 REIMPORT-2: Victoria-Welby-Entscheidung greift (v.d.-Schreibweise als Workaround fuer norm_name-Matching), Re-Import #2 ausgefuehrt (+13 Tiere/+31 Wuerfe/+9 Fotos, C hat beide Eltern); HUMANQUESTION D6 = letzte 5 offene Konflikte
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 2m27s
2026-06-06 14:31:14 +02:00
f9a68deb7a Merge feature/web-3: lokale Webseiten-Vorschau (GET /api/preview/{**path} + /webseite/vorschau iframe-Vorschau mit Geraete-Umschalter) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m51s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 14:22:31 +02:00
a693095886 ... 2026-06-06 13:38:03 +02:00
d5544412bd WEB-3: lokale Webseiten-Vorschau - GET /api/preview/{**path} (rendert live, korrekte Content-Types, nur Veroeffentlichte) + /webseite/vorschau (iframe, Seiten-Wechsler, Smartphone/Desktop-Umschalter, Neu laden) + Einstiege auf der Uebersicht + Fix: pages.ts rief /pages statt /api/pages (live-404, vom Mock kaschiert) + 1 Backend-Roundtrip + 4 e2e-Specs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:18:07 +02:00
d93e8d1586 Merge feature/gerbil-residency-toggle: residency checkbox on Gerbil create/edit form [god-QA pending] 2026-06-06 12:15:20 +02:00
114bbd92c8 Merge feature/extract-band-aware (presence-wins dedup + correctDob): conflicts 19→15 [god-QA pending] 2026-06-06 12:15:01 +02:00
a8d8ae0dfc conflict-decisions: correctDob remaps a wrong-birthdate duplicate before dedup
god added a `correctDob` (DD.MM.YYYY) decisions field: the matched (name+dob)
record is a DUPLICATE with a wrong birthdate → remap its DOB to correctDob so
dedup MERGES it into the canonical same-named animal. apply_dob_remaps runs
BEFORE dedup (it changes the dedup identity); tolerates a missing file; logged
as "DOB-Remaps: N". First use: Chelsea *15.10.2021 → *02.04.2021 (merges into
the canonical record). test_extract covers the remap + that both records then
share one name+dob identity.

Extractor-only. python + dotnet 121/121 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:14:23 +02:00
522f2eec51 docs(HUMANQUESTION): C8 answered — Himalaya exists (A- c[h]c[h]); Hermelin = aa c[h]c[h]; both kept 2026-06-06 12:13:20 +02:00
3f71d8e28e dedup: 'presence wins' — present-vs-absent token is not a conflict (Julian)
Breeder merge rule: when two source variants of the SAME animal differ ONLY
by a token PRESENT in one and ABSENT in the other — a whole locus (e.g. spsp
charted in one source, omitted in another) or a modifier on the same base
allele (e^f vs e, the [f] marker) — keep the present token; that is NOT a
conflict. Genuine VALUE contradictions still quarantine: different base
alleles (Ee↔ee), unknown-vs-filled (D-↔DD), different modifiers (c[h]↔c[chm]),
C-↔Cc[h], P-↔Pp.

Replaces the old `len(distinct normalized geno keys) > 1` test with
_genotype_conflict() (per-locus, per-allele compatibility; '?'-vs-filled is a
contradiction, modifier-present-vs-absent and whole-locus-absence are not).
Markers/flags (WP/DP/WFNZ/hörend) are already tags/flags, never genotype, so
they never reach conflict detection; empty Farbschlag/death already don't
conflict (only non-empty values are compared).

Clears Daja (keep spsp), Ichika (keep ee[f]) and the D4 marker cases:
Konflikte 19 -> 15. test_extract covers spsp/[f] present-vs-absent =
no conflict and the four genuine-contradiction shapes. python + dotnet
121/121 green; extractor-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:45 +02:00
0e7ec5ab61 BESTAND-FILTER follow-up: residency toggle on the Gerbil create/edit form
Completes residency end-to-end: a 'Gehört zum eigenen Bestand' checkbox on the
Rennmaus-Formular (default on for new animals) lets the user mark a tier as an
external ancestor by hand — previously isResident was only set at import. Wired
through FormState + formFromGerbil (defaults true) + the CreateGerbil payload.

German via de.ts (LF). e2e: edit Krümel, untick Bestand, save -> detail shows
the 'Extern' badge (proves form field -> payload -> persistence). Gate green:
build, eslint, vitest 78, e2e 86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:32 +02:00
12604fba7c data(import): D4 Victoria Welby resolved (ee[f]) — C's mother → C gets both parents — Julian 2026-06-06 12:12:23 +02:00
311c5461fd data(import): Chelsea (merge to *02.04.2021, correctDob) + Vestra (DD) resolved — Julian 2026-06-06 12:11:41 +02:00
3634d6ef9a data(import): D3 Silvain resolved (ee, Pp) — Julian 2026-06-06 12:09:41 +02:00
aa345aa796 data(import): D3 Milka resolved (Cc[h], EE) — Julian 2026-06-06 12:08:14 +02:00
0317c5d454 Merge feature/extract-band-aware: band-aware Farbschlag + conflict-decisions consumption + GEN-3f backend re-seed (ReseedColorVarietiesGen3f) [god-QA pending] 2026-06-06 12:05:02 +02:00
ea8c3e9966 Merge feature/bestand-filter: Tiere list defaults nur-Bestand + toggle + Extern marker [god-QA pending] 2026-06-06 12:05:02 +02:00
4d3383a4f1 data(import): D3 Zuleika resolved (DD Ee Gg PP) — Julian 2026-06-06 12:04:30 +02:00
19a0a6c79b data(import): 'presence wins' merge rule (Julian) — resolves Daja (spsp) + Ichika ([f]); D4 markers were common, 3/5 auto-resolved 2026-06-06 12:02:29 +02:00
b9033238c9 conflict-decisions: also apply dateOfDeath (D5 death-date resolutions)
god extended conflict-decisions.json with an optional dateOfDeath
(DD.MM.YYYY). apply_conflict_decisions now sets the animal's death date
(normalized) as authoritative when present — clearing D5 death-date
conflicts the same way genotype/farbschlag decisions are applied. No C#
change (death already flows to Gerbil.DateOfDeath). test_extract covers a
Flint dateOfDeath resolution. python green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:02:18 +02:00
594923e10f GEN-3f backend re-seed: ColorVariety catalog 73 -> 61 (CP- names)
Re-synced SeedColorVarieties from Kevin's merged GEN-3f frontend seed
(colorVarietySeed.generated.json, 61 rows) — verified byte-identical
(name + canonicalGenotype + order). Changes are all in the cchm colourpoint
group: 'Siam (Marder-Hell)' -> 'Siam' (het cchmch), CP suffix names ->
CP- prefix (CP-Agouti/CP-Silberagouti/CP-Algierfuchs/CP-Polarfuchs), new
CP-Fuchs/CP-Fuchs-Hell/CP-Blaufuchs, Zobel-Hell kept (het); 12 zero-
occurrence CP/dd siblings removed.

Migration ReseedColorVarietiesGen3f: Up = 38 UpdateData (rename/remap IN
PLACE on the same deterministic Id — references survive) + 12 DeleteData of
the removed rows (0 occurrences in her data per Kevin; Gerbil.ColorVarietyId
is ON DELETE SET NULL, and the live re-import re-matches Farbschlag BY NAME,
so no orphan/hard-delete risk). No AddColumn. has-pending-model-changes
clean. Updated the export test's seed-count threshold (>=70 -> >=60).

Last of the import-side batch on this branch (band-aware Farbschlag +
conflict-decisions + this re-seed) — ready for the single supervised
re-extract + idempotent re-import. dotnet 121/121 + python green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:59:57 +02:00
aa20d6f809 data(import): D5 Molly resolved (died 03.05.2022, 2021 was year typo) — Julian 2026-06-06 11:59:51 +02:00
afd321ec9f BESTAND-FILTER: Tiere-Liste zeigt standardmäßig nur den eigenen Bestand
Die Tiere-Liste (/rennmaeuse) filtert serverseitig auf isResident==true
(eigener Clan = Bestand) und blendet externe Ahnen (nur für den Stammbaum
erfasste Fremdtiere) standardmäßig aus — passend zu Julians Modell
(Kleine Chaoten = Bestand, extern = stammbaum-only).

- Neuer Schalter „Externe Ahnen einblenden“ (Default aus). An -> isResident-
  Filter entfällt, externe Tiere erscheinen mit. Serverseitig (Gridify), damit
  Paging und Trefferzahl korrekt bleiben. „Filter zurücksetzen“ setzt ihn zurück.
- Subtile „Extern“-Markierung (gestricheltes Badge) auf externen Tieren in
  Liste UND Detailseite, mit Tooltip-Erläuterung.
- Gerbil-Typ um isResident (optional bool) ergänzt; CreateGerbil ebenso.

Deutsch via de.ts (LF), mobil + Laptop. e2e-Mock: gerbil()-Factory setzt
isResident=true; Hilde & Max als externe Ahnen (isResident=false). Neue
e2e bestand.spec (Default blendet aus; Toggle zeigt + Extern-Markierung).
Gate grün: build, eslint, vitest 78, e2e 84.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:59:19 +02:00
ae825ae80a data(import): D5 Flint resolved (died 10.05.2021, 2022 was typo) + decisions schema gains dateOfDeath — Julian 2026-06-06 11:59:10 +02:00
e055061d85 docs(HUMANQUESTION): D3+D4 — show CONCRETE per-animal conflict (locus + both values); D4 markers were common (not the conflict), 3/5 auto-resolved; Chelsea=DOB-dup not genotype; Victoria Welby=C's mother 2026-06-06 11:57:54 +02:00
df13136955 Importer consumes conflict-decisions.json to un-quarantine (HUMANQUESTION D)
god maintains tools/import/conflict-decisions.json as Julian/his wife answer
the D-conflicts. extract.py now consumes it (apply_conflict_decisions): for an
animal matching normalize(name)+dob, it clears the conflict, marks
resolvedByDecision, and — when the decision carries a `genotype` (breeder
notation, parsed via genotype.py) and/or `farbschlag` — treats those as
AUTHORITATIVE. Tolerates a missing/empty/garbled file. Genuinely-unresolved
conflicts stay quarantined.

Loader (ImportService): SourceAnimal.ResolvedByDecision flows through; the
report surfaces Animals.ConflictsResolvedByDecision + a German note.

Result on real data: the 2 current decisions (Firefly D-/PP, WildFire PP)
un-quarantine → Konflikte 21 → 19. As god appends entries the count grows;
nothing else needed from me.

Tests: python test_extract (decision clears conflict + genotype authoritative
+ removes from conflicts list + tolerates missing file) and a C# loader test
(a resolved animal loads and is counted). Folded into the EXTRACT-BANDS branch
so the next re-extract applies band-aware Farbschlag + these decisions in one
pass. No schema change (JSON DTO fields). python + dotnet 121/121 green;
has-pending clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:56:29 +02:00
4aca1d528b EXTRACT band-aware Farbschlag: deep bands (gen>=2) have no colour cell
Julian-confirmed against the real xlsx (Kentucky, Akio Kids): pedigree
blocks have two shapes. EARLY bands (gen 0-1, cols B/E/H) are 5-cell
Name/DOB/Farbschlag/Genotype/Zucht — real Farbschlag present. DEEP bands
(gen>=2, cols K/N/Q...) are 3-cell Name/DOB/Genotype with NO Farbschlag —
the colour is derived from the genotype.

The old extractor grabbed the NEXT block's name or a stray health note
(e.g. "DD-Tumor", "Chronische Ohrenentzündung...") as the deep-band
Farbschlag — source of the D1 'name-in-Farbschlag' conflicts and a big
chunk of farbschlagUnmatched. Fix: gen_of(col)>=2 => never assign a
Farbschlag in that block (Name/DOB/Genotype only); the cell is left for the
next block's name search. Deep-band animals load with empty Farbschlag and
the UI computes 'Errechnet' from the stored genotype (lossless).

Dedup already prefers a real (non-empty) early-band Farbschlag, so an animal
appearing in both an early band (Chesnut -> 'Kohlfuchsschimmel') and deep
bands keeps the real value; Chesnut and Tennessee stay separate (distinct
genotype + name+DOB key).

Result: 248/306 loadable animals now have empty Farbschlag (genotype-
derived), 0 suspicious name/note values remain, 0 Farbschlag-based conflicts
(D1 dissolved). Regression test test_extract.py builds a two-band xlsx and
asserts the deep band yields no Farbschlag + the stray note doesn't bleed.

Extractor-only (no C# change). python test_genotype + test_extract green;
dotnet 118/118. Batches with Kevin's GEN-3f seed for one re-import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:56:29 +02:00
4e02133d95 docs(HUMANQUESTION): add C9 (optional) — CP-Fuchs/CP-Blaufuchs/CP-Fuchs-Hell genetic distinction for engine refinement 2026-06-06 11:53:14 +02:00
f3efd4aa2b Merge feature/gen-3f: c-locus seed/catalog reconciliation 73→61 (CP- prefix, collapse -Hell, Siam; computed==dropdown) [god-QA pending] 2026-06-06 11:51:46 +02:00
461d7feb2d data(import): D3 WildFire resolved (PP correct, not P-) — Julian 2026-06-06 11:50:25 +02:00
7536aba474 data(import): conflict-decisions.json + D3 Firefly resolved (D- correct, DD typo) — Julian 2026-06-06 11:49:35 +02:00
972570b142 GEN-3f: reconcile cchm colourpoint catalog to the breeder's CP- naming
Computed Farbschlag now == the breeder's dropdown/seed names for colourpoints,
so the mismatch hint stops and the live re-import name-matches her data
(god: she uses the CP- prefix form + Marder/Siam/Zobel/Zobel-Hell; ZERO of the
old portal suffix names). FULL collapse (god-approved, data-safe).

Catalog (BASE_COLORS) 73 -> 61: the 24 portal cchm rows collapse to 12 breeder
varieties — Marder, Siam, Zobel, Zobel-Hell (aa marten/sable group; E & D
irrelevant per the merged GEN-3e rule) and CP-Agouti, CP-Silberagouti,
CP-Algierfuchs, CP-Polarfuchs, CP-Fuchs, CP-Fuchs-Hell, CP-Blaufuchs,
CP-Orangeschimmel (A- 'CP-<base>'; -Hell shade variants collapsed).

- representativeGenotype() now splits a heterozygous 'x/y' token, so the het
  colourpoints Siam & Zobel-Hell (C: 'cchm/ch') carry the correct het canonical
  genotype and the engine computes their own names (verified).
- Engine covers all fox(ee)/dilute(dd) A- cchm bases via the E-family fallback
  (no Unbekannt). KNOWN BEST-EFFORT GAP: CP-Fuchs-Hell and CP-Blaufuchs both
  compute 'CP-Fuchs' (the het-lighter / blue-dilute distinction isn't encoded);
  their distinct rows remain for hand-pick + import name-match. Closing this
  fully needs the breeder's exact fox-CP genotype rule.

Regenerated colorVarietySeed.generated.json (61 rows) for Pam's re-seed
migration. Gate green: build, eslint, vitest 78, e2e 80. de.ts untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:47:40 +02:00
029ffb9895 docs(HUMANQUESTION): D1 resolved — col K+ has no Farbschlag (only gencode), colour derived from genotype; Chesnut≠Tennessee; fix dispatched 2026-06-06 11:45:11 +02:00
6decd59ce4 docs(HUMANQUESTION): D1 — add source Stammbaum file per name-bleed conflict + note importer auto-strips them 2026-06-06 11:39:10 +02:00
f015274d28 Merge feature/import-fk-fix: stage litters+save-once (insert ordering) + FK-integrity guard + dry-run ParentFksDropped/DerivedSkipped, SQLite FK regression tests [god-QA pending] 2026-06-06 11:36:03 +02:00
fd48f7fc09 Fix import FK fault: derived/back-linked litter referenced a not-yet-inserted parent
BUG (blocked the supervised live re-import): /import/execute threw
Npgsql 23503 FK_Litters_Gerbils_FatherId and rolled back. Root cause was
INSERT ORDERING — synthesized derived litters were SaveChanges()'d BEFORE
the parent gerbils (created later in the animal loop), so the litter's
Father/MotherId pointed at rows that didn't exist yet. The dry-run and the
EF in-memory test provider don't enforce FKs, so it slipped through.

Fix:
- Stage synthesized litters in the context but DON'T save them early; the
  single SaveChanges after the animal loop lets EF order parents → litters →
  offspring (all FKs nullable). Saving litters first was the fault.
- FK-integrity guard (god's spec): compute the persisted set (existing DB +
  this run's loadable) and null out any litter parent FK not in it; SKIP a
  derived litter whose BOTH parents are unresolvable (offspring loads with
  LitterId=null). Quarantined parents already resolve to null via
  ResolveParentGid; this is defense-in-depth + makes the invariant explicit.
- Report litterParentFksDropped + derivedLittersSkipped (LitterSummary) +
  a German note — so a green dry-run (0/0) GUARANTEES execute won't FK-fault.

Tests: two SQLite-backed regressions (SQLite enforces FKs, unlike the
in-memory provider) — a derived litter with NEW chart parents executes
without throwing, and a quarantined parent leaves that FK null. 120 C#
tests + python green; no schema change (has-pending clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:32:44 +02:00
105e63b9c4 Merge feature/inbox-1: Anfragen-Posteingang UI (list/triage/assign/draft/send) [god-QA pending]
# Conflicts:
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/App.tsx
2026-06-06 11:29:21 +02:00
0334fce275 Merge feature/web-0b: CMS admin UI /webseite (page+block editor, reorder, draft/publish status) [god-QA pending] 2026-06-06 11:25:55 +02:00
aaaf5b6fb7 INBOX-1: Anfragen-Posteingang UI - Liste (Status-Filter, Sync-Button mit MailNotConfigured-Hinweis), Detail (Triage/Zuordnen/Verwerfen, KI-Entwurf mit AiKeyMissing-Hinweis, bestaetigter Versand) + nav + 6 e2e-Specs (Mock um /api/requests erweitert)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:23:41 +02:00
c20e6b5426 WEB-0b: CMS-Verwaltung der öffentlichen Webseite (Hand-Editier-UI)
Neuer Bereich /webseite im Manager (Nav: "Mehr"-Blatt) zur Pflege der 6
Webseiten-Seiten gegen Pams WEB-0-Backend (LAN-only, keine Auth):

- Übersicht: alle Seiten mit Status (Entwurf/Veröffentlicht).
- Seiten-Editor: Titel + SEO-Beschreibung + Status speichern.
- Geordneter Block-Editor: Blöcke hinzufügen (Überschrift, Textabschnitt/
  Markdown, Bild, Galerie, Kontaktangaben), bearbeiten, nach oben/unten
  verschieben (PUT .../blocks/order), löschen. Die dynamische AbgabetiereList
  ist nicht manuell anlegbar; nur ihr Einleitungstext ist editierbar.

Vertraege: GET /pages, GET /pages/{slug}, PUT /pages/{id},
POST /pages/{pageId}/blocks, PUT /blocks/{id}, DELETE /blocks/{id},
PUT /pages/{pageId}/blocks/order. Enums als Strings (Draft/Published, Heading...).

Deutsch via de.ts (LF beibehalten), mobile-first. e2e-Mock um die CMS-Routen
erweitert (zustandsbehaftet) + Smoke (Block anlegen/bearbeiten/verschieben,
bleibt erhalten). Gate gruen: build, eslint, vitest 69, e2e 68.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:23:34 +02:00
36454f3747 docs(HUMANQUESTION): add C8 — Himalaya vs Hermelin (does breeder use Himalaya, or is all chch Hermelin?) 2026-06-06 11:12:52 +02:00
3335502ef1 Merge feature/web-1 [god-QA pending] 2026-06-06 11:10:52 +02:00
0766f8303c Merge feature/gen-3e [god-QA pending] 2026-06-06 11:10:52 +02:00
6357e77060 Merge chore/de-lf [god-QA pending] 2026-06-06 11:10:52 +02:00
5edbd67c96 chore: normalize de.ts to LF + pin via .gitattributes (stop noisy EOL-flip diffs) 2026-06-06 11:07:19 +02:00
0651e54343 SEED-SYNC: backend ColorVariety seed = corrected catalog (drop Schwarzschimmel→Orangeschimmel, +CP-Orangeschimmel, Blaufuchs dd genotype fix) + SyncColorVarietySeed migration 2026-06-06 11:07:10 +02:00
4dcd416ff6 WEB-1: static site renderer (SiteRenderer, dry-run endpoint, 22 tests)
GerbilManagerWebAPI/Cms/SiteRenderer.cs:
  - Render(JsonObject snapshot) -> IReadOnlyDictionary<string,string> (path->content)
  - Covers all 6 block types: Heading, RichText, Image, Gallery, ContactInfo,
    AbgabetiereList (auto-resolved animals from snapshot; empty placeholder)
  - start -> index.html; other slugs -> {slug}/index.html; assets/site.css
  - Draft pages excluded; nav links with aria-current for current page
  - Minimal Markdown->HTML (headings, bold, italic, code, links, ul/ol) --
    no extra dependencies, deterministic output
  - H() encodes only the 5 HTML special chars (not umlauts) for UTF-8 pages
  - Mobile-first CSS: site header+nav, animal card grid, responsive breakpoints,
    warm off-white palette (style reference: Jimdo Kleine Chaoten)

GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs:
  - GET /api/render-site -- dry-run: returns [{path, size}] for each rendered file

GerbilManager.Tests/SiteRendererTests.cs:
  - 21 unit tests (pure JsonObject, no DB): file paths, HTML structure, each block
    type, draft exclusion, XSS encoding, all 6 seeded pages, Markdown inline tests
  - 1 integration test (ApiFactory): /api/render-site returns CSS + index.html

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 11:06:43 +02:00
5fd8cf4395 GEN-3e: C-locus colourpoint naming (CP-<base> for A-+cchm; Marder/Siam/Zobel/Zobel-Hell for aa+cchm); chch kept as Hermelin/Himalaya via base-match 2026-06-06 11:04:57 +02:00
76fcad6168 Merge feature/pedigree-link residency: Gerbil.IsResident (Bestand vs external ancestors, parent-exception), AddGerbilResidency migration [god-QA pending] 2026-06-06 11:01:05 +02:00
91fa9058de Merge feature/gen-3d: dominance tiebreak — unknown locus → wild-type reading (unknown-C=full colour not white; markers unmarked) [god-QA pending] 2026-06-06 11:01:04 +02:00
6775707387 OWNERSHIP: residency classification (Bestand vs external ancestors)
Per hive/agents/god/OWNERSHIP-residency.md (Julian): "Clan kleine Chaoten"
is the wife's own Zucht. Classify each animal:
 (a) RESIDENT if zuchtCanon contains 'kleinechaote' (separator/declension-
     insensitive: "v.d. Kleinen Chaoten", "Clan-Kleine-Chaoten", …); OR
 (b) PARENT EXCEPTION — a parent of a Clan offspring is resident even if its
     own Zuchtname is foreign (you can only breed a Clan litter if the parents
     were in your care). Runs AFTER the parentRefs→litter linking.
Otherwise EXTERNAL (pedigree ancestor, not living stock — kept, not deleted).

Stored as a dedicated Gerbil.IsResident bool (distinct from Status/Abgabe —
origin/identity, not current location), defaulting true (own stock unless
marked external; DB HasDefaultValue(true)). Additive migration
AddGerbilResidency; has-pending-model-changes clean. Round-trips in
GerbilDto/GerbilInput and is Gridify-filterable (isResident==true) so Kevin
can build a "nur Bestand" filter. ImportService computes it at (re-)import
and refreshes existing rows on the idempotent sweep. ResidencySummary added
to the import report.

Projected on real data: 306 loadable → 227 resident (188 rule a, +39 via
parent exception), 79 external ancestors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:58:29 +02:00
8125c453d0 GEN-3d: dominance tiebreak — unknown locus resolves to wild-type reading (unknown-C => full-colour, not c[h] white; markers default unmarked) 2026-06-06 10:58:15 +02:00
8101cc7be5 Merge feature/pedigree-link: parentRefs→derived litters, box-colour=sex (blue♂/white♀), name-bleed conflict fix, Vance folded in [god-QA pending] 2026-06-06 10:51:06 +02:00
ea79703dbf PEDIGREE-LINK: chart parentRefs→litters, box-colour=sex, name-bleed fix
Structural fix (god, Julian-reported via 'C'): the loader ignored
SourceAnimal.ParentRefs, so animals whose ancestry exists only as
Stammbaum chart-position refs loaded with LitterId=null ("unbekannt").
ImportService now synthesizes/reuses a derived litter from parentRefs:
resolves father+mother via name+DOB, groups siblings (same parents+dob)
into one litter, sets Father/Mother + offspring LitterId, dates it to the
offspring DOB, and tags Notes "aus Stammbaum-Diagramm abgeleitet
(Konfidenz: …)" so it's transparent/reversible. Existing animals that
become linkable are re-linked on re-run (sweep-idempotent). Dry-run counts
included. Projected: ~124 loadable animals gain a parent link.

Box-colour = sex (Julian): blue box = male, white box = female. All 11
pedigrees encode this as a solid theme-8 (accent5/blue) fill vs no fill.
xlsx_util.cell_fill_sex reads it; extract.py sets animal.gender from the
box; ImportService.InferGender prefers it over sire/dam name inference.
Result: 306/306 loadable animals now sexed (154♂/152♀).

Extractor noise fix (god): reject Farbschlag values that are actually a
parent NAME bled across cells (contain v.d./von/of/gen.) — cleared phantom
conflicts (e.g. Chayton). Combined with GEN-3 Uw→G: Konflikte 32→21.
Also skip Excel "~$" lock files in the glob.

GEN-3a contract (Kevin): ComposeGenotype appends "Slsl" for WP/Sls
carriers (wild-type sl/sl omitted) so 8-locus strings stay unchanged.

Importer-only. The live re-import into Julian's DB stays a separate
supervised gated step. 95 C# tests + python genotype tests green;
has-pending-model-changes clean (no schema change on this branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:48:30 +02:00
ed576c0339 Merge feature/gen-3c: unknown allele displays '-' (stores '?'), fix Unbekannter Farbschlag (wildcard + E-locus family fallback) [god-QA pending] 2026-06-06 10:47:14 +02:00
c35a6eca04 GEN-3c: fix 'Unbekannter Farbschlag' — unknown=wildcard in matches() + E-pair-aware family fallback (eef=Fuchsschimmel) 2026-06-06 10:45:16 +02:00
2e20df624f GEN-3c: unknown allele displays as '-' (storage stays '?'); parser accepts '-' and skips fully-unknown tokens; de.ts hints 2026-06-06 10:45:15 +02:00
a57b481a7e Merge feature/inbox-2: POST /api/requests/{id}/draft (AI German reply draft, never auto-send), extract OpenAiChatClient to Ai/, privacy stripping, 8 tests [god-QA pending] 2026-06-06 10:37:56 +02:00
27424c574f Merge feature/feat-14c: Charakterbogen as preferred AI character source (suppress admin notes, weave traits to prose), +4 tests [god-QA pending] 2026-06-06 10:37:45 +02:00
fd28b2a954 INBOX-2: 8 Tests - Stripping, Prompt-Privacy (keine Mailadresse zum Anbieter), 503, Endpoint-Round-Trip mit Stub (Entwurf gespeichert+geliefert); ApiFactory.ConfigureTestServices-Hook (init-Property, xUnit-fixturefest) 2026-06-06 10:36:02 +02:00
8aab9d34f7 INBOX-2: POST /api/requests/{id}/draft - deutscher KI-Antwortentwurf (Datenminimierung: nur Anfragetext+ForSale-Liste, Zitat/Signatur-Stripping, 503 AiKeyMissing / 502 Upstream, speichert DraftReply) 2026-06-06 10:36:02 +02:00
6dabb5572c INBOX-2: extract provider-agnostic OpenAiChatClient from SaleAdService (shared AI wire, public surface unchanged) 2026-06-06 10:36:01 +02:00
36db013b76 Merge feature/gen-3b: genotype.py+extract.py normalization (Uw→G, Sls, flags), conflicts 32→27, Gerbil.IsDeaf additive migration [god-QA pending] 2026-06-06 10:32:57 +02:00
60d034cde4 Merge feature/gen-3a: Uw=G+G-display, Sls 2nd locus+lethal, flag tolerance, Schimmel catalog fix (no Schwarzschimmel, efef=Orangeschimmel) [god-QA pending] 2026-06-06 10:32:52 +02:00
5ec04984c9 FEAT-14c: Charakterbogen im Sale-Ad-Prompt - Traits+Notiz als bevorzugte Charakterquelle (Notizen-Fallback), Webe-Regel statt Stichwortliste im Systemprompt; 4 Tests 2026-06-06 10:29:38 +02:00
e2fd32ea12 GEN-3a: test that Uw input renders as canonical G (never echoed back) 2026-06-06 10:27:08 +02:00
2f089a902d GEN-3b: import notation normalization (Uw=G, Sls, deaf flag, tags)
genotype.py:
- Uw/uw aliased to G/g (same locus) so the D2 conflict group + pure-Uw
  cases stop being conflicts (Gg == Uwuw).
- Sls/WP recognized as a SECOND spotting locus (S(l)s(l)=WP het); carried
  into mapped8locus alongside Sp (Sp+Sls = Superschecke).
- dea/Dea/taub/hörend -> hearing/deaf phenotype FLAG (not a locus).
- WFNZ/RV/GV/DP -> provenance/breeding tags (not genotype, not conflicts).
- test_genotype.py: zero-dep unit tests for all four.

extract.py: surface deaf+tags on animals; dedup conflict detection now
compares the NORMALIZED genotype key (mapped8locus) instead of the raw
string, so Uw=G no longer triggers a conflict. Result: Konflikte 32 -> 27,
Zucht-Splits stays 0. Dedup identity = name + DOB + Zucht.

Backend: Gerbil.IsDeaf (bool?) + additive migration AddGerbilDeafFlag
(has-pending-model-changes clean) + GerbilDto/GerbilInput round-trip.
ImportService sets IsDeaf from animal.deaf and preserves Sls + tags + deaf
in RawImportData (kept out of the 8-locus compact Genotype contract until
GEN-3a adopts them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:26:01 +02:00
7aac59f3e5 GEN-3a: de.ts warning texts (SLS_LETHAL/SUPERSCHECKE_DEAF) + genetics tests (Uw alias, Sls het/lethal, Superschecke, flag tolerance, Schimmel fix) 2026-06-06 10:25:48 +02:00
a0b720df73 GEN-3a: Schimmel catalog fix (breeder C5) — remove Schwarzschimmel, efef base=Orangeschimmel, Silberschimmel wins efef-gg, +CP-Orangeschimmel; regenerate ColorVariety seed 2026-06-06 10:25:48 +02:00
1e7dc8f91d GEN-3a: Uw=G alias, second spotting locus Sls (SlSl lethal), Sp×Sls Superschecke, flag/metadata token tolerance (dea/WFNZ/DP/RV/GV) 2026-06-06 10:25:48 +02:00
c93c24f37c docs(HUMANQUESTION): C5 answered — no Schwarzschimmel (catalog error); correct Schimmel types (efef=Orange/efef pp=Rotaugen/efef gg=Silber/cchm efef=CP-Orange) + source note 2026-06-06 10:11:48 +02:00
bd9cc83205 docs(HUMANQUESTION): C4 answered — Wurfchronik Teil 2 exists, being revised, will be imported when delivered 2026-06-06 10:08:50 +02:00
8d0fc43c0a docs(HUMANQUESTION): C3 answered — auto-merge OK but require matching Zuchtname (Name+DOB+Zucht = same animal) 2026-06-06 10:07:46 +02:00
98596b5586 Merge feature/feat-13-followup: /hilfe Vertrag+Zuchtprofil sections + verified Abgabe->wizard handoff [god-QA: 80/47/e2e 64] 2026-06-06 10:06:28 +02:00
9013ff488e docs(HUMANQUESTION): C2 answered — '-' = unknown allele (confirmed) 2026-06-06 10:06:28 +02:00
2a1c4a9eb7 docs(HUMANQUESTION): C1 answered (Uw=G, Sls=WP, DP=Darkpatch, Dea/dea=hearing/deaf, WFNZ/RV/GV=annotations); D2 conflict group resolved 2026-06-06 09:59:33 +02:00
d79f039b8b FEAT-13-Followup: /hilfe sections for Abgabevertrag wizard + Zuchtprofil (du-Form, de.ts-referenced labels) + finish-step in Abgabe section
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:58:04 +02:00
8202dfc0e4 Merge feature/inbox-3: Gmail SMTP send + reply threading (In-Reply-To/References/Re:), MailNotConfigured/MailAuthFailed handling [god-QA: 80 + snapshot clean] 2026-06-06 09:51:32 +02:00
68366ad8c9 INBOX-3: SMTP send + Gmail threading (POST /api/requests/{id}/send)
- IGmailMailSender (mockable) + MailKit GmailMailSender (smtp.gmail.com:587 STARTTLS, App
  Password). SendReplyService builds a threaded reply: In-Reply-To = original Message-Id,
  References = stored chain + original (deduped), Subject = 'Re: ' (no double-prefix); sends,
  then sets Status=Answered + AnsweredAt + stores sent body in DraftReply. Never auto-sends.
- POST /api/requests/{id}/send {body}: 200 updated RequestDto; 404 unknown; 503 MailNotConfigured;
  502 MailAuthFailed (UI hint: re-enter App Password). No entity change (no migration).
- Tests (fake SMTP): threading headers, Answered transition, no-double-Re, not-configured +
  auth-failure keep status; BuildMimeMessage header mapping. Live send gated on App Password.
2026-06-06 09:50:24 +02:00
dcf849cfa7 Merge feature/inbox-0: Gmail request inbox backend (MailKit reader, Request entity, MailSettings, sync/triage endpoints) [god-QA: 75 + snapshot clean] 2026-06-06 09:44:42 +02:00
958a2dbac0 INBOX-0: regenerate AddRequestsAndMailSettings migration after rebase onto WEB-0 (clean snapshot: CMS + INBOX, Up adds only Requests+MailSettings) 2026-06-06 09:43:01 +02:00
5d7cbc66bf INBOX-0: Gmail request inbox backend (MailKit) + Request entity + sync/triage endpoints
- Request entity (GmailMessageId unique, ThreadId, threading headers, From*, Subject, BodyText,
  ReceivedAt, Status enum-as-string New|InProgress|Assigned|Answered|Abandoned, AssignedContactId? FK,
  DraftReply?, AnsweredAt?). MailSettings singleton (GmailAddress, AppPassword ENCRYPTED via Data
  Protection, PollInterval, Folder, LastUid). Migration AddRequestsAndMailSettings.
- IGmailMailReader (mockable) + MailKit GmailMailReader (imap.gmail.com:993 SSL, envelope+X-GM-THRID
  +body). RequestSyncService: dedup on Message-Id, track LastUid. MailKit 4.17.0.
- Endpoints: POST /api/requests/sync, GET /api/requests (Gridify, filter status), GET /api/requests/{id},
  PUT triage (status+assignedContactId), GET/PUT /api/mail-settings (App Password never returned).
- Tests (in-memory DB + canned reader): sync dedup/idempotency/config-guard, HTTP sync->triage->assign,
  mail-settings secrecy. + gitignore the runtime photo-storage/ dir (god housekeeping).
2026-06-06 09:39:36 +02:00
84181ed74d docs: HUMANQUESTION.md — fully German + 'nicht selbst bearbeiten, an Michael antworten' banner at top (wife reads this) 2026-06-06 09:37:25 +02:00
d510b0f822 Merge test/search-variations: 9 backend search-variation tests (all green) [god-QA: 70] 2026-06-06 09:33:51 +02:00
3716dcb1f3 test: SearchVariationsTests — 9 search variations (contains, case-insensitive, separator-insensitive nameSearch, umlauts, OriginBreeder filter, /breeders distinct, paging, combined). Fills the gap where SEARCH-1's tests never reached main. 2026-06-06 09:33:40 +02:00
d6e35f40ec Merge feature/search-2b: Herkunft filter -> originBreeder + /gerbils/breeders; name search -> nameSearch (separator-insensitive) [god-QA: 47/build/e2e 62] 2026-06-06 09:27:40 +02:00
70f7c88419 Merge feature/web-0: CMS model (Site/Page/Block/Media) + /api/site-snapshot (Abgabetiere auto from ForSale) + Page/Block CRUD [god-QA: 61] 2026-06-06 09:27:12 +02:00
acd7f4c386 docs: HUMANQUESTION.md — open questions/data requests for Julian + wife (incl. 32 conflict animals, blockers per item) 2026-06-06 09:25:32 +02:00
f73ac0d4d8 WEB-0: CMS endpoint tests — snapshot shape/nav, Abgabetiere resolves ForSale (Becken group), block CRUD+reorder (SQLite host) 2026-06-06 09:21:20 +02:00
94f055f9bf WEB-0: CMS content model + /api/site-snapshot + Page/Block CRUD
- Entities: Site (singleton, navOrder JSON), Page (slug/title/seoDescription/status enum),
  Block (single table, Type enum + Data JSON; RichText=Markdown), Media. Migration
  AddCmsEntities, seeds 6 Jimdo-mirror pages (placeholder Heading each; abgabetiere also an
  auto AbgabetiereList block) + Site singleton nav.
- GET /api/site-snapshot: one JSON doc (site nav + pages + ordered blocks {id,order,type,data});
  AbgabetiereList(auto) resolved from live ForSale gerbils -> {name, farbschlag, group(=Becken),
  photos[urls], aiSaleText:null} (no price per god). SiteSnapshotService.
- CRUD: GET/POST/PUT/DELETE pages; add/update/delete blocks; PUT .../blocks/order reorder.
  Block type allowlisted by enum; data validated as JSON object.
2026-06-06 09:21:20 +02:00
cab8c133c8 SEARCH-2b: e2e mock /gerbils/breeders + nameSearch/originBreeder seed; Herkunft + separator-insensitive smokes [QA-1] 2026-06-06 09:15:55 +02:00
13ff97a12b Merge feature/feat-14b: Charakterbogen UI (checkbox traits + note) on detail + Abgabe composer, feeds AI sale-ad [god-QA pending result-gate] 2026-06-06 09:15:55 +02:00
681e8c28fb SEARCH-2b: Herkunft dropdown -> originBreeder + GET /gerbils/breeders; name search -> nameSearch (client-normalized) 2026-06-06 09:15:55 +02:00
ba81f48e0f Merge feature/feat-14a: Charakterbogen backend (Gerbil.CharacterTraits + CharacterNote) + sale-ad request extension [god-QA: 58] 2026-06-06 09:15:36 +02:00
3afb04f646 FEAT-14a: Charakterbogen on Gerbil (traits + note) + sale-ad request extension
- Gerbil.CharacterTraits (List<string>, JSON text column, opaque labels) + CharacterNote
  (string?). On GerbilDto + Input; round-trips on GET/POST/PUT. Additive migration.
- SaleAdAnimal gains Traits (German labels) + CharacterNote; prompt builder emits
  "Charakter: …" + "Charakter-Notiz: …" lines for the AI Verkaufstext.
- Tests: traits/note round-trip (POST->GET via SQLite host), empty-default, prompt inclusion.
2026-06-06 09:10:30 +02:00
5d8bbfa93b FEAT-14b: Charakterbogen on animal detail (persist) + Abgabe composer; feed traits+note into sale-ad; e2e smoke 2026-06-06 09:08:54 +02:00
5b11780074 FEAT-14b: trait vocabulary (de.character), Gerbil/sale-ad types, Charakterbogen component 2026-06-06 09:08:54 +02:00
f858a02f50 Merge feature/search-1: separator-insensitive NameSearch field + OriginBreeder (Herkunft) filterable string from import + /gerbils/breeders distinct endpoint 2026-06-06 09:03:32 +02:00
09f11cb4e3 SEARCH-1: separator-insensitive NameSearch + OriginBreeder (Herkunft) filter
- Gerbil.NameSearch (lowercase, strip whitespace/.-_) auto-synced in ApplicationContext
  .SaveChanges; Gridify-filterable so 'clan kleine chaoten' matches 'Clan-Kleine-Chaoten'
  (client strips separators the same way). GerbilSearch.Normalize shared helper.
- Gerbil.OriginBreeder (free-text Herkunft) on DTO+Input, set by the FEAT-8 import from the
  source Zucht (imported animals have no OriginContact). Gridify-filterable.
- GET /gerbils/breeders: distinct non-empty OriginBreeder values for the Herkunft dropdown.
- Migration AddSearchFields (+ NameSearch backfill SQL for existing rows).
2026-06-06 09:01:12 +02:00
176 changed files with 37601 additions and 1391 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: 192.168.2.115:13000 (internes Gitea Container Registry)
# Images:
# truenas:13000/gulum/gerbilmanager-api:latest
# truenas:13000/gulum/gerbilmanager-frontend:latest
# 192.168.2.115:13000/gulum/gerbilmanager-api:latest
# 192.168.2.115:13000/gulum/gerbilmanager-frontend:latest
name: CI
@@ -25,7 +25,7 @@ on:
- main
env:
REGISTRY: truenas:13000
REGISTRY: 192.168.2.115:13000
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"

6
.gitignore vendored
View File

@@ -131,3 +131,9 @@ $RECYCLE.BIN/
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
# 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/

View File

@@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection = new("DataSource=:memory:");
/// <summary>
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
/// EINEN öffentlichen (parameterlosen) Konstruktor.
/// </summary>
public Action<IServiceCollection>? ConfigureTestServices { get; init; }
public string ContractRoot { get; } =
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
@@ -46,6 +53,8 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
ConfigureTestServices?.Invoke(services);
});
}

View File

@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;
namespace GerbilManager.Tests;
/// <summary>
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
/// styles.css kommt mit CSS-Content-Type.
/// </summary>
public class CmsPreviewTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
private static object PageInput(string slug, string title, string status) =>
new { slug, title, seoDescription = (string?)null, status };
private sealed record PageRow(Guid Id, string Slug);
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
{
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
.FirstOrDefault(p => p.Slug == slug);
if (existing is null)
{
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
else
{
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
}
}
[Fact]
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
{
var client = _factory.CreateClient();
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
var index = await client.GetAsync("/api/preview/");
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
// Expliziter Pfad funktioniert ebenso
var explicitIndex = await client.GetAsync("/api/preview/index.html");
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
var css = await client.GetAsync("/api/preview/assets/site.css");
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
// Unsinnige Pfade → 404
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
}
}

View File

@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>WEB-0: CMS snapshot shape, Abgabetiere auto-resolution from ForSale, block reorder.</summary>
public class CmsTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact]
public async Task Snapshot_has_site_nav_and_seeded_pages()
{
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var root = doc.RootElement;
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
var pages = root.GetProperty("pages").EnumerateArray().ToList();
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
Assert.Equal(8, pages.Count);
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
var listBlock = abg.GetProperty("blocks").EnumerateArray()
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList");
Assert.Equal("auto", listBlock.GetProperty("data").GetProperty("mode").GetString());
Assert.Equal(JsonValueKind.Array, listBlock.GetProperty("data").GetProperty("animals").ValueKind);
}
[Fact]
public async Task Abgabetiere_resolves_ForSale_animals_with_becken_group()
{
// a Becken + a ForSale gerbil in it
var encResp = await _client.PostAsJsonAsync("/enclosures", new { name = "Verkaufsbecken" });
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" });
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
.Single(p => p.GetProperty("slug").GetString() == "abgabetiere")
.GetProperty("blocks").EnumerateArray()
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList")
.GetProperty("data").GetProperty("animals").EnumerateArray().ToList();
var sale = animals.SingleOrDefault(a => a.GetProperty("name").GetString() == "Verkaufsmaus");
Assert.Equal(JsonValueKind.Object, sale.ValueKind);
Assert.Equal("Verkaufsbecken", sale.GetProperty("group").GetString());
Assert.Equal(JsonValueKind.Array, sale.GetProperty("photos").ValueKind);
Assert.DoesNotContain(animals, a => a.GetProperty("name").GetString() == "Bleibtmaus");
}
[Fact]
public async Task Block_crud_and_reorder()
{
// create a page + two blocks
var pageResp = await _client.PostAsJsonAsync("/api/pages", new { slug = "test-page", title = "Testseite" });
Assert.Equal(HttpStatusCode.Created, pageResp.StatusCode);
var pageId = JsonDocument.Parse(await pageResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
async Task<Guid> AddBlock(string type, object data) =>
JsonDocument.Parse(await (await _client.PostAsJsonAsync($"/api/pages/{pageId}/blocks", new { type, data }))
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid();
var b1 = await AddBlock("Heading", new { text = "Erstes", level = 2 });
var b2 = await AddBlock("RichText", new { markdown = "**hallo**" });
// reorder: b2 before b1
var reorder = await _client.PutAsJsonAsync($"/api/pages/{pageId}/blocks/order", new { blockIds = new[] { b2, b1 } });
Assert.Equal(HttpStatusCode.NoContent, reorder.StatusCode);
var blocks = JsonDocument.Parse(await _client.GetStringAsync("/api/pages/test-page"))
.RootElement.GetProperty("blocks").EnumerateArray().ToList();
Assert.Equal(b2.ToString(), blocks[0].GetProperty("id").GetString());
Assert.Equal(b1.ToString(), blocks[1].GetProperty("id").GetString());
// RichText markdown round-trips inside data
Assert.Equal("**hallo**", blocks[0].GetProperty("data").GetProperty("markdown").GetString());
}
}

View File

@@ -104,7 +104,7 @@ namespace GerbilManager.Tests
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
var g = root.GetProperty("gerbils")[0];
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());

View File

@@ -0,0 +1,75 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.SaleAd;
namespace GerbilManager.Tests;
/// <summary>FEAT-14a: Charakterbogen (traits + note) persists/round-trips, and the
/// sale-ad prompt includes the character info.</summary>
public class GerbilCharacterTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public GerbilCharacterTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact]
public async Task CharacterTraits_and_note_round_trip()
{
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
{
name = "Charakter Test",
gender = "female",
characterTraits = new[] { "zutraulich", "handzahm" },
characterNote = "sehr aktiv und neugierig",
}));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var created = await create.Content.ReadAsStringAsync();
// POST response is the created GerbilDto — traits serialise straight back
Assert.Contains("zutraulich", created);
Assert.Contains("handzahm", created);
Assert.Contains("sehr aktiv und neugierig", created);
var id = Regex.Match(created, "\"id\":\"([^\"]+)\"").Groups[1].Value;
Assert.NotEqual("", id);
// re-read from the DB to prove persistence (not just echo)
var fetched = await _client.GetStringAsync($"/gerbils/{id}");
Assert.Contains("zutraulich", fetched);
Assert.Contains("handzahm", fetched);
Assert.Contains("sehr aktiv und neugierig", fetched);
}
[Fact]
public async Task Omitting_traits_defaults_to_empty_list()
{
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
{
name = "Ohne Charakter",
gender = "male",
}));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var body = await create.Content.ReadAsStringAsync();
Assert.Contains("\"characterTraits\":[]", body.Replace(" ", ""));
}
[Fact]
public void SaleAd_prompt_includes_traits_and_note()
{
var request = new SaleAdRequest(
Animals: new List<SaleAdAnimal>
{
new("Bella", "Agouti", "2025-01-15", "verspielt",
Traits: new List<string> { "zutraulich", "handzahm" },
CharacterNote: "liebt Sonnenblumenkerne"),
},
StatusLine: "FREI",
Hints: "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: zutraulich, handzahm", prompt);
Assert.Contains("liebt Sonnenblumenkerne", prompt);
}
}

View File

@@ -0,0 +1,276 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
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 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());
}
}
}
}

View File

@@ -1,5 +1,6 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests
@@ -102,6 +103,281 @@ 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()
{
using var db = NewDb();
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
// GEN-3b: deafness is a persisted phenotype flag (NOT a genotype locus).
Assert.True(a1.IsDeaf);
// Sls (2nd spotting locus) + provenance tags are preserved in RawImportData
// (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them).
Assert.Contains("Sls", a1.RawImportData!);
Assert.Contains("WFNZ", a1.RawImportData!);
// GEN-3a contract: a WP/Sls carrier appends the trailing "Slsl" token (Kevin).
Assert.EndsWith("Slsl", a1.Genotype!);
}
[Fact]
public void ComposeGenotype_appends_Slsl_only_for_carriers()
{
// wild-type sl/sl is omitted -> plain 8-locus string
var wild = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "sl", "sl" } } };
Assert.DoesNotContain("Sl", ImportService.ComposeGenotype(wild));
// WP heterozygote -> trailing Slsl
var wp = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "Sl", "sl" } } };
Assert.EndsWith("Slsl", ImportService.ComposeGenotype(wp));
}
[Fact]
public async Task Synthesizes_litter_from_chart_parentRefs_links_offspring_and_parents()
{
// Offspring 'C' has chart-position parentRefs to father 'Papa' (loaded) and mother
// 'Mama' (loaded), but NO Wurfchronik litterRef -> the loader must synthesize a derived
// litter, link C to it, and set the litter's Father/Mother (PEDIGREE-LINK structural fix).
var dir = Path.Combine(Path.GetTempPath(), "pedlink-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
]}
]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report.Animals.ParentLinksFromChart);
Assert.Equal(1, report.Litters.DerivedFromChart);
var papa = await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa");
var mama = await db.Gerbils.SingleAsync(g => g.ExternalRef == "mama");
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
Assert.NotNull(c.LitterId); // C no longer "unbekannt"
// box-colour sex flows through (blue=male, white=female)
Assert.Equal(Gender.male, papa.Gender);
Assert.Equal(Gender.female, mama.Gender);
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
Assert.Equal(papa.Id, litter.FatherId);
Assert.Equal(mama.Id, litter.MotherId);
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
// OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
// resident (rule b); the unrelated foreign animal stays external.
Assert.True(c.IsResident); // rule (a)
Assert.True(papa.IsResident); // rule (b) parent exception
Assert.True(mama.IsResident); // rule (b)
Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
Assert.NotNull(report.Residency);
Assert.Equal(3, report.Residency!.Resident);
Assert.Equal(1, report.Residency.External);
Assert.Equal(2, report.Residency.FlippedByParentRule);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
{
// Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
// created in the SAME run, so they must be inserted before the litter. The EF in-memory
// provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + 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":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
{"name":"Mama v.d. Test","dob":"02.02.2022","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(); // schema WITH enforced FK constraints
// must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
Assert.NotNull(c.LitterId);
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
Assert.Equal(0, report.Litters.ParentFksDropped);
Assert.Equal(0, report.Litters.DerivedSkipped);
}
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()
{
// A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
// the derived litter keeps that side null; if both sides are unresolvable, no litter.
var dir = Path.Combine(Path.GetTempPath(), "fkq-" + 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":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
{"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[
{"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
{"name":"GoodMama v.d. Test","dob":"02.02.2022","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();
await new ImportService(db, dir, dir).RunAsync(execute: true); // must not throw
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined
var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
Assert.NotNull(c2.LitterId); // still linked (via mother)
var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
Assert.Null(litter.FatherId); // quarantined father -> null FK
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Decision_resolved_animal_loads_and_is_counted()
{
// extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
// un-quarantines an animal; the loader must then LOAD it and surface the count.
var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
"genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
"conflict":false,"resolvedByDecision":true}
]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined
Assert.Equal(0, report.Animals.Quarantined.Conflicts);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
{
@@ -118,6 +394,261 @@ namespace GerbilManager.Tests
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
}
[Fact]
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
{
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
// (conflict=false — loaded). After run 1: litter.FatherId = null.
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
var littersJson = """
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
""";
// Run 1: Vater is in conflict -> not loaded
var animals1 = """
[
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
]
""";
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
var animals2 = """
[
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
]
""";
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[Fact]
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
{
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
//
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
// Run 2: sire loaded (new animal).
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
// Backfill must use allDbNormToGid to find him.
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
var littersJson = """
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
""";
// Run 1: sire quarantined
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
// Run 2: sire now loaded
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
]
""");
await new ImportService(db, dir, dir).RunAsync(execute: true);
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
// Run 2 itself may or may not backfill (depends on name normalization alignment).
// For the test we care about run 3.
// Run 3: sire NOT in animals.json at all (absent from new extract).
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
// by manually resetting FatherId to null so run 3 must fix it.
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
litter3.FatherId = null;
await db.SaveChangesAsync();
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
]
""");
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[Fact]
public async Task ParentFkBackfill_dry_run_counts_without_writing()
{
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
var littersJson = """
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
""";
var animals1 = """
[
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
]
""";
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
// Run 2 dry-run with papi un-quarantined
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
Assert.Null(litter.FatherId); // not written in dry-run
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[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)]
@@ -127,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 = """
[
@@ -139,7 +738,8 @@ namespace GerbilManager.Tests
[
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
"farbschlag":"Agouti","farbschlagVariants":["Agouti"],
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"]},"rawGenotype":"aa CC D- ee[f]","unmappedTokens":[]},
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
"deaf":true,"tags":["WFNZ"],
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,

View File

@@ -0,0 +1,217 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Inbox;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// INBOX-2: KI-Antwortentwurf — Prompt-Assembly (inkl. Datenminimierung +
/// Zitat-/Signatur-Stripping), 503-unkonfiguriert, und der Endpoint-Round-Trip
/// gegen einen Stub-Anbieter (Entwurf wird gespeichert UND zurückgegeben).
/// </summary>
public class InboxDraftTests
{
private static Request SampleRequest() => new()
{
Id = Guid.NewGuid(),
GmailMessageId = $"<{Guid.NewGuid():N}@mail.example>",
FromAddress = "anna.musterfrau@example.de",
FromName = "Anna Musterfrau",
Subject = "Anfrage: zwei Weibchen?",
BodyText = "Hallo! Habt ihr aktuell zwei junge Weibchen zur Abgabe?\nViele Grüße, Anna",
ReceivedAt = DateTimeOffset.UtcNow,
};
// ── Zitat-/Signatur-Stripping ──────────────────────────────────────
[Fact]
public void StripQuotedText_entfernt_Zitate_Signatur_und_Outlook_Header()
{
var body = string.Join('\n',
"Hallo, habt ihr Tiere abzugeben?",
"> alte zitierte Zeile",
"Von: jemand@example.de",
"Danke!",
"-- ",
"Anna Musterfrau",
"Musterweg 1");
var stripped = DraftReplyService.StripQuotedText(body);
Assert.Contains("Hallo, habt ihr Tiere abzugeben?", stripped);
Assert.Contains("Danke!", stripped);
Assert.DoesNotContain("alte zitierte Zeile", stripped);
Assert.DoesNotContain("jemand@example.de", stripped);
Assert.DoesNotContain("Musterweg 1", stripped); // Signatur weg
}
[Fact]
public void StripQuotedText_schneidet_beim_Gmail_Zitat_Intro_ab()
{
var body = "Meine Frage steht oben.\nAm 05.06.2026 um 10:00 schrieb Zucht der kleinen Chaoten:\n> früherer Verlauf";
var stripped = DraftReplyService.StripQuotedText(body);
Assert.Equal("Meine Frage steht oben.", stripped);
}
// ── Prompt-Assembly + Datenminimierung ─────────────────────────────
[Fact]
public void UserPrompt_enthält_Anfrage_und_Abgabeliste_aber_keine_Mailadresse()
{
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(),
[new DraftReplyService.ForSaleAnimal("Frieda", "Gold"),
new DraftReplyService.ForSaleAnimal("Fine", null)]);
Assert.Contains("Von: Anna Musterfrau", prompt);
Assert.Contains("Betreff: Anfrage: zwei Weibchen?", prompt);
Assert.Contains("zwei junge Weibchen zur Abgabe", prompt);
Assert.Contains("- Frieda (Gold)", prompt);
Assert.Contains("- Fine", prompt);
// Datenminimierung: die E-Mail-Adresse der Absenderin geht NICHT zum Anbieter
Assert.DoesNotContain("anna.musterfrau@example.de", prompt);
}
[Fact]
public void UserPrompt_ohne_Abgabetiere_enthält_keine_Liste()
{
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), []);
Assert.DoesNotContain("abzugebende Tiere", prompt);
}
[Fact]
public void SystemPrompt_verlangt_Entwurf_ohne_Preise_und_ohne_erfundene_Fakten()
{
var prompt = DraftReplyService.BuildSystemPrompt();
Assert.Contains("ENTWERFEN", prompt);
Assert.Contains("KEINE Fakten erfinden", prompt);
Assert.Contains("Keine Preise", prompt);
Assert.Contains("keine Adressen", prompt);
}
// ── 503: unkonfiguriert (Service-Ebene) ────────────────────────────
[Fact]
public async Task DraftAsync_meldet_NotConfigured_ohne_AI_Konfiguration()
{
var service = new DraftReplyService(
new HttpClient(new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))),
Options.Create(new AiOptions()));
var result = await service.DraftAsync(SampleRequest(), []);
Assert.Equal(GerbilManagerWebAPI.Ai.AiCallStatus.NotConfigured, result.Status);
}
// ── Endpoint-Round-Trip ────────────────────────────────────────────
[Fact]
public async Task Draft_Endpoint_meldet_503_AiKeyMissing_wenn_unkonfiguriert()
{
using var factory = new ApiFactory();
var id = await SeedRequestAsync(factory);
var response = await factory.CreateClient().PostAsync($"/api/requests/{id}/draft", null);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Contains("AiKeyMissing", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Draft_Endpoint_speichert_und_liefert_den_Entwurf_des_Stubs()
{
const string canned = "Hallo Anna,\n\nschön, dass du fragst — aktuell suchen Frieda und Fine ein Zuhause.\n\nHerzliche Grüße";
var stub = new StubHandler(_ => Canned(canned));
using var factory = new ApiFactory
{
ConfigureTestServices = services =>
{
services.PostConfigure<AiOptions>(o =>
{
o.BaseUrl = "https://api.example.com/v1";
o.ApiKey = "test";
o.Model = "test-model";
});
services.AddHttpClient<DraftReplyService>()
.ConfigurePrimaryHttpMessageHandler(() => stub);
},
};
var id = await SeedRequestAsync(factory, alsoForSaleGerbil: true);
var client = factory.CreateClient();
var response = await client.PostAsync($"/api/requests/{id}/draft", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var dto = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(canned, dto.RootElement.GetProperty("draftReply").GetString());
// Persistiert: erneutes GET liefert den Entwurf
var again = await client.GetFromJsonAsync<JsonElement>($"/api/requests/{id}");
Assert.Equal(canned, again.GetProperty("draftReply").GetString());
// Wire-Privacy: Anfragetext + ForSale-Tier gingen zum Anbieter, die
// Mailadresse der Absenderin NICHT.
Assert.NotNull(stub.LastRequestBody);
Assert.Contains("zwei junge Weibchen", stub.LastRequestBody);
Assert.Contains("Aki", stub.LastRequestBody);
Assert.DoesNotContain("anna.musterfrau@example.de", stub.LastRequestBody);
}
// ── Helfer ─────────────────────────────────────────────────────────
private static async Task<Guid> SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
var request = SampleRequest();
db.Add(request);
if (alsoForSaleGerbil)
{
db.Add(new Gerbil
{
Id = Guid.NewGuid(),
Name = "Aki",
Gender = Gender.female,
Status = GerbilStatus.ForSale,
});
}
await db.SaveChangesAsync();
return request.Id;
}
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"),
};
}
/// <summary>HttpMessageHandler-Stub (Variante von SaleAdTests, hier mit Body-Capture).</summary>
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
public string? LastRequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return respond(request);
}
}
}
}

View File

@@ -0,0 +1,208 @@
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: NameSuggestionService — prompt assembly, 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 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);
Assert.Contains("norn,mythg", 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);
}
// ── 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("Active", 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("Active", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
}
}

View File

@@ -0,0 +1,82 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Inbox;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace GerbilManager.Tests;
/// <summary>INBOX-0 HTTP: sync endpoint imports (via a canned reader), status filter + triage,
/// and the Gmail App Password is never returned by GET /api/mail-settings.</summary>
public class RequestEndpointsTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public RequestEndpointsTests(ApiFactory factory) => _factory = factory;
/// <summary>Test double for the IMAP reader: returns whatever the test stages, filtered by UID.</summary>
private sealed class CannedReader : IGmailMailReader
{
public static List<MailSummary> Messages { get; set; } = new();
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
=> Task.FromResult((IReadOnlyList<MailSummary>)Messages.Where(m => m.Uid > sinceUid).ToList());
}
private HttpClient ClientWithCannedReader() =>
_factory.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
{
s.RemoveAll<IGmailMailReader>();
s.AddScoped<IGmailMailReader, CannedReader>();
})).CreateClient();
[Fact]
public async Task Sync_imports_then_triage_assigns_contact()
{
var client = ClientWithCannedReader();
CannedReader.Messages = new()
{
new("<triage@1>", null, null, null, "interessent@example.de", "Anna", "Suche Rennmäuse", "Hallo!",
DateTimeOffset.UtcNow, Uid: 1001),
};
// configure mail (otherwise sync reports MailNotConfigured)
Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync("/api/mail-settings",
new { gmailAddress = "zucht@gmail.com", appPassword = "app-pw-xyz" })).StatusCode);
var sync = await client.PostAsync("/api/requests/sync", null);
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
Assert.True(JsonDocument.Parse(await sync.Content.ReadAsStringAsync()).RootElement.GetProperty("imported").GetInt32() >= 1);
// find the imported request via the status filter
var listed = JsonDocument.Parse(await client.GetStringAsync("/api/requests?filter=status==New&pageSize=100"));
var item = listed.RootElement.GetProperty("items").EnumerateArray()
.Single(r => r.GetProperty("gmailMessageId").GetString() == "<triage@1>");
var reqId = item.GetProperty("id").GetString();
// a contact + triage
var contactId = JsonDocument.Parse(await (await client.PostAsJsonAsync("/contacts", new { name = "Anna" }))
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
var put = await client.PutAsJsonAsync($"/api/requests/{reqId}", new { status = "Assigned", assignedContactId = contactId });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var got = JsonDocument.Parse(await client.GetStringAsync($"/api/requests/{reqId}")).RootElement;
Assert.Equal("Assigned", got.GetProperty("status").GetString());
Assert.Equal(contactId, got.GetProperty("assignedContactId").GetString());
}
[Fact]
public async Task MailSettings_stores_password_but_never_returns_it()
{
var client = _factory.CreateClient();
var put = await client.PutAsJsonAsync("/api/mail-settings",
new { gmailAddress = "sekret@gmail.com", appPassword = "abcd efgh ijkl mnop" });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var json = await client.GetStringAsync("/api/mail-settings");
Assert.Contains("sekret@gmail.com", json);
Assert.Contains("\"hasAppPassword\":true", json.Replace(" ", ""));
Assert.DoesNotContain("abcd", json); // the app password must never leave the server
Assert.DoesNotContain("appPassword", json);
}
}

View File

@@ -0,0 +1,86 @@
using GerbilManagerWebAPI.Inbox;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests;
/// <summary>INBOX-0: sync dedup + idempotency + config-guard, against an in-memory DB and a
/// fake IGmailMailReader (the live Gmail/MailKit path is gated on Julian's App Password).</summary>
public class RequestSyncServiceTests
{
private static ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("inbox-" + Guid.NewGuid().ToString("N")).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated(); // seeds the MailSettings singleton
return db;
}
private sealed class FakeReader : IGmailMailReader
{
public List<MailSummary> Canned { get; set; } = new();
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
=> Task.FromResult((IReadOnlyList<MailSummary>)Canned.Where(m => m.Uid > sinceUid).ToList());
}
private static MailSummary M(uint uid, string msgId, string from) =>
new(msgId, ThreadId: null, InReplyTo: null, References: null,
FromAddress: from, FromName: null, Subject: "Anfrage", BodyText: "Hallo",
ReceivedAt: DateTimeOffset.UtcNow, Uid: uid);
private static async Task<(ApplicationContext db, RequestSyncService sync, FakeReader reader)> SetupAsync(bool configured = true)
{
var db = NewDb();
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
var s = await settings.GetAsync();
if (configured)
{
s.GmailAddress = "zucht@gmail.com";
settings.SetPassword(s, "app-pw-1234");
await db.SaveChangesAsync();
}
var reader = new FakeReader();
return (db, new RequestSyncService(db, reader, settings), reader);
}
[Fact]
public async Task Sync_imports_new_and_dedups_within_batch()
{
var (db, sync, reader) = await SetupAsync();
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de"), M(3, "<b@y>", "b@y.de") };
var res = await sync.SyncAsync();
Assert.Null(res.Error);
Assert.Equal(2, res.Imported); // <b@y> deduped
Assert.Equal(2, await db.Requests.CountAsync());
Assert.All(await db.Requests.ToListAsync(), r => Assert.Equal(RequestStatus.New, r.Status));
}
[Fact]
public async Task Resync_imports_nothing_new()
{
var (db, sync, reader) = await SetupAsync();
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de") };
await sync.SyncAsync();
var second = await sync.SyncAsync(); // LastUid advanced -> reader returns nothing newer
Assert.Equal(0, second.Imported);
Assert.Equal(2, await db.Requests.CountAsync());
}
[Fact]
public async Task Sync_without_config_reports_not_configured()
{
var (_, sync, reader) = await SetupAsync(configured: false);
reader.Canned = new() { M(1, "<a@x>", "a@x.de") };
var res = await sync.SyncAsync();
Assert.Equal("MailNotConfigured", res.Error);
Assert.Equal(0, res.Imported);
}
}

View File

@@ -67,12 +67,69 @@ 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);
}
// ── FEAT-14c: Charakterbogen (Traits + Notiz) im Prompt ────────────
[Fact]
public void UserPrompt_enthält_Traits_und_Charakternotiz()
{
var request = new SaleAdRequest(
[new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "alte Verwaltungsnotiz",
Traits: ["zutraulich", "buddelt gern"],
CharacterNote: "klettert abends auf die Hand")],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: zutraulich, buddelt gern", prompt);
Assert.Contains("Charakter-Notiz: klettert abends auf die Hand", prompt);
}
[Fact]
public void UserPrompt_bevorzugt_Charakterbogen_vor_generischen_Notizen()
{
// Traits vorhanden -> die generische Notiz (oft Verwaltungs-Info)
// gehört NICHT in den Prompt.
var request = new SaleAdRequest(
[new SaleAdAnimal("Balu", null, null, "Käfig wurde am 3.5. gereinigt",
Traits: ["neugierig"])],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: neugierig", prompt);
Assert.DoesNotContain("Notizen:", prompt);
Assert.DoesNotContain("Käfig wurde", prompt);
}
[Fact]
public void UserPrompt_fällt_ohne_Charakterbogen_auf_Notizen_zurück()
{
// Kein Charakterbogen (null bzw. leer) -> Notizen wie bisher.
var request = new SaleAdRequest(
[
new SaleAdAnimal("Mysti", null, null, "sehr verschmust", Traits: null),
new SaleAdAnimal("Bo", null, null, "mag Kolbenhirse", Traits: []),
],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Notizen: sehr verschmust", prompt);
Assert.Contains("Notizen: mag Kolbenhirse", prompt);
Assert.DoesNotContain("Charakter:", prompt);
}
[Fact]
public void SystemPrompt_verlangt_fließende_Prosa_statt_Stichwortliste()
{
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
Assert.Contains("Charakter-Eigenschaften", prompt);
Assert.Contains("niemals als Aufzählung", prompt);
}
[Theory]
[InlineData("2024-03-12", "12.03.2024")]
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen

View File

@@ -0,0 +1,150 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>
/// SEARCH verification — every search variation Julian asked about, against the
/// REAL endpoints (SQLite host). Covers: substring/contains, case-insensitivity,
/// the no-trailing-* contains fix, separator-insensitive nameSearch (spaces ==
/// hyphens), umlauts, the OriginBreeder (Herkunft) filter, the /gerbils/breeders
/// distinct dropdown source, paged envelope, and a combined status+name filter.
/// Each test uses a unique token so the shared class DB doesn't cross-contaminate.
/// </summary>
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")
{
var resp = await _client.PostAsJsonAsync("/gerbils",
new { name, gender = "female", status, originBreeder });
resp.EnsureSuccessStatusCode();
}
/// <summary>GET /gerbils?filter=... → the list of returned names.</summary>
private async Task<List<string>> Names(string filter)
{
var url = $"/gerbils?filter={Uri.EscapeDataString(filter)}&page=1&pageSize=200";
var doc = JsonDocument.Parse(await _client.GetStringAsync(url));
return doc.RootElement.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("name").GetString()!).ToList();
}
[Fact]
public async Task Contains_matches_substring_case_insensitively()
{
await Create("SuchSchokoEins");
await Create("SuchSchokoladeZwei");
await Create("SuchKeksDrei");
// lowercase query, capitalised names → exercises /i contains
var hits = await Names("name=*suchschoko/i");
Assert.Contains("SuchSchokoEins", hits);
Assert.Contains("SuchSchokoladeZwei", hits);
Assert.DoesNotContain("SuchKeksDrei", hits);
}
[Fact]
public async Task Contains_single_letter_returns_matches_not_the_trailing_star_bug()
{
// Regression guard for the contains hotfix (0ee2b53): the frontend used to
// build name=*VALUE* which Gridify read as literal "VALUE*" → 0 rows.
// Correct form name=*VALUE must return every name containing VALUE.
await Create("BugGuardAlpha");
await Create("BugGuardBeta");
var hits = await Names("name=*bugguard/i");
Assert.Equal(2, hits.Count(n => n.StartsWith("BugGuard")));
}
[Fact]
public async Task NameSearch_is_separator_insensitive_spaces_equal_hyphens()
{
// The headline ask: "clan kleine chaoten" must match "clan-kleine-chaoten".
await Create("SepClan-Kleine-Chaoten");
await Create("SepClan Kleine Chaoten");
await Create("SepClanKleineChaoten");
await Create("SepUnrelatedMaus");
// Frontend normalizes the typed term the same way the backend stores it:
// lowercase + strip whitespace/.-_ → "sepclankleinechaoten".
var hits = await Names("nameSearch=*sepclankleinechaoten");
Assert.Contains("SepClan-Kleine-Chaoten", hits); // hyphens
Assert.Contains("SepClan Kleine Chaoten", hits); // spaces
Assert.Contains("SepClanKleineChaoten", hits); // none
Assert.DoesNotContain("SepUnrelatedMaus", hits);
}
[Fact]
public async Task NameSearch_dotted_and_underscored_separators_also_match()
{
await Create("SepDot.Von.Den.Chaoten");
await Create("SepDot_Von_Den_Chaoten");
var hits = await Names("nameSearch=*sepdotvondenchaoten");
Assert.Equal(2, hits.Count(n => n.StartsWith("SepDot")));
}
[Fact]
public async Task NameSearch_handles_umlauts()
{
await Create("SuchKrümel von den Chaoten");
// nameSearch is stored pre-lowercased; the frontend sends a pre-lowercased
// normalized term, so this works without provider unicode case-folding.
var hits = await Names("nameSearch=*suchkrümelvondenchaoten");
Assert.Contains("SuchKrümel von den Chaoten", hits);
}
[Fact]
public async Task OriginBreeder_filter_returns_only_that_breeder()
{
await Create("HerkunftA1", originBreeder: "Zucht HerkunftTest Eins");
await Create("HerkunftA2", originBreeder: "Zucht HerkunftTest Eins");
await Create("HerkunftB1", originBreeder: "Zucht HerkunftTest Zwei");
var hits = await Names("originBreeder==Zucht HerkunftTest Eins");
Assert.Contains("HerkunftA1", hits);
Assert.Contains("HerkunftA2", hits);
Assert.DoesNotContain("HerkunftB1", hits);
}
[Fact]
public async Task Breeders_endpoint_returns_distinct_nonempty_sorted_values()
{
await Create("DistinctX1", originBreeder: "ZZZ Distinct Eins");
await Create("DistinctX2", originBreeder: "ZZZ Distinct Eins"); // duplicate breeder
await Create("DistinctX3", originBreeder: "ZZZ Distinct Zwei");
await Create("DistinctNoBreeder", originBreeder: null);
var list = JsonDocument.Parse(await _client.GetStringAsync("/gerbils/breeders"))
.RootElement.EnumerateArray().Select(x => x.GetString()!).ToList();
Assert.Contains("ZZZ Distinct Eins", list);
Assert.Contains("ZZZ Distinct Zwei", list);
Assert.Equal(1, list.Count(v => v == "ZZZ Distinct Eins")); // de-duplicated
Assert.DoesNotContain(list, string.IsNullOrWhiteSpace); // no empty/null
Assert.Equal(list.OrderBy(v => v, StringComparer.Ordinal).ToList(), list); // sorted
}
[Fact]
public async Task List_returns_paged_envelope()
{
var doc = JsonDocument.Parse(await _client.GetStringAsync("/gerbils?page=1&pageSize=5"));
var root = doc.RootElement;
Assert.True(root.TryGetProperty("items", out _));
Assert.True(root.TryGetProperty("totalCount", out _));
Assert.Equal(1, root.GetProperty("page").GetInt32());
Assert.Equal(5, root.GetProperty("pageSize").GetInt32());
}
[Fact]
public async Task Combined_status_and_name_filter()
{
await Create("KombiVerkauf", status: "ForSale");
await Create("KombiAktiv", status: "Active");
var hits = await Names("status==ForSale,nameSearch=*kombi");
Assert.Contains("KombiVerkauf", hits);
Assert.DoesNotContain("KombiAktiv", hits);
}
}

View File

@@ -0,0 +1,120 @@
using GerbilManagerWebAPI.Inbox;
using GerbilManagerWebAPI.Models;
using MailKit.Security;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests;
/// <summary>INBOX-3: threaded reply send — correct In-Reply-To/References/Subject, status
/// transition to Answered, and the not-configured / auth-failure error paths. Fake SMTP
/// sender (live Gmail gated on Julian's App Password).</summary>
public class SendReplyServiceTests
{
private sealed class CapturingSender : IGmailMailSender
{
public OutgoingMail? Last;
public bool ThrowAuth;
public Task SendAsync(MailConnection c, OutgoingMail m, CancellationToken ct = default)
{
if (ThrowAuth) throw new AuthenticationException("bad app password");
Last = m;
return Task.CompletedTask;
}
}
private static ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("send-" + Guid.NewGuid().ToString("N")).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
return db;
}
private static async Task<(ApplicationContext db, SendReplyService svc, CapturingSender sender, Request req)>
SetupAsync(bool configured = true, string subject = "Anfrage Rennmäuse", string? refs = "<a@x> <b@y>")
{
var db = NewDb();
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
var s = await settings.GetAsync();
if (configured) { s.GmailAddress = "zucht@gmail.com"; settings.SetPassword(s, "app-pw"); }
var req = new Request
{
Id = Guid.NewGuid(),
GmailMessageId = "<orig@gmail>",
ReferencesHeader = refs,
FromAddress = "kunde@web.de",
Subject = subject,
Status = RequestStatus.New,
ReceivedAt = DateTimeOffset.UtcNow,
};
db.Requests.Add(req);
await db.SaveChangesAsync();
var sender = new CapturingSender();
return (db, new SendReplyService(db, sender, settings), sender, req);
}
[Fact]
public async Task Send_threads_and_marks_answered()
{
var (db, svc, sender, req) = await SetupAsync();
var outcome = await svc.SendAsync(req, "Hallo, sehr gerne!");
Assert.True(outcome.Sent);
Assert.Null(outcome.Error);
Assert.NotNull(sender.Last);
Assert.Equal("kunde@web.de", sender.Last!.To);
Assert.Equal("Re: Anfrage Rennmäuse", sender.Last.Subject);
Assert.Equal("<orig@gmail>", sender.Last.InReplyTo);
Assert.Equal("<a@x> <b@y> <orig@gmail>", sender.Last.References); // chain + original
var saved = await db.Requests.SingleAsync();
Assert.Equal(RequestStatus.Answered, saved.Status);
Assert.NotNull(saved.AnsweredAt);
Assert.Equal("Hallo, sehr gerne!", saved.DraftReply);
}
[Fact]
public async Task Send_does_not_double_prefix_re_subject()
{
var (_, svc, sender, req) = await SetupAsync(subject: "Re: läuft schon", refs: null);
await svc.SendAsync(req, "ok");
Assert.Equal("Re: läuft schon", sender.Last!.Subject);
Assert.Equal("<orig@gmail>", sender.Last.References); // no prior chain -> just the original
}
[Fact]
public async Task Send_without_config_fails_and_keeps_status()
{
var (db, svc, sender, req) = await SetupAsync(configured: false);
var outcome = await svc.SendAsync(req, "ok");
Assert.Equal("MailNotConfigured", outcome.Error);
Assert.Null(sender.Last);
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
}
[Fact]
public async Task Send_auth_failure_does_not_mark_answered()
{
var (db, svc, sender, req) = await SetupAsync();
sender.ThrowAuth = true;
var outcome = await svc.SendAsync(req, "ok");
Assert.Equal("MailAuthFailed", outcome.Error);
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
}
[Fact]
public void BuildMimeMessage_sets_threading_headers()
{
var msg = GmailMailSender.BuildMimeMessage("zucht@gmail.com",
new OutgoingMail("kunde@web.de", "Re: Test", "Hallo", "<orig@gmail>", "<a@x> <orig@gmail>"));
Assert.Equal("Re: Test", msg.Subject);
Assert.Contains("kunde@web.de", msg.To.ToString());
Assert.Contains("orig@gmail", msg.InReplyTo);
Assert.Contains("orig@gmail", string.Join(' ', msg.References));
Assert.Contains("a@x", string.Join(' ', msg.References));
}
}

View File

@@ -0,0 +1,410 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms;
namespace GerbilManager.Tests;
/// <summary>
/// WEB-1: static site renderer — snapshot → HTML file map.
/// All tests are pure unit tests (no DB, no HTTP); they build JsonObject
/// snapshots directly and assert on rendered HTML.
/// </summary>
public class SiteRendererTests
{
// ── Snapshot builder helpers ─────────────────────────────────────────────
private static JsonObject MakeSnapshot(params (string slug, string title, JsonArray blocks)[] pages)
{
var navOrder = new JsonArray();
var pagesArr = new JsonArray();
foreach (var (slug, title, blocks) in pages)
{
navOrder.Add(slug);
pagesArr.Add(new JsonObject
{
["slug"] = slug,
["title"] = title,
["seoDescription"] = $"SEO für {title}",
["status"] = "Published",
["blocks"] = blocks,
});
}
return new JsonObject
{
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = navOrder },
["pages"] = pagesArr,
};
}
private static JsonArray Blocks(params JsonObject[] blocks)
{
var arr = new JsonArray();
int i = 0;
foreach (var b in blocks) { b["order"] = i++; arr.Add(b); }
return arr;
}
private static JsonObject HeadingBlock(string text, int level = 1) => new()
{
["type"] = "Heading",
["data"] = new JsonObject { ["text"] = text, ["level"] = level },
};
private static JsonObject RichTextBlock(string md) => new()
{
["type"] = "RichText",
["data"] = new JsonObject { ["markdown"] = md },
};
private static JsonObject ImageBlock(string url, string alt) => new()
{
["type"] = "Image",
["data"] = new JsonObject { ["url"] = url, ["alt"] = alt },
};
private static JsonObject ContactInfoBlock(string name, string email, string phone) => new()
{
["type"] = "ContactInfo",
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
};
private static JsonObject AbgabetiereBlock(string intro,
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
{
var animalArr = new JsonArray();
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
{
var photos = new JsonArray();
if (photo is not null) photos.Add(photo);
animalArr.Add(new JsonObject
{
["name"] = name,
["farbschlag"] = farbe,
["group"] = group,
["gender"] = gender,
["dateOfBirth"] = dob,
["characterNote"] = note,
["photos"] = photos,
["aiSaleText"] = (JsonNode?)null,
});
}
return new JsonObject
{
["type"] = "AbgabetiereList",
["data"] = new JsonObject { ["mode"] = "auto", ["intro"] = intro, ["animals"] = animalArr },
};
}
// ── Tests ────────────────────────────────────────────────────────────────
[Fact]
public void Render_produces_index_html_for_start_page()
{
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Willkommen"))));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("index.html"));
}
[Fact]
public void Render_produces_slug_subfolder_for_non_start_pages()
{
var snap = MakeSnapshot(
("start", "Start", Blocks()),
("abgabetiere", "Abgabetiere", Blocks()));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("abgabetiere/index.html"));
}
[Fact]
public void Render_includes_css_asset()
{
var snap = MakeSnapshot(("start", "Start", Blocks()));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
Assert.Contains("site-header", files[SiteRenderer.CssPath]);
}
[Fact]
public void Page_html_is_valid_doctype_and_contains_title()
{
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Hallo"))));
var html = SiteRenderer.Render(snap)["index.html"];
Assert.StartsWith("<!DOCTYPE html>", html);
Assert.Contains("<title>Startseite |", html);
Assert.Contains("lang=\"de\"", html);
}
[Fact]
public void Page_html_includes_seo_description()
{
var snap = MakeSnapshot(("start", "Start", Blocks()));
// seoDescription is set to "SEO für Start" by our helper
var html = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("meta name=\"description\"", html);
Assert.Contains("SEO für Start", html);
}
[Fact]
public void Nav_links_are_rendered_with_all_slugs()
{
var snap = MakeSnapshot(
("start", "Start", Blocks()),
("abgabetiere", "Abgabetiere", Blocks()),
("kontakt", "Kontakt", Blocks()));
var html = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("Abgabetiere", html);
Assert.Contains("Kontakt", html);
}
[Fact]
public void Current_page_nav_link_has_aria_current()
{
var snap = MakeSnapshot(
("start", "Start", Blocks()),
("abgabetiere", "Abgabetiere", Blocks()));
var abgHtml = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("aria-current=\"page\"", abgHtml);
}
[Fact]
public void Heading_block_renders_h_tag_with_text()
{
var snap = MakeSnapshot(("start", "Start", Blocks(HeadingBlock("Meine Rennmäuse", 2))));
var html = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("<h2", html);
Assert.Contains("Meine Rennmäuse", html);
}
[Fact]
public void RichText_block_renders_markdown_bold_and_paragraph()
{
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("**Hallo** Welt"))));
var html = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("<strong>Hallo</strong>", html);
}
[Fact]
public void RichText_block_renders_markdown_list()
{
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("- Eins\n- Zwei"))));
var html = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("<ul>", html);
Assert.Contains("<li>", html);
Assert.Contains("Eins", html);
}
[Fact]
public void Image_block_renders_img_with_alt()
{
var snap = MakeSnapshot(("start", "Start", Blocks(ImageBlock("/img/maus.jpg", "Eine Rennmaus"))));
var html = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("src=\"/img/maus.jpg\"", html);
Assert.Contains("alt=\"Eine Rennmaus\"", html);
}
[Fact]
public void ContactInfo_block_renders_name_and_email()
{
var snap = MakeSnapshot(("kontakt", "Kontakt",
Blocks(ContactInfoBlock("Kleine Chaoten", "info@kleine-chaoten.de", "+49 123 456789"))));
var html = SiteRenderer.Render(snap)["kontakt/index.html"];
Assert.Contains("Kleine Chaoten", html);
Assert.Contains("info@kleine-chaoten.de", html);
Assert.Contains("+49 123 456789", html);
}
[Fact]
public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Krümel", html);
Assert.Contains("CP-Agouti", html);
Assert.Contains("Großbecken", html);
Assert.Contains("/photos/files/abc.jpg", html);
Assert.Contains("cms-group-card", html);
}
[Fact]
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Weibchen", html);
Assert.Contains("Verfügbar", html);
Assert.Contains("cms-badge--free", html);
}
[Fact]
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("März 2024", html);
Assert.Contains("Sehr neugierig und verspielt.", html);
}
[Fact]
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Einzelkind", "Zobel", null, null, "male", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Einzelkind", html);
Assert.Contains("Männchen", html);
Assert.Contains("cms-group-card", html);
}
[Fact]
public void AbgabetiereList_empty_shows_placeholder_text()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock(""))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("cms-abgabe-empty", html);
}
[Fact]
public void Draft_pages_are_excluded_from_output()
{
var pages = new JsonArray
{
new JsonObject { ["slug"] = "start", ["title"] = "Start", ["status"] = "Published",
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
new JsonObject { ["slug"] = "entwurf", ["title"] = "Entwurf", ["status"] = "Draft",
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
};
var snap = new JsonObject
{
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = new JsonArray { "start", "entwurf" } },
["pages"] = pages,
};
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("index.html"));
Assert.False(files.ContainsKey("entwurf/index.html"));
}
[Fact]
public void Html_encoding_prevents_xss_in_page_title()
{
var snap = MakeSnapshot(("start", "<script>alert(1)</script>", Blocks()));
var html = SiteRenderer.Render(snap)["index.html"];
Assert.DoesNotContain("<script>", html);
Assert.Contains("&lt;script&gt;", html);
}
[Fact]
public void Render_six_seeded_pages_produces_expected_paths()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("ueber-die-zucht", "Über die Zucht", Blocks()),
("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock(""))),
("abgabebedingungen", "Abgabebedingungen", Blocks()),
("farben-genetik", "Farben & Genetik", Blocks()),
("kontakt", "Kontakt", Blocks()));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("index.html"));
Assert.True(files.ContainsKey("ueber-die-zucht/index.html"));
Assert.True(files.ContainsKey("abgabetiere/index.html"));
Assert.True(files.ContainsKey("abgabebedingungen/index.html"));
Assert.True(files.ContainsKey("farben-genetik/index.html"));
Assert.True(files.ContainsKey("kontakt/index.html"));
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
// 6 pages + 1 CSS
Assert.Equal(7, files.Count);
}
[Fact]
public void Render_impressum_and_datenschutz_pages_are_rendered()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("impressum/index.html"));
Assert.True(files.ContainsKey("datenschutz/index.html"));
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
}
[Fact]
public void Footer_contains_impressum_and_datenschutz_links()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var startHtml = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("impressum/index.html", startHtml);
Assert.Contains("datenschutz/index.html", startHtml);
// links in footer, not nav
Assert.Contains("site-footer-link", startHtml);
}
[Fact]
public void Footer_links_use_correct_relative_path_from_subpage()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
// subpage needs ../ prefix for footer links
Assert.Contains("../impressum/index.html", kontaktHtml);
Assert.Contains("../datenschutz/index.html", kontaktHtml);
}
// ── Markdown tests ───────────────────────────────────────────────────────
[Theory]
[InlineData("**fett**", "<strong>fett</strong>")]
[InlineData("*kursiv*", "<em>kursiv</em>")]
[InlineData("`code`", "<code>code</code>")]
public void Markdown_inline_renders_correctly(string md, string expectedFragment)
{
var html = SiteRenderer.MarkdownToHtml(md);
Assert.Contains(expectedFragment, html);
}
[Fact]
public void Markdown_heading_renders_h_tag()
{
var html = SiteRenderer.MarkdownToHtml("## Abschnitt");
Assert.Contains("<h2>Abschnitt</h2>", html);
}
[Fact]
public void Markdown_xss_text_is_encoded()
{
var html = SiteRenderer.MarkdownToHtml("<script>alert(1)</script>");
Assert.DoesNotContain("<script>", html);
}
}
/// <summary>WEB-1: smoke-test the /api/render-site endpoint via ApiFactory.</summary>
public class RenderSiteEndpointTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public RenderSiteEndpointTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact]
public async Task RenderSite_returns_file_list_with_css_and_pages()
{
var json = await _client.GetStringAsync("/api/render-site");
var files = JsonSerializer.Deserialize<List<RenderEntry>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
Assert.Contains(files, f => f.Path == SiteRenderer.CssPath);
Assert.Contains(files, f => f.Path == "index.html");
Assert.All(files, f => Assert.True(f.Size > 0));
}
private record RenderEntry(string Path, int Size);
}

View File

@@ -0,0 +1,99 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Ai
{
public enum AiCallStatus
{
Ok,
/// <summary>AI section not (fully) configured -> callers map to 503 "AiKeyMissing".</summary>
NotConfigured,
/// <summary>Provider call failed -> callers map to 502 "AiUpstreamError".</summary>
UpstreamError,
}
public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null);
/// <summary>
/// INBOX-2: provider-agnostic chat client, extracted from SaleAdService so the
/// reply-draft (and future AI features) reuse the SAME wire implementation:
/// plain JSON POST to {AI:BaseUrl}/chat/completions with a Bearer key — covers
/// Gemini (compat endpoint), Groq, Mistral, local Ollama; no vendor SDK.
/// Configuration stays the single AI section (AiOptions, env-only).
/// </summary>
public sealed class OpenAiChatClient(HttpClient http, IOptions<AiOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<AiCallResult> CompleteAsync(
string systemPrompt, string userPrompt, CancellationToken ct = default)
{
var ai = options.Value;
if (!ai.IsConfigured)
{
return new AiCallResult(AiCallStatus.NotConfigured, null,
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
}
var payload = new ChatRequest(
Model: ai.Model!,
Messages:
[
new ChatMessage("system", systemPrompt),
new ChatMessage("user", userPrompt),
],
Temperature: 0.7);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
{
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
Encoding.UTF8, "application/json"),
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
try
{
using var response = await http.SendAsync(httpRequest, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
return new AiCallResult(AiCallStatus.UpstreamError, null,
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
}
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
return string.IsNullOrWhiteSpace(text)
? new AiCallResult(AiCallStatus.UpstreamError, null,
"KI-Antwort enthielt keinen Text.")
: new AiCallResult(AiCallStatus.Ok, text);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
return new AiCallResult(AiCallStatus.UpstreamError, null,
$"KI-Anbieter nicht erreichbar: {ex.Message}");
}
}
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
internal static Uri BuildCompletionsUri(string baseUrl) =>
new($"{baseUrl.TrimEnd('/')}/chat/completions");
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
internal sealed record ChatMessage(string Role, string Content);
internal sealed record ChatResponse(List<ChatChoice>? Choices);
internal sealed record ChatChoice(ChatMessage? Message);
}
}

View File

@@ -17,15 +17,75 @@ public class ApplicationContext : DbContext
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<Page> Pages => Set<Page>();
public DbSet<Block> Blocks => Set<Block>();
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
// 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.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
SyncNameSearch();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
SyncNameSearch();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void SyncNameSearch()
{
foreach (var entry in ChangeTracker.Entries<Gerbil>())
{
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name);
}
}
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).
e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>();
// 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>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<string>()
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
v => v.ToList());
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
e.HasOne(g => g.Litter).WithMany()
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.OriginContact).WithMany()
@@ -36,6 +96,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 =>
@@ -44,6 +110,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 =>
@@ -86,9 +158,122 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<BreederSettings>()
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
// WEB epic: CMS content model.
modelBuilder.Entity<Site>(e =>
{
var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<Guid>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<Guid>()
: System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>());
var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
(a, b) => (a ?? new List<Guid>()).SequenceEqual(b ?? new List<Guid>()),
v => v == null ? 0 : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())),
v => v.ToList());
e.Property(s => s.NavOrder).HasConversion(navConverter, navComparer);
});
modelBuilder.Entity<Page>(e =>
{
e.HasIndex(p => p.Slug).IsUnique();
e.Property(p => p.Status).HasConversion<string>();
e.HasMany(p => p.Blocks).WithOne()
.HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
SeedCms(modelBuilder);
// INBOX epic: Gmail request inbox.
modelBuilder.Entity<Request>(e =>
{
e.Property(r => r.Status).HasConversion<string>();
e.HasIndex(r => r.GmailMessageId).IsUnique();
e.HasOne(r => r.AssignedContact).WithMany()
.HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict);
});
// exactly one MailSettings row, fixed id.
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// 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);
}
/// <summary>
/// Seed the public website's CMS: one Site + the 6 Jimdo-mirror pages, each with a
/// placeholder Heading; the abgabetiere page also carries an auto AbgabetiereList block
/// (resolved from live ForSale animals at snapshot time). Hand-editable afterwards.
/// </summary>
private static void SeedCms(ModelBuilder modelBuilder)
{
Guid Pid(int n) => new($"51720001-0000-0000-0000-{n:D12}");
Guid Bid(int n) => new($"51720002-0000-0000-0000-{n:D12}");
(int n, string Slug, string Title)[] pages =
{
(1, "start", "Startseite"),
(2, "ueber-die-zucht", "Über die Zucht"),
(3, "abgabetiere", "Abgabetiere"),
(4, "abgabebedingungen", "Abgabebedingungen"),
(5, "farben-genetik", "Farben & Genetik"),
(6, "kontakt", "Kontakt"),
};
// WEB-6a: footer-only legal pages (not in main nav)
(int n, string Slug, string Title, string BodyMd)[] legalPages =
{
(7, "impressum", "Impressum",
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
(8, "datenschutz", "Datenschutz",
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
};
var pageRows = new List<Page>();
var blockRows = new List<Block>();
foreach (var p in pages)
{
pageRows.Add(new Page { Id = Pid(p.n), Slug = p.Slug, Title = p.Title, Status = PageStatus.Published });
// placeholder Heading per page
blockRows.Add(new Block
{
Id = Bid(p.n), PageId = Pid(p.n), Order = 0, Type = BlockType.Heading,
Data = $"{{\"text\":\"{p.Title}\",\"level\":1}}",
});
}
// abgabetiere (n=3): auto AbgabetiereList as the second block
blockRows.Add(new Block
{
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
});
foreach (var lp in legalPages)
{
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
}
modelBuilder.Entity<Page>().HasData(pageRows);
modelBuilder.Entity<Block>().HasData(blockRows);
modelBuilder.Entity<Site>().HasData(new Site
{
Id = Site.SingletonId,
DefaultLocale = "de",
NavOrder = pages.Select(p => Pid(p.n)).ToList(),
});
}
/// <summary>
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties
@@ -97,81 +282,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"),
("Schwarzschimmel", "AA CC DD efef 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"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere"),
("Schimmel (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"),
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Polarfuchs-Hell CP", "AA cchmcchm DD ee 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"),
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
("Agouti-Hell dd CP", "AA cchmcchm 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"),
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Polarfuchs CP", "AA cchmcchm 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"),
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg PP spsp rere"),
// --- C-locus white / partial albino (IDs 1-3) ---
("Pink Eyed White (PEW)", "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];
@@ -179,11 +380,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,667 @@
// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII
// characters as named entities (e.g. ä→&auml;), which is wrong for UTF-8 pages.
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// WEB-1: Deterministic static site renderer. Consumes a CMS snapshot (from
/// SiteSnapshotService.BuildAsync) and produces a flat path→content dictionary
/// ready for upload to Cloudflare Pages. No external dependencies; Markdown
/// support handles the common subset used in RichText blocks.
/// </summary>
public static class SiteRenderer
{
public const string SiteName = "Kleine Chaoten";
public const string CssPath = "assets/site.css";
// ── Public entry point ───────────────────────────────────────────────
/// <summary>
/// Renders the snapshot to a flat file map (relative-path → file-content).
/// Only <c>Published</c> pages are included. The "start" page maps to
/// <c>index.html</c>; all others to <c>{slug}/index.html</c>.
/// </summary>
public static IReadOnlyDictionary<string, string> Render(JsonObject snapshot)
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
files[CssPath] = SiteCss();
var navSlugs = NavSlugs(snapshot);
var pages = snapshot["pages"] as JsonArray ?? [];
foreach (var node in pages)
{
if (node is not JsonObject page) continue;
if (Str(page["status"]) != "Published") continue;
var slug = Str(page["slug"]) ?? "page";
var title = Str(page["title"]) ?? slug;
var seoDes = Str(page["seoDescription"]);
var blocks = page["blocks"] as JsonArray ?? [];
var cssHref = slug == "start" ? CssPath : $"../{CssPath}";
var body = RenderBlocks(blocks, slug);
var html = PageHtml(title, seoDes, body, navSlugs, slug, cssHref);
var path = slug == "start" ? "index.html" : $"{slug}/index.html";
files[path] = html;
}
return files;
}
// ── Block rendering ──────────────────────────────────────────────────
private static string RenderBlocks(JsonArray blocks, string currentSlug)
{
var sb = new StringBuilder();
foreach (var node in blocks.OrderBy(b => b?["order"]?.GetValue<int>() ?? 0))
{
if (node is not JsonObject b) continue;
var type = Str(b["type"]);
var data = b["data"] as JsonObject ?? [];
sb.Append(type switch
{
"Heading" => RenderHeading(data),
"RichText" => RenderRichText(data),
"Image" => RenderImage(data),
"Gallery" => RenderGallery(data),
"ContactInfo" => RenderContactInfo(data),
"AbgabetiereList" => RenderAbgabetiereList(data),
_ => string.Empty,
});
}
return sb.ToString();
}
private static string RenderHeading(JsonObject d)
{
var level = d["level"]?.GetValue<int>() ?? 2;
level = Math.Clamp(level, 1, 6);
var text = H(Str(d["text"]) ?? "");
return $"\n<h{level} class=\"cms-heading\">{text}</h{level}>\n";
}
private static string RenderRichText(JsonObject d)
{
var md = Str(d["markdown"]) ?? "";
return $"\n<div class=\"cms-richtext\">{MarkdownToHtml(md)}</div>\n";
}
private static string RenderImage(JsonObject d)
{
var url = H(Str(d["url"]) ?? "");
var alt = H(Str(d["alt"]) ?? "");
return $"\n<figure class=\"cms-image\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n";
}
private static string RenderGallery(JsonObject d)
{
var images = d["images"] as JsonArray ?? [];
var sb = new StringBuilder("\n<div class=\"cms-gallery\">\n");
foreach (var img in images)
{
if (img is not JsonObject o) continue;
var url = H(Str(o["url"]) ?? "");
var alt = H(Str(o["alt"]) ?? "");
sb.Append($" <figure class=\"cms-gallery-item\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n");
}
sb.Append("</div>\n");
return sb.ToString();
}
private static string RenderContactInfo(JsonObject d)
{
var sb = new StringBuilder("\n<address class=\"cms-contact\">\n");
void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" <span class=\"cms-contact-row\">{icon} {H(val)}</span>"); }
Row(Str(d["name"]), "👤");
Row(Str(d["address"]), "📍");
var phone = Str(d["phone"]);
if (!string.IsNullOrWhiteSpace(phone))
sb.AppendLine($" <span class=\"cms-contact-row\">📞 <a href=\"tel:{H(phone)}\">{H(phone)}</a></span>");
var email = Str(d["email"]);
if (!string.IsNullOrWhiteSpace(email))
sb.AppendLine($" <span class=\"cms-contact-row\">✉️ <a href=\"mailto:{H(email)}\">{H(email)}</a></span>");
sb.Append("</address>\n");
return sb.ToString();
}
private static string RenderAbgabetiereList(JsonObject d)
{
var intro = Str(d["intro"]);
var animals = (d["animals"] as JsonArray ?? [])
.OfType<JsonObject>().ToList();
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
if (!string.IsNullOrWhiteSpace(intro))
sb.Append($" <p class=\"cms-abgabe-intro\">{H(intro)}</p>\n");
if (animals.Count == 0)
{
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
sb.Append("</section>\n");
return sb.ToString();
}
// Group by Becken name; null group → solo card keyed by animal name
var groups = animals
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
.ToList();
sb.Append(" <div class=\"cms-group-list\">\n");
foreach (var grp in groups)
{
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
var groupName = isSolo ? null : grp.Key;
var memberList = grp.ToList();
// Determine group gender label
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
string genderLabel = genders.Count == 1
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
sb.Append(" <article class=\"cms-group-card\">\n");
sb.Append(" <div class=\"cms-group-header\">\n");
if (!string.IsNullOrEmpty(groupName))
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
var metaParts = new List<string>();
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
sb.Append(" </div>\n");
// Gallery: first photo of each animal in the group
var gallery = memberList
.Select(a => (
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
name: Str(a["name"]) ?? ""))
.Where(x => !string.IsNullOrEmpty(x.url))
.ToList();
if (gallery.Count > 0)
{
sb.Append(" <div class=\"cms-group-gallery\">\n");
foreach (var (url, name) in gallery)
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
sb.Append(" </div>\n");
}
// Per-animal detail rows
sb.Append(" <ul class=\"cms-animal-list\">\n");
foreach (var a in memberList)
{
var name = H(Str(a["name"]) ?? "");
var farbe = Str(a["farbschlag"]);
var dob = Str(a["dateOfBirth"]);
var note = Str(a["characterNote"]);
var saleText = Str(a["aiSaleText"]);
sb.Append(" <li class=\"cms-animal-row\">\n");
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
var details = new List<string>();
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
if (details.Count > 0)
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
var personalityText = note ?? saleText;
if (!string.IsNullOrWhiteSpace(personalityText))
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
sb.Append(" </li>\n");
}
sb.Append(" </ul>\n");
sb.Append(" </article>\n");
}
sb.Append(" </div>\n");
sb.Append("</section>\n");
return sb.ToString();
}
// ── Page template ────────────────────────────────────────────────────
private static string PageHtml(
string title, string? seoDesc, string body,
IReadOnlyList<string> navSlugs, string currentSlug, string cssHref)
{
var fullTitle = H($"{title} | {SiteName}");
var metaDesc = seoDesc is null ? ""
: $"\n <meta name=\"description\" content=\"{H(seoDesc)}\">";
var navBase = currentSlug == "start" ? "" : "../";
var navHtml = BuildNav(navSlugs, currentSlug, navBase);
return $"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">{metaDesc}
<title>{fullTitle}</title>
<link rel="stylesheet" href="{H(cssHref)}">
</head>
<body>
<header class="site-header">
<a href="{navBase}index.html" class="site-logo">🐭 {H(SiteName)}</a>
<nav class="site-nav" aria-label="Hauptnavigation">
{navHtml}
</nav>
</header>
<main class="site-main">
{body}
</main>
<footer class="site-footer">
<p>&copy; {H(SiteName)} Mongolische Rennmäuse</p>
<nav class="site-footer-nav" aria-label="Rechtliches">
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
</nav>
</footer>
</body>
</html>
""";
}
private static string BuildNav(IReadOnlyList<string> slugs, string current, string navBase)
{
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["start"] = "Start",
["ueber-die-zucht"] = "Über die Zucht",
["abgabetiere"] = "Abgabetiere",
["abgabebedingungen"] = "Abgabebedingungen",
["farben-genetik"] = "Farben & Genetik",
["kontakt"] = "Kontakt",
["impressum"] = "Impressum",
["datenschutz"] = "Datenschutz",
};
var sb = new StringBuilder();
foreach (var slug in slugs)
{
var label = labels.TryGetValue(slug, out var l) ? l : slug;
var href = slug == "start" ? $"{navBase}index.html" : $"{navBase}{slug}/index.html";
var active = slug == current ? " aria-current=\"page\"" : "";
sb.AppendLine($" <a href=\"{H(href)}\" class=\"site-nav-link\"{active}>{H(label)}</a>");
}
return sb.ToString().TrimEnd();
}
// ── Minimal Markdown → HTML ──────────────────────────────────────────
internal static string MarkdownToHtml(string md)
{
if (string.IsNullOrEmpty(md)) return "";
var lines = md.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
var sb = new StringBuilder();
var inList = false;
var isOrdered = false;
void CloseList()
{
if (!inList) return;
sb.AppendLine(isOrdered ? "</ol>" : "</ul>");
inList = false;
}
foreach (var raw in lines)
{
var line = raw;
// ATX headings
var hm = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
if (hm.Success)
{
CloseList();
var lvl = hm.Groups[1].Length;
sb.AppendLine($"<h{lvl}>{InlineHtml(hm.Groups[2].Value)}</h{lvl}>");
continue;
}
// Unordered list
var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$");
if (ulm.Success)
{
if (!inList || isOrdered) { CloseList(); sb.AppendLine("<ul>"); inList = true; isOrdered = false; }
sb.AppendLine($"<li>{InlineHtml(ulm.Groups[1].Value)}</li>");
continue;
}
// Ordered list
var olm = Regex.Match(line, @"^\d+\.\s+(.+)$");
if (olm.Success)
{
if (!inList || !isOrdered) { CloseList(); sb.AppendLine("<ol>"); inList = true; isOrdered = true; }
sb.AppendLine($"<li>{InlineHtml(olm.Groups[1].Value)}</li>");
continue;
}
CloseList();
// Blank line
if (string.IsNullOrWhiteSpace(line))
{
sb.AppendLine();
continue;
}
// Horizontal rule
if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$"))
{
sb.AppendLine("<hr>");
continue;
}
// Regular paragraph
sb.AppendLine($"<p>{InlineHtml(line)}</p>");
}
CloseList();
return sb.ToString().Trim();
}
private static string InlineHtml(string text)
{
// First HTML-encode, then selectively un-encode our safe inline patterns
// to avoid encoding the tags we are about to add.
// Order matters: bold before italic.
text = H(text);
// Bold **text**
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
// Italic *text* (single, not preceded/followed by *)
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
// Inline code `text`
text = Regex.Replace(text, @"`(.+?)`", "<code>$1</code>");
// Links [text](url) — note: url is already HTML-encoded by H()
text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
return text;
}
// ── CSS ──────────────────────────────────────────────────────────────
internal static string SiteCss() => """
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-bg: #fdf7f0;
--color-surface: #fff;
--color-surface-warm: #fef9f4;
--color-text: #2a2118;
--color-muted: #7a6d62;
--color-accent: #b5331a;
--color-accent-hover: #8f2815;
--color-accent-soft: #fdf0ed;
--color-border: #e8ddd4;
--color-badge-free: #2d7a4a;
--color-badge-free-bg: #e6f4ec;
--font-body: Georgia, "Times New Roman", serif;
--font-ui: system-ui, -apple-system, sans-serif;
--max-w: 900px;
--radius: 10px;
}
body {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
line-height: 1.7;
font-size: 1rem;
}
a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
img { max-width: 100%; height: auto; display: block; }
p { margin-bottom: .75rem; }
/* ── Header ── */
.site-header {
background: var(--color-surface);
border-bottom: 2px solid var(--color-border);
padding: 0 1rem;
display: flex;
align-items: stretch;
gap: 0;
min-height: 56px;
}
.site-logo {
font-family: var(--font-body);
font-weight: 700;
font-size: 1.15rem;
color: var(--color-text);
white-space: nowrap;
display: flex;
align-items: center;
padding-right: 1.5rem;
border-right: 1px solid var(--color-border);
text-decoration: none;
flex-shrink: 0;
}
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
.site-nav {
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-left: .5rem;
}
.site-nav::-webkit-scrollbar { display: none; }
.site-nav-link {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
padding: .5rem .65rem;
border-radius: 6px;
white-space: nowrap;
transition: color .15s, background .15s;
}
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
.site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: var(--color-accent-soft);
font-weight: 600;
}
/* ── Main ── */
.site-main {
max-width: var(--max-w);
margin: 2.5rem auto;
padding: 0 1rem 4rem;
}
/* ── Footer ── */
.site-footer {
background: var(--color-surface);
border-top: 2px solid var(--color-border);
padding: 1.75rem 1rem;
text-align: center;
font-family: var(--font-ui);
font-size: .85rem;
color: var(--color-muted);
}
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
.site-footer-link:hover { color: var(--color-accent); }
/* ── CMS blocks ── */
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
h1.cms-heading { font-size: 2rem; margin-top: 0; }
h2.cms-heading { font-size: 1.5rem; }
h3.cms-heading { font-size: 1.2rem; }
.cms-richtext { margin: 1rem 0; }
.cms-richtext p { margin-bottom: .75rem; }
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
.cms-richtext li { margin-bottom: .35rem; }
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
.cms-image { margin: 1.75rem 0; }
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.cms-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem;
margin: 1.75rem 0;
}
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
.cms-contact {
font-style: normal;
font-family: var(--font-ui);
display: flex;
flex-direction: column;
gap: .5rem;
margin: 1.25rem 0;
background: var(--color-surface-warm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
}
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
/* ── Abgabetiere — group cards ── */
.cms-abgabe { margin: 1.25rem 0; }
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
.cms-group-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.cms-group-header {
padding: 1rem 1.25rem .75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem .75rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-warm);
}
.cms-group-name {
font-family: var(--font-body);
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
}
.cms-group-meta {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
display: flex;
align-items: center;
gap: .5rem;
}
/* availability badge */
.cms-badge {
display: inline-block;
font-size: .75rem;
font-weight: 600;
font-family: var(--font-ui);
padding: .2rem .55rem;
border-radius: 20px;
letter-spacing: .02em;
text-transform: uppercase;
}
.cms-badge--free {
background: var(--color-badge-free-bg);
color: var(--color-badge-free);
}
/* group photo strip */
.cms-group-gallery {
display: flex;
gap: .375rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: .75rem 1.25rem;
background: #f5ede4;
}
.cms-group-gallery::-webkit-scrollbar { display: none; }
.cms-group-photo {
width: 140px;
height: 140px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
/* animal detail list */
.cms-animal-list {
list-style: none;
padding: .75rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: .875rem;
}
.cms-animal-row {
display: flex;
flex-direction: column;
gap: .15rem;
padding-bottom: .875rem;
border-bottom: 1px solid var(--color-border);
}
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
.cms-animal-name {
font-family: var(--font-body);
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.cms-animal-detail {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
}
.cms-animal-note {
font-style: italic;
font-size: .925rem;
color: var(--color-text);
margin-top: .1rem;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.site-header { padding: 0 .75rem; min-height: 50px; }
.site-logo { font-size: 1rem; padding-right: 1rem; }
h1.cms-heading { font-size: 1.5rem; }
.site-main { margin-top: 1.5rem; }
.cms-group-photo { width: 110px; height: 110px; }
}
@media (min-width: 700px) {
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
}
""";
// ── Helpers ─────────────────────────────────────────────────────────
private static IReadOnlyList<string> NavSlugs(JsonObject snapshot)
{
var navArr = snapshot["site"]?["navOrder"] as JsonArray;
if (navArr is null) return [];
return navArr.Select(n => n?.GetValue<string>()).Where(s => s is not null).ToList()!;
}
/// <summary>
/// HTML-encode only the 5 HTML special characters. Non-ASCII characters (e.g. German
/// umlauts) are left as-is — the page declares UTF-8, so no entity encoding needed.
/// </summary>
private static string H(string? s)
{
if (s is null) return "";
return s.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
/// <summary>Extract a string value from a JsonNode; returns null if not a string.</summary>
private static string? Str(JsonNode? node) =>
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
}
}

View File

@@ -0,0 +1,130 @@
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// Builds the full CMS snapshot consumed by the static site renderer (WEB-1). The
/// abgabetiere page's auto AbgabetiereList block is resolved here from live ForSale
/// animals — nothing private is ever exposed (publish = snapshot of public content).
/// </summary>
public sealed class SiteSnapshotService
{
private readonly ApplicationContext _db;
public SiteSnapshotService(ApplicationContext db) => _db = db;
public async Task<JsonObject> BuildAsync()
{
var site = await _db.Sites.AsNoTracking().FirstOrDefaultAsync()
?? new Site { Id = Site.SingletonId };
var pages = await _db.Pages.AsNoTracking().Include(p => p.Blocks).ToListAsync();
var pageById = pages.ToDictionary(p => p.Id);
var navOrder = new JsonArray();
foreach (var id in site.NavOrder)
if (pageById.TryGetValue(id, out var p))
navOrder.Add(p.Slug);
JsonArray? forSale = null; // resolved lazily, reused across blocks
int NavIndex(Guid id) { var i = site.NavOrder.IndexOf(id); return i < 0 ? int.MaxValue : i; }
var pagesArr = new JsonArray();
foreach (var page in pages.OrderBy(p => NavIndex(p.Id)).ThenBy(p => p.Slug))
{
var blocks = new JsonArray();
foreach (var b in page.Blocks.OrderBy(b => b.Order))
{
var data = JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data) as JsonObject
?? new JsonObject();
if (b.Type == BlockType.AbgabetiereList && AsString(data["mode"]) == "auto")
{
forSale ??= await BuildForSaleAsync();
data["animals"] = forSale.DeepClone();
}
blocks.Add(new JsonObject
{
["id"] = b.Id.ToString(),
["order"] = b.Order,
["type"] = b.Type.ToString(),
["data"] = data,
});
}
pagesArr.Add(new JsonObject
{
["slug"] = page.Slug,
["title"] = page.Title,
["seoDescription"] = page.SeoDescription,
["status"] = page.Status.ToString(),
["blocks"] = blocks,
});
}
return new JsonObject
{
["site"] = new JsonObject
{
["defaultLocale"] = site.DefaultLocale,
["navOrder"] = navOrder,
},
["pages"] = pagesArr,
};
}
private async Task<JsonArray> BuildForSaleAsync()
{
var animals = await _db.Gerbils.AsNoTracking()
.Where(g => g.Status == GerbilStatus.ForSale)
.OrderBy(g => g.Name)
.Select(g => new
{
g.Id,
g.Name,
g.Gender,
g.DateOfBirth,
g.CharacterNote,
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
// group = Becken (enclosure) name — matches how the Abgabe composer
// groups ForSale animals by enclosure-mates (god ruling). null = single.
Group = g.Enclosure != null ? g.Enclosure.Name : null,
})
.ToListAsync();
var ids = animals.Select(a => a.Id).ToList();
var photos = await _db.GerbilPhotos.AsNoTracking()
.Where(p => ids.Contains(p.GerbilId))
.OrderBy(p => p.SortOrder)
.Select(p => new { p.GerbilId, p.FileName })
.ToListAsync();
var photosByGerbil = photos
.GroupBy(p => p.GerbilId)
.ToDictionary(g => g.Key, g => g.Select(x => $"/photos/files/{x.FileName}").ToList());
var arr = new JsonArray();
foreach (var a in animals)
{
var photoArr = new JsonArray();
if (photosByGerbil.TryGetValue(a.Id, out var urls))
foreach (var u in urls) photoArr.Add(u);
arr.Add(new JsonObject
{
["name"] = a.Name,
["gender"] = a.Gender.ToString(),
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
["characterNote"] = a.CharacterNote,
["farbschlag"] = a.Farbschlag,
["group"] = a.Group,
["photos"] = photoArr,
["aiSaleText"] = null, // populated by WEB-5 once a Gemini key exists
});
}
return arr;
}
private static string? AsString(JsonNode? node) =>
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
}
}

View File

@@ -24,7 +24,12 @@ namespace GerbilManagerWebAPI.Dtos
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
string? ExternalRef,
string? OriginBreeder,
List<string> CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool IsResident);
public record LitterDto(
Guid Id,
@@ -54,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
// Request DTOs -----------------------------------------------------------
public record GerbilInput(
string Name,
Gender Gender,
string? Name,
Gender? Gender,
GerbilStatus? Status,
Guid? LitterId,
Guid? OriginContactId,
@@ -69,7 +74,12 @@ namespace GerbilManagerWebAPI.Dtos
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
string? ExternalRef,
string? OriginBreeder,
List<string>? CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool? IsResident);
public record LitterInput(
string Name,

View File

@@ -0,0 +1,26 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Dtos
{
// Response DTOs ----------------------------------------------------------
public record PageSummaryDto(Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status);
public record PageDto(
Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status,
IReadOnlyList<BlockDto> Blocks);
/// <summary>Block.Data is emitted as a parsed JSON object (not a string).</summary>
public record BlockDto(Guid Id, int Order, BlockType Type, JsonNode? Data);
// Request DTOs -----------------------------------------------------------
public record PageInput(string Slug, string Title, string? SeoDescription, PageStatus? Status);
/// <summary>Data is the type-specific JSON object (e.g. {"markdown":"…"} for RichText).</summary>
public record BlockInput(BlockType Type, JsonElement Data, int? Order);
public record BlockOrderInput(List<Guid> BlockIds);
}

View File

@@ -0,0 +1,41 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Dtos
{
public record RequestDto(
Guid Id,
string GmailMessageId,
string? ThreadId,
string FromAddress,
string? FromName,
string? Subject,
string? BodyText,
DateTimeOffset ReceivedAt,
RequestStatus Status,
Guid? AssignedContactId,
string? DraftReply,
DateTimeOffset? AnsweredAt);
/// <summary>Triage update: change status and/or assign a contact.</summary>
public record RequestTriageInput(RequestStatus? Status, Guid? AssignedContactId);
/// <summary>Mail settings read shape — the App Password is NEVER returned (only HasAppPassword).</summary>
public record MailSettingsDto(
string? GmailAddress,
int PollIntervalMinutes,
string Folder,
bool BackgroundPollEnabled,
bool HasAppPassword);
/// <summary>Body of the (edited) reply to send for POST /api/requests/{id}/send.</summary>
public record SendReplyInput(string Body);
/// <summary>Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
/// empty string clears it.</summary>
public record MailSettingsInput(
string? GmailAddress,
string? AppPassword,
int? PollIntervalMinutes,
string? Folder,
bool? BackgroundPollEnabled);
}

View File

@@ -0,0 +1,186 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// WEB-0: CMS for the public website. GET /api/site-snapshot is the publish-time export
/// (with Abgabetiere resolved from live ForSale animals); the /api/pages + /api/blocks
/// CRUD edits content (hand-edit admin UI in WEB-0b, AI tools later). LAN-only like the
/// rest of the API.
/// </summary>
public static class CmsEndpoints
{
public static IEndpointRouteBuilder MapCmsEndpoints(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api").WithTags("CMS");
// ---- snapshot (consumed by the static renderer) ----
api.MapGet("/site-snapshot", async (ApplicationContext db) =>
TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync()));
// ---- static render dry-run (WEB-1: returns rendered file map as JSON) ----
api.MapGet("/render-site", async (ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
});
// ---- 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
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
if (!files.TryGetValue(key, out var content) &&
!files.TryGetValue($"{key}/index.html", out content))
{
return Results.NotFound();
}
return Results.Content(content, PreviewContentType(key));
});
// ---- pages ----
api.MapGet("/pages", async (ApplicationContext db) =>
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
.Select(p => new PageSummaryDto(p.Id, p.Slug, p.Title, p.SeoDescription, p.Status))
.ToListAsync()));
api.MapGet("/pages/{slug}", async Task<Results<Ok<PageDto>, NotFound>> (string slug, ApplicationContext db) =>
{
var p = await db.Pages.AsNoTracking().Include(x => x.Blocks)
.FirstOrDefaultAsync(x => x.Slug == slug);
return p is null ? TypedResults.NotFound() : TypedResults.Ok(ToPageDto(p));
});
api.MapPost("/pages", async Task<Results<Created<PageDto>, ValidationProblem, Conflict<string>>> (PageInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["slug"] = ["Slug and title are required."] });
if (await db.Pages.AnyAsync(p => p.Slug == input.Slug))
return TypedResults.Conflict($"A page with slug '{input.Slug}' already exists.");
var page = new Page
{
Id = Guid.NewGuid(),
Slug = input.Slug,
Title = input.Title,
SeoDescription = input.SeoDescription,
Status = input.Status ?? PageStatus.Draft,
};
db.Pages.Add(page);
await db.SaveChangesAsync();
return TypedResults.Created($"/api/pages/{page.Slug}", ToPageDto(page));
});
api.MapPut("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, PageInput input, ApplicationContext db) =>
{
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
if (page is null) return TypedResults.NotFound();
page.Slug = input.Slug;
page.Title = input.Title;
page.SeoDescription = input.SeoDescription;
if (input.Status is PageStatus s) page.Status = s;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
api.MapDelete("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
if (page is null) return TypedResults.NotFound();
db.Pages.Remove(page); // cascades blocks
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// ---- blocks ----
api.MapPost("/pages/{pageId:guid}/blocks", async Task<Results<Created<BlockDto>, NotFound, BadRequest<string>>> (
Guid pageId, BlockInput input, ApplicationContext db) =>
{
if (!await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound();
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
return TypedResults.BadRequest("Block data must be a JSON object.");
int order = input.Order ?? ((await db.Blocks.Where(b => b.PageId == pageId)
.Select(b => (int?)b.Order).MaxAsync() ?? -1) + 1);
var block = new Block
{
Id = Guid.NewGuid(),
PageId = pageId,
Order = order,
Type = input.Type,
Data = input.Data.ValueKind == JsonValueKind.Object ? input.Data.GetRawText() : "{}",
};
db.Blocks.Add(block);
await db.SaveChangesAsync();
return TypedResults.Created($"/api/blocks/{block.Id}", ToBlockDto(block));
});
api.MapPut("/blocks/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
Guid id, BlockInput input, ApplicationContext db) =>
{
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
if (block is null) return TypedResults.NotFound();
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
return TypedResults.BadRequest("Block data must be a JSON object.");
block.Type = input.Type;
if (input.Data.ValueKind == JsonValueKind.Object) block.Data = input.Data.GetRawText();
if (input.Order is int o) block.Order = o;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
api.MapDelete("/blocks/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
if (block is null) return TypedResults.NotFound();
db.Blocks.Remove(block);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// reorder: body = ordered block ids; Order set to the list index
api.MapPut("/pages/{pageId:guid}/blocks/order", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
Guid pageId, BlockOrderInput input, ApplicationContext db) =>
{
var blocks = await db.Blocks.Where(b => b.PageId == pageId).ToListAsync();
if (blocks.Count == 0 && !await db.Pages.AnyAsync(p => p.Id == pageId))
return TypedResults.NotFound();
var byId = blocks.ToDictionary(b => b.Id);
if (input.BlockIds.Count != blocks.Count || input.BlockIds.Any(bid => !byId.ContainsKey(bid)))
return TypedResults.BadRequest("blockIds must list exactly the page's block ids.");
for (int i = 0; i < input.BlockIds.Count; i++)
byId[input.BlockIds[i]].Order = i;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
/// <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"
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
: "text/html; charset=utf-8";
private static BlockDto ToBlockDto(Block b) =>
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
private static PageDto ToPageDto(Page p) => new(
p.Id, p.Slug, p.Title, p.SeoDescription, p.Status,
p.Blocks.OrderBy(b => b.Order).Select(ToBlockDto).ToList());
}
}

View File

@@ -19,6 +19,13 @@ namespace GerbilManagerWebAPI.Endpoints
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.Where(g => g.OriginBreeder != null && g.OriginBreeder != "")
.Select(g => g.OriginBreeder!)
.Distinct().OrderBy(b => b).ToListAsync()));
// GET /gerbils/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
@@ -32,19 +39,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();
@@ -70,28 +76,41 @@ 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;
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.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.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.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);
}
internal static GerbilDto ToDto(Gerbil g) => 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.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
}
}

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

@@ -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

@@ -0,0 +1,129 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Inbox;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// INBOX-0: Gmail request inbox. Sync imports mail into Request rows; the list/detail/triage
/// endpoints drive the in-app triage (frontend INBOX-1). LAN-only like the rest of the API.
/// </summary>
public static class RequestEndpoints
{
public static IEndpointRouteBuilder MapRequestEndpoints(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api").WithTags("Inbox");
// POST /api/requests/sync — fetch from Gmail + import (dedup on Message-Id)
api.MapPost("/requests/sync", async (RequestSyncService sync) =>
TypedResults.Ok(await sync.SyncAsync()));
// GET /api/requests?filter=status==New&orderBy=receivedAt desc (Gridify paged)
api.MapGet("/requests", async ([Microsoft.AspNetCore.Http.AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Requests.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
api.MapGet("/requests/{id:guid}", async Task<Results<Ok<RequestDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.Requests.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
// PUT /api/requests/{id} — triage: status and/or assigned contact
api.MapPut("/requests/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
Guid id, RequestTriageInput input, ApplicationContext db) =>
{
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
if (input.AssignedContactId is Guid cid && !await db.Contacts.AnyAsync(c => c.Id == cid))
return TypedResults.BadRequest("Assigned contact does not exist.");
r.AssignedContactId = input.AssignedContactId;
if (input.Status is RequestStatus s)
{
r.Status = s;
if (s == RequestStatus.Answered) r.AnsweredAt ??= DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// POST /api/requests/{id}/draft — INBOX-2: KI-Antwortentwurf. NUR Entwurf
// (human-in-the-loop, Versand ist /send). 503 AiKeyMissing solange die
// AI-Sektion unkonfiguriert ist (gleiches UI-Muster wie sale-ad).
api.MapPost("/requests/{id:guid}/draft", async Task<IResult> (
Guid id, DraftReplyService drafter, ApplicationContext db, CancellationToken ct) =>
{
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return TypedResults.NotFound();
// Datenminimierung: NUR die ForSale-Liste (Name + Farbschlag) geht
// zusätzlich zum Anfragetext an den Anbieter (arch §privacy).
var forSale = await db.Gerbils.AsNoTracking()
.Where(g => g.Status == GerbilStatus.ForSale)
.OrderBy(g => g.Name)
.Select(g => new DraftReplyService.ForSaleAnimal(g.Name, g.ColorVariety!.Name))
.ToListAsync(ct);
var result = await drafter.DraftAsync(r, forSale, ct);
if (result.Status == Ai.AiCallStatus.NotConfigured)
return Results.Json(new { code = "AiKeyMissing", message = result.Error }, statusCode: 503);
if (result.Status != Ai.AiCallStatus.Ok)
return Results.Json(new { code = "AiUpstreamError", message = result.Error }, statusCode: 502);
r.DraftReply = result.Text;
await db.SaveChangesAsync(ct);
return TypedResults.Ok(ToDto(r));
});
// POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered
api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> (
Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) =>
{
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
var outcome = await sender.SendAsync(r, input.Body ?? "");
return outcome.Error switch
{
null => TypedResults.Ok(ToDto(r)),
"MailNotConfigured" => TypedResults.Problem(
"Gmail ist noch nicht konfiguriert.", statusCode: 503, title: "MailNotConfigured"),
"MailAuthFailed" => TypedResults.Problem(
"Gmail-Anmeldung fehlgeschlagen — App-Passwort erneut eingeben.", statusCode: 502, title: "MailAuthFailed"),
var code => TypedResults.Problem(code, statusCode: 500, title: code),
};
});
// GET/PUT /api/mail-settings — App Password never leaves the server
api.MapGet("/mail-settings", async (MailSettingsService svc) =>
{
var s = await svc.GetAsync();
return TypedResults.Ok(new MailSettingsDto(
s.GmailAddress, s.PollIntervalMinutes, s.Folder, s.BackgroundPollEnabled, svc.HasAppPassword(s)));
});
api.MapPut("/mail-settings", async (MailSettingsInput input, MailSettingsService svc, ApplicationContext db) =>
{
var s = await svc.GetAsync();
if (input.GmailAddress is not null) s.GmailAddress = string.IsNullOrWhiteSpace(input.GmailAddress) ? null : input.GmailAddress.Trim();
if (input.PollIntervalMinutes is int m) s.PollIntervalMinutes = m;
if (input.Folder is not null) s.Folder = string.IsNullOrWhiteSpace(input.Folder) ? "INBOX" : input.Folder.Trim();
if (input.BackgroundPollEnabled is bool b) s.BackgroundPollEnabled = b;
// AppPassword: null => leave unchanged; "" => clear; value => set (encrypted)
if (input.AppPassword is not null) svc.SetPassword(s, input.AppPassword);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static RequestDto ToDto(Request r) => new(
r.Id, r.GmailMessageId, r.ThreadId, r.FromAddress, r.FromName, r.Subject, r.BodyText,
r.ReceivedAt, r.Status, r.AssignedContactId, r.DraftReply, r.AnsweredAt);
}
}

View File

@@ -1,38 +1,40 @@
<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="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="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,405 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
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 (atomic: crash → full rollback).
/// 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();
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
{
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
"extract_docx.py zuerst ausführen.");
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
.GroupBy(l => l.Date.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);
// 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.
// Two animals with the same base ref (same ws+name+litterDob) get -2, -3 suffixes.
var externalRefOrdinals = new Dictionary<string, int>();
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
var batchRefs = new HashSet<string>();
// --- Planning pass (dry-run counts + execute writes) ---
// Execute path is wrapped in a single transaction for atomicity.
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
if (execute)
tx = await _db.Database.BeginTransactionAsync();
try
{
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 = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
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,
});
}
}
// Flush all gerbil inserts + enrich updates in one shot (within the tx)
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
await _db.SaveChangesAsync();
if (tx is not null) await tx.CommitAsync();
}
catch
{
// tx.DisposeAsync (in finally) rolls back if not committed
throw;
}
finally
{
if (tx is not null) await tx.DisposeAsync();
}
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
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

@@ -16,11 +16,21 @@ namespace GerbilManagerWebAPI.Import
public List<string> FarbschlagVariants { get; set; } = new();
public SourceGenotype Genotype { get; set; } = new();
public string Zucht { get; set; } = "";
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
public List<SourceParentRef> ParentRefs { get; set; } = new();
public List<string> Photos { get; set; } = new();
public List<string> SourceFiles { get; set; } = new();
public bool Conflict { get; set; }
public SourceLitterRef? LitterRef { get; set; }
// GEN-3b normalization: hearing/deaf phenotype flag (null = not stated) and
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
public bool? Deaf { get; set; }
public List<string> Tags { get; set; } = new();
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
// this animal (its genotype/farbschlag are then authoritative). For reporting.
public bool ResolvedByDecision { get; set; }
}
public sealed class SourceGenotype
@@ -34,7 +44,9 @@ namespace GerbilManagerWebAPI.Import
{
public string Name { get; set; } = "";
public string Dob { get; set; } = "";
public string RoleGuess { get; set; } = "";
public string RoleGuess { get; set; } = ""; // "father" | "mother"
public string Method { get; set; } = ""; // e.g. "chart-position"
public string Confidence { get; set; } = ""; // "hoch" | "medium" | "niedrig"
}
public sealed class SourceLitterRef
@@ -66,9 +78,20 @@ namespace GerbilManagerWebAPI.Import
AnimalSummary Animals,
PhotoSummary Photos,
IReadOnlyList<string> Samples,
IReadOnlyList<string> Notes);
IReadOnlyList<string> Notes,
ResidencySummary? Residency = null);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported);
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
/// 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 WithoutDate = 0);
public sealed record AnimalSummary(
int InSource,
@@ -77,7 +100,10 @@ namespace GerbilManagerWebAPI.Import
int FarbschlagMatched,
int FarbschlagUnmatched,
int AlreadyImported,
QuarantineSummary Quarantined);
QuarantineSummary Quarantined,
int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0,
int FarbschlagDerivedFromGenotype = 0);
public sealed record QuarantineSummary(
int Conflicts,

View File

@@ -71,17 +71,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
// 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: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 +98,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();
@@ -128,67 +142,288 @@ namespace GerbilManagerWebAPI.Import
int photosAttached = 0, photosMissing = 0;
var createdAnimalByName = new Dictionary<string, Guid>(); // normalized name -> gerbil id (for litter back-link)
// 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, 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)
{
if (existingGerbilSet.Contains(a.Id)) { animalsExisting++; continue; }
animalsCreated++;
bool exists = existingGerbilSet.Contains(a.Id);
// 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? litterId = null;
Guid? wurfLitterId = null;
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
{
litterId = lid;
linked++;
}
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; }
}
if (colorVarietyId is null) fbUnmatched++; else fbMatched++;
var gender = InferGender(a, sireNames, damNames);
var gid = Guid.NewGuid();
var norm = Normalize(StripZucht(a.Name));
if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid);
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
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
// any animal that has no Wurfchronik litter and isn't already litter-linked. Siblings
// (same father+mother+dob) share one derived litter. Computed for dry-run counts too.
int parentLinksAdded = 0, derivedLitters = 0;
var synthLitterForGid = new Dictionary<Guid, Guid>(); // offspring gid -> synth litter id
var synthLitters = new Dictionary<string, SynthLitter>(); // parents+date key -> synth litter
foreach (var p in plan)
{
if (p.WurfLitterId is not null || p.CurrentLitterId is not null) continue;
if (p.A.ParentRefs is not { Count: > 0 }) continue;
var father = ResolveParentGid(p.A, "father", gidByNameDob);
var mother = ResolveParentGid(p.A, "mother", gidByNameDob);
if (father is null && mother is null) continue; // nothing resolvable to link
var dob = ParseDate(p.A.Dob);
var conf = p.A.ParentRefs.FirstOrDefault()?.Confidence ?? "medium";
var key = $"{father}|{mother}|{dob:yyyy-MM-dd}";
if (!synthLitters.TryGetValue(key, out var sl))
{
sl = new SynthLitter(Guid.NewGuid(), father, mother, dob, conf);
synthLitters[key] = sl;
derivedLitters++;
}
synthLitterForGid[p.Gid] = sl.Id;
parentLinksAdded++;
}
// FK-INTEGRITY (PEDIGREE-LINK bug fix): a litter's Father/MotherId must resolve to a
// gerbil that is created-or-existing, or Postgres throws FK_Litters_Gerbils_*. Compute
// the persisted set (existing DB rows + this run's loadable animals) and drop any parent
// FK that isn't in it; SKIP a derived litter whose BOTH parents are unresolvable (its
// offspring then load with LitterId=null — still better than 'unbekannt' won't regress).
// This runs in dry-run too, so a green dry-run GUARANTEES /import/execute won't FK-fault.
var persisted = new HashSet<Guid>(existingRows.Select(r => r.Id));
foreach (var p in plan) persisted.Add(p.Gid);
int litterParentFksDropped = 0, derivedLittersSkipped = 0;
foreach (var key in synthLitters.Keys.ToList())
{
var sl = synthLitters[key];
var f = sl.Father is Guid gf && persisted.Contains(gf) ? sl.Father : null;
var m = sl.Mother is Guid gm && persisted.Contains(gm) ? sl.Mother : null;
if (sl.Father is not null && f is null) litterParentFksDropped++;
if (sl.Mother is not null && m is null) litterParentFksDropped++;
if (f is null && m is null)
{
derivedLittersSkipped++; derivedLitters--;
foreach (var gid in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
synthLitterForGid.Remove(gid);
synthLitters.Remove(key);
continue;
}
synthLitters[key] = sl with { Father = f, Mother = m };
}
parentLinksAdded = synthLitterForGid.Count;
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters,
// each FK guarded by the persisted set. Used by residency rule (b) below; augmented with
// existing DB litters under execute.
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
foreach (var sl in synthLitters.Values)
litterParents[sl.Id] = (sl.Father, sl.Mother);
foreach (var sl in litters)
if (litterIdMap.TryGetValue(sl.Id, out var lid))
{
Guid? f = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) && persisted.Contains(fid) ? fid : null;
Guid? m = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) && persisted.Contains(mid) ? mid : null;
litterParents[lid] = (f, m);
}
// 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.
var existingLitterRows = await _db.Litters
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
var litterByParentsDate = new Dictionary<string, Guid>();
foreach (var l in existingLitterRows)
{
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date: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}";
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
{
// remap offspring to the existing litter; don't create a duplicate.
foreach (var g in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
synthLitterForGid[g] = existingId;
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 = null, // deferred — applied after gerbils SaveChanges
MotherId = null, // deferred — applied after gerbils SaveChanges
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
});
}
// NOTE: no SaveChanges here — staged with the gerbils below.
}
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
// kennel (zuchtCanon contains 'kleinechaote'); (b) parent of a Clan offspring, even if
// the parent's own Zuchtname is foreign. See hive/agents/god/OWNERSHIP-residency.md.
static bool ClanCanon(string? zc) =>
(zc ?? "").Contains("kleinechaote", StringComparison.OrdinalIgnoreCase);
var resident = new HashSet<Guid>();
foreach (var p in plan) if (ClanCanon(p.A.ZuchtCanon)) resident.Add(p.Gid); // (a)
int residentByA = resident.Count, flippedByParentRule = 0;
foreach (var p in plan)
{
if (!ClanCanon(p.A.ZuchtCanon)) continue;
var litId = p.WurfLitterId ?? (synthLitterForGid.TryGetValue(p.Gid, out var s) ? s : (Guid?)null);
if (litId is null || !litterParents.TryGetValue(litId.Value, out var par)) continue;
if (par.F is Guid gf && resident.Add(gf)) flippedByParentRule++; // (b)
if (par.M is Guid gm && resident.Add(gm)) flippedByParentRule++;
}
int residentTotal = plan.Count(p => resident.Contains(p.Gid));
int externalTotal = plan.Count - residentTotal;
foreach (var p in plan)
{
Guid? litterId = p.WurfLitterId
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
if (litterId is not null) linked++;
bool isResident = resident.Contains(p.Gid);
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
if (samples.Count < 16)
samples.Add($"Tier: {a.Name} (*{a.Dob}), Genotyp {ComposeGenotype(a.Genotype)}"
+ (litterId is not null ? ", Wurf-verknüpft" : "")
+ (colorVarietyId is not null ? $", Farbschlag „{a.Farbschlag}\"" : ""));
samples.Add($"Tier: {p.A.Name} (*{p.A.Dob}), Genotyp {ComposeGenotype(p.A.Genotype)}"
+ (litterId is not null ? (p.WurfLitterId is not null ? ", Wurf-verknüpft" : ", Eltern aus Diagramm") : "")
+ (p.ColorVarietyId is not null ? $", Farbschlag „{p.A.Farbschlag}\"" : ""));
if (p.Exists)
{
animalsExisting++;
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
if (row is not null)
{
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
row.IsResident = isResident;
}
}
continue;
}
animalsCreated++;
if (execute)
{
_db.Gerbils.Add(new Gerbil
{
Id = gid,
Name = a.Name,
Gender = gender,
Id = p.Gid,
Name = p.A.Name,
Gender = p.Gender,
Status = GerbilStatus.Active,
DateOfBirth = ParseDate(a.Dob),
DateOfDeath = ParseDate(a.Death),
DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId,
ColorVarietyId = colorVarietyId,
Genotype = ComposeGenotype(a.Genotype),
ColorVarietyId = p.ColorVarietyId,
Genotype = ComposeGenotype(p.A.Genotype),
IsDeaf = p.A.Deaf,
IsResident = isResident,
ImportSource = ImportSourceTag,
ExternalRef = a.Id,
ExternalRef = p.A.Id,
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
RawImportData = JsonSerializer.Serialize(new
{
a.Genotype.RawGenotype,
a.Genotype.UnmappedTokens,
a.Zucht,
a.SourceFiles,
FarbschlagRaw = a.Farbschlag,
p.A.Genotype.RawGenotype,
p.A.Genotype.UnmappedTokens,
// GEN-3b: Sls (2nd spotting locus) preserved here until Kevin's GEN-3a
// parser adopts it into the compact Genotype contract; tags + deaf too.
Sls = p.A.Genotype.Mapped8locus.TryGetValue("Sls", out var sls) ? sls : null,
p.A.Tags,
p.A.Deaf,
p.A.Zucht,
p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag,
}),
});
}
// photos
foreach (var rel in a.Photos)
foreach (var rel in p.A.Photos)
{
var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(src)) { photosMissing++; continue; }
@@ -201,7 +436,7 @@ namespace GerbilManagerWebAPI.Import
_db.GerbilPhotos.Add(new GerbilPhoto
{
Id = Guid.NewGuid(),
GerbilId = gid,
GerbilId = p.Gid,
FileName = fileName,
SortOrder = 0,
CreatedAt = DateTimeOffset.UtcNow,
@@ -211,7 +446,21 @@ namespace GerbilManagerWebAPI.Import
}
if (execute) await _db.SaveChangesAsync();
// ---- back-link litter parents by name (best effort) ----
// 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)
{
foreach (var sl in litters)
@@ -219,26 +468,174 @@ namespace GerbilManagerWebAPI.Import
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
if (litter is null) continue;
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId))
// guard: only link parents that are actually persisted (avoid an orphan FK).
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId))
litter.FatherId = fId;
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId))
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId))
litter.MotherId = mId;
}
await _db.SaveChangesAsync();
}
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
// have null Father/MotherId because the parent was previously quarantined may now be
// resolvable. Two lookup sources — must check BOTH:
// (a) createdAnimalByName: animals loaded/re-linked in THIS run (new or existing).
// (b) allDbNormToGid: ALL gerbils already in the DB, for parents loaded in an
// EARLIER run who are no longer in the current extract (e.g. alreadyImported
// animals absent from this run's animals.json, or name normalization mismatch
// between animals.json and the Wurfchronik sire/dam field).
// Counted for dry-run too; writes only when execute=true.
int parentFksBackfilled = 0;
{
// Build DB-wide normalized-name lookup (supplementary to createdAnimalByName).
var allDbNormToGid = existingRows
.GroupBy(g => Normalize(StripZucht(g.Name)))
.ToDictionary(grp => grp.Key, grp => grp.First().Id);
var existingWithNullParent = await _db.Litters
.Where(l => l.FatherId == null || l.MotherId == null)
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
.ToListAsync();
var sourceByName = litters
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
.ToDictionary(g => g.Key, g => g.First());
Guid? ResolveParentForBackfill(string rawName)
{
var n = Normalize(StripZucht(rawName));
if (n.Length == 0) return null;
if (createdAnimalByName.TryGetValue(n, out var fromLoadable) && persisted.Contains(fromLoadable))
return fromLoadable;
if (allDbNormToGid.TryGetValue(n, out var fromDb) && persisted.Contains(fromDb))
return fromDb;
return null;
}
foreach (var el in existingWithNullParent)
{
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
var newF = el.FatherId == null ? ResolveParentForBackfill(sl.SireName) : null;
var newM = el.MotherId == null ? ResolveParentForBackfill(sl.DamName) : null;
if (newF is null && newM is null) continue;
parentFksBackfilled++;
if (execute)
{
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
if (row is not null)
{
if (newF is not null) row.FatherId = newF;
if (newM is not null) row.MotherId = newM;
}
}
}
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).");
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),
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)),
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples,
Notes: notes);
Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
}
private T? Load<T>(string file)
@@ -256,14 +653,45 @@ namespace GerbilManagerWebAPI.Import
if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2)
return StripCaret(pair[0]) + StripCaret(pair[1]);
return "??";
});
}).ToList();
// GEN-3a contract (Kevin): Sls is appended LAST and ONLY for carriers — the
// wild-type sl/sl is omitted so existing 8-locus strings stay unchanged. WP het
// renders as the trailing token "Slsl"; S(l)S(l) is lethal so never appears.
if (g.Mapped8locus.TryGetValue("Sls", out var sls) && sls.Count == 2
&& !(sls[0] == "sl" && sls[1] == "sl"))
{
tokens.Add(StripCaret(sls[0]) + StripCaret(sls[1]));
}
return string.Join(' ', tokens);
}
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
// over sire/dam name inference (PEDIGREE-LINK, Julian 2026-06-06).
if (string.Equals(a.Gender, "male", StringComparison.OrdinalIgnoreCase)) return Gender.male;
if (string.Equals(a.Gender, "female", StringComparison.OrdinalIgnoreCase)) return Gender.female;
var n = Normalize(StripZucht(a.Name));
bool isSire = sires.Contains(n), isDam = dams.Contains(n);
if (isSire && !isDam) return Gender.male;
@@ -297,5 +725,27 @@ namespace GerbilManagerWebAPI.Import
n = Regex.Replace(n, @"[^a-z0-9äöüß]", "");
return n;
}
/// <summary>Dedup identity for parent resolution: normalized call-name + DOB.</summary>
private static string NameDobKey(string name, DateOnly? dob) =>
$"{Normalize(StripZucht(name))}|{dob:yyyy-MM-dd}";
/// <summary>Resolve a chart-position parentRef (by role) to a known gerbil id, or null.</summary>
private static Guid? ResolveParentGid(SourceAnimal a, string role, Dictionary<string, Guid> gidByNameDob)
{
var pr = a.ParentRefs.FirstOrDefault(p =>
string.Equals(p.RoleGuess, role, StringComparison.OrdinalIgnoreCase));
if (pr is null || string.IsNullOrWhiteSpace(pr.Name)) return null;
return gidByNameDob.TryGetValue(NameDobKey(pr.Name, ParseDate(pr.Dob)), out var id) ? id : null;
}
/// <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? 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,108 @@
using System.Text;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Ai;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// INBOX-2: AI reply DRAFT for an incoming inquiry (human-in-the-loop — the
/// breeder edits and sends herself, NEVER auto-send; send is INBOX-3).
///
/// PRIVACY / data minimization (INBOX-architecture.md §privacy): the prompt
/// carries ONLY the inquiry's own text (signatures/quoted history stripped),
/// the sender's display name for the greeting, a short German context, and
/// optionally the current ForSale animals (name + Farbschlag). NOT the
/// contact DB, NOT other requests, NOT any addresses.
/// </summary>
public sealed class DraftReplyService(HttpClient http, IOptions<AiOptions> options)
{
private readonly OpenAiChatClient _client = new(http, options);
public sealed record ForSaleAnimal(string Name, string? Farbschlag);
public Task<AiCallResult> DraftAsync(
Request request, IReadOnlyList<ForSaleAnimal> forSale, CancellationToken ct = default)
=> _client.CompleteAsync(BuildSystemPrompt(), BuildUserPrompt(request, forSale), ct);
internal static string BuildSystemPrompt() => """
Du hilfst einer Hobby-Rennmaus-Züchterin (Zucht der kleinen Chaoten), eine
freundliche deutsche Antwort auf eine Anfrage zu ENTWERFEN. Sie liest den
Entwurf, passt ihn an und versendet selbst.
Regeln:
- Ton: warm, persönlich, hilfsbereit wie eine erfahrene Hobby-Züchterin,
nicht wie ein Unternehmen. Anrede per du, Gruß mit dem Namen der
anfragenden Person, falls bekannt.
- KEINE Fakten erfinden: Verfügbarkeit, Tiere und Eigenschaften NUR aus den
mitgelieferten Daten. Ist keine Abgabetier-Liste mitgeliefert oder passt
nichts, verweise freundlich darauf, dass sie aktuelle Infos persönlich gibt.
- Keine Preise/Schutzgebühren nennen und keine Adressen oder sonstige
persönliche Daten solche Details klärt sie selbst im Gespräch.
- Gib NUR den Antworttext aus (ohne Betreff, ohne Erklärungen) und beende
mit einem herzlichen Gruß ohne Namens-Signatur.
""";
internal static string BuildUserPrompt(Request request, IReadOnlyList<ForSaleAnimal> forSale)
{
var sb = new StringBuilder();
sb.AppendLine("Entwirf eine Antwort auf folgende Anfrage:");
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(request.FromName))
sb.AppendLine($"Von: {request.FromName}");
if (!string.IsNullOrWhiteSpace(request.Subject))
sb.AppendLine($"Betreff: {request.Subject}");
sb.AppendLine();
sb.AppendLine("Anfrage-Text:");
sb.AppendLine(StripQuotedText(request.BodyText ?? ""));
if (forSale.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Aktuell abzugebende Tiere (einzige zulässige Quelle für Verfügbarkeits-Aussagen):");
foreach (var a in forSale)
{
sb.AppendLine(string.IsNullOrWhiteSpace(a.Farbschlag)
? $"- {a.Name}"
: $"- {a.Name} ({a.Farbschlag})");
}
}
return sb.ToString();
}
// ── Signatur-/Zitat-Stripping (best effort, bewusst konservativ) ────
private static readonly Regex QuoteIntro = new(
@"^Am .+ schrieb .+:\s*$", RegexOptions.Compiled);
private static readonly Regex OutlookHeader = new(
@"^(Von|Gesendet|An|Betreff):\s", RegexOptions.Compiled);
/// <summary>
/// Drops quoted history ('&gt;'-lines, the German Gmail "Am … schrieb …:"
/// intro, Outlook-style forwarded headers) and everything below a signature
/// delimiter ("-- "). Keeps the sender's own words untouched.
/// </summary>
internal static string StripQuotedText(string body)
{
var lines = body.Replace("\r\n", "\n").Split('\n');
var kept = new List<string>();
foreach (var line in lines)
{
var trimmed = line.TrimEnd();
if (trimmed == "--" || trimmed == "-- ")
break; // signature delimiter: everything below is signature
if (QuoteIntro.IsMatch(trimmed))
break; // quoted history follows
if (trimmed.StartsWith('>'))
continue;
if (OutlookHeader.IsMatch(trimmed))
continue;
kept.Add(line);
}
// collapse the whitespace the stripping may have left behind
return string.Join("\n", kept).Trim();
}
}
}

View File

@@ -0,0 +1,58 @@
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MailKit.Security;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Live Gmail reader over IMAP (App Password auth). Reads UIDs newer than the last seen,
/// fetches envelope + Gmail thread id + the plain-text body. Gated on Julian's App Password
/// (tests use a fake IGmailMailReader).
/// </summary>
public sealed class GmailMailReader : IGmailMailReader
{
public async Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default)
{
using var client = new ImapClient();
await client.ConnectAsync("imap.gmail.com", 993, SecureSocketOptions.SslOnConnect, ct);
await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct);
var folder = string.Equals(connection.Folder, "INBOX", StringComparison.OrdinalIgnoreCase)
? client.Inbox
: await client.GetFolderAsync(connection.Folder, ct);
await folder.OpenAsync(FolderAccess.ReadOnly, ct);
var range = new UniqueIdRange(new UniqueId(sinceUid + 1), UniqueId.MaxValue);
var uids = await folder.SearchAsync(SearchQuery.Uids(range), ct);
const MessageSummaryItems items = MessageSummaryItems.Envelope
| MessageSummaryItems.UniqueId
| MessageSummaryItems.GMailThreadId
| MessageSummaryItems.InternalDate;
var summaries = await folder.FetchAsync(uids, items, ct);
var result = new List<MailSummary>();
foreach (var s in summaries)
{
var env = s.Envelope;
var msg = await folder.GetMessageAsync(s.UniqueId, ct); // body + threading headers
var from = env?.From?.Mailboxes?.FirstOrDefault();
result.Add(new MailSummary(
MessageId: env?.MessageId ?? msg.MessageId ?? "",
ThreadId: s.GMailThreadId?.ToString(),
InReplyTo: msg.InReplyTo,
References: msg.References is { Count: > 0 } ? string.Join(' ', msg.References) : null,
FromAddress: from?.Address ?? "",
FromName: string.IsNullOrWhiteSpace(from?.Name) ? null : from!.Name,
Subject: env?.Subject ?? msg.Subject,
BodyText: msg.TextBody,
ReceivedAt: s.InternalDate ?? env?.Date ?? DateTimeOffset.UtcNow,
Uid: s.UniqueId.Id));
}
await client.DisconnectAsync(true, ct);
return result;
}
}
}

View File

@@ -0,0 +1,43 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Live Gmail sender over SMTP (App Password auth, STARTTLS). Builds a correctly-threaded
/// reply so it lands in the same Gmail conversation. Gated on Julian's App Password
/// (tests use a fake IGmailMailSender + assert BuildMimeMessage).
/// </summary>
public sealed class GmailMailSender : IGmailMailSender
{
public async Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default)
{
var message = BuildMimeMessage(connection.GmailAddress, mail);
using var client = new SmtpClient();
await client.ConnectAsync("smtp.gmail.com", 587, SecureSocketOptions.StartTls, ct);
await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
/// <summary>Builds the reply MimeMessage with In-Reply-To / References / Subject set for
/// Gmail threading. Public so the threading headers are unit-testable without SMTP.</summary>
public static MimeMessage BuildMimeMessage(string fromAddress, OutgoingMail mail)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(fromAddress));
message.To.Add(MailboxAddress.Parse(mail.To));
message.Subject = mail.Subject;
message.Body = new TextPart("plain") { Text = mail.Body };
if (!string.IsNullOrWhiteSpace(mail.InReplyTo))
message.InReplyTo = mail.InReplyTo;
if (!string.IsNullOrWhiteSpace(mail.References))
foreach (var id in mail.References.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
message.References.Add(id);
return message;
}
}
}

View File

@@ -0,0 +1,29 @@
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>Connection params for an IMAP fetch (decrypted password — never persisted/logged).</summary>
public sealed record MailConnection(string GmailAddress, string AppPassword, string Folder);
/// <summary>A fetched email reduced to what the Request entity needs.</summary>
public sealed record MailSummary(
string MessageId,
string? ThreadId,
string? InReplyTo,
string? References,
string FromAddress,
string? FromName,
string? Subject,
string? BodyText,
DateTimeOffset ReceivedAt,
uint Uid);
/// <summary>Reads mail from a folder. Abstracted so tests feed canned summaries
/// (the live MailKit/Gmail path is gated on Julian's App Password).</summary>
public interface IGmailMailReader
{
/// <summary>Fetch messages with UID &gt; <paramref name="sinceUid"/> from the configured folder.</summary>
Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default);
}
/// <summary>Outcome of a sync run.</summary>
public sealed record SyncResult(int Imported, string? Error);
}

View File

@@ -0,0 +1,27 @@
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>A reply to send, with RFC threading headers already computed.</summary>
public sealed record OutgoingMail(
string To,
string Subject,
string Body,
string? InReplyTo,
string? References);
/// <summary>Outcome of a send attempt. Error is null on success; otherwise a stable code
/// ("MailNotConfigured", "MailAuthFailed") the UI maps to a German hint.</summary>
public sealed record SendOutcome(bool Sent, string? Error)
{
public static readonly SendOutcome Ok = new(true, null);
public static SendOutcome Fail(string code) => new(false, code);
}
/// <summary>Sends mail. Abstracted so tests can assert the message/capture it without a live
/// SMTP server (the live MailKit/Gmail path is gated on Julian's App Password).</summary>
public interface IGmailMailSender
{
/// <summary>Sends the reply. Throws <see cref="MailKit.Security.AuthenticationException"/>
/// on bad/revoked credentials (surfaced as "MailAuthFailed").</summary>
Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,43 @@
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Reads/updates the singleton MailSettings. The Gmail App Password is encrypted at rest
/// with ASP.NET Data Protection and is NEVER returned over the wire (only used server-side
/// to open the IMAP/SMTP connection).
/// </summary>
public sealed class MailSettingsService
{
private readonly ApplicationContext _db;
private readonly IDataProtector _protector;
public MailSettingsService(ApplicationContext db, IDataProtectionProvider dp)
{
_db = db;
_protector = dp.CreateProtector("GerbilManager.MailSettings.AppPassword.v1");
}
public async Task<MailSettings> GetAsync(CancellationToken ct = default)
{
var s = await _db.MailSettings.FirstOrDefaultAsync(ct);
if (s is null)
{
s = new MailSettings { Id = MailSettings.SingletonId };
_db.MailSettings.Add(s);
await _db.SaveChangesAsync(ct);
}
return s;
}
public bool HasAppPassword(MailSettings s) => !string.IsNullOrEmpty(s.AppPasswordProtected);
public string? DecryptPassword(MailSettings s) =>
string.IsNullOrEmpty(s.AppPasswordProtected) ? null : _protector.Unprotect(s.AppPasswordProtected);
public void SetPassword(MailSettings s, string? plain) =>
s.AppPasswordProtected = string.IsNullOrEmpty(plain) ? null : _protector.Protect(plain);
}
}

View File

@@ -0,0 +1,69 @@
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Imports Gmail messages into Request rows: dedup on Message-Id, track the highest IMAP
/// UID to avoid rescans. The actual fetch is behind <see cref="IGmailMailReader"/> (mockable).
/// </summary>
public sealed class RequestSyncService
{
private readonly ApplicationContext _db;
private readonly IGmailMailReader _reader;
private readonly MailSettingsService _settings;
public RequestSyncService(ApplicationContext db, IGmailMailReader reader, MailSettingsService settings)
{
_db = db;
_reader = reader;
_settings = settings;
}
public async Task<SyncResult> SyncAsync(CancellationToken ct = default)
{
var settings = await _settings.GetAsync(ct);
var password = _settings.DecryptPassword(settings);
if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password))
return new SyncResult(0, "MailNotConfigured");
var conn = new MailConnection(settings.GmailAddress!, password!, settings.Folder);
var summaries = await _reader.FetchAsync(conn, settings.LastUid, ct);
uint maxUid = settings.LastUid;
int imported = 0;
if (summaries.Count > 0)
{
var fetchedIds = summaries.Select(s => s.MessageId).Where(id => !string.IsNullOrEmpty(id)).ToList();
var existing = (await _db.Requests
.Where(r => fetchedIds.Contains(r.GmailMessageId))
.Select(r => r.GmailMessageId).ToListAsync(ct)).ToHashSet();
foreach (var m in summaries)
{
if (m.Uid > maxUid) maxUid = m.Uid;
if (string.IsNullOrEmpty(m.MessageId) || !existing.Add(m.MessageId)) continue;
_db.Requests.Add(new Request
{
Id = Guid.NewGuid(),
GmailMessageId = m.MessageId,
ThreadId = m.ThreadId,
InReplyToMessageId = m.InReplyTo,
ReferencesHeader = m.References,
FromAddress = m.FromAddress,
FromName = m.FromName,
Subject = m.Subject,
BodyText = m.BodyText,
ReceivedAt = m.ReceivedAt,
Status = RequestStatus.New,
});
imported++;
}
}
settings.LastUid = maxUid;
await _db.SaveChangesAsync(ct);
return new SyncResult(imported, null);
}
}
}

View File

@@ -0,0 +1,73 @@
using GerbilManagerWebAPI.Models;
using MailKit.Security;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Sends a (human-edited) reply to a Request via SMTP, threaded into the original Gmail
/// conversation, and marks the request Answered. Only ever called on the explicit
/// POST /api/requests/{id}/send — never auto-sends.
/// </summary>
public sealed class SendReplyService
{
private readonly ApplicationContext _db;
private readonly IGmailMailSender _sender;
private readonly MailSettingsService _settings;
public SendReplyService(ApplicationContext db, IGmailMailSender sender, MailSettingsService settings)
{
_db = db;
_sender = sender;
_settings = settings;
}
public async Task<SendOutcome> SendAsync(Request request, string body, CancellationToken ct = default)
{
var settings = await _settings.GetAsync(ct);
var password = _settings.DecryptPassword(settings);
if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password))
return SendOutcome.Fail("MailNotConfigured");
var mail = new OutgoingMail(
To: request.FromAddress,
Subject: ReplySubject(request.Subject),
Body: body,
InReplyTo: request.GmailMessageId,
References: BuildReferences(request.ReferencesHeader, request.GmailMessageId));
try
{
await _sender.SendAsync(new MailConnection(settings.GmailAddress!, password!, settings.Folder), mail, ct);
}
catch (AuthenticationException)
{
return SendOutcome.Fail("MailAuthFailed");
}
request.Status = RequestStatus.Answered;
request.AnsweredAt = DateTimeOffset.UtcNow;
request.DraftReply = body; // record what was actually sent
await _db.SaveChangesAsync(ct);
return SendOutcome.Ok;
}
/// <summary>"Re: " prefix without double-prefixing an existing reply subject.</summary>
internal static string ReplySubject(string? original)
{
var s = (original ?? "").Trim();
if (s.Length == 0) return "Re:";
return s.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) ? s : $"Re: {s}";
}
/// <summary>References = existing chain + the original Message-Id (deduped, in order).</summary>
internal static string BuildReferences(string? existingChain, string originalMessageId)
{
var ids = (existingChain ?? "")
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
if (!string.IsNullOrWhiteSpace(originalMessageId) && !ids.Contains(originalMessageId))
ids.Add(originalMessageId);
return string.Join(' ', ids);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddSearchFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true);
// Backfill NameSearch for existing rows (lowercase, strip whitespace/.-_),
// matching GerbilSearch.Normalize. New/updated rows stay in sync via
// ApplicationContext.SaveChanges.
migrationBuilder.Sql(
"UPDATE \"Gerbils\" SET \"NameSearch\" = lower(regexp_replace(\"Name\", '[[:space:]._-]', '', 'g'));");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NameSearch",
table: "Gerbils");
migrationBuilder.DropColumn(
name: "OriginBreeder",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddCmsEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Media",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
Alt = table.Column<string>(type: "text", nullable: true),
Width = table.Column<int>(type: "integer", nullable: true),
Height = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Media", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Pages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Slug = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
SeoDescription = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Pages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Sites",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DefaultLocale = table.Column<string>(type: "text", nullable: false),
NavOrder = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sites", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Blocks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PageId = table.Column<Guid>(type: "uuid", nullable: false),
Order = table.Column<int>(type: "integer", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Data = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Blocks", x => x.Id);
table.ForeignKey(
name: "FK_Blocks_Pages_PageId",
column: x => x.PageId,
principalTable: "Pages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "Pages",
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
values: new object[,]
{
{ new Guid("51720001-0000-0000-0000-000000000001"), null, "start", "Published", "Startseite" },
{ new Guid("51720001-0000-0000-0000-000000000002"), null, "ueber-die-zucht", "Published", "Über die Zucht" },
{ new Guid("51720001-0000-0000-0000-000000000003"), null, "abgabetiere", "Published", "Abgabetiere" },
{ new Guid("51720001-0000-0000-0000-000000000004"), null, "abgabebedingungen", "Published", "Abgabebedingungen" },
{ new Guid("51720001-0000-0000-0000-000000000005"), null, "farben-genetik", "Published", "Farben & Genetik" },
{ new Guid("51720001-0000-0000-0000-000000000006"), null, "kontakt", "Published", "Kontakt" }
});
migrationBuilder.InsertData(
table: "Sites",
columns: new[] { "Id", "DefaultLocale", "NavOrder" },
values: new object[] { new Guid("5172e000-0000-0000-0000-000000000001"), "de", "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" });
migrationBuilder.InsertData(
table: "Blocks",
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
values: new object[,]
{
{ new Guid("51720002-0000-0000-0000-000000000001"), "{\"text\":\"Startseite\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000001"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000002"), "{\"text\":\"Über die Zucht\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000002"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000003"), "{\"text\":\"Abgabetiere\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000003"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000004"), "{\"text\":\"Abgabebedingungen\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000004"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000005"), "{\"text\":\"Farben & Genetik\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000005"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000006"), "{\"text\":\"Kontakt\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000006"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000010"), "{\"mode\":\"auto\",\"intro\":\"\"}", 1, new Guid("51720001-0000-0000-0000-000000000003"), "AbgabetiereList" }
});
migrationBuilder.CreateIndex(
name: "IX_Blocks_PageId",
table: "Blocks",
column: "PageId");
migrationBuilder.CreateIndex(
name: "IX_Pages_Slug",
table: "Pages",
column: "Slug",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Blocks");
migrationBuilder.DropTable(
name: "Media");
migrationBuilder.DropTable(
name: "Sites");
migrationBuilder.DropTable(
name: "Pages");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddRequestsAndMailSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MailSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GmailAddress = table.Column<string>(type: "text", nullable: true),
AppPasswordProtected = table.Column<string>(type: "text", nullable: true),
PollIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
Folder = table.Column<string>(type: "text", nullable: false),
BackgroundPollEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastUid = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MailSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Requests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GmailMessageId = table.Column<string>(type: "text", nullable: false),
ThreadId = table.Column<string>(type: "text", nullable: true),
InReplyToMessageId = table.Column<string>(type: "text", nullable: true),
ReferencesHeader = table.Column<string>(type: "text", nullable: true),
FromAddress = table.Column<string>(type: "text", nullable: false),
FromName = table.Column<string>(type: "text", nullable: true),
Subject = table.Column<string>(type: "text", nullable: true),
BodyText = table.Column<string>(type: "text", nullable: true),
ReceivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
AssignedContactId = table.Column<Guid>(type: "uuid", nullable: true),
DraftReply = table.Column<string>(type: "text", nullable: true),
AnsweredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Requests", x => x.Id);
table.ForeignKey(
name: "FK_Requests_Contacts_AssignedContactId",
column: x => x.AssignedContactId,
principalTable: "Contacts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
table: "MailSettings",
columns: new[] { "Id", "AppPasswordProtected", "BackgroundPollEnabled", "Folder", "GmailAddress", "LastUid", "PollIntervalMinutes" },
values: new object[] { new Guid("ab0c0000-0000-0000-0000-000000000001"), null, false, "INBOX", null, 0L, 15 });
migrationBuilder.CreateIndex(
name: "IX_Requests_AssignedContactId",
table: "Requests",
column: "AssignedContactId");
migrationBuilder.CreateIndex(
name: "IX_Requests_GmailMessageId",
table: "Requests",
column: "GmailMessageId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MailSettings");
migrationBuilder.DropTable(
name: "Requests");
}
}
}

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 AddGerbilDeafFlag : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsDeaf",
table: "Gerbils",
type: "boolean",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsDeaf",
table: "Gerbils");
}
}
}

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 AddGerbilResidency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsResident",
table: "Gerbils",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsResident",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,971 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class SyncColorVarietySeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
column: "Name",
value: "Siam (Marder-Hell)");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "Name",
value: "Marder dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "Name",
value: "Silberagouti-Hell dd CP");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "Name",
value: "Agouti-Hell dd CP");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schwarzschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
column: "Name",
value: "Marder");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schimmel (Orangeschimmel)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "Name",
value: "Siam (Marder-Hell) dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "Name",
value: "Silberagouti dd CP");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "Name",
value: "Agouti dd CP");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee gg PP spsp rere", "Blaufuchs dd" });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,632 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesGen3f : Migration
{
/// <inheritdoc />
protected override void Up(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"));
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.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000071"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000072"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000073"));
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmch DD EE GG PP spsp rere", "Siam" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmch DD EE gg PP spsp rere", "Zobel-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "CP-Agouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "CP-Silberagouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "CP-Algierfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "CP-Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd ee GG PP spsp rere", "CP-Fuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmch dd ee GG PP spsp rere", "CP-Fuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd ee gg PP spsp rere", "CP-Blaufuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 61 },
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 62 },
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 63 },
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 64 },
{ new Guid("00000000-0000-0000-0000-000000000066"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 65 },
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 66 },
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 67 },
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC dd EE GG pp spsp rere", "Topas dd", 68 },
{ new Guid("00000000-0000-0000-0000-000000000070"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 69 },
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 70 },
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa CC dd ee gg pp spsp rere", "Blaufuchs dd", 71 },
{ new Guid("00000000-0000-0000-0000-000000000073"), "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel", 72 }
});
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddImpressumDatenschutzPages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Pages",
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
values: new object[,]
{
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
});
migrationBuilder.InsertData(
table: "Blocks",
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
values: new object[,]
{
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
}
}
}

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);
}
}
}

View File

@@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>Publication state of a CMS page (serialised as string name).</summary>
public enum PageStatus
{
Draft = 0,
Published = 1,
}
/// <summary>
/// CMS block kinds. RichText holds MARKDOWN (sanitised on render). AbgabetiereList is
/// dynamic — resolved from live ForSale animals at snapshot time. Serialised as string.
/// </summary>
public enum BlockType
{
Heading = 0,
RichText = 1,
Image = 2,
Gallery = 3,
ContactInfo = 4,
AbgabetiereList = 5,
}
/// <summary>Singleton site settings (WEB epic CMS).</summary>
public class Site
{
/// <summary>Fixed id — there is exactly one Site row.</summary>
public static readonly Guid SingletonId = new("5172e000-0000-0000-0000-000000000001");
[Key]
public Guid Id { get; set; }
public string DefaultLocale { get; set; } = "de";
/// <summary>Ordered page ids for the public nav (JSON list).</summary>
public List<Guid> NavOrder { get; set; } = new();
}
/// <summary>A CMS page (one of the public website's pages).</summary>
public class Page
{
[Key]
public Guid Id { get; set; }
/// <summary>Stable URL slug (e.g. "abgabetiere") — the human key.</summary>
public required string Slug { get; set; }
public required string Title { get; set; }
public string? SeoDescription { get; set; }
public PageStatus Status { get; set; } = PageStatus.Draft;
public ICollection<Block> Blocks { get; } = new List<Block>();
}
/// <summary>
/// A content block on a page (single table, discriminated by <see cref="Type"/>).
/// Type-specific payload lives in <see cref="Data"/> as JSON (RichText = Markdown).
/// </summary>
public class Block
{
[Key]
public Guid Id { get; set; }
public Guid PageId { get; set; }
/// <summary>Explicit ordering within the page (AI tools reorder by this).</summary>
public int Order { get; set; }
public BlockType Type { get; set; }
/// <summary>Type-specific fields as a JSON object string.</summary>
public string Data { get; set; } = "{}";
}
/// <summary>A media asset referenced by Image/Gallery blocks.</summary>
public class Media
{
[Key]
public Guid Id { get; set; }
public required string FileName { get; set; }
public required string Url { get; set; }
public string? Alt { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
}
}

View File

@@ -50,5 +50,47 @@ namespace GerbilManagerWebAPI.Models
/// <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; }
/// <summary>Breeder/Herkunft (cattery/Zucht) as free text — set by the FEAT-8 import from
/// the source Zucht (imported animals have no OriginContact). Gridify-filterable; the
/// distinct values back the Tiere "Herkunft" dropdown (GET /gerbils/breeders).</summary>
public string? OriginBreeder { get; set; }
/// <summary>Separator-insensitive search key: Name lowercased with whitespace/.-_ stripped.
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
public string? NameSearch { get; set; }
/// <summary>FEAT-14: Charakterbogen trait labels (opaque to the backend — the
/// {key,label} vocabulary lives frontend-side). Stored as a JSON text column.</summary>
public List<string> CharacterTraits { get; set; } = new();
/// <summary>FEAT-14: free-text character note; feeds the AI Verkaufstext.</summary>
public string? CharacterNote { get; set; }
/// <summary>GEN-3b: hearing/deaf phenotype flag (NOT a genotype locus — it's the
/// downstream effect of high white load / Sp×Sls). null = not stated, true = deaf
/// (dea/taub), false = hearing (Dea/hörend). Set by the FEAT-8 import from the
/// after-spsp deafness annotation; see hive/agents/god/GENETIK-notation.md.</summary>
public bool? IsDeaf { get; set; }
/// <summary>Residency/ownership (ORIGIN, distinct from Abgabe location): true = part of
/// the Clan-kleine-Chaoten Bestand, false = external pedigree ancestor (bred elsewhere).
/// Rule (a) Zuchtname matches the Clan kennel, OR (b) it's a parent of a Clan offspring.
/// 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;
}
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
public static class GerbilSearch
{
public static string Normalize(string? name)
{
if (string.IsNullOrEmpty(name)) return "";
var chars = name.ToLowerInvariant()
.Where(c => !char.IsWhiteSpace(c) && c != '-' && c != '.' && c != '_');
return new string(chars.ToArray());
}
}
}

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>Triage state of an incoming Gmail request (serialised as string name).</summary>
public enum RequestStatus
{
New = 0,
InProgress = 1,
Assigned = 2,
Answered = 3,
Abandoned = 4,
}
/// <summary>
/// An incoming inquiry imported from Gmail (INBOX epic). One row per email,
/// deduped on the RFC Message-Id.
/// </summary>
public class Request
{
[Key]
public Guid Id { get; set; }
/// <summary>RFC 5322 Message-Id — unique; the dedup key.</summary>
public required string GmailMessageId { get; set; }
/// <summary>Gmail conversation id (X-GM-THRID).</summary>
public string? ThreadId { get; set; }
public string? InReplyToMessageId { get; set; }
public string? ReferencesHeader { get; set; }
public required string FromAddress { get; set; }
public string? FromName { get; set; }
public string? Subject { get; set; }
public string? BodyText { get; set; }
public DateTimeOffset ReceivedAt { get; set; }
public RequestStatus Status { get; set; } = RequestStatus.New;
public Guid? AssignedContactId { get; set; }
public Contact? AssignedContact { get; set; }
/// <summary>AI/edited draft reply (INBOX-2); send is INBOX-3. Null until drafted.</summary>
public string? DraftReply { get; set; }
public DateTimeOffset? AnsweredAt { get; set; }
}
/// <summary>
/// Singleton mail configuration (INBOX epic). The Gmail App Password is stored
/// ENCRYPTED at rest (ASP.NET Data Protection) and never returned over the wire.
/// </summary>
public class MailSettings
{
public static readonly Guid SingletonId = new("ab0c0000-0000-0000-0000-000000000001");
[Key]
public Guid Id { get; set; }
public string? GmailAddress { get; set; }
/// <summary>Data-Protection-encrypted Gmail App Password (never plaintext, never on the wire).</summary>
public string? AppPasswordProtected { get; set; }
public int PollIntervalMinutes { get; set; } = 15;
public string Folder { get; set; } = "INBOX";
public bool BackgroundPollEnabled { get; set; }
/// <summary>Highest IMAP UID seen, to avoid rescanning the whole folder.</summary>
public uint LastUid { get; set; }
}
}

View File

@@ -25,5 +25,14 @@ 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; }
}
}

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,107 @@
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,
};
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).";
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");
if (!string.IsNullOrWhiteSpace(usages))
sb.Append($", aus den Kulturkreisen: {usages}");
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,6 @@
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
@@ -41,6 +42,30 @@ builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// 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));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
// 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>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
var app = builder.Build();
@@ -73,9 +98,13 @@ app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.MapSaleAdEndpoints();
app.MapImportEndpoints();
app.MapImportDocxEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.Run();

View File

@@ -9,7 +9,11 @@ namespace GerbilManagerWebAPI.SaleAd
string? Farbschlag,
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
string? DateOfBirth,
string? Notes);
string? Notes,
/// <summary>FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt.</summary>
List<string>? Traits = null,
/// <summary>FEAT-14: free-text character note for the prompt.</summary>
string? CharacterNote = null);
public sealed record SaleAdRequest(
List<SaleAdAnimal> Animals,

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>
@@ -58,7 +52,8 @@ namespace GerbilManagerWebAPI.SaleAd
Harte Regeln:
- NIEMALS Preise oder Schutzgebühren nennen.
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Notizen). Fehlt eine Angabe, lässt du sie weg.
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Charakter-Angaben, Notizen). Fehlt eine Angabe, lässt du sie weg.
- Charakter-Eigenschaften (Stichworte wie „zutraulich“, „buddelt gern“) und die Charakter-Notiz sind die Grundlage der Persönlichkeits-Prosa: Verwebe sie zu FLIESSENDEM Text — niemals als Aufzählung oder Stichwortliste ausgeben.
- Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben.
- Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke.
@@ -85,7 +80,17 @@ namespace GerbilManagerWebAPI.SaleAd
sb.Append($" | Farbschlag: {animal.Farbschlag}");
if (!string.IsNullOrWhiteSpace(animal.DateOfBirth))
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
if (!string.IsNullOrWhiteSpace(animal.Notes))
// FEAT-14c: Charakterbogen (Traits + Notiz) ist die BEVORZUGTE
// Charakterquelle; die generischen Notizen dienen nur als
// Fallback, wenn kein Charakterbogen gepflegt ist (sie enthalten
// oft Verwaltungs-Infos, die nicht ins Inserat gehören).
var hasCharacter = animal.Traits is { Count: > 0 }
|| !string.IsNullOrWhiteSpace(animal.CharacterNote);
if (animal.Traits is { Count: > 0 })
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
if (!hasCharacter && !string.IsNullOrWhiteSpace(animal.Notes))
sb.Append($" | Notizen: {animal.Notes}");
sb.AppendLine();
}

View File

@@ -1,87 +1,35 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Ai;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// FEAT-12a: provider-agnostic AI client for sale-ad generation.
///
/// Speaks the OpenAI-compatible chat-completions wire shape — deliberately
/// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions
/// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral,
/// local Ollama and any future provider. The wire shape IS the abstraction.
/// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
/// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
/// future AI features reuse the SAME implementation); this service contributes
/// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
/// </summary>
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly OpenAiChatClient _client = new(http, options);
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
{
var ai = options.Value;
if (!ai.IsConfigured)
var result = await _client.CompleteAsync(
SaleAdPromptBuilder.BuildSystemPrompt(),
SaleAdPromptBuilder.BuildUserPrompt(request),
ct);
var status = result.Status switch
{
return new SaleAdResult(SaleAdStatus.NotConfigured, null,
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
}
var payload = new ChatRequest(
Model: ai.Model!,
Messages:
[
new ChatMessage("system", SaleAdPromptBuilder.BuildSystemPrompt()),
new ChatMessage("user", SaleAdPromptBuilder.BuildUserPrompt(request)),
],
Temperature: 0.7);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
{
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
Encoding.UTF8, "application/json"),
AiCallStatus.Ok => SaleAdStatus.Ok,
AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
_ => SaleAdStatus.UpstreamError,
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
try
{
using var response = await http.SendAsync(httpRequest, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
return new SaleAdResult(SaleAdStatus.UpstreamError, null,
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
}
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
return string.IsNullOrWhiteSpace(text)
? new SaleAdResult(SaleAdStatus.UpstreamError, null,
"KI-Antwort enthielt keinen Text.")
: new SaleAdResult(SaleAdStatus.Ok, text);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
return new SaleAdResult(SaleAdStatus.UpstreamError, null,
$"KI-Anbieter nicht erreichbar: {ex.Message}");
}
return new SaleAdResult(status, result.Text, result.Error);
}
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
/// <summary>Forwarder kept for the existing config-matrix tests.</summary>
internal static Uri BuildCompletionsUri(string baseUrl) =>
new($"{baseUrl.TrimEnd('/')}/chat/completions");
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
internal sealed record ChatMessage(string Role, string Content);
internal sealed record ChatResponse(List<ChatChoice>? Choices);
internal sealed record ChatChoice(ChatMessage? Message);
OpenAiChatClient.BuildCompletionsUri(baseUrl);
}
}

107
HUMANQUESTION.md Normal file
View File

@@ -0,0 +1,107 @@
# 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** | **Domain-Name** (registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. |
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). |
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo → Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) |
## B. Kleine Aktion (jederzeit)
| # | Aktion | Warum |
|---|---|---|
| **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 (http://192.168.2.124:5173). |
## C. Genetik-Feinschliff (optional, blockiert nichts)
| # | Frage | Betrifft |
|---|---|---|
| **REW-1** | **PEW = REW?** „Pink Eyed White" (PEW) und „Rotaugenweiß" (REW) sind dasselbe — soll der alte PEW-Eintrag mit REW **zusammengeführt** werden? | Doppel-Eintrag im Farbkatalog. |
| **REW-2** | **REW bei nicht-agouti?** Wird ein `aa`-Tier (z. B. Marder) mit Colourpoint + `pp` **auch** als REW gewertet, oder soll REW nur für agouti-basierte Tiere gelten? | REW-Erkennung (wird gerade A-unabhängig gebaut = aa zählt mit). |
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
## E. Charakterbogen-Eigenschaften (optional)
Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll etwas ergänzt/gestrichen werden?**
> 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. *beißt manchmal*) für ehrliche Inserate?
---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (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.)
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? |
|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 |
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? |
|---|---|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa****aa** |
| Percy of little runners (*16.12.2017) | P-Locus: **PP****Pp** |
| Iwana of little runners (*02.10.2018) | P-Locus: **PP****Pp** |
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-****Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** |
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) ↔ **c[chm]c[chm]** (Colourpoint) |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** |
| 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) |
*(Alle Gencode-Varianten + Quelldateien: `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 | **A4** (Domain + Cloudflare) |
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
| 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.*

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)
@@ -15,9 +15,15 @@ TAG=latest
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/SSD/gerbil/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=
# 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.
@@ -40,10 +40,17 @@ 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}"
volumes:
- photos:/data/photos
- keys:/data/keys
depends_on:
db:
condition: service_healthy
@@ -101,6 +108,14 @@ volumes:
type: none
o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/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/gerbil/keys}"
backups:
driver: local
driver_opts:

View File

@@ -1,18 +0,0 @@
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
version: '3.4'
services:
gerbilmanagerwebapi:
image: gerbilmanagerwebapi
build:
context: .
dockerfile: GerbilManagerWebAPI/Dockerfile
args:
- configuration=Debug
ports:
- 80:80
environment:
- ASPNETCORE_ENVIRONMENT=Development
volumes:
- ~/.vsdbg:/remote_debugger:rw

View File

@@ -1,57 +0,0 @@
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
version: '3.4'
services:
frontend:
image: gerbilmanagerweb
build:
context: .
dockerfile: gerbil-manager-web/Dockerfile
args:
# Der Browser erreicht die API über den am Host veröffentlichten Port
- VITE_API_BASE_URL=http://localhost:80
ports:
- 3000:3000
networks:
- net2
depends_on:
- backend
backend:
image: gerbilmanagerwebapi
build:
context: .
dockerfile: GerbilManagerWebAPI/Dockerfile
environment:
- ConnectionStrings:sqlConnection=server=database; database=GerbilManager; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True
ports:
- 80:80
networks:
- net1
- net2
depends_on:
- database
database:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=StrongerThenYYou1
- MSSQL_PID=Evaluation
ports:
- "1433:1433"
volumes:
- db-data:/var/opt/mssql
networks:
- net1
volumes:
db-data:
networks:
net1:
name: network1
net2:
name: network2

View File

@@ -1,4 +1,4 @@
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|---|---|---|---|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
```powershell
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
$env:AI__ApiKey = '<schlüssel>'
$env:AI__Model = 'gemini-2.0-flash'
$env:AI__Model = 'gemini-flash-latest'
```
oder per user-secrets im API-Projekt:
@@ -46,5 +46,5 @@ services:
environment:
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
AI__Model: gemini-2.0-flash
AI__Model: gemini-flash-latest
```

3
gerbil-manager-web/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# de.ts is large and edited by many agents — pin to LF so EOL flips don't
# create noisy whole-file diffs (GEN-3 follow-up, god-approved).
src/strings/de.ts text eol=lf

View File

@@ -40,6 +40,25 @@ npm run build
npm run preview
```
## Farbschlag-Katalog (AR-5)
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
Nach jeder Änderung dort den Generator laufen lassen:
```bash
npm run gen:catalog
```
Erzeugt zwei Artefakte und committet beide:
| Datei | Notation | Verwendung |
|---|---|---|
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
## E2E-Tests (QA-1, Playwright)
```bash
@@ -56,4 +75,4 @@ npx playwright test --project=phone # nur Smartphone-Viewport (390px)
überspringen sich selbst; die übrigen legen eigene Datensätze an.
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
`src/strings/de.ts`.
`src/strings/de.ts`.

View File

@@ -0,0 +1,129 @@
/**
* QA-2: Abgabe-Wizard (FEAT-12a + FEAT-13) — Abgabetiere gruppieren,
* KI-Inserat generieren, und Vertrag-Handoff via ?tiere= Query-Parameter.
*
* Mock-gebunden (Seed: 2 ForSale-Tiere im Quarantänebecken) → skipUnlessMock.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const ta = de.pages.abgabe
const tw = de.pages.vertraege.wizard
test.describe('Abgabe', () => {
test('Abgabe-Seite zeigt ForSale-Tiere und Inserat-Vorschau', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.getByRole('heading', { name: ta.title, exact: true })).toBeVisible()
await expect(page.getByText(ta.grouping.title)).toBeVisible()
// Die beiden ForSale-Tiere müssen als Tiername (strong-Element im Komposer) sichtbar sein
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Benny Abgabe' })).toBeVisible()
// Inserat-Vorschau (listing-preview) wird gerendert
await expect(page.locator('.listing-preview').first()).toBeVisible()
// Status-Dropdown standard: FREI
await expect(
page.locator('label.field').filter({ has: page.locator(`span:text-is("${ta.listing.status}")`) }).locator('select').first()
).toHaveValue('free')
})
test('KI-Inserat: "Text mit KI verbessern" — Mock liefert Inserat-Text', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
// Mock antwortet synchron; der generierte Text erscheint in der Vorschau
await expect(page.locator('.listing-preview').first()).toContainText('Generierter Inserat-Text')
})
test('KI-Inserat: 503 zeigt deutschen Hinweis (saleAdConfigured=false)', async ({ page, mockDb }) => {
skipUnlessMock()
mockDb!.saleAdConfigured = false
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
await expect(page.getByText(ta.ai.notConfigured)).toBeVisible()
})
test('Vertrag-Handoff: "Abgabe abschließen" öffnet Wizard mit vorausgewählten Tieren', async ({
page,
}) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
// Wizard öffnen (navigiert zu /vertraege/neu?tiere=sale-balu,sale-benny)
await page.getByRole('button', { name: ta.export.finish }).first().click()
await expect(page).toHaveURL(/\/vertraege\/neu\?tiere=/)
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer auswählen
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
})
})
test.describe('Vertrag-Wizard', () => {
test('4-Schritte-Wizard: Abnehmer → Tiere → Preis → Vertrag erzeugen', async ({ page }) => {
skipUnlessMock()
// Direkt mit vorausgewählten Tieren öffnen (wie über Abgabe-Seite)
await page.goto('/vertraege/neu?tiere=sale-balu,sale-benny')
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer wählen — Zoohandlung Meier aus dem Seed
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere — Vorauswahl aus ?tiere= ist aktiv
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
// Beide ForSale-Tiere sind vorausgewählt (Checkboxen angehakt)
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Balu Abgabe' }).getByRole('checkbox')
).toBeChecked()
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Benny Abgabe' }).getByRole('checkbox')
).toBeChecked()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: Preis & Datum
await page.getByLabel(tw.priceLabel).fill('50,00')
await page.getByRole('button', { name: tw.next }).click()
// Schritt 4: Zusammenfassung + Erzeugen
await expect(page.getByText(tw.summaryTitle)).toBeVisible()
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await page.getByRole('button', { name: tw.generate }).click()
// Erfolg: Vertrag erstellt, Download-Link vorhanden
await expect(page.getByRole('heading', { name: tw.successTitle })).toBeVisible()
await expect(page.getByRole('link', { name: tw.downloadDocx })).toBeVisible()
})
test('Schritt 1: fehlender Abnehmer zeigt Validierungsfehler', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
// Ohne Auswahl direkt auf Weiter klicken → Fehler-Alert erscheint
await page.getByRole('button', { name: tw.next }).click()
// Alert enthält den Hinweis (stepError), Schritt bleibt auf 1
await expect(page.locator('.alert--error').filter({ hasText: tw.pickContact })).toBeVisible()
})
test('Schritt 3: ungültiger Preis zeigt Validierungsmeldung', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
// Schritt 1: Abnehmer wählen
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere bestätigen
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: leeres Preisfeld -> Fehler
await expect(page.getByLabel(tw.priceLabel)).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
await expect(page.locator('.alert--error').filter({ hasText: tw.priceInvalid })).toBeVisible()
})
})

View File

@@ -0,0 +1,89 @@
/**
* INBOX-1: Anfragen-Posteingang — Liste/Filter, Triage, KI-Entwurf (inkl.
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
*/
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
const ta = de.pages.anfragen
const td = ta.detail
test.describe('Anfragen', () => {
test('Liste zeigt Anfragen neueste zuerst + Status-Filter', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.requests)
await expect(page.getByRole('heading', { name: ta.title })).toBeVisible()
// Neueste zuerst: Anna (05.06.) vor Ben (04.06.) vor Clara (01.06.)
const cards = page.locator('.anfrage-card')
await expect(cards).toHaveCount(3)
await expect(cards.nth(0)).toContainText('Anna Albrecht')
await expect(cards.nth(2)).toContainText('clara@example.com') // ohne Anzeigename → Adresse
// Status-Badge auf der Karte
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Filter: nur Beantwortet
await page.getByLabel(td.statusLabel).selectOption('Answered')
await expect(cards).toHaveCount(1)
await expect(cards.first()).toContainText('Danke!')
})
test('Sync ohne Gmail-Konfiguration zeigt deutschen Hinweis', async ({ page, mockDb }) => {
skipUnlessMock()
mockDb!.mailConfigured = false
await page.goto('/anfragen')
await page.getByRole('button', { name: ta.sync }).click()
await expect(page.getByText(ta.mailNotConfigured)).toBeVisible()
})
test('Detail: Triage — Abnehmer zuordnen setzt Status auf Zugeordnet', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/anfragen/req-anna')
await expect(page.getByRole('heading', { name: 'Anfrage: Pärchen zur Abgabe?' })).toBeVisible()
await expect(page.getByText('anna@example.de', { exact: false })).toBeVisible()
await page.getByLabel(td.assignContact).selectOption({ label: 'Zoohandlung Meier' })
await expect(page.locator('.anfrage-badge--assigned')).toBeVisible()
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
// Verwerfen (mit Bestätigung) → Status Verworfen, Zuordnung bleibt
acceptNextDialog(page)
await page.getByRole('button', { name: td.abandon }).click()
await expect(page.locator('.anfrage-badge--abandoned')).toBeVisible()
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
})
test('KI-Entwurf füllt das Antwortfeld; Senden markiert als Beantwortet', async ({ page }) => {
skipUnlessMock()
await page.goto('/anfragen/req-anna')
await page.getByRole('button', { name: td.draftButton }).click()
const textarea = page.locator('.anfrage-reply__text')
await expect(textarea).toHaveValue(/vielen Dank für deine Anfrage/)
// Entwurf bearbeiten, dann senden (bestätigt) → Beantwortet + Hinweis
await textarea.fill('Hallo Anna, ja — die beiden suchen noch ein Zuhause!')
acceptNextDialog(page)
await page.getByRole('button', { name: td.send }).click()
await expect(page.getByText(td.sent)).toBeVisible()
await expect(page.locator('.anfrage-badge--answered')).toBeVisible()
})
test('KI ohne Schlüssel: 503 wird zum freundlichen Hinweis', async ({ page, mockDb }) => {
skipUnlessMock()
mockDb!.aiConfigured = false
await page.goto('/anfragen/req-anna')
await page.getByRole('button', { name: td.draftButton }).click()
await expect(page.getByText(td.aiKeyMissing)).toBeVisible()
})
test('Senden ohne Text zeigt Hinweis statt Versand', async ({ page }) => {
skipUnlessMock()
await page.goto('/anfragen/req-anna')
await page.getByRole('button', { name: td.send }).click()
await expect(page.getByText(td.sendEmptyBody)).toBeVisible()
})
})

View File

@@ -7,7 +7,7 @@ const tk = de.pages.kontakte
test.describe('Becken', () => {
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
skipUnlessMock()
await page.goto('/becken')
await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click()
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
@@ -18,7 +18,7 @@ test.describe('Becken', () => {
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
skipUnlessMock()
await page.goto('/becken')
await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click()
acceptNextDialog(page)
await page.getByRole('button', { name: tb.delete.action }).click()
@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
await gotoSection(page, de.nav.contacts)
// Explizit auf die Listenüberschrift warten, bevor wir klicken — verhindert den
// CI-Flake, der auftrat wenn der Vite-Server unter paralleler Last noch nicht
// alle Routen der Seite gerendert hatte (gotoSection klickt nur den Nav-Link,
// wartet aber nicht auf den vollständigen Seitenaufbau).
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
await page.getByRole('link', { name: tk.newButton }).click()
await expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
const name = uniqueName('Kontakt')
await page.getByLabel(`${tk.fields.name} *`).fill(name)
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten

View File

@@ -0,0 +1,45 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Bestand ist sichtbar …
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// … aber die externe Ahne „Max“ (isResident=false) nicht.
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
})
test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
await expect(maxRow).toBeVisible()
await expect(maxRow.getByText(t.externalBadge, { exact: true })).toBeVisible()
// Der Bestand bleibt weiterhin sichtbar.
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
})
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
skipUnlessMock()
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
await page.goto('/rennmaeuse/kruemel/bearbeiten')
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
await expect(check).toBeChecked()
await check.uncheck()
await page.getByRole('button', { name: t.form.save }).click()
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
})

View File

@@ -0,0 +1,26 @@
/** FEAT-14b: Charakterbogen — Eigenschaft umschalten, persistieren, im Abgabe-Komposer wiederfinden. */
import { de, expect, skipUnlessMock, test } from './fixtures'
test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer wiederfinden', async ({
page,
}) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
// Eigenschaft 'neugierig' aktivieren und speichern.
await page.getByRole('checkbox', { name: 'neugierig' }).check()
await page.getByRole('button', { name: de.character.save }).click()
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
await page.goto('/abgabe')
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
const kruemelSection = page.locator('.group-composer__animal').filter({
has: page.locator('strong', { hasText: 'Krümel' }),
})
await expect(kruemelSection.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
})

View File

@@ -0,0 +1,85 @@
/**
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
*
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test.describe('FilterPanel Rennmäuse-Liste', () => {
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Toggle-Button sichtbar.
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeVisible()
// Status-Beschriftung im Drawer ist noch verborgen.
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Öffnen → Status-Feld wird sichtbar.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
// Schließen → wieder verborgen.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Phone: Badge zählt aktive Filter korrekt', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
await expect(toggle).toHaveText('Filter')
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
await toggle.click()
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
await expect(toggle).toHaveText('Filter (1)')
// Zurücksetzen → Badge weg.
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
await expect(toggle).toHaveText('Filter')
})
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kein Toggle-Button auf Desktop (display:none via Media Query).
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeHidden()
// Status-Beschriftung direkt sichtbar.
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
})
})

View File

@@ -39,12 +39,23 @@ export function skipUnlessMock() {
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
}
/**
* Test überspringen im Mock-Modus — nur im LIVE-Modus sinnvoll.
* Live-Tests MÜSSEN Ergebnisse assertieren (z. B. Liste nicht leer,
* konkreter Wert sichtbar), nicht nur HTTP-200. Lesson: der Mock spiegelt
* den Frontend-Code, nicht das echte Backend — ein Bug bleibt unsichtbar,
* wenn wir nur testen, ob die Seite lädt.
*/
export function skipUnlessLive() {
test.skip(!LIVE, 'nur im LIVE-Modus sinnvoll (E2E_BASE_URL setzen)')
}
/**
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
*/
export async function gotoSection(page: Page, label: string) {
const nav = page.getByRole('navigation')
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// App noch nicht geladen (about:blank) -> erst zur Startseite
if (!(await nav.isVisible())) {
await page.goto('/')
@@ -63,6 +74,18 @@ export function acceptNextDialog(page: Page) {
page.once('dialog', (d) => void d.accept())
}
/**
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
* kein Klick nötig — Controls sind direkt sichtbar.
*/
export async function openFilterPanel(page: Page) {
const toggle = page.locator('.filter-panel__toggle')
if (await toggle.isVisible()) {
await toggle.click()
}
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -0,0 +1,90 @@
/**
* QA-REGRESSION-1: GEHEGE-RENAME Lock-in.
*
* Sichert das Verhalten von drei GEHEGE-RENAME-Änderungen ab, die leicht still
* brechen können (Redirects, Nav-Einträge, Landing-URL):
* (a) /becken/* → /gehege/* Redirect (bookmarks / externe Links)
* (b) Nav zeigt 'Gehege' + 🏜️, kein 'Becken', kein 'Start'-Eintrag
* (c) '/' (Index) landet auf /rennmaeuse (nicht mehr auf einer Startseite)
*
* Bereits abgedeckt (NICHT dupliziert):
* - gotoSection(de.nav.enclosures) navigiert zu Gehege-Heading: navigation.spec.ts:19-20
* - CRUD + 409 auf /gehege: becken-kontakte.spec.ts
* - Wurf-Link + Herkunft: tiere.spec.ts:101-128
*/
import { de, expect, test } from './fixtures'
// ── (a) /becken/* → /gehege/* Redirect ──────────────────────────────────────
test('GEHEGE-RENAME: /becken leitet auf /gehege weiter (bookmark-safe)', async ({ page }) => {
await page.goto('/becken')
// React Router BeckenRedirect navigiert → /gehege; Gehege-Heading muss sichtbar sein
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
await expect(page).toHaveURL(/\/gehege$/)
})
test('GEHEGE-RENAME: /becken/neu leitet auf /gehege/neu weiter (deep redirect)', async ({
page,
}) => {
await page.goto('/becken/neu')
// Neues-Gehege-Formular muss sichtbar sein, URL muss /gehege/neu sein
await expect(page.getByRole('heading', { name: de.pages.becken.form?.createTitle ?? de.pages.becken.newButton })).toBeVisible({ timeout: 6_000 })
await expect(page).toHaveURL(/\/gehege\/neu$/)
})
// ── (b) Nav: 'Gehege' ✓, 'Becken' ✗, 'Start' ✗ ──────────────────────────────
test('GEHEGE-RENAME: Nav hat "Gehege"-Eintrag mit Terrarium-Icon, kein "Becken"', async ({
page,
}) => {
await page.goto('/rennmaeuse')
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// Gehege-Link soll vorhanden sein (via „Mehr"-Blatt auf Phone oder direkt auf Desktop)
// Wir öffnen „Mehr" um sekundäre Links sichtbar zu machen
const moreBtn = nav.getByRole('button', { name: de.nav.more })
if (await moreBtn.isVisible()) {
await moreBtn.click()
}
await expect(nav.getByRole('link', { name: de.nav.enclosures })).toBeVisible()
// "Becken" als separater Nav-Eintrag darf nicht mehr existieren
await expect(nav.getByRole('link', { name: 'Becken', exact: true })).toBeHidden()
})
test('GEHEGE-RENAME: Nav hat keinen "Start"-Eintrag mehr (de.nav.home entfernt)', async ({
page,
}) => {
await page.goto('/rennmaeuse')
// Warten bis die Tierliste geladen ist, dann Nav prüfen
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// 'Start' (de.nav.home wurde entfernt) darf kein Nav-Link mehr sein
await expect(nav.getByRole('link', { name: 'Start', exact: true })).toBeHidden()
})
// ── (c) '/' landet auf /rennmaeuse ──────────────────────────────────────────
test('GEHEGE-RENAME: "/" leitet auf /rennmaeuse weiter — keine Startseite mehr', async ({
page,
}) => {
await page.goto('/')
// URL muss /rennmaeuse sein (Navigate replace)
await expect(page).toHaveURL(/\/rennmaeuse$/)
// Tierliste-Heading sichtbar — nicht die alte Startseite
await expect(
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
).toBeVisible()
// Alten Willkommen-Text darf es nicht mehr geben (de.pages.home wurde entfernt)
await expect(page.getByText('Willkommen')).toBeHidden()
})
test('GEHEGE-RENAME: 404-Seite "Zur Startseite" landet weiterhin auf Tierliste', async ({
page,
}) => {
await page.goto('/diese-seite-gibt-es-nicht')
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
// Nach Klick auf "Zur Startseite" → '/' → redirect → /rennmaeuse
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await expect(page).toHaveURL(/\/rennmaeuse$/)
})

View File

@@ -0,0 +1,54 @@
/**
* UX-MOBILE-2: Genotyp-Tabellen-Overflow — die Tabelle scrollt horizontal,
* die Seite selbst bleibt NICHT breiter als der Viewport (kein horizontaler
* Page-Overflow auf 390px).
*/
import { de, expect, skipUnlessMock, test } from './fixtures'
const tl = de.pages.litters
test('Phone: Genotyp-Detail-Tabelle scrollt im eigenen Wrapper, kein Page-Overflow', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
// Wurf w-kruemel hat beide Eltern mit Genotypen → BreedingResultView rendert.
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
// Genotyp-Detail-Tabelle ausklappen.
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
// Scroll-Wrapper und Tabelle müssen vorhanden und sichtbar sein.
const wrapper = page.locator('.genotype-table-scroll').first()
await expect(wrapper).toBeVisible()
await expect(wrapper.locator('.genotype-table')).toBeVisible()
// Wrapper ist selbst scrollbar (Tabelle breiter als der sichtbare Bereich).
const wrapperScrollable = await wrapper.evaluate(
(el) => el.scrollWidth > el.clientWidth,
)
expect(wrapperScrollable).toBe(true)
// Die Seite selbst darf NICHT breiter als der Viewport sein.
const pageOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
)
expect(pageOverflow).toBe(false)
})
test('Desktop: Genotyp-Detail-Tabelle zeigt Wrapper auch auf breitem Viewport', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
await expect(page.locator('.genotype-table-scroll').first()).toBeVisible()
await expect(page.locator('.genotype-table').first()).toBeVisible()
})

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