Compare commits

...

94 Commits

Author SHA1 Message Date
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
106 changed files with 20269 additions and 974 deletions

3
.gitignore vendored
View File

@@ -131,3 +131,6 @@ $RECYCLE.BIN/
# MemPalace per-project files (issue #185) # MemPalace per-project files (issue #185)
mempalace.yaml mempalace.yaml
entities.json entities.json
# Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/

View File

@@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
{ {
private readonly SqliteConnection _connection = new("DataSource=:memory:"); 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; } = public string ContractRoot { get; } =
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}"); 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 provider = services.BuildServiceProvider();
using var scope = provider.CreateScope(); using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated(); scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
ConfigureTestServices?.Invoke(services);
}); });
} }

View File

@@ -0,0 +1,83 @@
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_six_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();
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
var pages = root.GetProperty("pages").EnumerateArray().ToList();
Assert.Equal(6, pages.Count);
// 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("gerbils").GetArrayLength());
Assert.Equal(1, root.GetProperty("litters").GetArrayLength()); Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
Assert.Equal(1, root.GetProperty("contacts").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]; var g = root.GetProperty("gerbils")[0];
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString()); 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()); 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

@@ -1,5 +1,6 @@
using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests namespace GerbilManager.Tests
@@ -102,6 +103,208 @@ namespace GerbilManager.Tests
Assert.Equal(2, await db.Litters.CountAsync()); 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 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] [Fact]
public void ComposeGenotype_strips_carets_and_fills_missing_loci() public void ComposeGenotype_strips_carets_and_fills_missing_loci()
{ {
@@ -139,7 +342,8 @@ namespace GerbilManager.Tests
[ [
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null, {"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
"farbschlag":"Agouti","farbschlagVariants":["Agouti"], "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, "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}}, "litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null, {"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,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

@@ -73,6 +73,63 @@ namespace GerbilManager.Tests
Assert.Contains("Großer und kleiner Bruder Dynamik", 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] [Theory]
[InlineData("2024-03-12", "12.03.2024")] [InlineData("2024-03-12", "12.03.2024")]
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen [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,331 @@
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)[] animals)
{
var animalArr = new JsonArray();
foreach (var (name, farbe, group, photo) in animals)
{
var photos = new JsonArray();
if (photo is not null) photos.Add(photo);
animalArr.Add(new JsonObject
{
["name"] = name,
["farbschlag"] = farbe,
["group"] = group,
["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_animal_cards_with_name_and_farbschlag()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
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-animal-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);
}
// ── 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,6 +17,35 @@ public class ApplicationContext : DbContext
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>(); public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>(); public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>(); 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -26,6 +55,22 @@ public class ApplicationContext : DbContext
e.Property(g => g.Gender).HasConversion<string>(); e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).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);
// 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() e.HasOne(g => g.Litter).WithMany()
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.OriginContact).WithMany() e.HasOne(g => g.OriginContact).WithMany()
@@ -86,9 +131,98 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<BreederSettings>() modelBuilder.Entity<BreederSettings>()
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId }); .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 });
SeedColorVarieties(modelBuilder); 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"),
};
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\":\"\"}",
});
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> /// <summary>
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list /// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties /// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties
@@ -103,7 +237,6 @@ public class ApplicationContext : DbContext
("Hermelin", "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"), ("Himalaya", "AA chch DD EE GG PP spsp rere"),
("Zobel", "aa cchmcchm 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"), ("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
("Agouti", "AA CC DD EE GG PP spsp rere"), ("Agouti", "AA CC DD EE GG PP spsp rere"),
("Schwarz", "aa CC DD EE GG PP spsp rere"), ("Schwarz", "aa CC DD EE GG PP spsp rere"),
@@ -123,55 +256,44 @@ public class ApplicationContext : DbContext
("C-Separator", "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"), ("Elfenbein", "AA CC DD EE gg pp spsp rere"),
("Kohlfuchs", "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"), ("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "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"), ("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
("Topas", "AA CC DD EE GG pp spsp rere"), ("Topas", "AA CC DD EE GG pp spsp rere"),
("Platin-Hell", "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"), ("Agouti dd", "AA CC dd EE GG PP spsp rere"),
("Silberagouti 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"), ("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Anthrazit 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"), ("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"), ("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"), ("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Blaufuchsschimmel", "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"), ("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, 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"), ("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE 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"), ("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"), ("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"), ("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"), ("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee 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-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"), ("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Topas dd", "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"), ("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"), ("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg PP spsp rere"), ("Siam", "aa cchmch DD EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
}; };
var rows = new ColorVariety[catalog.Length]; var rows = new ColorVariety[catalog.Length];

View File

@@ -0,0 +1,484 @@
// 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 ?? [];
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");
}
else
{
sb.Append(" <div class=\"cms-animal-grid\">\n");
foreach (var node in animals)
{
if (node is not JsonObject a) continue;
var name = H(Str(a["name"]) ?? "");
var farbe = H(Str(a["farbschlag"]) ?? "");
var group = Str(a["group"]);
var saleText = Str(a["aiSaleText"]);
var photos = a["photos"] as JsonArray ?? [];
sb.Append(" <article class=\"cms-animal-card\">\n");
// Profile photo (first photo)
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
if (!string.IsNullOrEmpty(firstPhoto))
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
sb.Append(" <div class=\"cms-animal-info\">\n");
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
if (!string.IsNullOrEmpty(farbe))
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
if (!string.IsNullOrWhiteSpace(group))
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
if (!string.IsNullOrWhiteSpace(saleText))
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
sb.Append(" </div>\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>
</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",
};
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: #fdf8f3;
--color-surface: #fff;
--color-text: #2c2c2c;
--color-muted: #6b6b6b;
--color-accent: #c0392b;
--color-border: #e0d8d0;
--font-body: system-ui, sans-serif;
--max-w: 860px;
}
body {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
line-height: 1.65;
}
a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; height: auto; display: block; }
/* ── Header ── */
.site-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: .75rem 1rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem 1.5rem;
}
.site-logo {
font-weight: 700;
font-size: 1.1rem;
color: var(--color-text);
white-space: nowrap;
}
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
.site-nav-link {
font-size: .9rem;
color: var(--color-muted);
padding: .2rem .4rem;
border-radius: 4px;
}
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: #fef0ee;
text-decoration: none;
}
/* ── Main ── */
.site-main {
max-width: var(--max-w);
margin: 2rem auto;
padding: 0 1rem 3rem;
}
/* ── Footer ── */
.site-footer {
border-top: 1px solid var(--color-border);
padding: 1.5rem 1rem;
text-align: center;
font-size: .85rem;
color: var(--color-muted);
}
/* ── CMS blocks ── */
.cms-heading { margin: 1.5rem 0 .5rem; }
h1.cms-heading { font-size: 1.8rem; }
h2.cms-heading { font-size: 1.4rem; }
.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: .3rem; }
.cms-image { margin: 1.5rem 0; }
.cms-image img { border-radius: 6px; }
.cms-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: .75rem;
margin: 1.5rem 0;
}
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
.cms-contact {
font-style: normal;
display: flex;
flex-direction: column;
gap: .4rem;
margin: 1rem 0;
}
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
/* ── Abgabetiere ── */
.cms-abgabe { margin: 1rem 0; }
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
.cms-animal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem;
}
.cms-animal-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
.cms-animal-info { padding: .875rem; }
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
@media (max-width: 480px) {
h1.cms-heading { font-size: 1.4rem; }
.cms-animal-grid { grid-template-columns: 1fr; }
}
""";
// ── 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,124 @@
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,
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,
["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? Genotype,
string? Notes, string? Notes,
string? ImportSource, string? ImportSource,
string? ExternalRef); string? ExternalRef,
string? OriginBreeder,
List<string> CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool IsResident);
public record LitterDto( public record LitterDto(
Guid Id, Guid Id,
@@ -69,7 +74,12 @@ namespace GerbilManagerWebAPI.Dtos
string? Genotype, string? Genotype,
string? Notes, string? Notes,
string? ImportSource, string? ImportSource,
string? ExternalRef); string? ExternalRef,
string? OriginBreeder,
List<string>? CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool? IsResident);
public record LitterInput( public record LitterInput(
string Name, 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,161 @@
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());
});
// ---- 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;
}
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() TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto))); .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} // GET /gerbils/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) => group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{ {
@@ -87,11 +94,17 @@ namespace GerbilManagerWebAPI.Endpoints
g.Notes = i.Notes; g.Notes = i.Notes;
g.ImportSource = i.ImportSource; g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef; g.ExternalRef = i.ExternalRef;
g.OriginBreeder = i.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote;
g.IsDeaf = i.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
} }
internal static GerbilDto ToDto(Gerbil g) => new( internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, 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.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,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

@@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" /> <PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" /> <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.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">

View File

@@ -16,11 +16,21 @@ namespace GerbilManagerWebAPI.Import
public List<string> FarbschlagVariants { get; set; } = new(); public List<string> FarbschlagVariants { get; set; } = new();
public SourceGenotype Genotype { get; set; } = new(); public SourceGenotype Genotype { get; set; } = new();
public string Zucht { get; set; } = ""; 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<SourceParentRef> ParentRefs { get; set; } = new();
public List<string> Photos { get; set; } = new(); public List<string> Photos { get; set; } = new();
public List<string> SourceFiles { get; set; } = new(); public List<string> SourceFiles { get; set; } = new();
public bool Conflict { get; set; } public bool Conflict { get; set; }
public SourceLitterRef? LitterRef { 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 public sealed class SourceGenotype
@@ -34,7 +44,9 @@ namespace GerbilManagerWebAPI.Import
{ {
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string Dob { 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 public sealed class SourceLitterRef
@@ -66,9 +78,15 @@ namespace GerbilManagerWebAPI.Import
AnimalSummary Animals, AnimalSummary Animals,
PhotoSummary Photos, PhotoSummary Photos,
IReadOnlyList<string> Samples, 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).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
public sealed record AnimalSummary( public sealed record AnimalSummary(
int InSource, int InSource,
@@ -77,7 +95,9 @@ namespace GerbilManagerWebAPI.Import
int FarbschlagMatched, int FarbschlagMatched,
int FarbschlagUnmatched, int FarbschlagUnmatched,
int AlreadyImported, int AlreadyImported,
QuarantineSummary Quarantined); QuarantineSummary Quarantined,
int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0);
public sealed record QuarantineSummary( public sealed record QuarantineSummary(
int Conflicts, int Conflicts,

View File

@@ -128,67 +128,238 @@ namespace GerbilManagerWebAPI.Import
int photosAttached = 0, photosMissing = 0; int photosAttached = 0, photosMissing = 0;
var createdAnimalByName = new Dictionary<string, Guid>(); // normalized name -> gerbil id (for litter back-link) 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 }).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);
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
var plan = new List<AnimalPlan>();
foreach (var a in loadable) foreach (var a in loadable)
{ {
if (existingGerbilSet.Contains(a.Id)) { animalsExisting++; continue; } bool exists = existingGerbilSet.Contains(a.Id);
animalsCreated++; var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
Guid? litterId = null; Guid? wurfLitterId = null;
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 } if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid)) && litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
{ wurfLitterId = lid;
litterId = lid;
linked++;
}
Guid? colorVarietyId = null; Guid? colorVarietyId = null;
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants) var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
.Where(s => !string.IsNullOrWhiteSpace(s)); .Where(s => !string.IsNullOrWhiteSpace(s));
foreach (var fb in fbCandidates) foreach (var fb in fbCandidates)
{
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid)) if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
{ colorVarietyId = vid; break; } { colorVarietyId = vid; break; }
}
if (colorVarietyId is null) fbUnmatched++; else fbMatched++;
var gender = InferGender(a, sireNames, damNames); var gender = InferGender(a, sireNames, damNames);
var gid = Guid.NewGuid();
var norm = Normalize(StripZucht(a.Name)); var norm = Normalize(StripZucht(a.Name));
if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid); 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;
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
}
// 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);
}
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
// before the animals — the offspring AND the parent gerbils are created in the loop
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
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;
}
_db.Litters.Add(new Litter
{
Id = sl.Id,
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
Date = sl.Date ?? default,
FatherId = sl.Father,
MotherId = sl.Mother,
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) if (samples.Count < 16)
samples.Add($"Tier: {a.Name} (*{a.Dob}), Genotyp {ComposeGenotype(a.Genotype)}" samples.Add($"Tier: {p.A.Name} (*{p.A.Dob}), Genotyp {ComposeGenotype(p.A.Genotype)}"
+ (litterId is not null ? ", Wurf-verknüpft" : "") + (litterId is not null ? (p.WurfLitterId is not null ? ", Wurf-verknüpft" : ", Eltern aus Diagramm") : "")
+ (colorVarietyId is not null ? $", Farbschlag „{a.Farbschlag}\"" : "")); + (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) if (execute)
{ {
_db.Gerbils.Add(new Gerbil _db.Gerbils.Add(new Gerbil
{ {
Id = gid, Id = p.Gid,
Name = a.Name, Name = p.A.Name,
Gender = gender, Gender = p.Gender,
Status = GerbilStatus.Active, Status = GerbilStatus.Active,
DateOfBirth = ParseDate(a.Dob), DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(a.Death), DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId, LitterId = litterId,
ColorVarietyId = colorVarietyId, ColorVarietyId = p.ColorVarietyId,
Genotype = ComposeGenotype(a.Genotype), Genotype = ComposeGenotype(p.A.Genotype),
IsDeaf = p.A.Deaf,
IsResident = isResident,
ImportSource = ImportSourceTag, ImportSource = ImportSourceTag,
ExternalRef = a.Id, ExternalRef = p.A.Id,
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
RawImportData = JsonSerializer.Serialize(new RawImportData = JsonSerializer.Serialize(new
{ {
a.Genotype.RawGenotype, p.A.Genotype.RawGenotype,
a.Genotype.UnmappedTokens, p.A.Genotype.UnmappedTokens,
a.Zucht, // GEN-3b: Sls (2nd spotting locus) preserved here until Kevin's GEN-3a
a.SourceFiles, // parser adopts it into the compact Genotype contract; tags + deaf too.
FarbschlagRaw = a.Farbschlag, 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 // photos
foreach (var rel in a.Photos) foreach (var rel in p.A.Photos)
{ {
var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar)); var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(src)) { photosMissing++; continue; } if (!File.Exists(src)) { photosMissing++; continue; }
@@ -201,7 +372,7 @@ namespace GerbilManagerWebAPI.Import
_db.GerbilPhotos.Add(new GerbilPhoto _db.GerbilPhotos.Add(new GerbilPhoto
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
GerbilId = gid, GerbilId = p.Gid,
FileName = fileName, FileName = fileName,
SortOrder = 0, SortOrder = 0,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -211,7 +382,7 @@ namespace GerbilManagerWebAPI.Import
} }
if (execute) await _db.SaveChangesAsync(); if (execute) await _db.SaveChangesAsync();
// ---- back-link litter parents by name (best effort) ---- // ---- back-link Wurfchronik litter parents by name (best effort) ----
if (execute) if (execute)
{ {
foreach (var sl in litters) foreach (var sl in litters)
@@ -219,26 +390,36 @@ namespace GerbilManagerWebAPI.Import
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue; if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid); var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
if (litter is null) continue; 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; 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; litter.MotherId = mId;
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); 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.");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
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 (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
return new ImportReport( return new ImportReport(
Executed: execute, Executed: execute,
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting), Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
Animals: new AnimalSummary( Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, 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),
Photos: new PhotoSummary(photosAttached, photosMissing), Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples, Samples: samples,
Notes: notes); Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
} }
private T? Load<T>(string file) private T? Load<T>(string file)
@@ -256,7 +437,16 @@ namespace GerbilManagerWebAPI.Import
if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2) if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2)
return StripCaret(pair[0]) + StripCaret(pair[1]); return StripCaret(pair[0]) + StripCaret(pair[1]);
return "??"; 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); return string.Join(' ', tokens);
} }
@@ -264,6 +454,11 @@ namespace GerbilManagerWebAPI.Import
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams) 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)); var n = Normalize(StripZucht(a.Name));
bool isSire = sires.Contains(n), isDam = dams.Contains(n); bool isSire = sires.Contains(n), isDam = dams.Contains(n);
if (isSire && !isDam) return Gender.male; if (isSire && !isDam) return Gender.male;
@@ -297,5 +492,26 @@ namespace GerbilManagerWebAPI.Import
n = Regex.Replace(n, @"[^a-z0-9äöüß]", ""); n = Regex.Replace(n, @"[^a-z0-9äöüß]", "");
return n; 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);
/// <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 }
});
}
}
}

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 /// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary> /// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; } 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

@@ -41,6 +41,17 @@ builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName); .BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>( builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); 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));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
builder.Services.AddDataProtection();
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(); var app = builder.Build();
@@ -76,6 +87,8 @@ app.MapImportEndpoints();
app.MapContractEndpoints(); app.MapContractEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapExportEndpoints(); app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.Run(); app.Run();

View File

@@ -9,7 +9,11 @@ namespace GerbilManagerWebAPI.SaleAd
string? Farbschlag, string? Farbschlag,
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary> /// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
string? DateOfBirth, 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( public sealed record SaleAdRequest(
List<SaleAdAnimal> Animals, List<SaleAdAnimal> Animals,

View File

@@ -58,7 +58,8 @@ namespace GerbilManagerWebAPI.SaleAd
Harte Regeln: Harte Regeln:
- NIEMALS Preise oder Schutzgebühren nennen. - 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. - Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben.
- Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke. - Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke.
@@ -85,7 +86,17 @@ namespace GerbilManagerWebAPI.SaleAd
sb.Append($" | Farbschlag: {animal.Farbschlag}"); sb.Append($" | Farbschlag: {animal.Farbschlag}");
if (!string.IsNullOrWhiteSpace(animal.DateOfBirth)) if (!string.IsNullOrWhiteSpace(animal.DateOfBirth))
sb.Append($" | geboren am {FormatGermanDate(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.Append($" | Notizen: {animal.Notes}");
sb.AppendLine(); sb.AppendLine();
} }

View File

@@ -1,87 +1,35 @@
using System.Net.Http.Headers; using GerbilManagerWebAPI.Ai;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.SaleAd namespace GerbilManagerWebAPI.SaleAd
{ {
/// <summary> /// <summary>
/// FEAT-12a: provider-agnostic AI client for sale-ad generation. /// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
/// /// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
/// Speaks the OpenAI-compatible chat-completions wire shape — deliberately /// future AI features reuse the SAME implementation); this service contributes
/// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions /// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
/// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral,
/// local Ollama and any future provider. The wire shape IS the abstraction.
/// </summary> /// </summary>
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options) public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private readonly OpenAiChatClient _client = new(http, options);
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default) public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
{ {
var ai = options.Value; var result = await _client.CompleteAsync(
if (!ai.IsConfigured) SaleAdPromptBuilder.BuildSystemPrompt(),
SaleAdPromptBuilder.BuildUserPrompt(request),
ct);
var status = result.Status switch
{ {
return new SaleAdResult(SaleAdStatus.NotConfigured, null, AiCallStatus.Ok => SaleAdStatus.Ok,
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model)."); AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
} _ => SaleAdStatus.UpstreamError,
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"),
}; };
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey); return new SaleAdResult(status, result.Text, result.Error);
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}");
}
} }
/// <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) => internal static Uri BuildCompletionsUri(string baseUrl) =>
new($"{baseUrl.TrimEnd('/')}/chat/completions"); OpenAiChatClient.BuildCompletionsUri(baseUrl);
// ── 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);
} }
} }

155
HUMANQUESTION.md Normal file
View File

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

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

@@ -0,0 +1,87 @@
/**
* 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, 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)
// 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

@@ -0,0 +1,43 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, 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()
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,21 @@
/** 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')
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
})

View File

@@ -135,6 +135,66 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'PUT') return json(route, 204) if (method === 'PUT') return json(route, 204)
} }
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
if (path === '/requests/sync' && method === 'POST') {
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
}
let rm = path.match(/^\/requests\/([^/]+)\/draft$/)
if (rm && method === 'POST') {
if (!db.aiConfigured) {
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
}
const r = db.requests.find((x) => x.id === rm![1])
if (!r) return json(route, 404, { title: 'Not Found' })
r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße`
return json(route, 200, r)
}
rm = path.match(/^\/requests\/([^/]+)\/send$/)
if (rm && method === 'POST') {
if (!db.mailConfigured) {
return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' })
}
const r = db.requests.find((x) => x.id === rm![1])
if (!r) return json(route, 404, { title: 'Not Found' })
r.status = 'Answered'
r.answeredAt = '2026-06-06T10:00:00Z'
return json(route, 200, r)
}
rm = path.match(/^\/requests(?:\/([^/]+))?$/)
if (rm) {
const reqId = rm[1] ? decodeURIComponent(rm[1]) : null
if (!reqId && method === 'GET') {
return json(route, 200, pagedResponse(db.requests as unknown as Row[], url))
}
const r = db.requests.find((x) => x.id === reqId)
if (!r) return json(route, 404, { title: 'Not Found' })
if (method === 'GET') return json(route, 200, r)
if (method === 'PUT') {
// Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen.
const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null }
r.assignedContactId = body.assignedContactId ?? null
if (body.status) {
r.status = body.status as typeof r.status
if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z'
}
return json(route, 204)
}
return json(route, 405)
}
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
// generischen /gerbils/:id-Route abfangen.
if (path === '/gerbils/breeders' && method === 'GET') {
const values = [
...new Set(
db.gerbils
.map((g) => g.originBreeder)
.filter((v): v is string => typeof v === 'string' && v.length > 0),
),
].sort()
return json(route, 200, values)
}
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge) // Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/) let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
if (m) { if (m) {
@@ -155,6 +215,81 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}) })
} }
// WEB-0b: CMS-Endpunkte (Seiten + Blöcke). Vor den generischen Kollektionen,
// weil 'pages'/'blocks' verschachtelte Routen haben und kein Standard-CRUD sind.
const toSummary = (p: (typeof db.pages)[number]) => ({
id: p.id, slug: p.slug, title: p.title, seoDescription: p.seoDescription, status: p.status,
})
const toBlock = (b: { id: string; order: number; type: string; data: unknown }) => ({
id: b.id, order: b.order, type: b.type, data: b.data,
})
const toPage = (p: (typeof db.pages)[number]) => ({
...toSummary(p),
blocks: [...p.blocks].sort((a, b) => a.order - b.order).map(toBlock),
})
if (path === '/pages' && method === 'GET') {
return json(route, 200, [...db.pages].sort((a, b) => a.slug.localeCompare(b.slug)).map(toSummary))
}
// GET /pages/{slug} · PUT /pages/{id}
m = path.match(/^\/pages\/([^/]+)$/)
if (m && method === 'GET') {
const p = db.pages.find((x) => x.slug === decodeURIComponent(m![1]))
return p ? json(route, 200, toPage(p)) : json(route, 404, { title: 'Not Found' })
}
// PUT /pages/{id}
if (m && method === 'PUT') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as Partial<typeof p>
if (typeof body.slug === 'string') p.slug = body.slug
if (typeof body.title === 'string') p.title = body.title
p.seoDescription = (body.seoDescription as string | null) ?? null
if (body.status === 'Draft' || body.status === 'Published') p.status = body.status
return json(route, 204)
}
// POST /pages/{pageId}/blocks
m = path.match(/^\/pages\/([^/]+)\/blocks$/)
if (m && method === 'POST') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as { type: string; data?: Record<string, unknown>; order?: number }
const order = body.order ?? (p.blocks.reduce((mx, b) => Math.max(mx, b.order), -1) + 1)
const block = { id: newId('blk'), order, type: body.type, data: body.data ?? {} }
p.blocks.push(block)
return json(route, 201, toBlock(block))
}
// PUT /pages/{pageId}/blocks/order (Body: { blockIds })
m = path.match(/^\/pages\/([^/]+)\/blocks\/order$/)
if (m && method === 'PUT') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const { blockIds } = request.postDataJSON() as { blockIds: string[] }
const byId = new Map(p.blocks.map((b) => [b.id, b]))
if (blockIds.length !== p.blocks.length || blockIds.some((id) => !byId.has(id)))
return json(route, 400, 'blockIds must list exactly the page block ids.')
blockIds.forEach((id, i) => { byId.get(id)!.order = i })
return json(route, 204)
}
// PUT/DELETE /blocks/{id}
m = path.match(/^\/blocks\/([^/]+)$/)
if (m) {
const pageOf = db.pages.find((p) => p.blocks.some((b) => b.id === m![1]))
const block = pageOf?.blocks.find((b) => b.id === m![1])
if (!pageOf || !block) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as { type?: string; data?: Record<string, unknown>; order?: number }
if (typeof body.type === 'string') block.type = body.type
if (body.data) block.data = body.data
if (typeof body.order === 'number') block.order = body.order
return json(route, 204)
}
if (method === 'DELETE') {
pageOf.blocks = pageOf.blocks.filter((b) => b.id !== m![1])
return json(route, 204)
}
}
// Generische Kollektionen: /<resource> und /<resource>/<id> // Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined const col = m ? collections[m[1]] : undefined

View File

@@ -6,6 +6,7 @@
* FEAT-2/FEAT-6-Flüsse. * FEAT-2/FEAT-6-Flüsse.
*/ */
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types' import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
import type { InboxRequest } from '../src/api/requests'
export interface HealthRecordRow { export interface HealthRecordRow {
id: string id: string
@@ -25,6 +26,24 @@ export interface WeightRecordRow {
notes: string | null notes: string | null
} }
/** WEB-0b: CMS-Block (data ist das typ-spezifische JSON-Objekt). */
export interface MockBlock {
id: string
order: number
type: string
data: Record<string, unknown>
}
/** WEB-0b: CMS-Seite der öffentlichen Webseite. */
export interface MockPage {
id: string
slug: string
title: string
seoDescription: string | null
status: 'Draft' | 'Published'
blocks: MockBlock[]
}
export interface MockDb { export interface MockDb {
gerbils: Gerbil[] gerbils: Gerbil[]
litters: Litter[] litters: Litter[]
@@ -33,6 +52,12 @@ export interface MockDb {
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[] colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
healthRecords: HealthRecordRow[] healthRecords: HealthRecordRow[]
weightRecords: WeightRecordRow[] weightRecords: WeightRecordRow[]
pages: MockPage[]
// INBOX-1: Anfragen-Posteingang. Die Flags steuern die Fehlerpfade des Mocks
// (Specs können sie pro Test umlegen): KI-Entwurf 503 / Sync+Send Mail-Fehler.
requests: InboxRequest[]
aiConfigured: boolean
mailConfigured: boolean
} }
function gerbil( function gerbil(
@@ -58,8 +83,13 @@ function gerbil(
colorVarietyId, colorVarietyId,
originContactId: null, originContactId: null,
receiverContactId: null, receiverContactId: null,
originBreeder: null,
// SEARCH-2b: backend stores a normalized name (lowercase, [\s._-] stripped).
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
genotype, genotype,
notes: null, notes: null,
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
isResident: true,
} }
} }
@@ -70,8 +100,9 @@ export function seedDb(): MockDb {
...gerbil('kruemel', 'Krümel', 'female', '2025-03-12', 'w-kruemel', 'cv-agouti', 'Aa CC DD EE GG Pp spsp rere'), ...gerbil('kruemel', 'Krümel', 'female', '2025-03-12', 'w-kruemel', 'cv-agouti', 'Aa CC DD EE GG Pp spsp rere'),
enclosureId: 'enc-gross', enclosureId: 'enc-gross',
originContactId: 'con-meier', originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten',
}, },
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross' }, { ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' },
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'), gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'), gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'), gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
@@ -81,8 +112,9 @@ export function seedDb(): MockDb {
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'), gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'), gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'), gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), // BESTAND-FILTER: externe Ahnen (Gründertiere fremder Zuchten, nur für den Stammbaum).
gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), { ...gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), isResident: false, originBreeder: 'Zoohandlung Meier' },
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve // Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' }, { ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' }, { ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
@@ -138,5 +170,93 @@ export function seedDb(): MockDb {
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null }, { id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
] ]
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords } // WEB-0b: die 6 Webseiten-Seiten (wie vom Backend geseedet), mit ein paar Blöcken.
const pages: MockPage[] = [
{
id: 'page-start',
slug: 'start',
title: 'Startseite',
seoDescription: 'Willkommen bei unserer Rennmauszucht.',
status: 'Published',
blocks: [
{ id: 'blk-start-1', order: 0, type: 'Heading', data: { text: 'Willkommen', level: 2 } },
{ id: 'blk-start-2', order: 1, type: 'RichText', data: { markdown: 'Schön, dass du da bist.' } },
],
},
{ id: 'page-zucht', slug: 'ueber-die-zucht', title: 'Über die Zucht', seoDescription: null, status: 'Draft', blocks: [] },
{
id: 'page-abgabe',
slug: 'abgabetiere',
title: 'Abgabetiere',
seoDescription: null,
status: 'Published',
blocks: [
{ id: 'blk-abgabe-1', order: 0, type: 'AbgabetiereList', data: { mode: 'auto', intro: 'Diese Tiere suchen ein Zuhause.' } },
],
},
{ id: 'page-bedingungen', slug: 'abgabebedingungen', title: 'Abgabebedingungen', seoDescription: null, status: 'Draft', blocks: [] },
{ id: 'page-farben', slug: 'farben-genetik', title: 'Farben & Genetik', seoDescription: null, status: 'Draft', blocks: [] },
{ id: 'page-kontakt', slug: 'kontakt', title: 'Kontakt', seoDescription: null, status: 'Draft', blocks: [] },
]
// INBOX-1: Anfragen (neueste zuerst nach receivedAt sortierbar)
const requests: InboxRequest[] = [
{
id: 'req-anna',
gmailMessageId: '<anna-1@mail.example>',
threadId: 'thr-1',
fromAddress: 'anna@example.de',
fromName: 'Anna Albrecht',
subject: 'Anfrage: Pärchen zur Abgabe?',
bodyText:
'Hallo,\n\nich habe euer Inserat gesehen — sind die beiden Agouti-Jungs noch zu haben?\n\nViele Grüße\nAnna',
receivedAt: '2026-06-05T18:30:00Z',
status: 'New',
assignedContactId: null,
draftReply: null,
answeredAt: null,
},
{
id: 'req-ben',
gmailMessageId: '<ben-1@mail.example>',
threadId: 'thr-2',
fromAddress: 'ben@example.org',
fromName: 'Ben Berger',
subject: 'Frage zur Haltung',
bodyText: 'Guten Tag, was für ein Becken empfehlt ihr für zwei Rennmäuse?',
receivedAt: '2026-06-04T09:00:00Z',
status: 'InProgress',
assignedContactId: null,
draftReply: 'Hallo Ben, wir empfehlen mindestens 100×50 cm …',
answeredAt: null,
},
{
id: 'req-clara',
gmailMessageId: '<clara-1@mail.example>',
threadId: null,
fromAddress: 'clara@example.com',
fromName: null,
subject: 'Danke!',
bodyText: 'Die beiden sind super angekommen — danke nochmal!',
receivedAt: '2026-06-01T12:00:00Z',
status: 'Answered',
assignedContactId: 'con-meier',
draftReply: null,
answeredAt: '2026-06-02T08:00:00Z',
},
]
return {
gerbils,
litters,
enclosures,
contacts,
colorVarieties,
healthRecords,
weightRecords,
pages,
requests,
aiConfigured: true,
mailConfigured: true,
}
} }

View File

@@ -15,22 +15,33 @@ test('Liste lädt und die Namenssuche filtert', async ({ page }) => {
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
}) })
test('Herkunft-Filter zeigt nur Tiere des gewählten Kontakts (SEARCH-2)', async ({ page }) => { test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEARCH-2b)', async ({
page,
}) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse') await page.goto('/rennmaeuse')
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
// Herkunft (originContactId) auf den Seed-Kontakt 'Zoohandlung Meier' (nur Krümel). // Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
.locator('select') .locator('select')
.selectOption({ label: 'Zoohandlung Meier' }) .selectOption({ label: 'Clan-Kleine-Chaoten' })
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
}) })
test('Namenssuche ist trennzeichen-unempfindlich (SEARCH-2b)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse')
// 'Krü mel' (mit Leerzeichen) wird normalisiert zu 'krümel' -> matcht Krümel.
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Krü mel')
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
})
test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => { test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
await gotoSection(page, de.nav.gerbils) await gotoSection(page, de.nav.gerbils)
await page.getByRole('link', { name: t.newButton }).click() await page.getByRole('link', { name: t.newButton }).click()

View File

@@ -0,0 +1,57 @@
/** WEB-0b: CMS-Verwaltung der öffentlichen Webseite — Smoke-Test. */
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.webseite
test('Webseiten-Übersicht zeigt die Seiten mit Status', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.website)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
const cards = page.locator('.webseite-card')
await expect(cards).toHaveCount(6)
const start = page.locator('.webseite-card', { hasText: 'Startseite' })
await expect(start.getByText(t.statusPublished, { exact: true })).toBeVisible()
})
test('Block hinzufügen, bearbeiten und verschieben bleibt erhalten', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.website)
// Startseite öffnen (hat bereits zwei Blöcke).
await page
.locator('.webseite-card', { hasText: 'Startseite' })
.getByRole('link', { name: t.edit })
.click()
await expect(page.getByRole('heading', { name: 'Startseite' })).toBeVisible()
const blocks = page.locator('.block-card')
await expect(blocks).toHaveCount(2)
// Eine Überschrift hinzufügen -> jetzt drei Blöcke.
await page.getByRole('button', { name: `+ ${t.blocks.types.Heading}` }).click()
await expect(blocks).toHaveCount(3)
// Den neuen (letzten) Block beschriften und speichern.
const last = blocks.last()
await last.getByLabel(t.blocks.fields.headingText).fill('Neue Überschrift')
await last.getByRole('button', { name: t.blocks.save }).click()
await expect(last.getByText(t.blocks.saved, { exact: true })).toBeVisible()
// Den neuen Block nach oben verschieben (von Position 3 auf 2).
await last.getByRole('button', { name: t.blocks.moveUp }).click()
// Neu laden über die Übersicht — der Mock ist zustandsbehaftet.
await page.getByRole('link', { name: t.back }).click()
await page
.locator('.webseite-card', { hasText: 'Startseite' })
.getByRole('link', { name: t.edit })
.click()
// Drei Blöcke, und die neue Überschrift steht jetzt an zweiter Stelle.
await expect(page.locator('.block-card')).toHaveCount(3)
await expect(page.locator('.block-card').nth(1).getByLabel(t.blocks.fields.headingText)).toHaveValue(
'Neue Überschrift',
)
})

View File

@@ -22,6 +22,10 @@ import HilfePage from './pages/HilfePage'
import VertraegeListPage from './pages/VertraegeListPage' import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage' import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage' import EinstellungenPage from './pages/EinstellungenPage'
import WebseitePage from './pages/WebseitePage'
import WebseiteEditorPage from './pages/WebseiteEditorPage'
import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage'
export default function App() { export default function App() {
return ( return (
@@ -65,6 +69,16 @@ export default function App() {
<Route path="neu" element={<VertragWizardPage />} /> <Route path="neu" element={<VertragWizardPage />} />
</Route> </Route>
<Route path="einstellungen" element={<EinstellungenPage />} /> <Route path="einstellungen" element={<EinstellungenPage />} />
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
<Route path="webseite">
<Route index element={<WebseitePage />} />
<Route path=":slug" element={<WebseiteEditorPage />} />
</Route>
{/* INBOX-1: Anfragen-Posteingang */}
<Route path="anfragen">
<Route index element={<AnfragenPage />} />
<Route path=":id" element={<AnfrageDetailPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { ADDABLE_BLOCK_TYPES, defaultBlockData, type BlockType } from '../pages'
describe('defaultBlockData', () => {
it('liefert sinnvolle Startwerte je Block-Typ', () => {
expect(defaultBlockData('Heading')).toEqual({ text: '', level: 2 })
expect(defaultBlockData('RichText')).toEqual({ markdown: '' })
expect(defaultBlockData('Image')).toEqual({ url: '', alt: '' })
expect(defaultBlockData('Gallery')).toEqual({ images: [] })
expect(defaultBlockData('ContactInfo')).toEqual({ name: '', email: '', phone: '', address: '' })
expect(defaultBlockData('AbgabetiereList')).toEqual({ mode: 'auto', intro: '' })
})
it('deckt jeden addbaren Typ ab', () => {
for (const type of ADDABLE_BLOCK_TYPES) {
expect(defaultBlockData(type)).toBeTypeOf('object')
}
})
it('bietet die dynamische AbgabetiereList NICHT zum manuellen Anlegen an', () => {
const addable: readonly BlockType[] = ADDABLE_BLOCK_TYPES
expect(addable).not.toContain('AbgabetiereList')
})
})

View File

@@ -11,6 +11,11 @@ export function getGerbil(id: string): Promise<Gerbil> {
return api.get<Gerbil>(`${resources.gerbils}/${id}`) return api.get<Gerbil>(`${resources.gerbils}/${id}`)
} }
/** SEARCH-2b: distinct non-empty originBreeder (Herkunft) values, sorted. */
export function listOriginBreeders(): Promise<string[]> {
return api.get<string[]>(`${resources.gerbils}/breeders`)
}
export function createGerbil(body: CreateGerbil): Promise<Gerbil> { export function createGerbil(body: CreateGerbil): Promise<Gerbil> {
return api.post<Gerbil>(resources.gerbils, body) return api.post<Gerbil>(resources.gerbils, body)
} }

View File

@@ -0,0 +1,149 @@
/**
* WEB-0b: CMS-Inhalte der öffentlichen Webseite (Seiten + Blöcke).
*
* Backend-Vertrag (GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs):
* - GET /pages -> PageSummary[] (kein Paging!)
* - GET /pages/{slug} -> Page (inkl. Blöcke)
* - PUT /pages/{id} -> 204
* - POST /pages/{pageId}/blocks -> 201 Block
* - PUT /blocks/{id} -> 204
* - DELETE /blocks/{id} -> 204
* - PUT /pages/{pageId}/blocks/order -> 204 (Body: { blockIds })
*
* Enums kommen dank globalem JsonStringEnumConverter als STRINGS
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
*/
import { api } from './client'
export type PageStatus = 'Draft' | 'Published'
export type BlockType =
| 'Heading'
| 'RichText'
| 'Image'
| 'Gallery'
| 'ContactInfo'
| 'AbgabetiereList'
/** Block-Typen, die die Hand-Editier-Oberfläche neu anlegen kann (in Anzeige-Reihenfolge). */
export const ADDABLE_BLOCK_TYPES: readonly BlockType[] = [
'Heading',
'RichText',
'Image',
'Gallery',
'ContactInfo',
] as const
// ── Typ-spezifische Block-Daten ────────────────────────────────────────────
export interface HeadingData {
text: string
level: 2 | 3
}
export interface RichTextData {
/** Markdown-Quelltext (wird erst beim Veröffentlichen gerendert + bereinigt). */
markdown: string
}
export interface ImageData {
url: string
alt: string
}
export interface GalleryImage {
url: string
alt: string
}
export interface GalleryData {
images: GalleryImage[]
}
export interface ContactInfoData {
name: string
email: string
phone: string
address: string
}
export interface AbgabetiereListData {
/** auto = aus den aktuellen Abgabetieren aufgelöst; manual = von Hand gepflegt. */
mode: 'auto' | 'manual'
intro: string
}
export type BlockData = Record<string, unknown>
export interface Block {
id: string
order: number
type: BlockType
data: BlockData
}
export interface PageSummary {
id: string
slug: string
title: string
seoDescription: string | null
status: PageStatus
}
export interface Page extends PageSummary {
blocks: Block[]
}
export interface PageInput {
slug: string
title: string
seoDescription: string | null
status: PageStatus
}
export interface BlockInput {
type: BlockType
data: BlockData
order?: number
}
/** Sinnvolle Startwerte für einen neu angelegten Block. */
export function defaultBlockData(type: BlockType): BlockData {
switch (type) {
case 'Heading':
return { text: '', level: 2 }
case 'RichText':
return { markdown: '' }
case 'Image':
return { url: '', alt: '' }
case 'Gallery':
return { images: [] }
case 'ContactInfo':
return { name: '', email: '', phone: '', address: '' }
case 'AbgabetiereList':
return { mode: 'auto', intro: '' }
}
}
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
export function listPages(): Promise<PageSummary[]> {
return api.get<PageSummary[]>('/pages')
}
export function getPage(slug: string): Promise<Page> {
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`)
}
export function updatePage(id: string, input: PageInput): Promise<void> {
return api.put<void>(`/pages/${id}`, input)
}
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
return api.post<Block>(`/pages/${pageId}/blocks`, input)
}
export function updateBlock(id: string, input: BlockInput): Promise<void> {
return api.put<void>(`/blocks/${id}`, input)
}
export function deleteBlock(id: string): Promise<void> {
return api.delete(`/blocks/${id}`)
}
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds })
}

View File

@@ -0,0 +1,87 @@
/**
* INBOX-1: Anfragen-Posteingang (/api/requests, Backend INBOX-0/2/3).
*
* Triage-Hinweis: PUT setzt assignedContactId UNBEDINGT aus dem Input
* (Backend überschreibt mit null, wenn das Feld fehlt) — beim reinen
* Status-Wechsel also IMMER die aktuelle Zuordnung mitsenden.
*/
import { ApiError, api } from './client'
import { toQueryString, type GridifyQuery } from './gridify'
import type { Paged } from './types'
/** C#-Enum RequestStatus — Stringnamen auf dem Draht. */
export type RequestStatus = 'New' | 'InProgress' | 'Assigned' | 'Answered' | 'Abandoned'
export const REQUEST_STATUSES: RequestStatus[] = [
'New',
'InProgress',
'Assigned',
'Answered',
'Abandoned',
]
export interface InboxRequest {
id: string
gmailMessageId: string
threadId: string | null
fromAddress: string
fromName: string | null
subject: string | null
bodyText: string | null
receivedAt: string
status: RequestStatus
assignedContactId: string | null
draftReply: string | null
answeredAt: string | null
}
export interface RequestTriage {
status?: RequestStatus | null
assignedContactId?: string | null
}
/** Ergebnis von POST /api/requests/sync. */
export interface SyncResult {
imported: number
/** null = ok; "MailNotConfigured" | "MailAuthFailed" | … */
error: string | null
}
const BASE = '/api/requests'
export function listRequests(query: GridifyQuery): Promise<Paged<InboxRequest>> {
return api.get<Paged<InboxRequest>>(`${BASE}${toQueryString(query)}`)
}
export function getRequest(id: string): Promise<InboxRequest> {
return api.get<InboxRequest>(`${BASE}/${id}`)
}
export function triageRequest(id: string, triage: RequestTriage): Promise<void> {
return api.put<void>(`${BASE}/${id}`, triage)
}
export function syncRequests(): Promise<SyncResult> {
return api.post<SyncResult>(`${BASE}/sync`, {})
}
/** KI-Entwurf erzeugen (INBOX-2); 503 {code:'AiKeyMissing'} solange unkonfiguriert. */
export function draftReply(id: string): Promise<InboxRequest> {
return api.post<InboxRequest>(`${BASE}/${id}/draft`, {})
}
/** (Bearbeitete) Antwort senden (INBOX-3); setzt serverseitig Answered. */
export function sendReply(id: string, body: string): Promise<InboxRequest> {
return api.post<InboxRequest>(`${BASE}/${id}/send`, { body })
}
/**
* ProblemDetails-Titel aus einem ApiError lesen (Send-Endpunkt meldet
* MailNotConfigured/MailAuthFailed über `title`, nicht `code`).
*/
export function problemTitle(err: unknown): string | null {
if (err instanceof ApiError && err.body && typeof err.body === 'object' && 'title' in err.body) {
const title = (err.body as { title: unknown }).title
return typeof title === 'string' ? title : null
}
return null
}

View File

@@ -20,6 +20,9 @@ export interface SaleAdAnimal {
farbschlag: string | null farbschlag: string | null
dateOfBirth: string | null dateOfBirth: string | null
notes: string | null notes: string | null
/** FEAT-14: German trait LABELS (human-readable for the prompt), not keys. */
traits?: string[]
characterNote?: string | null
} }
export interface SaleAdRequest { export interface SaleAdRequest {

View File

@@ -31,9 +31,29 @@ export interface Gerbil {
colorVarietyId: string | null colorVarietyId: string | null
originContactId: string | null originContactId: string | null
receiverContactId: string | null receiverContactId: string | null
/** SEARCH-1/2b: free-text Herkunft (Züchter) from the import; Gridify-filterable. */
originBreeder?: string | null
/**
* SEARCH-1/2b: normalized name (lowercase, [\s._-] stripped) — filter-only,
* not part of the returned payload; present here so it can be a Gridify field.
*/
nameSearch?: string | null
/** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */ /** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */
genotype: string | null genotype: string | null
notes: string | null notes: string | null
/**
* FEAT-14: Charakterbogen. Selected trait KEYS (see de.character.traits).
* Optional until FEAT-14a backend exposes the fields; treat absent as [].
*/
characterTraits?: string[] | null
characterNote?: string | null
/**
* Residency (Bestand): true = part of the own clan (Kleine Chaoten), false =
* external/pedigree-only ancestor imported for the Stammbaum. Gridify-filterable
* (`isResident==true`); the Tiere list defaults to resident-only. Absent = treat
* as resident (backend default true).
*/
isResident?: boolean
} }
/** Payload for POST /gerbils. */ /** Payload for POST /gerbils. */
@@ -50,8 +70,12 @@ export interface CreateGerbil {
colorVarietyId?: string | null colorVarietyId?: string | null
originContactId?: string | null originContactId?: string | null
receiverContactId?: string | null receiverContactId?: string | null
originBreeder?: string | null
genotype?: string | null genotype?: string | null
notes?: string | null notes?: string | null
characterTraits?: string[] | null
characterNote?: string | null
isResident?: boolean
} }
/** Payload for PUT /gerbils/{id} (all optional / partial update). */ /** Payload for PUT /gerbils/{id} (all optional / partial update). */

View File

@@ -22,10 +22,14 @@ const SECONDARY: NavItem[] = [
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' }, { to: '/becken', label: de.nav.enclosures, icon: '🛁' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' }, { to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' }, { to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// INBOX-1: Anfragen-Posteingang
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' }, { to: '/statistik', label: de.nav.statistics, icon: '📊' },
// FEAT-13: Abgabeverträge + Zuchtprofil // FEAT-13: Abgabeverträge + Zuchtprofil
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' }, { to: '/vertraege', label: de.nav.contracts, icon: '📄' },
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' }, { to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
{ to: '/hilfe', label: de.nav.help, icon: '❓' }, { to: '/hilfe', label: de.nav.help, icon: '❓' },
] ]

View File

@@ -0,0 +1,62 @@
import { de } from '../strings/de'
import { ALL_TRAITS } from '../format/traits'
import './charakterbogen.css'
export interface CharakterbogenProps {
/** Selected trait keys. */
traits: string[]
note: string
onTraitsChange: (traits: string[]) => void
onNoteChange: (note: string) => void
}
/**
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
* Controlled & reusable: rendered on the animal detail page (persisted) and in
* the Abgabe listing composer (feeds the AI sale-text). German labels from
* de.character.traits; the stored value is the trait KEY.
*/
export default function Charakterbogen({
traits,
note,
onTraitsChange,
onNoteChange,
}: CharakterbogenProps) {
const t = de.character
const selected = new Set(traits)
const toggle = (key: string) => {
const next = new Set(selected)
if (next.has(key)) next.delete(key)
else next.add(key)
// Preserve the vocabulary order for stable output.
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
}
return (
<div className="charakterbogen">
<ul className="trait-grid">
{ALL_TRAITS.map((tr) => (
<li key={tr.key}>
<label className="trait-chip">
<input
type="checkbox"
checked={selected.has(tr.key)}
onChange={() => toggle(tr.key)}
/>
<span>{tr.label}</span>
</label>
</li>
))}
</ul>
<label className="field">
<span>{t.noteLabel}</span>
<textarea
value={note}
placeholder={t.notePlaceholder}
onChange={(e) => onNoteChange(e.target.value)}
/>
</label>
</div>
)
}

View File

@@ -7,7 +7,9 @@ import { listGerbilPhotos, photoSrc, type GerbilPhoto } from '../api/photos'
import { generateSaleAd } from '../api/saleAd' import { generateSaleAd } from '../api/saleAd'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { traitLabels } from '../format/traits'
import FarbschlagImage from './FarbschlagImage' import FarbschlagImage from './FarbschlagImage'
import Charakterbogen from './Charakterbogen'
type SaleStatus = 'free' | 'loose' | 'reserved' type SaleStatus = 'free' | 'loose' | 'reserved'
@@ -34,9 +36,13 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
const [status, setStatus] = useState<SaleStatus>('free') const [status, setStatus] = useState<SaleStatus>('free')
const [reservedName, setReservedName] = useState('') const [reservedName, setReservedName] = useState('')
const [tagline, setTagline] = useState('') const [tagline, setTagline] = useState('')
// Per-animal personality text, seeded from notes on first render. // Per-animal character note, seeded from the persisted characterNote (or notes).
const [personality, setPersonality] = useState<Record<string, string>>(() => const [personality, setPersonality] = useState<Record<string, string>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.notes ?? ''])), Object.fromEntries(animals.map((a) => [a.id, a.characterNote ?? a.notes ?? ''])),
)
// Per-animal selected trait keys, seeded from the persisted Charakterbogen.
const [traitsByAnimal, setTraitsByAnimal] = useState<Record<string, string[]>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.characterTraits ?? []])),
) )
const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({}) const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({})
const [hints, setHints] = useState('') const [hints, setHints] = useState('')
@@ -147,7 +153,9 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
name: a.name, name: a.name,
farbschlag: farbschlagOf(a), farbschlag: farbschlagOf(a),
dateOfBirth: a.dateOfBirth, dateOfBirth: a.dateOfBirth,
notes: personality[a.id] ?? a.notes ?? null, notes: a.notes ?? null,
traits: traitLabels(traitsByAnimal[a.id] ?? []),
characterNote: personality[a.id] ?? null,
})), })),
statusLine, statusLine,
hints, hints,
@@ -201,14 +209,12 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''} {a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
</span> </span>
</div> </div>
<label className="field"> <Charakterbogen
<span>{t.listing.personality}</span> traits={traitsByAnimal[a.id] ?? []}
<textarea note={personality[a.id] ?? ''}
value={personality[a.id] ?? ''} onTraitsChange={(tr) => setTraitsByAnimal((s) => ({ ...s, [a.id]: tr }))}
placeholder={t.listing.personalityPlaceholder} onNoteChange={(v) => setPersonality((s) => ({ ...s, [a.id]: v }))}
onChange={(e) => setPersonality((s) => ({ ...s, [a.id]: e.target.value }))} />
/>
</label>
<div className="photo-select"> <div className="photo-select">
{(photosById.get(a.id) ?? []).length === 0 ? ( {(photosById.get(a.id) ?? []).length === 0 ? (
<small className="muted">{t.listing.noPhotos}</small> <small className="muted">{t.listing.noPhotos}</small>

View File

@@ -0,0 +1,41 @@
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
.charakterbogen .trait-grid {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: grid;
grid-template-columns: 1fr;
gap: 0.3rem;
}
@media (min-width: 480px) {
.charakterbogen .trait-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 768px) {
.charakterbogen .trait-grid {
grid-template-columns: 1fr 1fr 1fr;
}
}
.trait-chip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
cursor: pointer;
min-height: 44px;
}
.trait-chip input {
width: 1.1rem;
height: 1.1rem;
min-height: 0;
flex: 0 0 auto;
}

View File

@@ -17,3 +17,16 @@ export function formatDate(iso: string | null | undefined): string {
if (!y || !m || !d) return iso if (!y || !m || !d) return iso
return `${d}.${m}.${y}` return `${d}.${m}.${y}`
} }
/** INBOX-1: ISO-Zeitstempel → "TT.MM.JJJJ, HH:MM" (lokale Zeit, de-DE). */
export function formatDateTime(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}

View File

@@ -0,0 +1,15 @@
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
import { de } from '../strings/de'
export const ALL_TRAITS = de.character.traits
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
export function traitLabel(key: string): string {
return LABEL_BY_KEY.get(key) ?? key
}
/** Selected keys -> German labels (for display + the AI sale-text prompt). */
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
return (keys ?? []).map(traitLabel)
}

View File

@@ -15,6 +15,7 @@ import {
toJSON, toJSON,
fromJSON, fromJSON,
wildType, wildType,
extractGenotypeFlags,
} from '../genotype' } from '../genotype'
import { combineLocus } from '../punnett' import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci' import { LOCI, type LocusKey } from '../loci'
@@ -28,7 +29,7 @@ import {
} from '../catalog' } from '../catalog'
/** The first 18 entries are the frozen contract names (must round-trip exactly). */ /** The first 18 entries are the frozen contract names (must round-trip exactly). */
const FROZEN_COUNT = 18 const FROZEN_COUNT = 17
import { breed } from '../breed' import { breed } from '../breed'
import { GeneticsWarningCode } from '../warnings' import { GeneticsWarningCode } from '../warnings'
@@ -64,6 +65,7 @@ describe('Genotype serialization', () => {
P: ['p', 'P'], P: ['p', 'P'],
Sp: ['sp', 'sp'], Sp: ['sp', 'sp'],
Re: ['re', 're'], Re: ['re', 're'],
Sls: ['sl', 'sl'],
}) })
expect(g.A).toEqual(['A', 'a']) expect(g.A).toEqual(['A', 'a'])
expect(g.C).toEqual(['C', 'ch']) expect(g.C).toEqual(['C', 'ch'])
@@ -171,14 +173,16 @@ describe('Farbschlag catalog', () => {
}) })
it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => { it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
// Colourpoint marked + dilute + grey combo not in the catalog. // E-dominant (no Fuchs/Schimmel family) + an uncatalogued cchm/dd/gg/pp combo.
const match = farbschlagFor(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere')) const match = farbschlagFor(fromDisplayString('aa CC dd EE gg pp spsp rere'))
expect(match.unknown).toBe(true) expect(match.unknown).toBe(true)
expect(match.name).toBe('Unbekannter Farbschlag') expect(match.name).toBe('Unbekannter Farbschlag')
}) })
it('has the expected catalogue coverage (GEN-2: frozen 18 + 55 portal varieties)', () => { it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
expect(CATALOG_SIZE).toBe(73) // GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
expect(CATALOG_SIZE).toBe(61)
}) })
it('frozen contract names round-trip to themselves (DB-key guard)', () => { it('frozen contract names round-trip to themselves (DB-key guard)', () => {
@@ -204,7 +208,11 @@ describe('Farbschlag catalog', () => {
expect(new Set(names).size).toBe(names.length) expect(new Set(names).size).toBe(names.length)
for (const entry of BASE_COLORS) { for (const entry of BASE_COLORS) {
for (const [locus, value] of Object.entries(entry.tokens)) { for (const [locus, value] of Object.entries(entry.tokens)) {
expect(LOCI[locus as LocusKey].alleles).toContain(value) // GEN-3f: a token may be a heterozygous pair "x/y" (e.g. C: 'cchm/ch'
// for the het colourpoints Siam/Zobel-Hell) — validate each allele.
for (const allele of value.split('/')) {
expect(LOCI[locus as LocusKey].alleles).toContain(allele)
}
} }
} }
}) })
@@ -220,7 +228,7 @@ describe('Farbschlag catalog', () => {
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => { it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
expect(genotypeToFarbschlag(wildType())).toBe('Agouti') expect(genotypeToFarbschlag(wildType())).toBe('Agouti')
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin') expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin')
expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))).toBe( expect(genotypeToFarbschlag(fromDisplayString('aa CC dd EE gg pp spsp rere'))).toBe(
'Unbekannter Farbschlag', 'Unbekannter Farbschlag',
) )
}) })
@@ -240,6 +248,7 @@ describe('Partially-unknown parents (wildcards)', () => {
P: ['P', 'P'], P: ['P', 'P'],
Sp: ['sp', 'sp'], Sp: ['sp', 'sp'],
Re: ['re', 're'], Re: ['re', 're'],
Sls: ['sl', 'sl'],
}) })
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere') const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
const result = breed(father, mother) const result = breed(father, mother)
@@ -263,3 +272,209 @@ describe('Probabilities are exact fractions summing to 1', () => {
expect(toNumber(sum)).toBeCloseTo(1, 10) expect(toNumber(sum)).toBeCloseTo(1, 10)
}) })
}) })
describe('GEN-3a: Uw=G alias', () => {
it('Uwuw parses as Gg, UwUw as GG', () => {
expect(fromDisplayString('AA CC DD EE Uwuw PP spsp rere').G).toEqual(['G', 'g'])
expect(fromDisplayString('AA CC DD EE UwUw PP spsp rere').G).toEqual(['G', 'G'])
expect(fromDisplayString('AA CC DD EE uwuw PP spsp rere').G).toEqual(['g', 'g'])
})
it('always RENDERS G, never Uw (breeder preference)', () => {
// Uw/uw is an input/import alias only; output must echo G/g.
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
'AA CC DD EE Gg PP spsp rere',
)
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
})
})
describe('GEN-3a: second spotting locus Sls (WP)', () => {
it('S(l)s(l) and WP both parse to the Sls heterozygote Slsl', () => {
expect(fromDisplayString('AA CC DD EE GG PP spsp rere S(l)s(l)').Sls).toEqual(['Sl', 'sl'])
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
})
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
'AA CC DD EE GG PP spsp rere Slsl',
)
})
it('WP × WP: S(l)S(l) is lethal — removed, renormalized, SLS_LETHAL warning', () => {
const parent = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
const result = breed(parent, parent)
expect(result.offspring.every((o) => !o.genotype.includes('SlSl'))).toBe(true)
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
expect(sum).toBeCloseTo(1, 10)
// survivors 2/3 Slsl : 1/3 slsl
const wp = result.offspring.find((o) => o.genotype.includes('Slsl'))!
expect(wp.probability.text).toBe('2/3')
const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.SlsLethal)
expect(warn?.detail?.youngLostFraction).toBe('1/4')
})
it('Sp × Sls → Superschecke deafness warning', () => {
const father = fromDisplayString('AA CC DD EE GG PP Spsp rere')
const mother = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
const result = breed(father, mother)
expect(
result.warnings.some((w) => w.code === GeneticsWarningCode.SuperscheckeDeaf),
).toBe(true)
})
})
describe('GEN-3a: flag/metadata tokens tolerated', () => {
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
})
it('extractGenotypeFlags reads deafness + tags', () => {
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere dea WFNZ').deaf).toBe(true)
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere Dea').deaf).toBe(false)
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere WFNZ RV').tags).toEqual(['WFNZ', 'RV'])
})
})
describe('GEN-3a: Schimmel catalog fix (breeder C5)', () => {
it('efef base -> Orangeschimmel (not Schwarzschimmel)', () => {
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'Orangeschimmel',
)
})
it('efef pp -> Rotaugenschimmel, efef gg -> Silberschimmel', () => {
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG pp spsp rere'))).toBe(
'Rotaugenschimmel',
)
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef gg PP spsp rere'))).toBe(
'Silberschimmel',
)
})
it('no Schwarzschimmel anywhere in the catalog', () => {
expect(BASE_COLORS.some((e) => e.name === 'Schwarzschimmel')).toBe(false)
})
})
describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
it("accepts '-' input, stores '?', displays '-'", () => {
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
})
it("'?' and '-' inputs are equivalent", () => {
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
'Aa C- DD EE GG Pp spsp rere',
)
})
})
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Fuchsschimmel',
)
})
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
expect(farbschlagFor(fromDisplayString('a- C- D- ee G- P-')).unknown).toBe(false)
expect(farbschlagFor(fromDisplayString('A- C- D- efef G- P-')).unknown).toBe(false)
})
it('unknown allele no longer blocks a match (wildcard, never Unbekannt)', () => {
// C unknown must not force Unbekannt — it resolves to SOME named variety.
expect(farbschlagFor(fromDisplayString('aa C- DD EE GG PP spsp rere')).unknown).toBe(false)
})
})
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
expect(name).toBe('Schwarz')
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
})
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
// 'sp-' (one sp + unknown) -> sp/sp -> no Schecke (naive most-dominant would wrongly add it).
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
})
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Fuchsschimmel',
)
})
})
describe('GEN-3e: C-locus colourpoint naming', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('A- + cchm/cchm -> CP-<base colour> (CP-Silberagouti example)', () => {
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
})
it('A- + cchm/ch -> CP-<base colour>', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
})
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
})
it('chch stays Hermelin (aa) / Himalaya (A-); full C => no CP', () => {
expect(name('aa chch DD EE GG PP spsp rere')).toBe('Hermelin')
expect(name('AA chch DD EE GG PP spsp rere')).toBe('Himalaya')
expect(name('AA CC DD EE GG PP spsp rere')).toBe('Agouti') // one full C -> no colourpoint
})
it('colourpoint composes with the Schecke modifier', () => {
expect(name('aa cchmcchm DD EE GG PP Spsp rere')).toBe('Marder Schecke')
})
})
describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her live data)', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
it('every CP- name her data uses exists as a catalog row (import name-match)', () => {
for (const n of [
'CP-Agouti', 'CP-Silberagouti', 'CP-Algierfuchs', 'CP-Polarfuchs',
'CP-Fuchs', 'CP-Fuchs-Hell', 'CP-Blaufuchs', 'CP-Orangeschimmel',
'Marder', 'Siam', 'Zobel', 'Zobel-Hell',
]) {
expect(has(n)).toBe(true)
}
})
it('the removed portal suffix-names are gone (collapsed)', () => {
for (const n of [
'Agouti CP', 'Agouti CP-Hell', 'Silberagouti CP', 'Algierfuchs CP',
'Polarfuchs CP', 'Blaufuchs CP', 'Kohlfuchs CP', 'Kohlfuchsschimmel CP',
'Marder dd', 'Zobel dd', 'Siam (Marder-Hell)', 'Agouti dd CP',
]) {
expect(has(n)).toBe(false)
}
})
it('the engine computes the agouti CP- names her data uses (E-family fallback covers fox/dilute)', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
})
})

View File

@@ -20,9 +20,15 @@
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from * meta rows dropped, 17 matched the frozen names). Genotypes normalized from
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown). * portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
*/ */
import { LOCUS_ORDER, type LocusKey } from './loci' import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import { makeGenotype, toDisplayString, wildType, type AllelePair, type Genotype } from './genotype' import {
import { phenotypeTokens, type PhenotypeTokens } from './phenotype' makeGenotype,
toDisplayString,
wildType,
WILDCARD,
type AllelePair,
type Genotype,
} from './genotype'
export interface FarbschlagEntry { export interface FarbschlagEntry {
/** German variety name. FROZEN for the original 18 (DB keys). */ /** German variety name. FROZEN for the original 18 (DB keys). */
@@ -41,12 +47,14 @@ export interface FarbschlagEntry {
* portal collision group. * portal collision group.
*/ */
export const BASE_COLORS: readonly FarbschlagEntry[] = [ export const BASE_COLORS: readonly FarbschlagEntry[] = [
// ── Frozen 18 (names are DB-key contract; do not rename) ── // ── Frozen names (DB-key contract; do not rename) ──
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
// to the frozen-name rule; the ColorVariety seed drops it too.
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' }, { name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' }, { name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' }, { name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' }, { name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
{ name: 'Schwarzschimmel', english: 'Black Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' } },
{ name: 'Rotaugenschimmel', english: 'Red-Eyed Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotaugen-schimmel.jpg' }, { name: 'Rotaugenschimmel', english: 'Red-Eyed Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotaugen-schimmel.jpg' },
{ name: 'Agouti', english: 'Golden Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-mit-erklaerung-der-genloci.JPG' }, { name: 'Agouti', english: 'Golden Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-mit-erklaerung-der-genloci.JPG' },
{ name: 'Schwarz', english: 'Black', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'schwarz.jpg' }, { name: 'Schwarz', english: 'Black', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'schwarz.jpg' },
@@ -68,55 +76,58 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'C-Separator', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'c-separator.jpg' }, { name: 'C-Separator', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'c-separator.jpg' },
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' }, { name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' }, { name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
{ name: 'Siam (Marder-Hell)', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' }, { name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' }, { name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
{ name: 'Schimmel (Orangeschimmel)', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' }, // GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' }, { name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' }, { name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' }, { name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' }, { name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' }, { name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' }, { name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
{ name: 'Agouti CP-Hell', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp-hell.JPG' }, // GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
{ name: 'Blaufuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-cp.jpg' }, // A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' }, { name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' }, { name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
{ name: 'Polarfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell-cp.jpg' },
{ name: 'Kohlfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel.jpg' }, { name: 'Kohlfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel.jpg' },
{ name: 'Blaufuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'blaufuchsschimmel.jpg' }, { name: 'Blaufuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'blaufuchsschimmel.jpg' },
{ name: 'Kohlfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell.jpg' }, { name: 'Kohlfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell.jpg' },
{ name: 'Goldfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs-hell.jpg' }, { name: 'Goldfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs-hell.jpg' },
{ name: 'Goldfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'goldfuchsschimmel.jpg' }, { name: 'Goldfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'goldfuchsschimmel.jpg' },
{ name: 'Gold-Hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold-hell.jpg' }, { name: 'Gold-Hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold-hell.jpg' },
{ name: 'Siam (Marder-Hell) dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell-dd.jpg' },
{ name: 'Marder dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'marder-dd.jpg' },
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
{ name: 'Silberagouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd-cp.jpg' },
{ name: 'Silberagouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-hell-dd-cp.jpg' },
{ name: 'Agouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd-cp.jpg' },
{ name: 'Agouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-hell-dd-cp.jpg' },
{ name: 'Blaufuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-hell.jpeg' }, { name: 'Blaufuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-hell.jpeg' },
{ name: 'Rotfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotfuchsschimmel.jpg' }, { name: 'Rotfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotfuchsschimmel.jpg' },
{ name: 'Polarfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell.jpeg' }, { name: 'Polarfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell.jpeg' },
{ name: 'Kohlfuchsschimmel, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-hell.jpg' }, { name: 'Kohlfuchsschimmel, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-hell.jpg' },
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' }, { name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
{ name: 'Zobel dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-dd.jpg' },
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' }, { name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
{ name: 'Kohlfuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-cp.jpg' },
{ name: 'Algierfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
{ name: 'Silberagouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
{ name: 'Agouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
{ name: 'Algierfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell-cp.jpg' },
{ name: 'Kohlfuchs,hell CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-cp.jpg' },
{ name: 'Polarfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' }, { name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' }, { name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Zobel-Hell dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell-dd.jpg' },
{ name: 'Kohlfuchsschimmel CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-cp.JPG' },
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' }, { name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
// collapse (other loci irrelevant for the CP prefix). These names == the strings
// in her live data (god: extract animals.json) so the re-import name-matches and
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
] ]
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag' export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
@@ -130,26 +141,108 @@ export interface FarbschlagMatch {
readonly unknown: boolean readonly unknown: boolean
} }
function matches(tokens: PhenotypeTokens, entry: FarbschlagEntry): boolean { /**
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
* raw pair, which runs before this.)
*/
function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
}
return dominantAllele(locus, x, y)
}
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
return (Object.keys(entry.tokens) as LocusKey[]).every( return (Object.keys(entry.tokens) as LocusKey[]).every(
(locus) => tokens[locus] === entry.tokens[locus], (locus) => locusToken(g, locus) === entry.tokens[locus],
) )
} }
/**
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
* Returns null when E is dominant (full colour) or fully unknown.
*/
function eFamily(g: Genotype): string | null {
const [x, y] = g.E
if (x === 'e' && y === 'e') return 'Fuchs'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
if (x === 'ef' && y === 'ef') return 'Schimmel'
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
return null
}
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */ /** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
return [x, y]
}
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
function baseColourFor(g: Genotype): string | null {
const family = eFamily(g)
const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
return base?.name ?? family
}
/**
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
* Returns the colourpoint name, or null when it doesn't apply (full C present,
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
*/
function colourpointName(g: Genotype): string | null {
const c = resolvedPair(g, 'C')
if (c.includes('C')) return null // a full C allele => full colour, no CP
if (c[0] === 'ch' && c[1] === 'ch') return null // chch -> base matcher (Hermelin/Himalaya)
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
const agouti = resolvedPair(g, 'A').includes('A')
if (!agouti) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// A- colourpoint -> CP-<base colour>, base as if C were full.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
return base ? `CP-${base}` : null
}
export function farbschlagFor(g: Genotype): FarbschlagMatch { export function farbschlagFor(g: Genotype): FarbschlagMatch {
const tokens = phenotypeTokens(g)
const base = BASE_COLORS.find((e) => matches(tokens, e)) ?? null
const modifiers: string[] = [] const modifiers: string[] = []
if (tokens.Sp === 'Sp') modifiers.push('Schecke') if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (tokens.Re === 'Re') modifiers.push('Rex') if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
if (!base) { const baseName = colourpointName(g) ?? baseColourFor(g)
if (!baseName) {
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true } return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
} }
const name = [base.name, ...modifiers].join(' ') const name = [baseName, ...modifiers].join(' ')
return { name, base, unknown: false } return { name, base: null, unknown: false }
} }
/** /**
@@ -174,7 +267,14 @@ export function representativeGenotype(entry: FarbschlagEntry): Genotype {
const out = {} as Record<LocusKey, AllelePair> const out = {} as Record<LocusKey, AllelePair>
for (const locus of LOCUS_ORDER) { for (const locus of LOCUS_ORDER) {
const token = entry.tokens[locus] const token = entry.tokens[locus]
out[locus] = token ? [token, token] : base[locus] if (!token) {
out[locus] = base[locus]
continue
}
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
out[locus] = [a, b]
} }
return makeGenotype(out) return makeGenotype(out)
} }

View File

@@ -1,440 +1,381 @@
[ [
{ {
"name": "Pink Eyed White (PEW)", "name": "Pink Eyed White (PEW)",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere", "english": "Pink Eyed White",
"sortOrder": 0, "canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg" "sortOrder": 0,
}, "image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
{ },
"name": "Hermelin", {
"canonicalGenotype": "aa chch DD EE GG PP spsp rere", "name": "Hermelin",
"sortOrder": 1, "english": "Dark Tailed White",
"image": "hermelin.jpeg" "canonicalGenotype": "aa chch DD EE GG PP spsp rere",
}, "sortOrder": 1,
{ "image": "hermelin.jpeg"
"name": "Himalaya", },
"canonicalGenotype": "AA chch DD EE GG PP spsp rere", {
"sortOrder": 2, "name": "Himalaya",
"image": "himalaya.jpg" "english": "Himalayan",
}, "canonicalGenotype": "AA chch DD EE GG PP spsp rere",
{ "sortOrder": 2,
"name": "Zobel", "image": "himalaya.jpg"
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere", },
"sortOrder": 3, {
"image": "zobel.jpeg" "name": "Zobel",
}, "english": "Sable",
{ "canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
"name": "Schwarzschimmel", "sortOrder": 3,
"canonicalGenotype": "AA CC DD efef GG PP spsp rere", "image": "zobel.jpeg"
"sortOrder": 4, },
"image": null {
}, "name": "Rotaugenschimmel",
{ "english": "Red-Eyed Roan",
"name": "Rotaugenschimmel", "canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere", "sortOrder": 4,
"sortOrder": 5, "image": "rotaugen-schimmel.jpg"
"image": "rotaugen-schimmel.jpg" },
}, {
{ "name": "Agouti",
"name": "Agouti", "english": "Golden Agouti",
"canonicalGenotype": "AA CC DD EE GG PP spsp rere", "canonicalGenotype": "AA CC DD EE GG PP spsp rere",
"sortOrder": 6, "sortOrder": 5,
"image": "agouti-mit-erklaerung-der-genloci.JPG" "image": "agouti-mit-erklaerung-der-genloci.JPG"
}, },
{ {
"name": "Schwarz", "name": "Schwarz",
"canonicalGenotype": "aa CC DD EE GG PP spsp rere", "english": "Black",
"sortOrder": 7, "canonicalGenotype": "aa CC DD EE GG PP spsp rere",
"image": "schwarz.jpg" "sortOrder": 6,
}, "image": "schwarz.jpg"
{ },
"name": "Silberagouti", {
"canonicalGenotype": "AA CC DD EE gg PP spsp rere", "name": "Silberagouti",
"sortOrder": 8, "english": "Grey Agouti",
"image": "silberagouti.jpg" "canonicalGenotype": "AA CC DD EE gg PP spsp rere",
}, "sortOrder": 7,
{ "image": "silberagouti.jpg"
"name": "Anthrazit", },
"canonicalGenotype": "aa CC DD EE gg PP spsp rere", {
"sortOrder": 9, "name": "Anthrazit",
"image": "anthrazit.jpg" "english": "Slate",
}, "canonicalGenotype": "aa CC DD EE gg PP spsp rere",
{ "sortOrder": 8,
"name": "Algierfuchs", "image": "anthrazit.jpg"
"canonicalGenotype": "AA CC DD ee GG PP spsp rere", },
"sortOrder": 10, {
"image": "algierfuchs.jpg" "name": "Algierfuchs",
}, "english": "Dark-Eyed Honey",
{ "canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"name": "Blau", "sortOrder": 9,
"canonicalGenotype": "aa CC dd EE GG PP spsp rere", "image": "algierfuchs.jpg"
"sortOrder": 11, },
"image": "blau-schwarz-dd.JPG" {
}, "name": "Blau",
{ "english": "Blue",
"name": "Gold", "canonicalGenotype": "aa CC dd EE GG PP spsp rere",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere", "sortOrder": 10,
"sortOrder": 12, "image": "blau-schwarz-dd.JPG"
"image": "gold.jpg" },
}, {
{ "name": "Gold",
"name": "Platin", "english": "Argente Golden",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere", "canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 13, "sortOrder": 11,
"image": "platin.JPG" "image": "gold.jpg"
}, },
{ {
"name": "Goldfuchs", "name": "Platin",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere", "english": "Lilac",
"sortOrder": 14, "canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"image": "goldfuchs.jpg" "sortOrder": 12,
}, "image": "platin.JPG"
{ },
"name": "Rotfuchs", {
"canonicalGenotype": "aa CC DD ee GG pp spsp rere", "name": "Goldfuchs",
"sortOrder": 15, "english": "Yellow Fox",
"image": "rotfuchs.JPG" "canonicalGenotype": "AA CC DD ee GG pp spsp rere",
}, "sortOrder": 13,
{ "image": "goldfuchs.jpg"
"name": "dd Gold", },
"canonicalGenotype": "AA CC dd EE GG pp spsp rere", {
"sortOrder": 16, "name": "Rotfuchs",
"image": "gold-dd.jpg" "english": "Argente Nutmeg",
}, "canonicalGenotype": "aa CC DD ee GG pp spsp rere",
{ "sortOrder": 14,
"name": "dd Platin", "image": "rotfuchs.JPG"
"canonicalGenotype": "aa CC dd EE GG pp spsp rere", },
"sortOrder": 17, {
"image": "platin-dd.jpg" "name": "dd Gold",
}, "english": "dd Argente Golden",
{ "canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"name": "Altweiss (REW)", "sortOrder": 15,
"canonicalGenotype": "aa CC DD EE gg pp spsp rere", "image": "gold-dd.jpg"
"sortOrder": 18, },
"image": "altweiss-rew.jpeg" {
}, "name": "dd Platin",
{ "english": "dd Lilac",
"name": "Apricot (Blassfuchs)", "canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"canonicalGenotype": "AA CC DD ee gg pp spsp rere", "sortOrder": 16,
"sortOrder": 19, "image": "platin-dd.jpg"
"image": "apricot-blassfuchs.jpg" },
}, {
{ "name": "Altweiss (REW)",
"name": "Blaufuchs", "canonicalGenotype": "aa CC DD EE gg pp spsp rere",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere", "sortOrder": 17,
"sortOrder": 20, "image": "altweiss-rew.jpeg"
"image": "blaufuchs.jpg" },
}, {
{ "name": "Apricot (Blassfuchs)",
"name": "C-Separator", "canonicalGenotype": "AA CC DD ee gg pp spsp rere",
"canonicalGenotype": "aa CC DD ee gg pp spsp rere", "sortOrder": 18,
"sortOrder": 21, "image": "apricot-blassfuchs.jpg"
"image": "c-separator.jpg" },
}, {
{ "name": "Blaufuchs",
"name": "Elfenbein", "canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"canonicalGenotype": "AA CC DD EE gg pp spsp rere", "sortOrder": 19,
"sortOrder": 22, "image": "blaufuchs.jpg"
"image": "elfenbein.jpg" },
}, {
{ "name": "C-Separator",
"name": "Kohlfuchs", "canonicalGenotype": "aa CC DD ee gg pp spsp rere",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere", "sortOrder": 20,
"sortOrder": 23, "image": "c-separator.jpg"
"image": "kohlfuchs.jpg" },
}, {
{ "name": "Elfenbein",
"name": "Marder", "canonicalGenotype": "AA CC DD EE gg pp spsp rere",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere", "sortOrder": 21,
"sortOrder": 24, "image": "elfenbein.jpg"
"image": "marder.JPG" },
}, {
{ "name": "Kohlfuchs",
"name": "Siam (Marder-Hell)", "canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere", "sortOrder": 22,
"sortOrder": 25, "image": "kohlfuchs.jpg"
"image": "siam-marder-hell.JPG" },
}, {
{ "name": "Polarfuchs",
"name": "Polarfuchs", "canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere", "sortOrder": 23,
"sortOrder": 26, "image": "polarfuchs.jpg"
"image": "polarfuchs.jpg" },
}, {
{ "name": "Saphir",
"name": "Saphir", "canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere", "sortOrder": 24,
"sortOrder": 27, "image": "saphir.jpg"
"image": "saphir.jpg" },
}, {
{ "name": "Orangeschimmel",
"name": "Schimmel (Orangeschimmel)", "canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere", "sortOrder": 25,
"sortOrder": 28, "image": "schimmel-orangeschimmel.jpg"
"image": "schimmel-orangeschimmel.jpg" },
}, {
{ "name": "Topas",
"name": "Topas", "canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere", "sortOrder": 26,
"sortOrder": 29, "image": "topas.jpg"
"image": "topas.jpg" },
}, {
{ "name": "Platin-Hell",
"name": "Platin-Hell", "canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere", "sortOrder": 27,
"sortOrder": 30, "image": "platin-hell.jpg"
"image": "platin-hell.jpg" },
}, {
{ "name": "Agouti dd",
"name": "Agouti dd", "canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere", "sortOrder": 28,
"sortOrder": 31, "image": "agouti-dd.jpg"
"image": "agouti-dd.jpg" },
}, {
{ "name": "Silberagouti dd",
"name": "Silberagouti dd", "canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere", "sortOrder": 29,
"sortOrder": 32, "image": "silberagouti-dd.jpg"
"image": "silberagouti-dd.jpg" },
}, {
{ "name": "Kohlfuchs dd",
"name": "Kohlfuchs dd", "canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere", "sortOrder": 30,
"sortOrder": 33, "image": "kohlfuchs-dd.jpg"
"image": "kohlfuchs-dd.jpg" },
}, {
{ "name": "Anthrazit dd",
"name": "Anthrazit dd", "canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere", "sortOrder": 31,
"sortOrder": 34, "image": "anthrazit-dd.jpg"
"image": "anthrazit-dd.jpg" },
}, {
{ "name": "Silberschimmel",
"name": "Agouti CP-Hell", "canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere", "sortOrder": 32,
"sortOrder": 35, "image": "silberschimmel.jpg"
"image": "agouti-cp-hell.JPG" },
}, {
{ "name": "Polarfuchsschimmel",
"name": "Blaufuchs CP", "canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "aa cchmcchm DD ee gg PP spsp rere", "sortOrder": 33,
"sortOrder": 36, "image": "polarfuchsschimmel.jpg"
"image": "blaufuchs-cp.jpg" },
}, {
{ "name": "Algierfuchsschimmel",
"name": "Polarfuchsschimmel", "canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere", "sortOrder": 34,
"sortOrder": 37, "image": "algierfuchsschimmel.jpg"
"image": "polarfuchsschimmel.jpg" },
}, {
{ "name": "Kohlfuchsschimmel",
"name": "Silberschimmel", "canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere", "sortOrder": 35,
"sortOrder": 38, "image": "kohlfuchsschimmel.jpg"
"image": "silberschimmel.jpg" },
}, {
{ "name": "Blaufuchsschimmel",
"name": "Algierfuchsschimmel", "canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere", "sortOrder": 36,
"sortOrder": 39, "image": "blaufuchsschimmel.jpg"
"image": "algierfuchsschimmel.jpg" },
}, {
{ "name": "Kohlfuchs, hell",
"name": "Polarfuchs-Hell CP", "canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere", "sortOrder": 37,
"sortOrder": 40, "image": "kohlfuchs-hell.jpg"
"image": "polarfuchs-hell-cp.jpg" },
}, {
{ "name": "Goldfuchs, hell",
"name": "Kohlfuchsschimmel", "canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere", "sortOrder": 38,
"sortOrder": 41, "image": "goldfuchs-hell.jpg"
"image": "kohlfuchsschimmel.jpg" },
}, {
{ "name": "Goldfuchsschimmel",
"name": "Blaufuchsschimmel", "canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere", "sortOrder": 39,
"sortOrder": 42, "image": "goldfuchsschimmel.jpg"
"image": "blaufuchsschimmel.jpg" },
}, {
{ "name": "Gold-Hell",
"name": "Kohlfuchs, hell", "canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere", "sortOrder": 40,
"sortOrder": 43, "image": "gold-hell.jpg"
"image": "kohlfuchs-hell.jpg" },
}, {
{ "name": "Blaufuchs, hell",
"name": "Goldfuchs, hell", "canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere", "sortOrder": 41,
"sortOrder": 44, "image": "blaufuchs-hell.jpeg"
"image": "goldfuchs-hell.jpg" },
}, {
{ "name": "Rotfuchsschimmel",
"name": "Goldfuchsschimmel", "canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere", "sortOrder": 42,
"sortOrder": 45, "image": "rotfuchsschimmel.jpg"
"image": "goldfuchsschimmel.jpg" },
}, {
{ "name": "Polarfuchs, hell",
"name": "Gold-Hell", "canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere", "sortOrder": 43,
"sortOrder": 46, "image": "polarfuchs-hell.jpeg"
"image": "gold-hell.jpg" },
}, {
{ "name": "Kohlfuchsschimmel, hell",
"name": "Siam (Marder-Hell) dd", "canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere", "sortOrder": 44,
"sortOrder": 47, "image": "kohlfuchsschimmel-hell.jpg"
"image": "siam-marder-hell-dd.jpg" },
}, {
{ "name": "Rotfuchs, hell",
"name": "Marder dd", "canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere", "sortOrder": 45,
"sortOrder": 48, "image": "rotfuchs-hell.jpg"
"image": "marder-dd.jpg" },
}, {
{ "name": "Kohlfuchs-Hell",
"name": "Zobel-Hell", "canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere", "sortOrder": 46,
"sortOrder": 49, "image": "kohlfuchs-hell-2.jpg"
"image": "zobel-hell.jpg" },
}, {
{ "name": "Algierfuchs, hell",
"name": "Silberagouti dd CP", "canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere", "sortOrder": 47,
"sortOrder": 50, "image": "algierfuchs-hell.JPG"
"image": "silberagouti-dd-cp.jpg" },
}, {
{ "name": "Topas dd",
"name": "Silberagouti-Hell dd CP", "canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere", "sortOrder": 48,
"sortOrder": 51, "image": "topas-dd.jpg"
"image": "silberagouti-hell-dd-cp.jpg" },
}, {
{ "name": "Blaufuchs dd",
"name": "Agouti dd CP", "canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere", "sortOrder": 49,
"sortOrder": 52, "image": "blaufuchs-dd.jpg"
"image": "agouti-dd-cp.jpg" },
}, {
{ "name": "Marder",
"name": "Agouti-Hell dd CP", "canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere", "sortOrder": 50,
"sortOrder": 53, "image": "marder.JPG"
"image": "agouti-hell-dd-cp.jpg" },
}, {
{ "name": "Siam",
"name": "Blaufuchs, hell", "canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere", "sortOrder": 51,
"sortOrder": 54, "image": "siam-marder-hell.JPG"
"image": "blaufuchs-hell.jpeg" },
}, {
{ "name": "Zobel-Hell",
"name": "Rotfuchsschimmel", "canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere", "sortOrder": 52,
"sortOrder": 55, "image": "zobel-hell.jpg"
"image": "rotfuchsschimmel.jpg" },
}, {
{ "name": "CP-Agouti",
"name": "Polarfuchs, hell", "canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere", "sortOrder": 53,
"sortOrder": 56, "image": "agouti-cp.jpg"
"image": "polarfuchs-hell.jpeg" },
}, {
{ "name": "CP-Silberagouti",
"name": "Kohlfuchsschimmel, hell", "canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere", "sortOrder": 54,
"sortOrder": 57, "image": "silberagouti-cp.JPG"
"image": "kohlfuchsschimmel-hell.jpg" },
}, {
{ "name": "CP-Algierfuchs",
"name": "Rotfuchs, hell", "canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere", "sortOrder": 55,
"sortOrder": 58, "image": "algierfuchs-cp.jpg"
"image": "rotfuchs-hell.jpg" },
}, {
{ "name": "CP-Polarfuchs",
"name": "Zobel dd", "canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere", "sortOrder": 56,
"sortOrder": 59, "image": "polarfuchs-cp.jpg"
"image": "zobel-dd.jpg" },
}, {
{ "name": "CP-Fuchs",
"name": "Kohlfuchs-Hell", "canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere", "sortOrder": 57
"sortOrder": 60, },
"image": "kohlfuchs-hell-2.jpg" {
}, "name": "CP-Fuchs-Hell",
{ "canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"name": "Kohlfuchs CP", "sortOrder": 58
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere", },
"sortOrder": 61, {
"image": "kohlfuchs-cp.jpg" "name": "CP-Blaufuchs",
}, "canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
{ "sortOrder": 59
"name": "Algierfuchs CP", },
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere", {
"sortOrder": 62, "name": "CP-Orangeschimmel",
"image": "algierfuchs-cp.jpg" "canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
}, "sortOrder": 60
{ }
"name": "Silberagouti CP", ]
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"sortOrder": 63,
"image": "silberagouti-cp.JPG"
},
{
"name": "Agouti CP",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
"sortOrder": 64,
"image": "agouti-cp.jpg"
},
{
"name": "Algierfuchs-Hell CP",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"sortOrder": 65,
"image": "algierfuchs-hell-cp.jpg"
},
{
"name": "Kohlfuchs,hell CP",
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
"sortOrder": 66,
"image": "kohlfuchs-hell-cp.jpg"
},
{
"name": "Polarfuchs CP",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"sortOrder": 67,
"image": "polarfuchs-cp.jpg"
},
{
"name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 68,
"image": "algierfuchs-hell.JPG"
},
{
"name": "Topas dd",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 69,
"image": "topas-dd.jpg"
},
{
"name": "Zobel-Hell dd",
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
"sortOrder": 70,
"image": "zobel-hell-dd.jpg"
},
{
"name": "Kohlfuchsschimmel CP",
"canonicalGenotype": "aa cchmcchm DD efef GG PP spsp rere",
"sortOrder": 71,
"image": "kohlfuchsschimmel-cp.JPG"
},
{
"name": "Blaufuchs dd",
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
"sortOrder": 72,
"image": "blaufuchs-dd.jpg"
}
]

View File

@@ -64,18 +64,30 @@ export function makeGenotype(input: Record<LocusKey, AllelePair>): Genotype {
export function wildType(): Genotype { export function wildType(): Genotype {
const out = {} as Record<LocusKey, AllelePair> const out = {} as Record<LocusKey, AllelePair>
for (const locus of LOCUS_ORDER) { for (const locus of LOCUS_ORDER) {
// Wild-type is homozygous for the most dominant allele, EXCEPT the // Wild-type is homozygous for the most dominant allele, EXCEPT the marker
// marker loci Sp/Re whose wild form is the recessive (unmarked) allele. // loci Sp/Re/Sls whose wild form is the recessive (unmarked) allele.
const alleles = LOCI[locus].alleles const alleles = LOCI[locus].alleles
const a = locus === 'Sp' || locus === 'Re' ? alleles[alleles.length - 1] : alleles[0] const marker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const a = marker ? alleles[alleles.length - 1] : alleles[0]
out[locus] = [a, a] out[locus] = [a, a]
} }
return out return out
} }
/** Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". */ /**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
* convention) — e.g. ['C','?'] renders "C-".
*/
export function toDisplayString(g: Genotype): string { export function toDisplayString(g: Genotype): string {
return LOCUS_ORDER.map((locus) => g[locus][0] + g[locus][1]).join(' ') return LOCUS_ORDER.filter(
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
)
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
.join(' ')
} }
/** Stable JSON-storable object (already the in-memory shape; returned as a copy). */ /** Stable JSON-storable object (already the in-memory shape; returned as a copy). */
@@ -114,18 +126,81 @@ function splitToken(token: string): [string, string] {
} }
/** /**
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere") back into a Genotype. * GEN-3a: tokens that are NOT genotype loci — health/provenance metadata that may
* Tokens may be given in any order; each token must belong to a distinct locus. * appear in a herd-book genotype string. Stripped on parse (see extractGenotypeFlags).
* - dea/Dea/taub = deafness flag (after spsp); DP/DarkPatch = non-Mendelian patch flag
* - WFNZ/RV/GV = provenance/breeding-method annotations
*/
const FLAG_TOKENS = new Set(['DP', 'DarkPatch', 'dea', 'Dea', 'taub', 'WFNZ', 'RV', 'GV'])
/**
* Normalize one whitespace-token to canonical allele symbols, or null if it is a
* non-genotype flag/metadata token (to be stripped):
* - Uw/uw -> G/g (international Underwhite == German Grey locus)
* - S(l)/s(l) -> Sl/sl (second spotting locus notation)
* - WP -> Slsl (WP is the visible S(l)s(l) heterozygote)
*/
function normalizeToken(tok: string): string | null {
if (FLAG_TOKENS.has(tok)) return null
let t = tok
if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
t = t.replace(/-/g, '?')
return t
}
/**
* Canonical normalized genotype string (flags/metadata removed, Uw/S(l)/WP
* resolved). Exported so the import pipeline (GEN-3b) can mirror this exactly.
*/
export function normalizeGenotypeString(input: string): string {
return input
.trim()
.split(/\s+/)
.filter(Boolean)
.map(normalizeToken)
.filter((t): t is string => t !== null)
.join(' ')
}
/**
* Extract non-Punkett flags from a raw genotype string: deafness (dea/taub =
* deaf, Dea = hearing) and provenance/pattern tags (WFNZ/RV/GV/DP).
*/
export function extractGenotypeFlags(input: string): { deaf?: boolean; tags: string[] } {
const tokens = input.trim().split(/\s+/).filter(Boolean)
let deaf: boolean | undefined
const tags: string[] = []
for (const tok of tokens) {
if (tok === 'dea' || tok === 'taub') deaf = true
else if (tok === 'Dea') deaf = false
else if (tok === 'DP' || tok === 'DarkPatch' || tok === 'WFNZ' || tok === 'RV' || tok === 'GV')
tags.push(tok)
}
return { deaf, tags }
}
/**
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere [Slsl]") back into a
* Genotype. Tokens may be in any order; each must belong to a distinct locus.
* Uw/S(l)/WP are normalized and flag/metadata tokens (dea, WFNZ, …) are stripped.
* Missing loci default to wild-type. * Missing loci default to wild-type.
*/ */
export function fromDisplayString(input: string): Genotype { export function fromDisplayString(input: string): Genotype {
const tokens = input.trim().split(/\s+/).filter(Boolean) const tokens = input.trim().split(/\s+/).filter(Boolean)
const acc = {} as Record<LocusKey, AllelePair> const acc = {} as Record<LocusKey, AllelePair>
for (const token of tokens) { for (const raw of tokens) {
const token = normalizeToken(raw)
if (token === null) continue // flag/metadata token — not a locus
const [a, b] = splitToken(token) const [a, b] = splitToken(token)
const refAllele = a === WILDCARD ? b : a const refAllele = a === WILDCARD ? b : a
if (refAllele === WILDCARD) { if (refAllele === WILDCARD) {
throw new Error(`Token "${token}" is fully unknown; cannot infer its locus`) // Fully-unknown token ("--"/"??", e.g. a positional placeholder): the locus
// can't be inferred — skip it (defaults to wild-type), don't throw.
continue
} }
const locus = ALLELE_TO_LOCUS[refAllele] const locus = ALLELE_TO_LOCUS[refAllele]
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`) if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)

View File

@@ -25,6 +25,7 @@ interface LethalRule {
const LETHAL_RULES: readonly LethalRule[] = [ const LETHAL_RULES: readonly LethalRule[] = [
{ locus: 'Sp', allele: 'Sp', kind: 'lethal', warning: GeneticsWarningCode.ScheckeLethal }, { locus: 'Sp', allele: 'Sp', kind: 'lethal', warning: GeneticsWarningCode.ScheckeLethal },
{ locus: 'Sls', allele: 'Sl', kind: 'lethal', warning: GeneticsWarningCode.SlsLethal },
{ locus: 'Re', allele: 'Re', kind: 'semi', warning: GeneticsWarningCode.RexSemiLethal }, { locus: 'Re', allele: 'Re', kind: 'semi', warning: GeneticsWarningCode.RexSemiLethal },
] ]
@@ -67,14 +68,23 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
? survivors ? survivors
: survivors.map((e) => ({ value: e.value, probability: divide(e.probability, survivingMass) })) : survivors.map((e) => ({ value: e.value, probability: divide(e.probability, survivingMass) }))
if (lethalMass.num > 0) { // One lethal warning PER lethal rule that actually removed young (so SpSp ->
warnings.push({ // ScheckeLethal and S(l)S(l) -> SlsLethal are reported distinctly).
code: GeneticsWarningCode.ScheckeLethal, for (const rule of LETHAL_RULES) {
detail: { if (rule.kind !== 'lethal') continue
youngLostFraction: toString(lethalMass), const mass = dist.reduce<Fraction>(
youngLostPercent: Number(((lethalMass.num / lethalMass.den) * 100).toFixed(2)), (acc, e) => (isHomozygous(e.value, rule.locus, rule.allele) ? add(acc, e.probability) : acc),
}, ZERO,
}) )
if (mass.num > 0) {
warnings.push({
code: rule.warning,
detail: {
youngLostFraction: toString(mass),
youngLostPercent: Number(((mass.num / mass.den) * 100).toFixed(2)),
},
})
}
} }
// Semi-lethal: warn if any surviving genotype is homozygous for a semi-lethal allele. // Semi-lethal: warn if any surviving genotype is homozygous for a semi-lethal allele.
@@ -95,5 +105,22 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
} }
} }
// Superschecke: surviving young carrying BOTH spotting markers (Sp present and
// S(l) present) are very-high-white and deafness-prone — info warning.
const superMass = distribution.reduce<Fraction>((acc, e) => {
const hasSp = e.value.Sp.includes('Sp')
const hasSl = e.value.Sls.includes('Sl')
return hasSp && hasSl ? add(acc, e.probability) : acc
}, ZERO)
if (superMass.num > 0) {
warnings.push({
code: GeneticsWarningCode.SuperscheckeDeaf,
detail: {
affectedFraction: toString(superMass),
affectedPercent: Number(((superMass.num / superMass.den) * 100).toFixed(2)),
},
})
}
return { distribution, warnings } return { distribution, warnings }
} }

View File

@@ -12,8 +12,12 @@
* - de.wikibooks.org/wiki/Die_Rennmaus/_Farbvarianten_und_Farbgenetik * - de.wikibooks.org/wiki/Die_Rennmaus/_Farbvarianten_und_Farbgenetik
*/ */
/** Canonical locus keys, in conventional display order. */ /**
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re'] as const * Canonical locus keys, in conventional display order. Sls (second spotting
* locus) is appended LAST so legacy 8-locus genotype strings still parse — a
* missing Sls token defaults to wild-type sl/sl.
*/
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re', 'Sls'] as const
export type LocusKey = (typeof LOCUS_ORDER)[number] export type LocusKey = (typeof LOCUS_ORDER)[number]
export interface LocusDef { export interface LocusDef {
@@ -33,9 +37,12 @@ export interface LocusDef {
* E = full extension * E = full extension
* ef = Schimmel/roan (progressive whitening) * ef = Schimmel/roan (progressive whitening)
* e = Fox (suppresses eumelanin) * e = Fox (suppresses eumelanin)
* Sp/Re are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts): * Sp/Re/Sls are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts):
* Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero. * Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero.
* Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal. * Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal.
* Sls = second spotting locus (S(l), WP/Minimalschecke). S(l)s(l) het = the WP
* phenotype; S(l)S(l) homozygous = lethal (Rumpback/megacolon). Sp + Sls
* together => Superschecke (very high white, deafness-prone).
*/ */
export const LOCI: Readonly<Record<LocusKey, LocusDef>> = { export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
A: { key: 'A', nameDe: 'Agouti', alleles: ['A', 'a'] }, A: { key: 'A', nameDe: 'Agouti', alleles: ['A', 'a'] },
@@ -46,6 +53,7 @@ export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
P: { key: 'P', nameDe: 'Rotaugenaufhellung (Pink-Eye)', alleles: ['P', 'p'] }, P: { key: 'P', nameDe: 'Rotaugenaufhellung (Pink-Eye)', alleles: ['P', 'p'] },
Sp: { key: 'Sp', nameDe: 'Schecke', alleles: ['Sp', 'sp'] }, Sp: { key: 'Sp', nameDe: 'Schecke', alleles: ['Sp', 'sp'] },
Re: { key: 'Re', nameDe: 'Rex', alleles: ['Re', 're'] }, Re: { key: 'Re', nameDe: 'Rex', alleles: ['Re', 're'] },
Sls: { key: 'Sls', nameDe: 'Zweite Scheckung (WP)', alleles: ['Sl', 'sl'] },
} }
/** Set of all valid allele symbols, longest-first (for maximal-munch parsing). */ /** Set of all valid allele symbols, longest-first (for maximal-munch parsing). */

View File

@@ -10,6 +10,10 @@ export const GeneticsWarningCode = {
ScheckeLethal: 'SCHECKE_LETHAL', ScheckeLethal: 'SCHECKE_LETHAL',
/** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */ /** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */
RexSemiLethal: 'REX_SEMI_LETHAL', RexSemiLethal: 'REX_SEMI_LETHAL',
/** WP × WP: S(l)S(l) is prenatal-lethal (Rumpback/megacolon); fewer live young. */
SlsLethal: 'SLS_LETHAL',
/** Sp + Sls together -> Superschecke: very high white, deafness-prone (info). */
SuperscheckeDeaf: 'SUPERSCHECKE_DEAF',
} as const } as const
export type GeneticsWarningCode = export type GeneticsWarningCode =

View File

@@ -330,6 +330,24 @@ textarea {
color: #3a6ea5; color: #3a6ea5;
} }
/* BESTAND-FILTER: subtle marker for external (non-Bestand) animals. Inline next
to the name, so it should not be pushed to the grid edge like the status badge. */
.badge--external {
justify-self: start;
margin-left: 0.4rem;
vertical-align: middle;
background: #f0f0f0;
color: #777;
border: 1px dashed #bbb;
}
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
.field--check {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.gerbil-card { .gerbil-card {
grid-template-columns: 2fr 1fr 1.5fr 1fr auto; grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
@@ -741,4 +759,4 @@ textarea {
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
} }

View File

@@ -0,0 +1,245 @@
/**
* INBOX-1: Anfrage-Detail — Nachricht lesen, Triage (Status / Verwerfen /
* Abnehmer zuordnen), KI-Antwortentwurf (INBOX-2) und Versand (INBOX-3).
*
* - Triage-PUT überschreibt assignedContactId immer → bei jedem Update wird
* die aktuelle Zuordnung mitgesendet (Backend-Vertrag, siehe api/requests.ts).
* - KI entwirft NUR (human-in-the-loop): Entwurf landet editierbar im
* Textfeld; gesendet wird ausschließlich nach Bestätigung.
* - 503 AiKeyMissing / MailNotConfigured / MailAuthFailed → deutsche Hinweise
* (gleiches Muster wie die Verkaufstext-KI).
*/
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { errorCode } from '../api/client'
import {
REQUEST_STATUSES,
draftReply,
getRequest,
problemTitle,
sendReply,
triageRequest,
type InboxRequest,
type RequestStatus,
} from '../api/requests'
import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { StatusBadge } from './AnfragenPage'
import { formatDateTime } from '../format/labels'
import './anfragen.css'
export default function AnfrageDetailPage() {
const t = de.pages.anfragen
const td = t.detail
const { id = '' } = useParams()
const request = useApi(() => getRequest(id), [id])
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
// Antwort-Text: lokaler Entwurf; aus draftReply vorbefüllt, sobald geladen.
const [reply, setReply] = useState('')
const [replyInitFor, setReplyInitFor] = useState<string | null>(null)
if (request.data && replyInitFor !== request.data.id) {
setReplyInitFor(request.data.id)
setReply(request.data.draftReply ?? '')
}
const [notice, setNotice] = useState<string | null>(null)
const [hint, setHint] = useState<string | null>(null)
/** Aktuellen Server-Stand nach einer Mutation übernehmen (ohne Neu-Laden). */
const [override, setOverride] = useState<InboxRequest | null>(null)
const current = override && override.id === id ? override : request.data
const triage = useMutation((next: { status?: RequestStatus; assignedContactId: string | null }) =>
triageRequest(id, next),
)
async function applyTriage(next: { status?: RequestStatus; assignedContactId: string | null }) {
setNotice(null)
const result = await triage.run(next)
if (result.ok && current) {
setOverride({
...current,
status: next.status ?? current.status,
assignedContactId: next.assignedContactId,
})
}
}
const draft = useMutation(() => draftReply(id))
async function onDraft() {
setHint(null)
setNotice(null)
const result = await draft.run()
if (result.ok) {
setOverride(result.value)
setReply(result.value.draftReply ?? '')
return
}
const code = errorCode(result.cause)
if (code === 'AiKeyMissing') setHint(td.aiKeyMissing)
else if (code === 'AiUpstreamError') setHint(td.aiUpstreamError)
// sonst: draft.error treibt den Alert
}
const send = useMutation(() => sendReply(id, reply))
async function onSend() {
setHint(null)
setNotice(null)
if (reply.trim() === '') {
setHint(td.sendEmptyBody)
return
}
if (!window.confirm(td.sendConfirm)) return
const result = await send.run()
if (result.ok) {
setOverride(result.value)
setNotice(td.sent)
return
}
const title = problemTitle(result.cause)
if (title === 'MailNotConfigured') setHint(t.mailNotConfigured)
else if (title === 'MailAuthFailed') setHint(t.mailAuthFailed)
}
if (request.loading) return <p className="muted">{de.common.loading}</p>
if (request.error || !current) {
return (
<section className="page">
<p className="muted">{request.error ?? td.notFound}</p>
<Link to="/anfragen" className="btn">
{td.back}
</Link>
</section>
)
}
const r = current
const contactItems = contacts.data?.items ?? []
const mutationError = triage.error ?? draft.error ?? send.error
return (
<section className="page anfrage-detail">
<header className="page-head">
<div>
<h2>{r.subject ?? td.noSubject}</h2>
<StatusBadge status={r.status} />
</div>
<div className="head-actions">
<Link to="/anfragen" className="btn">
{td.back}
</Link>
</div>
</header>
<dl className="def-list">
<div className="def-row">
<dt>{td.from}</dt>
<dd>{r.fromName ? `${r.fromName} <${r.fromAddress}>` : r.fromAddress}</dd>
</div>
<div className="def-row">
<dt>{td.receivedAt}</dt>
<dd>{formatDateTime(r.receivedAt)}</dd>
</div>
{r.answeredAt && (
<div className="def-row">
<dt>{td.answeredAt}</dt>
<dd>{formatDateTime(r.answeredAt)}</dd>
</div>
)}
</dl>
<h3>{td.message}</h3>
<div className="anfrage-body">{r.bodyText?.trim() ? r.bodyText : td.noBody}</div>
<h3>{td.triage}</h3>
{mutationError && <div className="alert alert--error">{mutationError}</div>}
<div className="anfrage-triage">
<label className="field">
<span>{td.statusLabel}</span>
<select
value={r.status}
disabled={triage.pending}
onChange={(e) =>
applyTriage({
status: e.target.value as RequestStatus,
assignedContactId: r.assignedContactId,
})
}
>
{REQUEST_STATUSES.map((s) => (
<option key={s} value={s}>
{t.statusLabels[s]}
</option>
))}
</select>
</label>
<label className="field">
<span>{td.assignContact}</span>
<select
value={r.assignedContactId ?? ''}
disabled={triage.pending || contacts.loading}
onChange={(e) => {
const contactId = e.target.value || null
// Zuordnung setzt den Status auf „Zugeordnet“ (Beantwortet bleibt).
applyTriage({
status:
contactId && r.status !== 'Answered' ? 'Assigned' : undefined,
assignedContactId: contactId,
})
}}
>
<option value="">{td.assignNone}</option>
{contactItems.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
{r.status !== 'Abandoned' && (
<button
type="button"
className="btn btn--danger anfrage-abandon"
disabled={triage.pending}
onClick={() => {
if (!window.confirm(td.abandonConfirm)) return
applyTriage({ status: 'Abandoned', assignedContactId: r.assignedContactId })
}}
>
{td.abandon}
</button>
)}
</div>
<h3>{td.reply}</h3>
{notice && <div className="alert">{notice}</div>}
{hint && <div className="alert">{hint}</div>}
<div className="form anfrage-reply">
<textarea
className="anfrage-reply__text"
placeholder={td.replyPlaceholder}
value={reply}
onChange={(e) => setReply(e.target.value)}
/>
<p className="muted anfrage-reply__hint">{td.draftHint}</p>
<div className="form-actions">
<button type="button" className="btn" onClick={onDraft} disabled={draft.pending}>
{draft.pending ? td.drafting : td.draftButton}
</button>
<button
type="button"
className="btn btn--primary"
onClick={onSend}
disabled={send.pending}
>
{send.pending ? td.sending : td.send}
</button>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,150 @@
/**
* INBOX-1: Anfragen-Posteingang — Liste mit Status-Filter (Gridify) und
* manuellem Gmail-Abruf („Anfragen abrufen“, POST /api/requests/sync).
* Solange Gmail unkonfiguriert ist, antwortet der Sync mit MailNotConfigured
* → freundlicher deutscher Hinweis statt Fehler.
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import {
REQUEST_STATUSES,
listRequests,
syncRequests,
type RequestStatus,
} from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDateTime } from '../format/labels'
import './anfragen.css'
const PAGE_SIZE = 20
export function StatusBadge({ status }: { status: RequestStatus }) {
return (
<span className={`badge anfrage-badge anfrage-badge--${status.toLowerCase()}`}>
{de.pages.anfragen.statusLabels[status]}
</span>
)
}
export default function AnfragenPage() {
const t = de.pages.anfragen
const [status, setStatus] = useState<RequestStatus | ''>('')
const [page, setPage] = useState(1)
const [notice, setNotice] = useState<string | null>(null)
const requests = useApi(
() =>
listRequests({
page,
pageSize: PAGE_SIZE,
orderBy: 'receivedAt desc',
filter: status === '' ? undefined : `status==${status}`,
}),
[page, status],
)
const sync = useMutation(() => syncRequests())
async function onSync() {
setNotice(null)
const result = await sync.run()
if (!result.ok) return // sync.error treibt den Alert
if (result.value.error === 'MailNotConfigured') setNotice(t.mailNotConfigured)
else if (result.value.error === 'MailAuthFailed') setNotice(t.mailAuthFailed)
else {
setNotice(t.syncImported(result.value.imported))
if (result.value.imported > 0) requests.reload()
}
}
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
</div>
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
{sync.pending ? t.syncing : t.sync}
</button>
</div>
</header>
{notice && <div className="alert">{notice}</div>}
{sync.error && <div className="alert alert--error">{sync.error}</div>}
{/* Status-Filter */}
<div className="filters">
<label className="field">
<span>{t.detail.statusLabel}</span>
<select
value={status}
onChange={(e) => {
setStatus(e.target.value as RequestStatus | '')
setPage(1)
}}
>
<option value="">{t.filterAll}</option>
{REQUEST_STATUSES.map((s) => (
<option key={s} value={s}>
{t.statusLabels[s]}
</option>
))}
</select>
</label>
</div>
{requests.loading && <p className="muted">{de.common.loading}</p>}
{requests.error && (
<div className="alert alert--error">
<span>{requests.error}</span>
<button type="button" className="btn" onClick={requests.reload}>
{de.common.retry}
</button>
</div>
)}
{requests.data && requests.data.items.length === 0 && (
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
)}
{requests.data && requests.data.items.length > 0 && (
<ul className="card-list">
{requests.data.items.map((r) => (
<li key={r.id}>
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
<StatusBadge status={r.status} />
<span className="anfrage-card__subject">
{r.subject ?? de.pages.anfragen.detail.noSubject}
</span>
<span className="gerbil-card__meta">{formatDateTime(r.receivedAt)}</span>
</Link>
</li>
))}
</ul>
)}
{requests.data && requests.data.totalCount > PAGE_SIZE && (
<nav className="pager" aria-label="Seitennavigation">
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
{de.common.previous}
</button>
<span>
{de.common.page} {page} {de.common.of}{' '}
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
</span>
<button
type="button"
className="btn"
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
onClick={() => setPage(page + 1)}
>
{de.common.next}
</button>
</nav>
)}
</section>
)
}

View File

@@ -7,6 +7,7 @@ import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import Charakterbogen from '../components/Charakterbogen'
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto // FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
import GerbilHealthTab from '../components/GerbilHealthTab' import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab' import GerbilPhotosTab from '../components/GerbilPhotosTab'
@@ -43,6 +44,13 @@ export default function GerbilDetailPage() {
const gerbil = useApi(() => getGerbil(id), [id]) const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' })) const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
// FEAT-14: Charakterbogen (persisted on the Gerbil).
const [charTraits, setCharTraits] = useState<string[]>([])
const [charNote, setCharNote] = useState('')
const [charInit, setCharInit] = useState<string | null>(null)
const saveCharacter = useMutation(() =>
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
)
const colorVarieties = useApi(() => listColorVarieties(), []) const colorVarieties = useApi(() => listColorVarieties(), [])
const enclosures = useApi(() => listEnclosures(), []) const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), []) const contacts = useApi(() => listContacts(), [])
@@ -82,6 +90,13 @@ export default function GerbilDetailPage() {
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—') const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
// Seed the Charakterbogen once from the loaded gerbil (adjust-state-during-render).
if (charInit !== g.id) {
setCharInit(g.id)
setCharTraits(g.characterTraits ?? [])
setCharNote(g.characterNote ?? '')
}
return ( return (
<section className="page"> <section className="page">
<header className="page-head"> <header className="page-head">
@@ -89,6 +104,11 @@ export default function GerbilDetailPage() {
<GerbilProfilePhoto gerbilId={g.id} /> <GerbilProfilePhoto gerbilId={g.id} />
<h2>{g.name}</h2> <h2>{g.name}</h2>
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span> <span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{g.isResident === false && (
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
{de.pages.gerbils.externalBadge}
</span>
)}
</div> </div>
<div className="head-actions"> <div className="head-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary"> <Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
@@ -181,6 +201,28 @@ export default function GerbilDetailPage() {
<p className="muted">{t.detail.genotypeNotSet}</p> <p className="muted">{t.detail.genotypeNotSet}</p>
)} )}
<h3>{de.character.sectionTitle}</h3>
<Charakterbogen
traits={charTraits}
note={charNote}
onTraitsChange={setCharTraits}
onNoteChange={setCharNote}
/>
<div className="form-actions">
<button
type="button"
className="btn btn--primary"
disabled={saveCharacter.pending}
onClick={async () => {
const r = await saveCharacter.run()
if (r.ok) gerbil.reload()
}}
>
{saveCharacter.pending ? t.form.saving : de.character.save}
</button>
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
<h3 className="visually-hidden">{t.detail.moreData}</h3> <h3 className="visually-hidden">{t.detail.moreData}</h3>
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => ( {(['photos', 'health', 'weight'] as const).map((key) => (

View File

@@ -24,6 +24,7 @@ interface FormState {
receiverContactId: string receiverContactId: string
genotype: string genotype: string
notes: string notes: string
isResident: boolean
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
receiverContactId: '', receiverContactId: '',
genotype: '', genotype: '',
notes: '', notes: '',
isResident: true,
} }
function formFromGerbil(g: { function formFromGerbil(g: {
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
receiverContactId: string | null receiverContactId: string | null
genotype: string | null genotype: string | null
notes: string | null notes: string | null
isResident?: boolean | null
}): FormState { }): FormState {
return { return {
name: g.name, name: g.name,
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
receiverContactId: g.receiverContactId ?? '', receiverContactId: g.receiverContactId ?? '',
genotype: g.genotype ?? '', genotype: g.genotype ?? '',
notes: g.notes ?? '', notes: g.notes ?? '',
isResident: g.isResident ?? true,
} }
} }
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
receiverContactId: nn(form.receiverContactId), receiverContactId: nn(form.receiverContactId),
genotype: nn(form.genotype), genotype: nn(form.genotype),
notes: nn(form.notes), notes: nn(form.notes),
isResident: form.isResident,
} }
const result = await mutation.run(body) const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`) if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} /> <textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label> </label>
<label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span>
<input
type="checkbox"
checked={form.isResident}
onChange={(e) => set('isResident', e.target.checked)}
/>
</label>
{mutation.error && <div className="alert alert--error">{mutation.error}</div>} {mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions"> <div className="form-actions">

View File

@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { listGerbils, updateGerbil } from '../api/gerbils' import { listGerbils, listOriginBreeders, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts } from '../api/lookups' import { listColorVarieties } from '../api/lookups'
import { andFilter, condition, type GridifyQuery } from '../api/gridify' import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types' import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
@@ -11,10 +11,11 @@ import './gerbils.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
// SEARCH-2: name search field. Switches to Pam's separator-insensitive // SEARCH-2b: separator-insensitive search via Pam's normalized field. The term
// normalized field once SEARCH-1 lands (see conversation SEARCH-1); 'name' // is normalized the SAME way server-side stores it (lowercase + strip [\s._-]),
// works today via the contains hotfix. // so 'clan kleine chaoten' matches 'clan-kleine-chaoten'.
const NAME_SEARCH_FIELD = 'name' const NAME_SEARCH_FIELD = 'nameSearch'
const normalizeSearch = (s: string): string => s.toLowerCase().replace(/[\s._-]/g, '')
type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc' type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc'
const SORT_ORDER_BY: Record<SortKey, string> = { const SORT_ORDER_BY: Record<SortKey, string> = {
@@ -30,7 +31,9 @@ export default function GerbilsPage() {
const [status, setStatus] = useState<GerbilStatus | ''>('Active') const [status, setStatus] = useState<GerbilStatus | ''>('Active')
const [gender, setGender] = useState<Gender | ''>('') const [gender, setGender] = useState<Gender | ''>('')
const [colorVarietyId, setColorVarietyId] = useState('') const [colorVarietyId, setColorVarietyId] = useState('')
const [originContactId, setOriginContactId] = useState('') const [originBreeder, setOriginBreeder] = useState('')
// BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors.
const [showExternal, setShowExternal] = useState(false)
const [sort, setSort] = useState<SortKey>('nameAsc') const [sort, setSort] = useState<SortKey>('nameAsc')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -40,14 +43,18 @@ export default function GerbilsPage() {
for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name) for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name)
return map return map
}, [colorVarieties.data]) }, [colorVarieties.data])
const contacts = useApi(() => listContacts(), []) const breeders = useApi(() => listOriginBreeders(), [])
const filter = andFilter( const filter = andFilter(
search.trim() && condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: search.trim() }), search.trim() &&
condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: normalizeSearch(search) }),
status && condition({ field: 'status', op: '==', value: status }), status && condition({ field: 'status', op: '==', value: status }),
gender && condition({ field: 'gender', op: '==', value: gender }), gender && condition({ field: 'gender', op: '==', value: gender }),
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }), colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
originContactId && condition({ field: 'originContactId', op: '==', value: originContactId }), originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
// Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on,
// restrict to resident animals (server-side, so paging/counts stay correct).
!showExternal && condition({ field: 'isResident', op: '==', value: true }),
) )
const orderBy = SORT_ORDER_BY[sort] const orderBy = SORT_ORDER_BY[sort]
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE } const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
@@ -59,7 +66,8 @@ export default function GerbilsPage() {
setStatus('Active') setStatus('Active')
setGender('') setGender('')
setColorVarietyId('') setColorVarietyId('')
setOriginContactId('') setOriginBreeder('')
setShowExternal(false)
setPage(1) setPage(1)
setSort('nameAsc') setSort('nameAsc')
} }
@@ -164,13 +172,13 @@ export default function GerbilsPage() {
<label className="field"> <label className="field">
<span>{t.fields.origin}</span> <span>{t.fields.origin}</span>
<select <select
value={originContactId} value={originBreeder}
onChange={(e) => onFilterChange(setOriginContactId)(e.target.value)} onChange={(e) => onFilterChange(setOriginBreeder)(e.target.value)}
> >
<option value="">{t.filters.all}</option> <option value="">{t.filters.all}</option>
{(contacts.data ?? []).map((c) => ( {(breeders.data ?? []).map((b) => (
<option key={c.id} value={c.id}> <option key={b} value={b}>
{c.name} {b}
</option> </option>
))} ))}
</select> </select>
@@ -184,6 +192,14 @@ export default function GerbilsPage() {
<option value="birthAsc">{t.sort.birthAsc}</option> <option value="birthAsc">{t.sort.birthAsc}</option>
</select> </select>
</label> </label>
<label className="field field--check" title={t.filters.showExternalHint}>
<span>{t.filters.showExternal}</span>
<input
type="checkbox"
checked={showExternal}
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
/>
</label>
<button type="button" className="btn" onClick={resetFilters}> <button type="button" className="btn" onClick={resetFilters}>
{t.filters.reset} {t.filters.reset}
</button> </button>
@@ -232,7 +248,14 @@ export default function GerbilsPage() {
aria-label={g.name} aria-label={g.name}
/> />
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card"> <Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<span className="gerbil-card__name">{g.name}</span> <span className="gerbil-card__name">
{g.name}
{g.isResident === false && (
<span className="badge badge--external" title={t.externalTitle}>
{t.externalBadge}
</span>
)}
</span>
<span className={`badge badge--${g.status.toLowerCase()}`}> <span className={`badge badge--${g.status.toLowerCase()}`}>
{statusLabel(g.status)} {statusLabel(g.status)}
</span> </span>

View File

@@ -15,6 +15,10 @@ const st = de.pages.stammbaum
const ge = de.pages.genetik const ge = de.pages.genetik
const ab = de.pages.abgabe const ab = de.pages.abgabe
const stat = de.pages.statistik const stat = de.pages.statistik
// FEAT-13-Followup: Vertrag + Zuchtprofil
const v = de.pages.vertraege
const vw = de.pages.vertraege.wizard
const ez = de.pages.einstellungen.zuchtprofil
const scheckeWarning = de.genetics.warnings.SCHECKE_LETHAL const scheckeWarning = de.genetics.warnings.SCHECKE_LETHAL
const rexWarning = de.genetics.warnings.REX_SEMI_LETHAL const rexWarning = de.genetics.warnings.REX_SEMI_LETHAL
@@ -139,11 +143,45 @@ const sections: Section[] = [
<li> <li>
<strong>KI-Unterstützung (optional):</strong> Mit <strong>{ab.ai.improve}"</strong> kann der Text automatisch verbessert werden — dazu ist ein API-Schlüssel nötig. <strong>KI-Unterstützung (optional):</strong> Mit <strong>{ab.ai.improve}"</strong> kann der Text automatisch verbessert werden — dazu ist ein API-Schlüssel nötig.
</li> </li>
<li>
<strong>Abschließen:</strong> Sind die Tiere vergeben, klicke in der Gruppe auf <strong>„{ab.export.finish}"</strong> das öffnet den Vertrags-Assistenten mit den Tieren der Gruppe (siehe Abschnitt <em>{t.sections.vertraege}"</em>).
</li>
</ol> </ol>
<p className="hilfe-hint">Tipp: Bearbeite jede Gruppe einzeln — Text und Fotos für eine Gruppe gleichzeitig zusammenstellen, dann weiter zur nächsten.</p> <p className="hilfe-hint">Tipp: Bearbeite jede Gruppe einzeln — Text und Fotos für eine Gruppe gleichzeitig zusammenstellen, dann weiter zur nächsten.</p>
</> </>
), ),
}, },
{
id: 'vertraege',
title: t.sections.vertraege,
content: (
<>
<p>Den Vertrags-Assistenten <strong>„{vw.title}"</strong> erreichst du auf drei Wegen: über <strong>{ab.export.finish}"</strong> in einer Abgabe-Gruppe, über den gleichnamigen Knopf auf der Tierakte oder unter <strong>„{nav.contracts}"</strong> <strong>{v.newButton}"</strong>.</p>
<p>Der Assistent führt dich in vier Schritten durch die Abgabe:</p>
<ol>
<li><strong>„{vw.steps[0]}"</strong> wähle den Käufer aus deinen Kontakten oder lege ihn direkt an (Name, Adresse, Telefon, E-Mail so erscheinen sie im Vertrag).</li>
<li><strong>{vw.steps[1]}"</strong> — bestätige die Tiere; kommst du aus einer Abgabe-Gruppe oder Tierakte, sind sie schon angehakt.</li>
<li><strong>„{vw.steps[2]}"</strong> Kaufpreis (z.&nbsp;B. <code>72,00</code>) und Übergabedatum eintragen.</li>
<li><strong>{vw.steps[3]}"</strong> — Zusammenfassung prüfen und <strong>„{vw.generate}"</strong> klicken.</li>
</ol>
<p>Danach kannst du den fertigen Vertrag als <strong>Word-Datei (.docx)</strong> herunterladen er sieht aus wie deine bisherigen Verträge, mit deinen Zuchtdaten, dem Käufer und einer Tabelle pro Tier. Gleichzeitig werden die Tiere automatisch als <em>{de.pages.gerbils.statusLabels.GivenAway}"</em> markiert (Abnehmer und Abgabedatum sind dann in der Tierakte eingetragen).</p>
<p>Alle erstellten Verträge findest du unter <strong>„{nav.contracts}"</strong> dort kannst du sie jederzeit erneut <strong>{v.download}"</strong> oder löschen.</p>
<p className="hilfe-hint">Tipp: Fülle zuerst dein Zuchtprofil aus (siehe Abschnitt „{t.sections.einstellungen}") diese Angaben erscheinen als Verkäufer-Block in jedem Vertrag.</p>
</>
),
},
{
id: 'einstellungen',
title: t.sections.einstellungen,
content: (
<>
<p>Unter <strong>{nav.settings}"</strong> pflegst du dein <strong>„{ez.title}"</strong>: {ez.intro}</p>
<p>Wichtig für den Vertrag sind <strong>{ez.fields.name}"</strong>, <strong>„{ez.fields.address}"</strong> und <strong>{ez.fields.city}"</strong> (der Ort erscheint in der Unterschriftszeile). Zuchtname, Telefon, E-Mail und Homepage vervollständigen den Briefkopf.</p>
<p>Solange etwas fehlt, zeigen die Einstellungen und der Vertrags-Assistent einen Hinweis — du musst die Angaben nur einmal eintragen.</p>
<p>Außerdem findest du hier den <strong>„{de.pages.datenexport.title}"</strong>, mit dem du alle Daten als Zip herunterladen kannst.</p>
</>
),
},
{ {
id: 'statistik', id: 'statistik',
title: t.sections.statistik, title: t.sections.statistik,

View File

@@ -0,0 +1,439 @@
/**
* WEB-0b: Seiten-Editor der öffentlichen Webseite.
* - Kopf: Titel, SEO-Beschreibung, Status (Entwurf/Veröffentlicht).
* - Geordneter Block-Editor: Blöcke hinzufügen, bearbeiten, verschieben, löschen.
*
* Reihenfolge/Anlegen/Löschen rufen das Backend auf und laden danach neu; der
* Editor wird über `key` neu montiert, sodass der lokale Zustand frisch aus den
* geladenen Daten initialisiert wird (keine Prop→State-Synchronisation per Effekt).
*/
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import {
ADDABLE_BLOCK_TYPES,
addBlock,
defaultBlockData,
deleteBlock,
getPage,
reorderBlocks,
updateBlock,
updatePage,
type Block,
type BlockData,
type BlockType,
type GalleryImage,
type Page,
type PageStatus,
} from '../api/pages'
import { useApi, useMutation } from '../hooks/useApi'
import './webseite.css'
const asStr = (v: unknown): string => (typeof v === 'string' ? v : '')
export default function WebseiteEditorPage() {
const { slug = '' } = useParams<{ slug: string }>()
const page = useApi(() => getPage(slug), [slug])
if (page.loading) return <p className="muted">{de.common.loading}</p>
if (page.error || !page.data) {
return (
<section className="page">
<h2>{de.pages.webseite.title}</h2>
<div className="alert alert--error">
<span>{page.error}</span>
<button type="button" className="btn" onClick={page.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
// Signatur der Blöcke -> erzwingt Neu-Montage nach Reload (frische Init).
const signature = page.data.blocks.map((b) => `${b.id}:${b.order}`).join('|')
return <PageEditor key={signature} page={page.data} reload={page.reload} />
}
function PageEditor({ page, reload }: { page: Page; reload: () => void }) {
const t = de.pages.webseite
const [title, setTitle] = useState(page.title)
const [seo, setSeo] = useState(page.seoDescription ?? '')
const [status, setStatus] = useState<PageStatus>(page.status)
const [savedHead, setSavedHead] = useState(false)
const saveHead = useMutation(() =>
updatePage(page.id, {
slug: page.slug,
title,
seoDescription: seo.trim() === '' ? null : seo,
status,
}),
)
async function onSaveHead() {
const r = await saveHead.run()
if (r.ok) {
setSavedHead(true)
reload()
}
}
const blocks = [...page.blocks].sort((a, b) => a.order - b.order)
const adder = useMutation((type: BlockType) =>
addBlock(page.id, { type, data: defaultBlockData(type) }),
)
async function onAdd(type: BlockType) {
const r = await adder.run(type)
if (r.ok) reload()
}
const remover = useMutation((id: string) => deleteBlock(id))
async function onDelete(id: string) {
if (!window.confirm(t.blocks.confirmDelete)) return
const r = await remover.run(id)
if (r.ok) reload()
}
const reorder = useMutation((ids: string[]) => reorderBlocks(page.id, ids))
async function onMove(index: number, dir: -1 | 1) {
const target = index + dir
if (target < 0 || target >= blocks.length) return
const ids = blocks.map((b) => b.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
const r = await reorder.run(ids)
if (r.ok) reload()
}
const busy = adder.pending || remover.pending || reorder.pending
return (
<section className="page">
<header className="page-head">
<div>
<h2>{page.title}</h2>
<p className="muted">/{page.slug}</p>
</div>
<div className="head-actions">
<Link to="/webseite" className="btn">
{t.back}
</Link>
</div>
</header>
{/* Seiten-Kopf bearbeiten */}
<div className="webseite-section">
<label className="field">
<span className="field-label">{t.editor.pageTitleLabel}</span>
<input
className="input"
value={title}
onChange={(e) => {
setTitle(e.target.value)
setSavedHead(false)
}}
/>
</label>
<label className="field">
<span className="field-label">{t.editor.seoLabel}</span>
<textarea
className="input"
rows={2}
value={seo}
onChange={(e) => {
setSeo(e.target.value)
setSavedHead(false)
}}
/>
<span className="field-hint">{t.editor.seoHint}</span>
</label>
<label className="field">
<span className="field-label">{t.statusLabel}</span>
<select
className="input"
value={status}
onChange={(e) => {
setStatus(e.target.value as PageStatus)
setSavedHead(false)
}}
>
<option value="Draft">{t.statusDraft}</option>
<option value="Published">{t.statusPublished}</option>
</select>
</label>
{saveHead.error && <div className="alert alert--error">{saveHead.error}</div>}
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSaveHead} disabled={saveHead.pending}>
{t.editor.save}
</button>
{savedHead && <span className="saved-hint">{t.editor.saved}</span>}
</div>
</div>
{/* Block-Editor */}
<h3 className="webseite-blocks-title">{t.blocks.sectionTitle}</h3>
{(reorder.error || remover.error || adder.error) && (
<div className="alert alert--error">{reorder.error ?? remover.error ?? adder.error}</div>
)}
{blocks.length === 0 ? (
<p className="muted">{t.blocks.empty}</p>
) : (
<ol className="block-list">
{blocks.map((b, i) => (
<li key={b.id}>
<BlockCard
block={b}
index={i}
count={blocks.length}
busy={busy}
onMove={onMove}
onDelete={onDelete}
/>
</li>
))}
</ol>
)}
<div className="block-add" role="group" aria-label={t.blocks.addLabel}>
<span className="field-label">{t.blocks.addLabel}</span>
{ADDABLE_BLOCK_TYPES.map((type) => (
<button
key={type}
type="button"
className="btn"
disabled={adder.pending}
onClick={() => onAdd(type)}
>
+ {t.blocks.types[type]}
</button>
))}
</div>
</section>
)
}
function BlockCard({
block,
index,
count,
busy,
onMove,
onDelete,
}: {
block: Block
index: number
count: number
busy: boolean
onMove: (index: number, dir: -1 | 1) => void
onDelete: (id: string) => void
}) {
const t = de.pages.webseite.blocks
const [data, setData] = useState<BlockData>(block.data)
const [saved, setSaved] = useState(false)
const save = useMutation(() => updateBlock(block.id, { type: block.type, data }))
async function onSave() {
const r = await save.run()
if (r.ok) setSaved(true)
}
const set = (patch: BlockData) => {
setData((d) => ({ ...d, ...patch }))
setSaved(false)
}
return (
<div className="block-card">
<div className="block-card__head">
<span className="block-card__type">{t.types[block.type]}</span>
<span className="block-card__tools">
<button
type="button"
className="btn btn--icon"
aria-label={t.moveUp}
disabled={busy || index === 0}
onClick={() => onMove(index, -1)}
>
</button>
<button
type="button"
className="btn btn--icon"
aria-label={t.moveDown}
disabled={busy || index === count - 1}
onClick={() => onMove(index, 1)}
>
</button>
<button
type="button"
className="btn btn--danger"
disabled={busy}
onClick={() => onDelete(block.id)}
>
{t.delete}
</button>
</span>
</div>
<BlockFields type={block.type} data={data} set={set} />
{save.error && <div className="alert alert--error">{save.error}</div>}
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSave} disabled={save.pending}>
{t.save}
</button>
{saved && <span className="saved-hint">{t.saved}</span>}
</div>
</div>
)
}
function BlockFields({
type,
data,
set,
}: {
type: BlockType
data: BlockData
set: (patch: BlockData) => void
}) {
const f = de.pages.webseite.blocks.fields
switch (type) {
case 'Heading':
return (
<>
<label className="field">
<span className="field-label">{f.headingText}</span>
<input className="input" value={asStr(data.text)} onChange={(e) => set({ text: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.headingLevel}</span>
<select
className="input"
value={data.level === 3 ? '3' : '2'}
onChange={(e) => set({ level: Number(e.target.value) })}
>
<option value="2">{f.levelMain}</option>
<option value="3">{f.levelSub}</option>
</select>
</label>
</>
)
case 'RichText':
return (
<label className="field">
<span className="field-label">{f.markdown}</span>
<textarea
className="input block-markdown"
rows={6}
value={asStr(data.markdown)}
onChange={(e) => set({ markdown: e.target.value })}
/>
<span className="field-hint">{f.markdownHint}</span>
</label>
)
case 'Image':
return (
<>
<label className="field">
<span className="field-label">{f.imageUrl}</span>
<input className="input" value={asStr(data.url)} onChange={(e) => set({ url: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.imageAlt}</span>
<input className="input" value={asStr(data.alt)} onChange={(e) => set({ alt: e.target.value })} />
</label>
</>
)
case 'Gallery':
return <GalleryFields data={data} set={set} />
case 'ContactInfo':
return (
<>
<label className="field">
<span className="field-label">{f.contactName}</span>
<input className="input" value={asStr(data.name)} onChange={(e) => set({ name: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.contactEmail}</span>
<input className="input" value={asStr(data.email)} onChange={(e) => set({ email: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.contactPhone}</span>
<input className="input" value={asStr(data.phone)} onChange={(e) => set({ phone: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.contactAddress}</span>
<textarea
className="input"
rows={2}
value={asStr(data.address)}
onChange={(e) => set({ address: e.target.value })}
/>
</label>
</>
)
case 'AbgabetiereList':
return (
<>
<p className="field-hint">{f.abgabeAutoNote}</p>
<label className="field">
<span className="field-label">{f.abgabeIntro}</span>
<textarea
className="input"
rows={3}
value={asStr(data.intro)}
onChange={(e) => set({ intro: e.target.value })}
/>
</label>
</>
)
}
}
function GalleryFields({ data, set }: { data: BlockData; set: (patch: BlockData) => void }) {
const f = de.pages.webseite.blocks.fields
const images: GalleryImage[] = Array.isArray(data.images) ? (data.images as GalleryImage[]) : []
const update = (i: number, patch: Partial<GalleryImage>) =>
set({ images: images.map((img, j) => (j === i ? { ...img, ...patch } : img)) })
return (
<div className="field">
<span className="field-label">{f.galleryImages}</span>
{images.map((img, i) => (
<div key={i} className="gallery-row">
<input
className="input"
placeholder={f.imageUrl}
value={asStr(img.url)}
onChange={(e) => update(i, { url: e.target.value })}
/>
<input
className="input"
placeholder={f.imageAlt}
value={asStr(img.alt)}
onChange={(e) => update(i, { alt: e.target.value })}
/>
<button
type="button"
className="btn btn--danger"
onClick={() => set({ images: images.filter((_, j) => j !== i) })}
>
{f.galleryRemove}
</button>
</div>
))}
<button type="button" className="btn" onClick={() => set({ images: [...images, { url: '', alt: '' }] })}>
+ {f.galleryAdd}
</button>
</div>
)
}

View File

@@ -0,0 +1,66 @@
/** WEB-0b: Übersicht der Webseiten-Seiten mit Status (Entwurf/Veröffentlicht). */
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listPages, type PageStatus } from '../api/pages'
import { useApi } from '../hooks/useApi'
import './webseite.css'
function StatusBadge({ status }: { status: PageStatus }) {
const t = de.pages.webseite
const published = status === 'Published'
return (
<span className={published ? 'status-badge status-badge--published' : 'status-badge status-badge--draft'}>
{published ? t.statusPublished : t.statusDraft}
</span>
)
}
export default function WebseitePage() {
const t = de.pages.webseite
const pages = useApi(() => listPages(), [])
if (pages.loading) return <p className="muted">{de.common.loading}</p>
if (pages.error || !pages.data) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{pages.error}</span>
<button type="button" className="btn" onClick={pages.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">{t.intro}</p>
</div>
</header>
{pages.data.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<ul className="card-list">
{pages.data.map((p) => (
<li key={p.id} className="gerbil-card webseite-card">
<span className="gerbil-card__name">{p.title}</span>
<span className="gerbil-card__meta">/{p.slug}</span>
<span className="webseite-card__actions">
<StatusBadge status={p.status} />
<Link to={`/webseite/${p.slug}`} className="btn btn--primary">
{t.edit}
</Link>
</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -0,0 +1,84 @@
/* INBOX-1: Anfragen-Posteingang — seitenspezifische Stile
(Standing-Rule-2-Muster: eigene Datei statt index.css). */
/* ── Listen-Karte ── */
.anfrage-card {
grid-template-columns: 1fr auto;
}
.anfrage-card__subject {
grid-column: 1 / -1;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Status-Badges (eigene Farben je Status) ── */
.anfrage-badge--new {
background: var(--color-accent);
color: #fff;
}
.anfrage-badge--inprogress {
background: #e7eef6;
color: #3a6ea5;
}
.anfrage-badge--assigned {
background: #efe6f6;
color: #7a4ea5;
}
.anfrage-badge--answered {
background: #e6f2e6;
color: #3a7a3a;
}
.anfrage-badge--abandoned {
background: #ececec;
color: #666;
}
/* ── Detail ── */
.anfrage-body {
white-space: pre-wrap;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 0.9rem 1rem;
margin: 0.5rem 0 1.25rem;
overflow-wrap: anywhere;
}
.anfrage-triage {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin: 0.5rem 0 1.25rem;
}
.anfrage-triage .field {
min-width: 12rem;
}
.anfrage-abandon {
margin-left: auto;
}
.anfrage-reply {
max-width: 40rem;
}
.anfrage-reply__text {
min-height: 10rem;
}
.anfrage-reply__hint {
margin: 0;
font-size: 0.8rem;
}

View File

@@ -0,0 +1,123 @@
/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite. */
/* ── Seitenliste ── */
.webseite-card__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.status-badge {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
border: 1px solid var(--color-border);
}
.status-badge--draft {
background: var(--color-bg);
color: var(--color-text-muted);
}
.status-badge--published {
background: var(--color-accent-soft);
color: var(--color-accent);
border-color: var(--color-accent);
}
/* ── Editor: Kopf + Blöcke ── */
.webseite-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
margin-bottom: 1.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
}
.field-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.saved-hint {
align-self: center;
font-size: 0.85rem;
color: var(--color-accent);
}
.webseite-blocks-title {
margin: 0 0 0.5rem;
}
.block-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.block-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
}
.block-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.block-card__type {
font-weight: 600;
}
.block-card__tools {
display: flex;
gap: 0.35rem;
}
.btn--icon {
min-width: 2.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
font-size: 1rem;
line-height: 1;
}
.block-markdown {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.gallery-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.gallery-row .input {
flex: 1 1 12rem;
}
.block-add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}

View File

@@ -26,11 +26,15 @@ export const de = {
// FEAT-13 (Kelly): Verträge + Einstellungen // FEAT-13 (Kelly): Verträge + Einstellungen
contracts: 'Verträge', contracts: 'Verträge',
settings: 'Einstellungen', settings: 'Einstellungen',
// INBOX-1 (Kelly): Anfragen-Posteingang
requests: 'Anfragen',
openMenu: 'Menü öffnen', openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen', closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation', mainNavigation: 'Hauptnavigation',
// HELP-1 // HELP-1
help: 'Hilfe', help: 'Hilfe',
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
website: 'Webseite',
}, },
pages: { pages: {
home: { home: {
@@ -45,6 +49,9 @@ export const de = {
empty: 'Keine Rennmäuse gefunden.', empty: 'Keine Rennmäuse gefunden.',
countLabel: 'Tiere', countLabel: 'Tiere',
searchPlaceholder: 'Name suchen …', searchPlaceholder: 'Name suchen …',
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
externalBadge: 'Extern',
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
// Filter-Beschriftungen // Filter-Beschriftungen
filters: { filters: {
title: 'Filter', title: 'Filter',
@@ -54,6 +61,9 @@ export const de = {
all: 'Alle', all: 'Alle',
reset: 'Filter zurücksetzen', reset: 'Filter zurücksetzen',
sortBy: 'Sortieren nach', sortBy: 'Sortieren nach',
// BESTAND-FILTER: eigener Bestand (Clan) vs. externe Ahnen (nur Stammbaum)
showExternal: 'Externe Ahnen einblenden',
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
}, },
// Sortier-Optionen // Sortier-Optionen
sort: { sort: {
@@ -105,7 +115,10 @@ export const de = {
editTitle: 'Rennmaus bearbeiten', editTitle: 'Rennmaus bearbeiten',
none: '— keine Angabe —', none: '— keine Angabe —',
genotypeHint: genotypeHint:
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.', 'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',
@@ -217,7 +230,7 @@ export const de = {
pickAnimalNoGenotype: 'Für dieses Tier ist kein Genotyp hinterlegt bitte unten eingeben.', pickAnimalNoGenotype: 'Für dieses Tier ist kein Genotyp hinterlegt bitte unten eingeben.',
clearAnimal: 'Auswahl entfernen', clearAnimal: 'Auswahl entfernen',
genotypeLabel: 'Genotyp', genotypeLabel: 'Genotyp',
genotypeHint: 'Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.', genotypeHint: 'Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
genotypeInvalid: 'Der Genotyp ist ungültig.', genotypeInvalid: 'Der Genotyp ist ungültig.',
genotypePreview: 'Farbschlag', genotypePreview: 'Farbschlag',
run: 'Probeverpaarung berechnen', run: 'Probeverpaarung berechnen',
@@ -455,6 +468,61 @@ export const de = {
saved: 'Gespeichert.', saved: 'Gespeichert.',
}, },
}, },
// ── INBOX-1 (Kelly): Anfragen-Posteingang ──
anfragen: {
title: 'Anfragen',
empty: 'Keine Anfragen — hole neue E-Mails mit „Anfragen abrufen“.',
emptyFiltered: 'Keine Anfragen mit diesem Status.',
countText: (n: number) => (n === 1 ? '1 Anfrage' : `${n} Anfragen`),
sync: 'Anfragen abrufen',
syncing: 'Abrufen …',
syncImported: (n: number) =>
n === 0 ? 'Keine neuen Anfragen.' : n === 1 ? '1 neue Anfrage abgerufen.' : `${n} neue Anfragen abgerufen.`,
mailNotConfigured:
'Gmail ist noch nicht eingerichtet — Adresse und App-Passwort folgen in den Einstellungen.',
mailAuthFailed:
'Gmail-Anmeldung fehlgeschlagen — bitte das App-Passwort neu eintragen.',
filterAll: 'Alle',
statusLabels: {
New: 'Neu',
InProgress: 'In Bearbeitung',
Assigned: 'Zugeordnet',
Answered: 'Beantwortet',
Abandoned: 'Verworfen',
},
// Detailansicht
detail: {
back: 'Zurück zur Liste',
notFound: 'Diese Anfrage wurde nicht gefunden.',
from: 'Von',
receivedAt: 'Eingegangen',
answeredAt: 'Beantwortet am',
message: 'Nachricht',
noBody: '(kein Text)',
noSubject: '(kein Betreff)',
// Triage
triage: 'Bearbeitung',
statusLabel: 'Status',
abandon: 'Verwerfen',
abandonConfirm: 'Anfrage wirklich verwerfen?',
assignContact: 'Abnehmer zuordnen',
assignNone: '— kein Abnehmer —',
assignedTo: 'Zugeordneter Abnehmer',
// Antwort
reply: 'Antwort',
replyPlaceholder: 'Antwort schreiben oder mit KI entwerfen …',
draftButton: 'Antwort entwerfen',
drafting: 'Entwurf wird erstellt …',
draftHint: 'Die KI erstellt nur einen Entwurf — gesendet wird erst nach deiner Bestätigung.',
aiKeyMissing: 'KI-Schlüssel fehlt — der Entwurfs-Assistent wird mit dem Schlüssel aktiviert.',
aiUpstreamError: 'Der KI-Dienst hat gerade ein Problem — bitte später erneut versuchen.',
send: 'Antwort senden',
sending: 'Senden …',
sendConfirm: 'Antwort jetzt per E-Mail senden?',
sendEmptyBody: 'Bitte zuerst eine Antwort schreiben.',
sent: 'Antwort gesendet — die Anfrage ist als „Beantwortet“ markiert.',
},
},
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ── // ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
kontakte: { kontakte: {
title: 'Kontakte', title: 'Kontakte',
@@ -576,6 +644,69 @@ export const de = {
photoNote: photoNote:
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.', 'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
}, },
// ── WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite ──
webseite: {
title: 'Webseite',
intro:
'Hier pflegst du die Inhalte deiner öffentlichen Webseite. Änderungen werden erst beim Veröffentlichen online sichtbar.',
empty: 'Es sind noch keine Seiten angelegt.',
statusLabel: 'Status',
statusDraft: 'Entwurf',
statusPublished: 'Veröffentlicht',
edit: 'Bearbeiten',
back: 'Zurück zur Übersicht',
// Seiten-Editor (Kopf)
editor: {
pageTitleLabel: 'Seitentitel',
seoLabel: 'Beschreibung für Suchmaschinen (SEO)',
seoHint: 'Kurzer Text (ca. 12 Sätze), der bei Google unter dem Titel erscheint.',
save: 'Seite speichern',
saved: 'Seite gespeichert.',
},
// Block-Editor
blocks: {
sectionTitle: 'Inhaltsblöcke',
empty: 'Diese Seite hat noch keine Inhaltsblöcke. Füge unten den ersten hinzu.',
addLabel: 'Block hinzufügen:',
moveUp: 'Nach oben',
moveDown: 'Nach unten',
delete: 'Block löschen',
confirmDelete: 'Diesen Block wirklich löschen?',
save: 'Block speichern',
saved: 'Gespeichert.',
// Block-Typ-Namen
types: {
Heading: 'Überschrift',
RichText: 'Textabschnitt',
Image: 'Bild',
Gallery: 'Bildergalerie',
ContactInfo: 'Kontaktangaben',
AbgabetiereList: 'Abgabetiere (automatisch)',
},
// Feld-Beschriftungen je Block-Typ
fields: {
headingText: 'Überschrift-Text',
headingLevel: 'Größe',
levelMain: 'Hauptüberschrift',
levelSub: 'Unterüberschrift',
markdown: 'Text',
markdownHint:
'Einfache Formatierung möglich: **fett**, *kursiv*, Listen mit „- “, Links als [Text](https://…).',
imageUrl: 'Bild-Adresse (URL)',
imageAlt: 'Bildbeschreibung (für Suchmaschinen & Barrierefreiheit)',
galleryImages: 'Bilder',
galleryAdd: 'Bild hinzufügen',
galleryRemove: 'Entfernen',
contactName: 'Name',
contactEmail: 'E-Mail',
contactPhone: 'Telefon',
contactAddress: 'Adresse',
abgabeIntro: 'Einleitungstext',
abgabeAutoNote:
'Die Liste der Abgabetiere wird beim Veröffentlichen automatisch aus deinen aktuellen Abgabetieren erzeugt. Du kannst nur den Einleitungstext bearbeiten.',
},
},
},
}, },
// ── HELP-1: In-App-Anleitung ── // ── HELP-1: In-App-Anleitung ──
hilfe: { hilfe: {
@@ -589,6 +720,9 @@ export const de = {
genetik: 'Probeverpaarung & Genetik', genetik: 'Probeverpaarung & Genetik',
stammbaum: 'Stammbaum', stammbaum: 'Stammbaum',
abgabe: 'Abgabe-Ablauf', abgabe: 'Abgabe-Ablauf',
// FEAT-13-Followup (Kelly): Vertrag + Zuchtprofil
vertraege: 'Abgabevertrag erstellen',
einstellungen: 'Zuchtprofil (Einstellungen)',
statistik: 'Statistik', statistik: 'Statistik',
datensicherung: 'Datensicherung', datensicherung: 'Datensicherung',
}, },
@@ -622,9 +756,41 @@ export const de = {
'Schecke × Schecke: Reinerbige Tiere (SpSp) sterben bereits im Mutterleib — etwa ein Viertel weniger Jungtiere.', 'Schecke × Schecke: Reinerbige Tiere (SpSp) sterben bereits im Mutterleib — etwa ein Viertel weniger Jungtiere.',
REX_SEMI_LETHAL: REX_SEMI_LETHAL:
'Rex × Rex: Reinerbige Tiere (ReRe) sind nur eingeschränkt lebensfähig.', 'Rex × Rex: Reinerbige Tiere (ReRe) sind nur eingeschränkt lebensfähig.',
SLS_LETHAL:
'WP × WP: Reinerbige Tiere (S(l)S(l)) sterben bereits im Mutterleib (Rumpback) — weniger Jungtiere.',
SUPERSCHECKE_DEAF:
'Schecke × WP: Superschecken (sehr hoher Weißanteil) sind möglich — erhöhtes Taubheitsrisiko.',
}, },
unknownFarbschlag: 'Unbekannter Farbschlag', unknownFarbschlag: 'Unbekannter Farbschlag',
}, },
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
character: {
sectionTitle: 'Charakter & Eigenschaften',
noteLabel: 'Notizen zum Charakter',
notePlaceholder: 'Ein paar Sätze zum Charakter dieses Tieres …',
save: 'Charakter speichern',
saved: 'Charakter gespeichert.',
none: 'Noch keine Eigenschaften ausgewählt.',
traits: [
{ key: 'zutraulich', label: 'zutraulich' },
{ key: 'handzahm', label: 'handzahm' },
{ key: 'neugierig', label: 'neugierig' },
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
{ key: 'verschmust', label: 'verschmust' },
{ key: 'eigenstaendig', label: 'eigenständig' },
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
{ key: 'futterfreudig', label: 'futterfreudig' },
{ key: 'buddelt', label: 'buddelt gern' },
{ key: 'klettert', label: 'klettert gern' },
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
{ key: 'vertraeglich', label: 'gut verträglich' },
{ key: 'schreckhaft', label: 'schreckhaft' },
],
},
} as const } as const
export type Strings = typeof de export type Strings = typeof de

View File

@@ -0,0 +1,89 @@
{
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
"resolutions": [
{
"name": "Firefly von den Kleinen Chaoten",
"dob": "18.12.2019",
"decision": "D-locus = D- (the DD in one source was a typo)",
"genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "WildFire von den Kleinen Chaoten",
"dob": "05.10.2017",
"decision": "P-locus = PP (not P-)",
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Flint von den Kleinen Chaoten",
"dob": "23.12.2017",
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
"dateOfDeath": "10.05.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Molly of Black Forest",
"dob": "13.09.2021",
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
"dateOfDeath": "03.05.2022",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Daja of Little Rose",
"dob": "16.05.2021",
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Ichika von den Kleinen Chaoten",
"dob": "19.04.2020",
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
"genotype": "aa CC D- ee[f] Gg pp spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Zuleika von den Kleinen Chaoten",
"dob": "24.10.2015",
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Milka of LennyLengo",
"dob": "09.12.2018",
"decision": "C-locus = Cc[h], E-locus = EE",
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Silvain von den Kleinen Chaoten",
"dob": "27.03.2022",
"decision": "E-locus = ee, P-locus = Pp",
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Chelsea von den Kleinen Chaoten",
"dob": "15.10.2021",
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
"correctDob": "02.04.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
},
{
"name": "Vestra von den Schlossmäusen",
"dob": "08.02.2019",
"decision": "D-locus = DD",
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Victoria Welby gen. Welby von den Kleinen Chaoten",
"dob": "16.01.2023",
"decision": "E-locus = ee[f] (Fuchs). NOTE: this is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent.",
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
}
]
}

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