Compare commits

..

144 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
45b213e11d Merge feature/search-2: Herkunft filter dropdown on Tiere list + live-search UX confirmed [god-QA: 55/47/e2e 60] 2026-06-06 09:00:21 +02:00
38b9717ad2 SEARCH-2: Herkunft (originContactId) filter dropdown on Tiere list + e2e smoke; name-search field behind NAME_SEARCH_FIELD for SEARCH-1 normalized swap 2026-06-06 08:58:34 +02:00
0ee2b53b24 Hotfix: gerbil search contains operator — name=*a not name=*a* + mock mirrors real Gridify [god-QA: 47 unit + 58 e2e; live: name=*a -> 203 hits] 2026-06-06 08:52:08 +02:00
459c66bc3e Hotfix: gerbil search contains operator — drop trailing '*' (Gridify =* is contains; name=*a not name=*a*) + e2e mock mirrors real Gridify 2026-06-06 08:52:02 +02:00
0cee3e2710 Merge feature/polish-1: canonical Gridify '=', InvalidParentGender German message, Abgabe-abschliessen->wizard wiring, de.ts sweep, e2e mock accepts both operators [god-QA: 55/47/58] 2026-06-06 08:18:08 +02:00
385cccb352 POLISH-1: e2e mock accepts canonical '=' (and legacy '==') equals operator [QA-1 coordination] 2026-06-06 08:16:57 +02:00
e73b561b77 POLISH-1: move stray UI strings to de.ts (pageNav/moreData), gitignore Playwright artefacts, wire 'Abgabe abschliessen' -> /vertraege/neu?tiere= (FEAT-13b) 2026-06-06 08:14:52 +02:00
55a72bfe12 POLISH-1: gridify emits canonical '='; localize litter InvalidParentGender 400 (ApiError body) + de strings 2026-06-06 08:14:52 +02:00
e87d0aafea Merge feature/export-1: Datenexport (zip JSON+German CSVs) on /einstellungen + CI flake-cap [god-QA pending result-gate]
# Conflicts:
#	GerbilManager.Tests/GerbilManager.Tests.csproj
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/src/App.tsx
#	gerbil-manager-web/src/components/AppShell.tsx
#	gerbil-manager-web/src/pages/EinstellungenPage.tsx
2026-06-06 08:06:38 +02:00
db53fad5a4 Merge feature/feat-13: Abgabevertrag phase B — SaleContract+wizard, /einstellungen Zuchtprofil, contract endpoints w/ transactional Abgabe, Kontakt structured fields [god-QA: 40/40+47/47+e2e 56/56]
# Conflicts:
#	GerbilManager.Tests/GerbilManager.Tests.csproj
#	GerbilManagerWebAPI/Program.cs
2026-06-06 08:00:55 +02:00
d3f38b9be4 FEAT-13: resolve leftover rebase conflict markers in App.tsx routes (hilfe + vertraege/einstellungen union)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:58:01 +02:00
f08dbd2219 QA-1: CI-Flake-Schutz - Worker-Cap 4 unter CI (geteilter Vite-Dev-Server, Dwights Beobachtung) 2026-06-06 07:57:27 +02:00
cd795d3831 Merge feature/feat-8c: Excel import loader — /import/dry-run + gated /import/execute, quarantine policy, provenance + idempotency [god-QA: 43/43] 2026-06-06 07:57:21 +02:00
be62e785db FEAT-13: e2e an strukturierte Kontaktfelder angepasst + 2 Abdeckungstests (Patch von Oscar, e2e-Owner)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:56:48 +02:00
d8ccb451d9 FEAT-13: singular-aware contract count text (1 Vertrag / n Vertraege)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:56:48 +02:00
9c3bcfc9f9 FEAT-13: Abgabe wizard (/vertraege/neu, 4 steps, ?tiere= prefill), Vertraege list w/ download+delete, /einstellungen Zuchtprofil form, contracts/settings API clients, nav entries, detail-page entry point + replace orphaned contactInfo with email/phone/address across Kontakt pages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:56:48 +02:00
0c05a5acf1 FEAT-13: SaleContract + BreederSettings entities (additive migration, join table justified in code docs), /contracts + /settings/breeder-profile Minimal-API endpoints w/ Abgabe-completion transaction + SQLite-backed endpoint round-trip tests (21/21)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:55:34 +02:00
b7ee48401a FEAT-8c: loader tests (in-memory DB + fixture) + public test helpers
6 tests: dry-run categorisation (no writes), execute loads conflict-free + links
high-confidence + quarantines conflict/stub, idempotency, ComposeGenotype (caret strip
+ ?? fill), ParseDate. Added EF InMemory to the test project.
2026-06-06 07:55:30 +02:00
ac5ab9d0ba FEAT-8c: import loader — /import/dry-run + /import/execute (gated)
- Additive migration: Gerbil.RawImportData + Litter.PairingCode (Zuchtnummer der Verpaarung).
- ImportService consumes tools/import/output (animals.json + litters.json): litters first
  (authoritative), then conflict-free animals with a DOB; high-confidence (hoch) litter links
  set LitterId, date-only/ambiguous links quarantined; conflicts + stubs never loaded.
  Genotype mapped8locus -> frozen compact string; rawGenotype+unmappedTokens preserved in
  RawImportData; Farbschlag matched to ColorVariety; gender inferred from litter role;
  provenance ImportSource/ExternalRef; idempotent (ExternalRef / Name+Date). Photos copied to store.
- ImportEndpoints: POST /import/dry-run (no writes, Julian-readable report) + /import/execute (gated).
2026-06-06 07:55:30 +02:00
a1549266c3 EXPORT-1: e2e - Download-Smoke (/einstellungen) + Mock: /export-Zip, /settings/breeder-profile, URL-Praedikat fuer /api-relative Basis (OPS-1-proof) 2026-06-06 07:55:14 +02:00
8fa02a5d49 EXPORT-1: Datenexport-Karte auf /einstellungen (Anker-Download, deutsche Erklaertexte + Foto-Hinweis) 2026-06-06 07:55:14 +02:00
e431c50347 FEAT-8c: mark conflict animals in animals.json for the API loader
Adds a machine-readable conflict:bool to each merged animal (true when files
disagree on genotype/farbschlag/death) so the import loader can quarantine the
32 conflicts without parsing the German review report.
2026-06-06 07:55:08 +02:00
f86a044143 EXPORT-1: GET /export - Zip mit export.json (volle Treue) + deutschen Excel-CSVs (Semikolon, BOM, TT.MM.JJJJ) + LIESMICH; 9 Tests (Escaping + API-Round-Trip) 2026-06-06 07:50:42 +02:00
ce1e578bf2 Merge feature/help-1: in-app German user guide /hilfe (9 accordion sections, du-Form, rename-safe labels) [god-QA: 37/37+47/47+e2e 54/54] 2026-06-06 07:49:47 +02:00
98060b890b HELP-1: in-app German user guide (/hilfe, accordion, 9 sections)
HilfePage.tsx: accordion via <details>/<summary> (native, accessible, mobile-first);
9 sections in du-Form: Erste Schritte, Rennmaeuse, Becken, Wuerfe, Genetik,
Stammbaum, Abgabe-Ablauf, Statistik, Datensicherung. All UI labels imported from
de.ts (rename-safe); SCHECKE/Rex warnings verbatim from genetics.warnings.

de.ts: de.nav.help + de.hilfe (title, subtitle, 9 section keys)
AppShell.tsx: Hilfe (heart question mark) added to SECONDARY nav (Mehr-sheet on phone,
sidebar on desktop)
App.tsx: Route path=hilfe -> <HilfePage />
hilfe.css: accordion, hint boxes, warning colour; piggybacks on existing CSS vars

e2e/hilfe.spec.ts: 3 smoke tests x 2 viewports = 6 new tests
  -- Hilfe reachable via nav
  -- First accordion section expands on click
  -- SCHECKE warning visible in Genetik section

Full evidence: dotnet test 18/18, npm test 47/47, npm run build ok,
npm run e2e 54/54 (48 existing + 6 new hilfe tests).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:47:45 +02:00
78da534c7c FEAT-13: e2e an strukturierte Kontaktfelder angepasst + 2 Abdeckungstests (drops on rebase once Kelly commits the same patch) 2026-06-06 07:46:51 +02:00
42718606a9 Merge feature/ops-1: TrueNAS deployment (compose + backup sidecar + nginx proxy + prod Dockerfiles), Gitea CI draft, German ops guide [god-QA: 18/18+47/47+e2e 48/48] 2026-06-06 07:42:18 +02:00
d6bf6c6fe3 OPS-1: fix e2e regression -- API_BASE_URL conditional on import.meta.env.PROD
Dev (Vite dev server / e2e mock mode): falls back to 'http://localhost:5179' so
Playwright route interception on http://localhost:5179/** keeps working.
Production (npm run build -> PROD=true): falls back to '/api' so nginx proxy
strips the prefix without any hostname baked in at build time.
VITE_API_BASE_URL (set by Aspire in dev, injected LAN IP) always wins over both.

Evidence: npm run e2e 48/48 PASS (mock mode, both viewports).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:39:33 +02:00
04f9e087aa Merge feature/feat-12a-backend: provider-agnostic AI sale-ad endpoint (Gemini/Groq/Mistral/Ollama via OpenAI-compat, env config, graceful 503) [god-QA: 37/37] 2026-06-06 07:39:26 +02:00
95ecb1c0ee FEAT-12a: provider setup docs (env vars per provider incl. TrueNAS compose placement) 2026-06-06 07:37:42 +02:00
36c97e8a7a FEAT-12a: tests - prompt assembly, 503-unconfigured matrix, stub-provider parsing/auth, completions-URL config matrix (Gemini/Groq/Mistral/Ollama) 2026-06-06 07:37:42 +02:00
d9e5a757e9 FEAT-12a: POST /gerbils/sale-ad endpoint (503 AiKeyMissing when unconfigured, 502 AiUpstreamError) + DI wiring 2026-06-06 07:37:42 +02:00
f6bb13ca2f FEAT-12a: provider-agnostic sale-ad AI core - AiOptions (env-only), German prompt builder w/ few-shot, OpenAI-compatible chat-completions client (no SDK) 2026-06-06 07:37:42 +02:00
a221e366e6 FEAT-13: singular-aware contract count text (1 Vertrag / n Vertraege)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:34:25 +02:00
3ec4f660b0 FEAT-13: Abgabe wizard (/vertraege/neu, 4 steps, ?tiere= prefill), Vertraege list w/ download+delete, /einstellungen Zuchtprofil form, contracts/settings API clients, nav entries, detail-page entry point + replace orphaned contactInfo with email/phone/address across Kontakt pages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:34:25 +02:00
daa6683405 FEAT-13: SaleContract + BreederSettings entities (additive migration, join table justified in code docs), /contracts + /settings/breeder-profile Minimal-API endpoints w/ Abgabe-completion transaction + SQLite-backed endpoint round-trip tests (21/21)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:34:25 +02:00
69a0279bb7 OPS-1: TrueNAS compose, backup sidecar, Gitea CI draft, ops docs
deploy/truenas/compose.yaml:
  - 4 services: db (postgres:17), api (.NET), frontend (nginx), backup sidecar
  - Named volumes as NAS bind-mounts (paths configured in .env)
  - Healthchecks: db pg_isready, api /health, frontend waits on api
  - ANTHROPIC_API_KEY placeholder for FEAT-12a AI stub

deploy/truenas/.env.example: POSTGRES_PASSWORD, PORT, REGISTRY, dataset paths

deploy/truenas/scripts/:
  - entrypoint.sh: installs daily 03:00 cron, starts crond in foreground
  - backup.sh: pg_dump -> .sql + tar czf photos -> .tar.gz + rotation
  - restore.sh: psql < dump + tar xzf photos; confirmation prompt (-f to skip)
  Predecessor logic (commit 6a37f9e) translated from PowerShell to POSIX sh
  for Alpine containers. Same pg_dump/restore semantics, same KEEP_DAYS rotation.

.gitea/workflows/ci.yml (DRAFT -- inactive until Julian confirms Actions):
  dotnet test + npm test/build on push/PR to main;
  docker build+push to Gitea registry on main push only

docs/ops.md:
  German ops guide -- architecture diagram, first-install checklist,
  start/stop/update commands, backup runbook (manual + auto), ZFS-snapshot
  layering strategy, CI activation steps, open questions, troubleshooting table

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:31:19 +02:00
2f2e5b1f56 OPS-1: Dockerfiles (net10 API + nginx frontend) + production config
- GerbilManagerWebAPI/Dockerfile: net7->net10 multi-stage; build context=repo
  root (includes ServiceDefaults); port 8080 (.NET 10 container default)
- gerbil-manager-web/Dockerfile: drop VITE_API_BASE_URL build-arg; nginx proxies
  /api/* to api:8080 (strip prefix), /scalar, /openapi/ -- one published port 80
- gerbil-manager-web/src/api/client.ts: default API_BASE_URL /api (relative),
  no hostname coupling at build time; Aspire still injects absolute LAN URL in dev
- GerbilManagerWebAPI/Program.cs: run EF Migrate() at startup always (not Dev-only)
- GerbilManager.ServiceDefaults/Extensions.cs: expose /health + /alive in all
  environments (compose healthcheck requires them in Production)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:31:19 +02:00
adea82a0e7 QA-1: document e2e usage (mock/live modes, viewports) in web README 2026-06-06 07:21:27 +02:00
ebd61d73a5 QA-1: smoke specs - Navigation, Tiere CRUD+Tabs, Becken/Kontakte+409, Wuerfe+Jungtier, Probeverpaarung+SCHECKE-Warnung, Stammbaum re-root+print, Statistik (24 flows x 2 viewports, German assertions) 2026-06-06 07:16:52 +02:00
ec5e755e72 QA-1: stateful in-memory mock API per route interception (DATA-2 contract: Gridify paging, camelCase DTOs, 409 delete-conflicts) + fixtures 2026-06-06 07:16:52 +02:00
8d7999c4a5 QA-1: Playwright e2e harness - config (phone 390px + desktop projects, mock/live via E2E_BASE_URL), npm run e2e, vitest excludes e2e/ 2026-06-06 07:16:43 +02:00
c9723d2fc8 Merge feature/feat-12a: Abgeben flow — multi-select ForSale, /abgabe group composer, paste-package, AI stub [god-QA: 47/47 + build] 2026-06-06 07:16:26 +02:00
07dd5c9b8e FEAT-12a: multi-select bulk 'Zur Abgabe stellen' on the Tiere list 2026-06-06 07:13:30 +02:00
aa79f9dbe5 Revert OPS-1 Windows-plan commits (dada3af, 6a37f9e): superseded by TrueNAS deployment target; re-landed on main against GIT-3 — content preserved on Dwight's branch-to-be 2026-06-06 07:12:20 +02:00
3b3497079b Merge feature/feat-13: Abgabevertrag generator phase A (template + pure OpenXml generator + 10 tests) [god-QA: 18/18] 2026-06-06 07:11:30 +02:00
dada3af968 OPS-1: startup scripts, autostart task, backup task, ops README, production config
- appsettings.Production.json: Photos:RootPath -> C:\gerbil-data\photos so
  photo files land on a stable host path when ASPNETCORE_ENVIRONMENT=Production
- Start-GerbilManager.ps1: creates data dirs, sets Production env, launches
  AppHost minimised; saves PID for Stop script
- Stop-GerbilManager.ps1: kills AppHost + Vite node processes by PID / port
- Register-AutoStart.ps1: Windows Scheduled Task on user logon (RunLevel Highest)
- Register-BackupTask.ps1: daily 03:00 Scheduled Task for Backup script
- deploy/README.md: Julian ops guide — start/stop commands, phone URL, DHCP
  reservation, firewall rule (Public-profile Wi-Fi), backup/restore procedures,
  prerequisites checklist, troubleshooting table (German)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:11:09 +02:00
6a37f9e46e OPS-1: fix backup/restore scripts — ASCII-only + array coerce for Count
- Replace em dashes (UTF-8 multi-byte) with ASCII hyphens; PowerShell 5.1
  reads ps1 files as CP1252 by default so any non-ASCII in source causes
  cascading parse errors
- Extract POSTGRES_PASSWORD from container env (Aspire uses scram-sha-256;
  peer/trust auth does not work) and pass as PGPASSWORD env var to pg_dump
  and psql in both Backup and Restore scripts
- @() wrap on Get-ChildItem result to force array before .Count under
  Set-StrictMode -Version Latest (single-item returns a bare object in PS5)
- Restore-test evidence: 73 ColorVarieties -> DELETE 12 -> 61 -> pg_restore
  -> 73 confirmed (run 2026-06-06 07:09 against postgres-gatkzrgq)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:09:53 +02:00
44f306d18f FEAT-12a: Abgabe page — Becken-default grouping + per-group listing composer, clipboard/zip export, AI stub 2026-06-06 07:09:48 +02:00
cbe77c927a FEAT-13: ContractGenerator (pure OpenXml, embedded template, per-animal table cloning) + BreederProfile/ContractData records + 10 xUnit tests incl. schema validation & PII guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:09:07 +02:00
171 changed files with 28902 additions and 900 deletions

115
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,115 @@
# GerbilManager CI — Gitea Actions
# =================================
# STATUS: ENTWURF — inaktiv bis Julian Gitea Actions aktiviert.
# Aktivierung: Gitea -> Repository -> Einstellungen -> Actions -> "Actions aktivieren"
# Anschliessend: Gitea Actions Runner auf der NAS installieren (siehe docs/ops.md).
#
# Was dieser Workflow tut:
# 1. dotnet test (alle xUnit-Projekte)
# 2. npm test + npm run build (Frontend)
# 3. Docker-Images bauen und in die Gitea-Registry pushen
#
# Registry: truenas:13000 (internes Gitea Container Registry)
# Images:
# truenas:13000/gulum/gerbilmanager-api:latest
# truenas:13000/gulum/gerbilmanager-frontend:latest
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
REGISTRY: truenas:13000
REGISTRY_OWNER: gulum
DOTNET_VERSION: "10.0.x"
NODE_VERSION: "22"
jobs:
test-backend:
name: Backend Tests (.NET)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: .NET SDK einrichten
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Pakete wiederherstellen
run: dotnet restore GerbilManager.slnx
- name: Build
run: dotnet build GerbilManager.slnx --no-restore -c Release
- name: Tests ausfuehren
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
test-frontend:
name: Frontend Tests (Node/Vite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Node.js einrichten
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: gerbil-manager-web/package-lock.json
- name: Abhaengigkeiten installieren
run: npm ci
working-directory: gerbil-manager-web
- name: Tests ausfuehren
run: npm test
working-directory: gerbil-manager-web
- name: Produktionsbuild pruefen
run: npm run build
working-directory: gerbil-manager-web
build-and-push:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: [test-backend, test-frontend]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Bei Gitea Registry anmelden
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
- name: API-Image bauen und pushen
uses: docker/build-push-action@v5
with:
context: .
file: GerbilManagerWebAPI/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:${{ github.sha }}
- name: Frontend-Image bauen und pushen
uses: docker/build-push-action@v5
with:
context: .
file: gerbil-manager-web/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:${{ github.sha }}

3
.gitignore vendored
View File

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

View File

@@ -108,19 +108,13 @@ public static class Extensions
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
// Health checks always exposed — compose healthchecks and Aspire both rely on them.
// Trusted home LAN only (no public exposure, no auth — see board constraint).
app.MapHealthChecks(HealthEndpointPath);
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
Predicate = r => r.Tags.Contains("live")
});
return app;
}

View File

@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace GerbilManager.Tests;
/// <summary>
/// FEAT-13: In-Memory-Test-Host für Endpoint-Round-Trips. Ersetzt die
/// Aspire/Npgsql-Registrierung durch SQLite in-memory (eine offene Verbindung
/// hält die DB am Leben), Umgebung "Testing" überspringt Database.Migrate()
/// (Npgsql-Migrationen laufen nicht auf SQLite) — Schema via EnsureCreated,
/// inklusive der HasData-Seeds (73 Farbschläge + Zuchtprofil-Singleton).
/// Vertrags-Dateien landen in einem Temp-Ordner, der mit dem Host stirbt.
/// </summary>
public sealed class ApiFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection = new("DataSource=:memory:");
/// <summary>
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
/// EINEN öffentlichen (parameterlosen) Konstruktor.
/// </summary>
public Action<IServiceCollection>? ConfigureTestServices { get; init; }
public string ContractRoot { get; } =
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
// Dummy, damit Aspires AddNpgsqlDbContext beim Host-Aufbau zufrieden ist;
// die eigentliche Registrierung wird unten durch SQLite ersetzt.
builder.UseSetting("ConnectionStrings:gerbilmanager",
"Host=localhost;Database=test;Username=test;Password=test");
builder.UseSetting("Contracts:RootPath", ContractRoot);
builder.ConfigureServices(services =>
{
// EF 9+: AddDbContext registriert die Provider-Konfiguration als
// IDbContextOptionsConfiguration<T> — ohne deren Entfernung blieben
// Npgsql UND SQLite registriert (ein Provider pro ServiceProvider).
services.RemoveAll<Microsoft.EntityFrameworkCore.Infrastructure.IDbContextOptionsConfiguration<ApplicationContext>>();
services.RemoveAll<DbContextOptions<ApplicationContext>>();
services.RemoveAll<ApplicationContext>();
_connection.Open();
services.AddDbContext<ApplicationContext>(o => o.UseSqlite(_connection));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
ConfigureTestServices?.Invoke(services);
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_connection.Dispose();
if (Directory.Exists(ContractRoot)) Directory.Delete(ContractRoot, recursive: true);
}
}
}

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

@@ -0,0 +1,198 @@
using System.IO.Compression;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace GerbilManager.Tests;
/// <summary>
/// FEAT-13 phase B: Endpoint-Round-Trips für /contracts + /settings/breeder-profile
/// gegen den In-Memory-Host (ApiFactory). Deckt den Abgabe-Abschluss ab:
/// Vertrag erzeugen -> Tiere stehen auf Abgegeben -> .docx ist herunterladbar.
/// </summary>
public class ContractEndpointTests : IClassFixture<ApiFactory>
{
private static readonly JsonSerializerOptions Json = CreateJsonOptions();
private static JsonSerializerOptions CreateJsonOptions()
{
var o = new JsonSerializerOptions(JsonSerializerDefaults.Web);
o.Converters.Add(new JsonStringEnumConverter());
return o;
}
private readonly ApiFactory _factory;
public ContractEndpointTests(ApiFactory factory) => _factory = factory;
private sealed record IdDto(Guid Id);
private sealed record GerbilView(Guid Id, string Status, Guid? ReceiverContactId, DateOnly? GoHomeDate);
private sealed record ContractView(
Guid Id, Guid ContactId, decimal Price, DateOnly HandoverDate, DateOnly ContractDate,
string FileName, List<Guid> GerbilIds, string Url);
private sealed record Paged<T>(List<T> Items, int TotalCount, int Page, int PageSize);
private async Task<Guid> CreateContactAsync(HttpClient client, string name = "Herr Max Beispiel")
{
var response = await client.PostAsJsonAsync("/contacts", new
{
name,
email = "max@example.com",
phone = "0987 654321",
address = "Beispielallee 7, 54321 Beispielstadt",
}, Json);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
}
private async Task<Guid> CreateGerbilAsync(HttpClient client, string name)
{
var response = await client.PostAsJsonAsync("/gerbils", new
{
name,
gender = "female",
dateOfBirth = "2025-03-09",
}, Json);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
}
[Fact]
public async Task ZuchtprofilRoundTrip_PutDannGet()
{
var client = _factory.CreateClient();
var put = await client.PutAsJsonAsync("/settings/breeder-profile", new
{
zuchtName = "Zucht Testhausen",
name = "Frau Erika Muster",
address = "Musterweg 1, 12345 Testhausen",
phone = "0123 456789",
email = "zucht@example.org",
homepage = "https://zucht.example.org/",
city = "Testhausen",
}, Json);
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var profile = await client.GetFromJsonAsync<Dictionary<string, string>>("/settings/breeder-profile", Json);
Assert.Equal("Zucht Testhausen", profile!["zuchtName"]);
Assert.Equal("Testhausen", profile["city"]);
}
[Fact]
public async Task VertragErzeugen_SchließtDieAbgabeAb()
{
var client = _factory.CreateClient();
// Zuchtprofil füllen, damit der Verkäufer-Block im Dokument landet.
await client.PutAsJsonAsync("/settings/breeder-profile", new
{
zuchtName = "Zucht Testhausen",
name = "Frau Erika Muster",
address = "Musterweg 1, 12345 Testhausen",
phone = "0123 456789",
email = "zucht@example.org",
homepage = "https://zucht.example.org/",
city = "Testhausen",
}, Json);
var contactId = await CreateContactAsync(client);
var krümel = await CreateGerbilAsync(client, "Krümel");
var luna = await CreateGerbilAsync(client, "Luna");
// POST /contracts — der Abschluss.
var post = await client.PostAsJsonAsync("/contracts", new
{
contactId,
gerbilIds = new[] { krümel, luna },
price = 72.0m,
handoverDate = "2026-06-05",
}, Json);
Assert.Equal(HttpStatusCode.Created, post.StatusCode);
var contract = (await post.Content.ReadFromJsonAsync<ContractView>(Json))!;
Assert.Equal(contactId, contract.ContactId);
Assert.Equal(2, contract.GerbilIds.Count);
Assert.Equal(new DateOnly(2026, 6, 5), contract.ContractDate); // Standard = Übergabedatum
Assert.Equal($"/contracts/{contract.Id}/file", contract.Url);
// Tiere stehen jetzt auf Abgegeben — mit Abnehmer und Abgabedatum.
foreach (var id in new[] { krümel, luna })
{
var gerbil = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{id}", Json);
Assert.Equal("GivenAway", gerbil!.Status);
Assert.Equal(contactId, gerbil.ReceiverContactId);
Assert.Equal(new DateOnly(2026, 6, 5), gerbil.GoHomeDate);
}
// Download: echtes .docx mit beiden Tieren, Käufer, de-DE-Preis.
var file = await client.GetAsync(contract.Url);
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
Assert.Equal(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file.Content.Headers.ContentType!.MediaType);
Assert.Contains("Abgabevertrag_2026-06-05_Herr-Max-Beispiel.docx",
file.Content.Headers.ContentDisposition!.FileNameStar ?? file.Content.Headers.ContentDisposition.FileName);
var text = DocumentText(await file.Content.ReadAsByteArrayAsync());
Assert.Contains("Krümel", text);
Assert.Contains("Luna", text);
Assert.Contains("Herr Max Beispiel", text);
Assert.Contains("Kaufpreis von 72,00 €", text);
Assert.Contains("Testhausen, den 05.06.2026", text);
Assert.DoesNotContain("{{", text);
// Liste: nach Abnehmer filterbar (Gridify).
var list = await client.GetFromJsonAsync<Paged<ContractView>>(
$"/contracts?filter=contactId=={contactId}", Json);
Assert.Equal(1, list!.TotalCount);
Assert.Equal(contract.Id, list.Items[0].Id);
// DELETE räumt Zeile und Datei ab; Tier-Status bleibt unangetastet.
var delete = await client.DeleteAsync($"/contracts/{contract.Id}");
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(contract.Url)).StatusCode);
var still = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{krümel}", Json);
Assert.Equal("GivenAway", still!.Status);
}
[Fact]
public async Task VertragOhneTiereOderMitUnbekanntemTier_Validierungsfehler()
{
var client = _factory.CreateClient();
var contactId = await CreateContactAsync(client, "Frau Lisa Test");
var empty = await client.PostAsJsonAsync("/contracts", new
{
contactId,
gerbilIds = Array.Empty<Guid>(),
price = 10m,
handoverDate = "2026-06-05",
}, Json);
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
var unknown = await client.PostAsJsonAsync("/contracts", new
{
contactId,
gerbilIds = new[] { Guid.NewGuid() },
price = 10m,
handoverDate = "2026-06-05",
}, Json);
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
}
/// <summary>Sichtbarer Text aus word/document.xml (Tags entfernt).</summary>
private static string DocumentText(byte[] docx)
{
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
}
}

View File

@@ -0,0 +1,224 @@
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Validation;
using GerbilManagerWebAPI.Contracts;
namespace GerbilManager.Tests;
/// <summary>
/// FEAT-13: Tests des Abgabevertrag-Generators. Die erzeugte .docx wird als
/// Zip geöffnet und auf word/document.xml geprüft — kein Word nötig.
/// </summary>
public class ContractGeneratorTests
{
private static BreederProfile Seller() => new()
{
ZuchtName = "Zucht Testhausen",
Name = "Frau Erika Muster",
Address = "Musterweg 1, 12345 Testhausen",
Phone = "0123 456789",
Email = "zucht@example.org",
Homepage = "https://zucht.example.org/",
City = "Testhausen",
};
private static ContractAnimal Krümel() => new(
Name: "Krümel",
Geschlecht: "Weiblich",
Geburtsdatum: new DateOnly(2025, 3, 9),
Farbschlag: "Agouti");
private static ContractData OneAnimal() => new(
Seller: Seller(),
Buyer: new ContractBuyer(
Name: "Herr Max Beispiel",
Address: "Beispielallee 7, 54321 Beispielstadt",
Phone: "0987 654321",
Email: "max@example.com"),
Animals: [Krümel()],
Price: 72m,
HandoverDate: new DateOnly(2026, 6, 5));
/// <summary>
/// Sichtbarer Text aus word/document.xml der erzeugten Datei. Tags werden
/// entfernt, damit Aussagen über Run-Grenzen hinweg möglich sind (Werte
/// und Fließtext liegen in getrennten w:t-Runs).
/// </summary>
private static string DocumentText(byte[] docx)
{
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
}
[Fact]
public void EinTier_EnthaeltAlleVertragswerte()
{
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
// Verkäufer-Block (aus dem Zuchtprofil)
Assert.Contains("Zucht Testhausen", text);
Assert.Contains("Frau Erika Muster", text);
Assert.Contains("Musterweg 1, 12345 Testhausen", text);
Assert.Contains("0123 456789", text);
Assert.Contains("zucht@example.org", text);
Assert.Contains("https://zucht.example.org/", text);
// Käufer-Block
Assert.Contains("Herr Max Beispiel", text);
Assert.Contains("Beispielallee 7, 54321 Beispielstadt", text);
Assert.Contains("0987 654321", text);
Assert.Contains("max@example.com", text);
// Tier
Assert.Contains("Krümel", text);
Assert.Contains("Weiblich", text);
Assert.Contains("09.03.2025", text);
Assert.Contains("Agouti", text);
// Kaufpreis (de-DE) + Übergabedatum (TT.MM.JJJJ)
Assert.Contains("Kaufpreis von 72,00 €", text);
Assert.Contains("am 05.06.2026 dem Käufer", text);
// Unterschriftszeile: Ort des Züchters + Vertragsdatum (= Übergabedatum)
Assert.Contains("Testhausen, den 05.06.2026", text);
}
[Fact]
public void KeineUnersetztenTokens()
{
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
Assert.DoesNotContain("{{", text);
Assert.DoesNotContain("}}", text);
}
[Fact]
public void DreiTiere_KlontDieTierTabelleProTier()
{
var data = OneAnimal() with
{
Animals =
[
Krümel(),
new ContractAnimal("Fridolin", "Männlich", new DateOnly(2023, 5, 1), "Schwarz"),
new ContractAnimal("Luna", "Weiblich", new DateOnly(2023, 8, 15), "Gold"),
],
};
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Krümel", text);
Assert.Contains("Fridolin", text);
Assert.Contains("Luna", text);
Assert.Contains("01.05.2023", text);
Assert.Contains("15.08.2023", text);
// Pro Tier eine Tabelle: „Tierart:“ ist das fixe Label jeder Tier-Tabelle.
Assert.Equal(3, Regex.Matches(text, "Tierart:").Count);
Assert.DoesNotContain("{{", text);
}
[Fact]
public void PreisUndDatum_DeutscheFormate()
{
var data = OneAnimal() with { Price = 1234.5m, HandoverDate = new DateOnly(2026, 1, 3) };
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Kaufpreis von 1.234,50 €", text);
Assert.Contains("am 03.01.2026 dem Käufer", text);
}
[Fact]
public void VertragsDatum_UeberschreibtUebergabedatumInDerUnterschriftszeile()
{
var data = OneAnimal() with { ContractDate = new DateOnly(2026, 6, 7) };
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Testhausen, den 07.06.2026", text);
Assert.Contains("am 05.06.2026 dem Käufer", text); // Übergabedatum bleibt eigenständig
}
[Fact]
public void OptionaleFelder_LeerStattPlatzhalter()
{
var data = OneAnimal() with
{
Buyer = new ContractBuyer("Frau Lisa Test", "Testgasse 2, 11111 Teststadt"),
Animals = [new ContractAnimal("Momo", "Unbekannt")],
};
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Frau Lisa Test", text);
Assert.Contains("Momo", text);
Assert.DoesNotContain("{{", text);
Assert.DoesNotContain("null", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void OhneTiere_WirftArgumentException()
{
var data = OneAnimal() with { Animals = [] };
var ex = Assert.Throws<ArgumentException>(() => ContractGenerator.Generate(data));
Assert.Contains("mindestens ein Tier", ex.Message);
}
[Fact]
public void ErzeugteDatei_IstEinGueltigesDocxPaket()
{
var docx = ContractGenerator.Generate(OneAnimal());
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
Assert.Contains(zip.Entries, e => e.FullName == "[Content_Types].xml");
Assert.Contains(zip.Entries, e => e.FullName == "word/document.xml");
// Tierfotos wurden beim Vorlagenbau entfernt, das Zucht-Logo bleibt.
Assert.Contains(zip.Entries, e => e.FullName == "word/media/image1.jpeg");
Assert.DoesNotContain(zip.Entries, e => e.FullName == "word/media/image2.jpeg");
}
[Fact]
public void ErzeugtesDokument_ValidiertGegenOpenXmlSchema()
{
// Office-2013-Schema: die Vorlage nutzt w:tblLook-Attribute der
// Word-2010-Form, die der 2007-Default des Validators nicht kennt.
var validator = new OpenXmlValidator(DocumentFormat.OpenXml.FileFormatVersions.Office2013);
var data = OneAnimal() with
{
Animals = [Krümel(), new ContractAnimal("Fridolin", "Männlich"), new ContractAnimal("Luna", "Weiblich")],
};
using var generatedDoc = WordprocessingDocument.Open(
new MemoryStream(ContractGenerator.Generate(data)), isEditable: false);
var errors = validator.Validate(generatedDoc)
.Select(e => $"{e.ErrorType}: {e.Description} [{e.Path?.XPath}]")
.ToList();
Assert.True(errors.Count == 0, "OpenXml-Validierungsfehler:\n" + string.Join("\n", errors));
}
[Fact]
public void VorlageSelbst_EnthaeltKeineEchtdaten()
{
// Die Vorlage wird hier über den Generator-Pfad geladen: ein Vertrag
// mit leeren Werten darf keinerlei Daten des Mustervertrags enthalten.
var data = new ContractData(
new BreederProfile(),
new ContractBuyer("", ""),
[new ContractAnimal("", "")],
0m,
new DateOnly(2026, 1, 1));
var text = DocumentText(ContractGenerator.Generate(data));
// Stichproben der Original-PII (Name/Ort/Mail des Mustervertrags).
Assert.DoesNotContain("Nießner", text);
Assert.DoesNotContain("Hartengrund", text);
Assert.DoesNotContain("Ronneburg", text);
Assert.DoesNotContain("Schädtler", text);
Assert.DoesNotContain("gmx.de", text);
Assert.DoesNotContain("jimdofree", text);
}
}

View File

@@ -0,0 +1,144 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Export;
using GerbilManagerWebAPI.Models;
using Microsoft.Extensions.DependencyInjection;
namespace GerbilManager.Tests
{
/// <summary>
/// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export
/// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile).
/// </summary>
public class ExportTests
{
// ── CSV-Escaping ───────────────────────────────────────────────────
[Theory]
[InlineData(null, "")]
[InlineData("", "")]
[InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten
[InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten
[InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln
[InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten
[InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV)
public void Escape_behandelt_Sonderfälle(string? input, string expected)
=> Assert.Equal(expected, ExportService.Escape(input));
[Fact]
public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen()
{
var csv = ExportService.BuildCsv(
["Name", "Notizen"],
[["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]);
var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.Equal("Name;Notizen", lines[0]);
Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]);
Assert.Equal("Bo;", lines[2]);
}
// ── Round-Trip über die API ────────────────────────────────────────
[Fact]
public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten()
{
using var factory = new ApiFactory();
// Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht
Guid gerbilId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" };
var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" };
var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 };
var colorVariety = db.ColorVarieties.First(); // HasData-Seed
var gerbil = new Gerbil
{
Id = Guid.NewGuid(),
Name = "Krümel",
Gender = Gender.female,
Status = GerbilStatus.Active,
DateOfBirth = new DateOnly(2025, 3, 12),
LitterId = litter.Id,
EnclosureId = enclosure.Id,
OriginContactId = contact.Id,
ColorVarietyId = colorVariety.Id,
Genotype = "Aa CC Dd EE GG Pp Spsp rere",
ImportSource = "Stammbaum von Akio Kids.xlsx",
};
gerbilId = gerbil.Id;
db.AddRange(contact, enclosure, litter, gerbil,
new HealthRecord
{
Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15),
Type = HealthRecordType.Vaccination, Description = "Jahresimpfung",
CreatedAt = DateTimeOffset.UtcNow,
},
new WeightRecord
{
Id = Guid.NewGuid(), GerbilId = gerbil.Id,
Date = new DateOnly(2026, 5, 1), WeightGrams = 78,
});
await db.SaveChangesAsync();
}
var client = factory.CreateClient();
var response = await client.GetAsync("/export");
response.EnsureSuccessStatusCode();
Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType);
Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName);
using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read);
string[] expectedEntries =
["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv",
"gesundheit.csv", "gewichte.csv", "LIESMICH.txt"];
foreach (var name in expectedEntries)
Assert.NotNull(zip.GetEntry(name));
// export.json: Zähler + volle Treue (Genotyp, Import-Herkunft)
using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _));
var root = json.RootElement;
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
var g = root.GetProperty("gerbils")[0];
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString());
// tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum
var tiere = ReadEntry(zip, "tiere.csv", out var hadBom);
Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel");
var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]);
var row = lines[1];
Assert.Contains("Krümel", row);
Assert.Contains("Weiblich", row);
Assert.Contains("Aktiv", row);
Assert.Contains("12.03.2025", row);
Assert.Contains("Großbecken", row);
Assert.Contains("Wurf K", row);
// kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen)
var kontakte = ReadEntry(zip, "kontakte.csv", out _);
Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte);
// LIESMICH erklärt den Foto-Speicherort
Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _));
}
private static string ReadEntry(ZipArchive zip, string name, out bool hadBom)
{
using var stream = zip.GetEntry(name)!.Open();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0));
}
}
}

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

@@ -9,6 +9,9 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />

View File

@@ -0,0 +1,365 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-8c loader tests against a small JSON fixture + an in-memory DB (never Julian's
/// instance). Covers categorisation, the quarantine policy, genotype composition,
/// Farbschlag matching, high-confidence linking, and idempotency.
/// </summary>
public class ImportServiceTests : IDisposable
{
private readonly string _dir;
public ImportServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "feat8c-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
File.WriteAllText(Path.Combine(_dir, "litters.json"), LittersJson);
File.WriteAllText(Path.Combine(_dir, "animals.json"), AnimalsJson);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("feat8c-" + Guid.NewGuid().ToString("N"))
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated(); // applies the 73-variety HasData seed
return db;
}
[Fact]
public async Task DryRun_categorises_without_writing()
{
using var db = NewDb();
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: false);
Assert.False(report.Executed);
Assert.Equal(2, report.Litters.InSource);
Assert.Equal(2, report.Litters.Created);
// 4 animals: a1 (hoch) + a4 (date-only) loadable; a2 conflict, a3 stub quarantined
Assert.Equal(4, report.Animals.InSource);
Assert.Equal(2, report.Animals.Created);
Assert.Equal(1, report.Animals.LinkedToLitter); // only a1 (hoch)
Assert.Equal(1, report.Animals.FarbschlagMatched); // a1 -> Agouti
Assert.Equal(1, report.Animals.Quarantined.Conflicts);
Assert.Equal(1, report.Animals.Quarantined.Stubs);
Assert.Equal(1, report.Animals.Quarantined.DateOnlyLinks);
// nothing written in dry-run
Assert.Equal(0, await db.Gerbils.CountAsync());
Assert.Equal(0, await db.Litters.CountAsync());
}
[Fact]
public async Task Execute_loads_conflict_free_and_links_high_confidence()
{
using var db = NewDb();
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(2, await db.Litters.CountAsync());
Assert.Equal(2, await db.Gerbils.CountAsync()); // a1, a4 only
// conflict + stub never loaded
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a2"));
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a3"));
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
Assert.NotNull(a1.LitterId); // high-confidence link
Assert.Equal("FEAT-8 Stammbaum/Wurfchronik", a1.ImportSource);
Assert.NotNull(a1.ColorVarietyId); // Agouti matched
Assert.Contains("aa CC", a1.Genotype); // composed from mapped8locus
Assert.Contains("??", a1.Genotype!); // missing loci -> ??
Assert.Contains("eef", a1.Genotype!); // e^f caret stripped
Assert.Contains("RawGenotype", a1.RawImportData!); // raw verbatim preserved
var a4 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a4");
Assert.Null(a4.LitterId); // date-only link quarantined
// PairingCode carried through
Assert.True(await db.Litters.AnyAsync(l => l.PairingCode == "G01/ZdkC"));
}
[Fact]
public async Task Execute_is_idempotent()
{
using var db = NewDb();
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Animals.Created);
Assert.Equal(0, second.Litters.Created);
Assert.Equal(2, await db.Gerbils.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]
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
{
var g = new SourceGenotype
{
Mapped8locus = new()
{
["A"] = new() { "a", "a" },
["C"] = new() { "C", "c^chm" },
["E"] = new() { "e", "e^f" },
},
};
// order A C D E G P Sp Re ; missing -> ??
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
}
[Theory]
[InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)]
public void ParseDate_handles_german_dates(string s, int y, int m, int d)
{
var date = ImportService.ParseDate(s);
Assert.Equal(new DateOnly(y, m, d), date);
}
// ---- fixtures ----
private const string LittersJson = """
[
{"id":"L1","litterId":"A","date":"01.02.2020","damName":"Mama [X]","sireName":"Papa of Y","totalBorn":4,"zuchtnummer":"G01/ZdkC","note":"erster Wurf"},
{"id":"L2","litterId":"B","date":"05.03.2021","damName":"Oma [Z]","sireName":"Opa of W","totalBorn":2,"zuchtnummer":"G02/ZdkC","note":""}
]
""";
private const string AnimalsJson = """
[
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
"farbschlag":"Agouti","farbschlagVariants":["Agouti"],
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
"deaf":true,"tags":["WFNZ"],
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,
"farbschlag":"Schwarz","farbschlagVariants":["Schwarz"],
"genotype":{"mapped8locus":{"A":["a","a"]},"rawGenotype":"aa","unmappedTokens":[]},
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1","f2"],"conflict":true},
{"id":"a3","name":"Namenlos","dob":"","death":"","gender":null,
"farbschlag":"","farbschlagVariants":[],
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false},
{"id":"a4","name":"Unsicher","dob":"05.03.2021","death":"","gender":null,
"farbschlag":"","farbschlagVariants":[],
"genotype":{"mapped8locus":{"A":["A","a"]},"rawGenotype":"Aa","unmappedTokens":[]},
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
"litterRef":{"litterId":"L2","method":"geburtsdatum","confidence":"niedrig"}}
]
""";
}
}

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

@@ -0,0 +1,282 @@
using System.Net;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-12a: sale-ad backend — prompt assembly, the 503-unconfigured path,
/// the OpenAI-compatible wire handling against a stubbed provider, and the
/// provider config matrix (Gemini / Groq / Mistral / Ollama base URLs).
/// </summary>
public class SaleAdTests
{
private static SaleAdRequest SampleRequest() => new(
Animals:
[
new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "ruhig, nimmt Leckerlis aus der Hand"),
new SaleAdAnimal("Benny", "Schwarz Schecke", "2024-03-12", "neugieriger Entdecker"),
],
StatusLine: "FREI",
Hints: "bitte die Bruder-Dynamik betonen");
// ── Prompt assembly ────────────────────────────────────────────────
[Fact]
public void UserPrompt_enthält_Tiere_Status_und_Hinweise()
{
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
Assert.Contains("Status-Zeile: FREI", prompt);
Assert.Contains("Name: Balu", prompt);
Assert.Contains("Farbschlag: CP-Agouti", prompt);
Assert.Contains("Name: Benny", prompt);
Assert.Contains("ruhig, nimmt Leckerlis aus der Hand", prompt);
Assert.Contains("Wünsche/Hinweise: bitte die Bruder-Dynamik betonen", prompt);
}
[Fact]
public void UserPrompt_formatiert_Geburtsdatum_deutsch()
{
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
Assert.Contains("geboren am 12.03.2024", prompt);
Assert.DoesNotContain("2024-03-12", prompt);
}
[Fact]
public void UserPrompt_lässt_fehlende_Angaben_weg()
{
var request = new SaleAdRequest(
[new SaleAdAnimal("Mysti", null, null, null)], "RESERVIERT", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Name: Mysti", prompt);
Assert.DoesNotContain("Farbschlag:", prompt);
Assert.DoesNotContain("geboren am", prompt);
Assert.DoesNotContain("Notizen:", prompt);
Assert.DoesNotContain("Wünsche/Hinweise:", prompt);
}
[Fact]
public void SystemPrompt_enthält_Stilregeln_und_beide_Beispiele()
{
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
Assert.Contains("Zucht der kleinen Chaoten", prompt);
Assert.Contains("NIEMALS Preise", prompt);
Assert.Contains("KEINE Fakten erfinden", prompt);
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
Assert.Contains("Status: FREI", prompt);
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
}
// ── FEAT-14c: Charakterbogen (Traits + Notiz) im Prompt ────────────
[Fact]
public void UserPrompt_enthält_Traits_und_Charakternotiz()
{
var request = new SaleAdRequest(
[new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "alte Verwaltungsnotiz",
Traits: ["zutraulich", "buddelt gern"],
CharacterNote: "klettert abends auf die Hand")],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: zutraulich, buddelt gern", prompt);
Assert.Contains("Charakter-Notiz: klettert abends auf die Hand", prompt);
}
[Fact]
public void UserPrompt_bevorzugt_Charakterbogen_vor_generischen_Notizen()
{
// Traits vorhanden -> die generische Notiz (oft Verwaltungs-Info)
// gehört NICHT in den Prompt.
var request = new SaleAdRequest(
[new SaleAdAnimal("Balu", null, null, "Käfig wurde am 3.5. gereinigt",
Traits: ["neugierig"])],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: neugierig", prompt);
Assert.DoesNotContain("Notizen:", prompt);
Assert.DoesNotContain("Käfig wurde", prompt);
}
[Fact]
public void UserPrompt_fällt_ohne_Charakterbogen_auf_Notizen_zurück()
{
// Kein Charakterbogen (null bzw. leer) -> Notizen wie bisher.
var request = new SaleAdRequest(
[
new SaleAdAnimal("Mysti", null, null, "sehr verschmust", Traits: null),
new SaleAdAnimal("Bo", null, null, "mag Kolbenhirse", Traits: []),
],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Notizen: sehr verschmust", prompt);
Assert.Contains("Notizen: mag Kolbenhirse", prompt);
Assert.DoesNotContain("Charakter:", prompt);
}
[Fact]
public void SystemPrompt_verlangt_fließende_Prosa_statt_Stichwortliste()
{
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
Assert.Contains("Charakter-Eigenschaften", prompt);
Assert.Contains("niemals als Aufzählung", prompt);
}
[Theory]
[InlineData("2024-03-12", "12.03.2024")]
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen
public void FormatGermanDate_konvertiert_ISO_nach_TT_MM_JJJJ(string input, string expected)
=> Assert.Equal(expected, SaleAdPromptBuilder.FormatGermanDate(input));
// ── 503: unkonfiguriert ────────────────────────────────────────────
[Theory]
[InlineData(null, "key", "model")]
[InlineData("https://api.example.com/v1", null, "model")]
[InlineData("https://api.example.com/v1", "key", null)]
[InlineData(null, null, null)]
public async Task GenerateAsync_meldet_NotConfigured_wenn_eine_Einstellung_fehlt(
string? baseUrl, string? apiKey, string? model)
{
var service = CreateService(baseUrl, apiKey, model,
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
var result = await service.GenerateAsync(SampleRequest());
Assert.Equal(SaleAdStatus.NotConfigured, result.Status);
Assert.Null(result.Text);
}
// ── Fake-Server: OpenAI-kompatible Antwort wird geparst ────────────
[Fact]
public async Task GenerateAsync_parst_die_Chat_Completion_des_Stubs()
{
var handler = new StubHandler(_ => Canned("Status: FREI\n\nWunderbares Duo …"));
var service = CreateService("https://api.groq.com/openai/v1", "k", "llama-3.3-70b", handler);
var result = await service.GenerateAsync(SampleRequest());
Assert.Equal(SaleAdStatus.Ok, result.Status);
Assert.Equal("Status: FREI\n\nWunderbares Duo …", result.Text);
}
[Fact]
public async Task GenerateAsync_sendet_Modell_Messages_und_Bearer_Key()
{
var handler = new StubHandler(_ => Canned("ok"));
var service = CreateService("https://api.example.com/v1", "geheim", "test-model", handler);
await service.GenerateAsync(SampleRequest());
Assert.NotNull(handler.LastRequest);
Assert.Equal("Bearer", handler.LastRequest!.Headers.Authorization?.Scheme);
Assert.Equal("geheim", handler.LastRequest.Headers.Authorization?.Parameter);
using var body = JsonDocument.Parse(handler.LastRequestBody!);
Assert.Equal("test-model", body.RootElement.GetProperty("model").GetString());
var messages = body.RootElement.GetProperty("messages");
Assert.Equal(2, messages.GetArrayLength());
Assert.Equal("system", messages[0].GetProperty("role").GetString());
Assert.Equal("user", messages[1].GetProperty("role").GetString());
}
[Fact]
public async Task GenerateAsync_meldet_UpstreamError_bei_Provider_HTTP_Fehler()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
{
Content = new StringContent("rate limited"),
});
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.GenerateAsync(SampleRequest());
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
}
[Fact]
public async Task GenerateAsync_meldet_UpstreamError_bei_leerer_Antwort()
{
var handler = new StubHandler(_ => Canned(""));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.GenerateAsync(SampleRequest());
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
}
// ── Provider-Konfigurationsmatrix: BaseUrl -> /chat/completions ────
[Theory]
[InlineData("https://generativelanguage.googleapis.com/v1beta/openai",
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")] // Gemini
[InlineData("https://api.groq.com/openai/v1",
"https://api.groq.com/openai/v1/chat/completions")] // Groq
[InlineData("https://api.mistral.ai/v1",
"https://api.mistral.ai/v1/chat/completions")] // Mistral
[InlineData("http://truenas:11434/v1",
"http://truenas:11434/v1/chat/completions")] // Ollama (lokal)
[InlineData("https://api.groq.com/openai/v1/",
"https://api.groq.com/openai/v1/chat/completions")] // trailing slash
public async Task Konfigurationsmatrix_baut_die_richtige_Completions_URL(
string baseUrl, string expectedUri)
{
Assert.Equal(new Uri(expectedUri), SaleAdService.BuildCompletionsUri(baseUrl));
// und der Stub sieht den Aufruf tatsächlich auf dieser URL
var handler = new StubHandler(_ => Canned("ok"));
var service = CreateService(baseUrl, "k", "m", handler);
await service.GenerateAsync(SampleRequest());
Assert.Equal(new Uri(expectedUri), handler.LastRequest!.RequestUri);
}
// ── Helfer ─────────────────────────────────────────────────────────
private static SaleAdService CreateService(
string? baseUrl, string? apiKey, string? model, StubHandler handler)
{
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
return new SaleAdService(new HttpClient(handler), options);
}
private static HttpResponseMessage Canned(string content)
{
var completion = new
{
choices = new[] { new { message = new { role = "assistant", content } } },
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(completion),
Encoding.UTF8, "application/json"),
};
}
/// <summary>HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastRequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastRequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return respond(request);
}
}
}
}

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

@@ -15,6 +15,37 @@ public class ApplicationContext : DbContext
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<Page> Pages => Set<Page>();
public DbSet<Block> Blocks => Set<Block>();
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
SyncNameSearch();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
SyncNameSearch();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void SyncNameSearch()
{
foreach (var entry in ChangeTracker.Entries<Gerbil>())
{
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -24,6 +55,22 @@ public class ApplicationContext : DbContext
e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>();
// Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true);
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<string>()
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
v => v.ToList());
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
e.HasOne(g => g.Litter).WithMany()
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.OriginContact).WithMany()
@@ -59,9 +106,123 @@ public class ApplicationContext : DbContext
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
// FEAT-13: Abgabeverträge + Zuchtprofil.
modelBuilder.Entity<SaleContract>(e =>
{
e.Property(c => c.Price).HasPrecision(10, 2);
// Restrict: ein Kontakt mit Verträgen ist ein Dokumentenbestand,
// kein versehentlich löschbarer Datensatz.
e.HasOne(c => c.Contact).WithMany()
.HasForeignKey(c => c.ContactId).OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<SaleContractAnimal>(e =>
{
e.HasKey(a => new { a.SaleContractId, a.GerbilId });
e.HasOne<SaleContract>().WithMany(c => c.Animals)
.HasForeignKey(a => a.SaleContractId).OnDelete(DeleteBehavior.Cascade);
// Cascade: wird ein Tier gelöscht, verschwindet nur die Verknüpfung —
// der Vertrag (und seine .docx als Beleg) bleibt bestehen.
e.HasOne(a => a.Gerbil).WithMany()
.HasForeignKey(a => a.GerbilId).OnDelete(DeleteBehavior.Cascade);
});
// Zuchtprofil: genau eine (leere) Zeile mit fixer Id.
modelBuilder.Entity<BreederSettings>()
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
// WEB epic: CMS content model.
modelBuilder.Entity<Site>(e =>
{
var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<Guid>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<Guid>()
: System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>());
var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
(a, b) => (a ?? new List<Guid>()).SequenceEqual(b ?? new List<Guid>()),
v => v == null ? 0 : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())),
v => v.ToList());
e.Property(s => s.NavOrder).HasConversion(navConverter, navComparer);
});
modelBuilder.Entity<Page>(e =>
{
e.HasIndex(p => p.Slug).IsUnique();
e.Property(p => p.Status).HasConversion<string>();
e.HasMany(p => p.Blocks).WithOne()
.HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
SeedCms(modelBuilder);
// INBOX epic: Gmail request inbox.
modelBuilder.Entity<Request>(e =>
{
e.Property(r => r.Status).HasConversion<string>();
e.HasIndex(r => r.GmailMessageId).IsUnique();
e.HasOne(r => r.AssignedContact).WithMany()
.HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict);
});
// exactly one MailSettings row, fixed id.
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
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>
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties
@@ -76,7 +237,6 @@ public class ApplicationContext : DbContext
("Hermelin", "aa chch DD EE GG PP spsp rere"),
("Himalaya", "AA chch DD EE GG PP spsp rere"),
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
("Schwarzschimmel", "AA CC DD efef GG PP spsp rere"),
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
("Agouti", "AA CC DD EE GG PP spsp rere"),
("Schwarz", "aa CC DD EE GG PP spsp rere"),
@@ -96,55 +256,44 @@ public class ApplicationContext : DbContext
("C-Separator", "aa CC DD ee gg pp spsp rere"),
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere"),
("Schimmel (Orangeschimmel)", "AA CC DD efef GG PP spsp rere"),
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
("Topas", "AA CC DD EE GG pp spsp rere"),
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Polarfuchs-Hell CP", "AA cchmcchm DD ee gg PP spsp rere"),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
("Agouti-Hell dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Polarfuchs CP", "AA cchmcchm DD ee gg PP spsp rere"),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Topas dd", "AA CC dd EE GG pp spsp rere"),
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg PP spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam", "aa cchmch DD EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
};
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

@@ -0,0 +1,29 @@
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags.
/// Phase A: wird aus der appsettings-Sektion <c>"BreederProfile"</c> gebunden
/// (z. B. <c>builder.Configuration.GetSection("BreederProfile").Get&lt;BreederProfile&gt;()</c>);
/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE
/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier.
/// </summary>
public sealed class BreederProfile
{
/// <summary>Name der Zucht, z. B. „Zucht der Kleinen Chaoten“.</summary>
public string ZuchtName { get; set; } = "";
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
public string Name { get; set; } = "";
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
public string Address { get; set; } = "";
public string Phone { get; set; } = "";
public string Email { get; set; } = "";
public string Homepage { get; set; } = "";
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
public string City { get; set; } = "";
}

View File

@@ -0,0 +1,34 @@
namespace GerbilManagerWebAPI.Contracts;
/// <summary>Käufer/Abnehmer-Block des Abgabevertrags.</summary>
/// <param name="Name">Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“.</param>
/// <param name="Address">Anschrift einzeilig: „Straße Nr, PLZ Ort“.</param>
public sealed record ContractBuyer(
string Name,
string Address,
string? Phone = null,
string? Email = null);
/// <summary>Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier).</summary>
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
public sealed record ContractAnimal(
string Name,
string Geschlecht,
DateOnly? Geburtsdatum = null,
string? Farbschlag = null);
/// <summary>
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
/// </summary>
/// <param name="Price">Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“.</param>
/// <param name="HandoverDate">Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ.</param>
/// <param name="ContractDate">Datum der Unterschriftszeile; Standard = Übergabedatum.</param>
public sealed record ContractData(
BreederProfile Seller,
ContractBuyer Buyer,
IReadOnlyList<ContractAnimal> Animals,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate = null);

View File

@@ -0,0 +1,152 @@
using System.Globalization;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage
/// (<c>Contracts/Templates/Abgabevertrag.docx</c>, abgeleitet aus dem echten
/// Mustervertrag; Boilerplate-Abschnitte 48 stehen verbatim in der Vorlage).
///
/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein
/// <see cref="ContractData"/>, Ausgabe sind die fertigen .docx-Bytes.
/// Die Vorlage enthält pro Wert genau EINEN {{Token}}-Run (beim Vorlagenbau
/// zusammengeführt), darum genügt einfache Textersetzung; die Tier-Tabelle
/// wird pro Tier geklont (variable Anzahl 1..n).
/// </summary>
public static class ContractGenerator
{
private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx";
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
/// <summary>Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage.</summary>
public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate());
/// <summary>Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle).</summary>
public static byte[] Generate(ContractData data, byte[] template)
{
ArgumentNullException.ThrowIfNull(data);
if (data.Animals.Count == 0)
{
throw new ArgumentException("Ein Abgabevertrag braucht mindestens ein Tier.", nameof(data));
}
using var stream = new MemoryStream();
stream.Write(template);
using (var document = WordprocessingDocument.Open(stream, isEditable: true))
{
var body = document.MainDocumentPart?.Document.Body
?? throw new InvalidOperationException("Vorlage ohne Dokumentrumpf.");
FillAnimalTables(body, data.Animals);
ReplaceTokens(body, GlobalTokens(data));
document.MainDocumentPart!.Document.Save();
}
return stream.ToArray();
}
/// <summary>
/// Die Vorlage enthält genau eine Tier-Tabelle (mit {{TierName}}). Für
/// jedes weitere Tier wird sie samt Abstands-Absatz geklont; danach wird
/// jede Tabelle mit den Werten „ihres“ Tieres gefüllt.
/// </summary>
private static void FillAnimalTables(Body body, IReadOnlyList<ContractAnimal> animals)
{
var templateTable = body.Descendants<Table>()
.Single(t => t.InnerText.Contains("{{TierName}}"));
var tables = new List<Table> { templateTable };
// Abstands-Absatz hinter der Tabelle (Optik wie im Original-Mehrtier-Vertrag).
OpenXmlElement anchor = templateTable.NextSibling() is Paragraph spacer
? spacer
: templateTable;
for (var i = 1; i < animals.Count; i++)
{
var clone = (Table)templateTable.CloneNode(deep: true);
anchor = anchor.InsertAfterSelf(clone);
anchor = anchor.InsertAfterSelf(new Paragraph());
tables.Add(clone);
}
for (var i = 0; i < animals.Count; i++)
{
ReplaceTokens(tables[i], AnimalTokens(animals[i]));
}
}
private static Dictionary<string, string> GlobalTokens(ContractData data) => new()
{
["{{ZuchtName}}"] = data.Seller.ZuchtName,
["{{VerkaeuferName}}"] = data.Seller.Name,
["{{VerkaeuferAdresse}}"] = data.Seller.Address,
["{{VerkaeuferTelefon}}"] = data.Seller.Phone,
["{{VerkaeuferEmail}}"] = data.Seller.Email,
["{{VerkaeuferHomepage}}"] = data.Seller.Homepage,
["{{KaeuferName}}"] = data.Buyer.Name,
["{{KaeuferAdresse}}"] = data.Buyer.Address,
["{{KaeuferTelefon}}"] = data.Buyer.Phone ?? "",
["{{KaeuferEmail}}"] = data.Buyer.Email ?? "",
["{{Kaufpreis}}"] = FormatPrice(data.Price),
["{{Uebergabedatum}}"] = FormatDate(data.HandoverDate),
["{{VertragsOrt}}"] = data.Seller.City,
["{{VertragsDatum}}"] = FormatDate(data.ContractDate ?? data.HandoverDate),
};
private static Dictionary<string, string> AnimalTokens(ContractAnimal animal) => new()
{
["{{TierName}}"] = animal.Name,
["{{TierGeschlecht}}"] = animal.Geschlecht,
["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "",
["{{TierFarbschlag}}"] = animal.Farbschlag ?? "",
};
/// <summary>z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“.</summary>
private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €";
/// <summary>TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag).</summary>
private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German);
/// <summary>
/// Ersetzt Tokens in allen Text-Runs unterhalb von <paramref name="root"/>.
/// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein
/// Run-übergreifendes Matching nötig.
/// </summary>
private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary<string, string> tokens)
{
foreach (var text in root.Descendants<Text>())
{
if (!text.Text.Contains("{{"))
{
continue;
}
foreach (var (token, value) in tokens)
{
if (text.Text.Contains(token))
{
text.Text = text.Text.Replace(token, value);
}
}
if (text.Text.Length > 0 && (char.IsWhiteSpace(text.Text[0]) || char.IsWhiteSpace(text.Text[^1])))
{
text.Space = SpaceProcessingModeValues.Preserve;
}
}
}
private static byte[] LoadEmbeddedTemplate()
{
using var resource = typeof(ContractGenerator).Assembly.GetManifestResourceStream(TemplateResource)
?? throw new InvalidOperationException($"Eingebettete Vorlage fehlt: {TemplateResource}");
using var buffer = new MemoryStream();
resource.CopyTo(buffer);
return buffer.ToArray();
}
}

View File

@@ -1,20 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
# Build context: repo root (includes GerbilManager.ServiceDefaults).
# docker build -f GerbilManagerWebAPI/Dockerfile -t gerbilmanager-api .
ENV ASPNETCORE_URLS=http://+:80
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:7.0 AS build
ARG configuration=Release
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["GerbilManagerWebAPI/GerbilManagerWebAPI.csproj", "GerbilManagerWebAPI/"]
RUN dotnet restore "GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
COPY . .
WORKDIR "/src/GerbilManagerWebAPI"
ARG configuration=Release
RUN dotnet publish "GerbilManagerWebAPI.csproj" -c $configuration -o /app/publish /p:UseAppHost=false
FROM base AS final
# Restore: project files only for layer-cache efficiency.
COPY GerbilManager.ServiceDefaults/GerbilManager.ServiceDefaults.csproj GerbilManager.ServiceDefaults/
COPY GerbilManagerWebAPI/GerbilManagerWebAPI.csproj GerbilManagerWebAPI/
RUN dotnet restore GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
# Full source copy + publish.
COPY GerbilManager.ServiceDefaults/ GerbilManager.ServiceDefaults/
COPY GerbilManagerWebAPI/ GerbilManagerWebAPI/
WORKDIR /src/GerbilManagerWebAPI
RUN dotnet publish GerbilManagerWebAPI.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "GerbilManagerWebAPI.dll"]

View File

@@ -24,7 +24,12 @@ namespace GerbilManagerWebAPI.Dtos
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
string? ExternalRef,
string? OriginBreeder,
List<string> CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool IsResident);
public record LitterDto(
Guid Id,
@@ -69,7 +74,12 @@ namespace GerbilManagerWebAPI.Dtos
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
string? ExternalRef,
string? OriginBreeder,
List<string>? CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool? IsResident);
public record LitterInput(
string Name,

View File

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

View File

@@ -0,0 +1,33 @@
namespace GerbilManagerWebAPI.Dtos
{
// FEAT-13: Abgabeverträge + Zuchtprofil (eigene Datei, hält ApiDtos.cs konfliktfrei).
public record SaleContractDto(
Guid Id,
Guid ContactId,
decimal Price,
DateOnly HandoverDate,
DateOnly ContractDate,
string FileName,
DateTimeOffset CreatedAt,
IReadOnlyList<Guid> GerbilIds,
// Download-URL der .docx ("/contracts/{id}/file").
string Url);
public record SaleContractInput(
Guid ContactId,
List<Guid> GerbilIds,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate);
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
public record BreederProfileDto(
string ZuchtName,
string Name,
string Address,
string Phone,
string Email,
string Homepage,
string City);
}

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

@@ -0,0 +1,201 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-13: Abgabeverträge.
/// POST /contracts -> erzeugt .docx (phase-A-Generator), speichert sie im
/// Vertrags-Dateiroot, legt die Vertragszeile an und stellt
/// die Tiere in DERSELBEN Transaktion auf Abgegeben
/// (ReceiverContactId, GoHomeDate, Status).
/// GET /contracts -> Gridify-paged (z. B. filter=contactId==…)
/// GET /contracts/{id} -> Metadaten
/// GET /contracts/{id}/file -> .docx-Download (deutscher Dateiname)
/// DELETE /contracts/{id} -> entfernt Zeile + Datei (Tier-Status bleibt unberührt)
/// </summary>
public static class ContractEndpoints
{
private const string DocxContentType =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/contracts").WithTags("Contracts");
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
.Include(c => c.Animals)
.ToPagedResultAsync(query, ToDto)));
// GET /contracts/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Animals)
.FirstOrDefaultAsync(x => x.Id == id);
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
});
// POST /contracts — der Abgabe-Abschluss.
group.MapPost("/", async Task<Results<Created<SaleContractDto>, ValidationProblem>> (
SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var errors = new Dictionary<string, string[]>();
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
if (gerbilIds.Count == 0)
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
if (input.Price < 0)
errors["price"] = ["Der Kaufpreis darf nicht negativ sein."];
var contact = await db.Contacts.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == input.ContactId);
if (contact is null)
errors["contactId"] = ["Der Abnehmer wurde nicht gefunden."];
var gerbils = await db.Gerbils
.Include(g => g.ColorVariety)
.Where(g => gerbilIds.Contains(g.Id))
.ToListAsync();
if (gerbils.Count != gerbilIds.Count)
errors["gerbilIds"] = ["Mindestens ein ausgewähltes Tier wurde nicht gefunden."];
if (errors.Count > 0) return TypedResults.ValidationProblem(errors);
var settings = await db.BreederSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
?? new BreederSettings();
var contractDate = input.ContractDate ?? input.HandoverDate;
var data = new ContractData(
Seller: ToSeller(settings),
Buyer: new ContractBuyer(contact!.Name, contact.Address ?? "", contact.Phone, contact.Email),
Animals: gerbils
.Select(g => new ContractAnimal(g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name))
.ToList(),
Price: input.Price,
HandoverDate: input.HandoverDate,
ContractDate: contractDate);
var bytes = ContractGenerator.Generate(data);
var fileName = $"{Guid.NewGuid():N}.docx";
var root = ContractRoot(config, env);
Directory.CreateDirectory(root);
await File.WriteAllBytesAsync(Path.Combine(root, fileName), bytes);
var entity = new SaleContract
{
Id = Guid.NewGuid(),
ContactId = contact.Id,
Price = input.Price,
HandoverDate = input.HandoverDate,
ContractDate = contractDate,
FileName = fileName,
CreatedAt = DateTimeOffset.UtcNow,
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
};
db.SaleContracts.Add(entity);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
foreach (var g in gerbils)
{
g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate;
g.Status = GerbilStatus.GivenAway;
}
try
{
await db.SaveChangesAsync();
}
catch
{
// DB fehlgeschlagen -> verwaiste Datei wieder aufräumen.
var orphan = Path.Combine(root, fileName);
if (File.Exists(orphan)) File.Delete(orphan);
throw;
}
return TypedResults.Created($"/contracts/{entity.Id}", ToDto(entity));
});
// GET /contracts/{id}/file — Download mit sprechendem deutschen Dateinamen.
group.MapGet("/{id:guid}/file", async Task<Results<PhysicalFileHttpResult, NotFound>> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Contact)
.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
var path = Path.Combine(ContractRoot(config, env), c.FileName);
if (!File.Exists(path)) return TypedResults.NotFound();
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact?.Name)}.docx";
return TypedResults.PhysicalFile(path, DocxContentType, download);
});
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
// (das wäre Magie — Status korrigiert man am Tier selbst).
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
var path = Path.Combine(ContractRoot(config, env), c.FileName);
if (File.Exists(path)) File.Delete(path);
db.SaleContracts.Remove(c); // Joins kaskadieren
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
private static BreederProfile ToSeller(BreederSettings s) => new()
{
ZuchtName = s.ZuchtName,
Name = s.Name,
Address = s.Address,
Phone = s.Phone,
Email = s.Email,
Homepage = s.Homepage,
City = s.City,
};
/// <summary>Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument).</summary>
internal static string GeschlechtText(Gender gender) => gender switch
{
Gender.male => "Männlich",
Gender.female => "Weiblich",
_ => "Unbekannt",
};
/// <summary>Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-').</summary>
private static string Sanitize(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "Abnehmer";
var cleaned = new string(name.Trim()
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
.ToArray());
return cleaned.Trim('-');
}
internal static SaleContractDto ToDto(SaleContract c) => new(
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
c.Animals.Select(a => a.GerbilId).ToList(),
$"/contracts/{c.Id}/file");
}
}

View File

@@ -0,0 +1,36 @@
using GerbilManagerWebAPI.Export;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// EXPORT-1: GET /export — komplette Datensicherung als Zip
/// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
/// </summary>
public static class ExportEndpoints
{
public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) =>
{
var data = new ExportService.ExportData(
await db.Gerbils.AsNoTracking().ToListAsync(ct),
await db.Litters.AsNoTracking().ToListAsync(ct),
await db.Contacts.AsNoTracking().ToListAsync(ct),
await db.Enclosures.AsNoTracking().ToListAsync(ct),
await db.ColorVarieties.AsNoTracking().ToListAsync(ct),
await db.HealthRecords.AsNoTracking().ToListAsync(ct),
await db.WeightRecords.AsNoTracking().ToListAsync(ct),
await db.GerbilPhotos.AsNoTracking().ToListAsync(ct));
var today = DateOnly.FromDateTime(DateTime.Now);
var bytes = ExportService.BuildZip(data, today);
return Results.File(bytes, "application/zip",
$"rennmaus-export-{today:yyyy-MM-dd}.zip");
})
.WithTags("Export");
return app;
}
}
}

View File

@@ -19,6 +19,13 @@ namespace GerbilManagerWebAPI.Endpoints
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.Where(g => g.OriginBreeder != null && g.OriginBreeder != "")
.Select(g => g.OriginBreeder!)
.Distinct().OrderBy(b => b).ToListAsync()));
// GET /gerbils/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
@@ -87,11 +94,17 @@ namespace GerbilManagerWebAPI.Endpoints
g.Notes = i.Notes;
g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef;
g.OriginBreeder = i.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote;
g.IsDeaf = i.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
}
internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef);
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
}
}

View File

@@ -0,0 +1,35 @@
using GerbilManagerWebAPI.Import;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output.
/// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes)
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
/// against Julian's DB under god-supervision after he approves the dry-run.
/// </summary>
public static class ImportEndpoints
{
public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/import").WithTags("Import");
group.MapPost("/dry-run", async Task<Ok<ImportReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var report = await new ImportService(db, config, env).RunAsync(execute: false);
return TypedResults.Ok(report);
});
group.MapPost("/execute", async Task<Ok<ImportReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var report = await new ImportService(db, config, env).RunAsync(execute: true);
return TypedResults.Ok(report);
});
return app;
}
}
}

View File

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

View File

@@ -0,0 +1,34 @@
using GerbilManagerWebAPI.SaleAd;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-12a: POST /gerbils/sale-ad — AI-gestütztes Abgabe-Inserat (frozen
/// contract, see gerbil-manager-web/src/api/saleAd.ts).
///
/// 503 {code:"AiKeyMissing"} while the AI section is unconfigured — the
/// frontend maps exactly this code to its German disabled-state hint.
/// </summary>
public static class SaleAdEndpoints
{
public static IEndpointRouteBuilder MapSaleAdEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/gerbils/sale-ad",
async (SaleAdRequest request, SaleAdService service, CancellationToken ct) =>
{
var result = await service.GenerateAsync(request, ct);
return result.Status switch
{
SaleAdStatus.Ok => Results.Ok(new SaleAdResponse(result.Text!)),
SaleAdStatus.NotConfigured => Results.Json(
new SaleAdError("AiKeyMissing", result.Error ?? ""), statusCode: 503),
_ => Results.Json(
new SaleAdError("AiUpstreamError", result.Error ?? ""), statusCode: 502),
};
})
.WithTags("SaleAd");
return app;
}
}
}

View File

@@ -0,0 +1,60 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-13: Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeile,
/// per Migration leer geseedet, von der Züchterin unter /einstellungen gepflegt.
/// GET /settings/breeder-profile -> BreederProfileDto
/// PUT /settings/breeder-profile -> 204
/// </summary>
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/settings").WithTags("Settings");
group.MapGet("/breeder-profile", async Task<Ok<BreederProfileDto>> (ApplicationContext db) =>
{
var s = await LoadAsync(db, track: false);
return TypedResults.Ok(ToDto(s));
});
group.MapPut("/breeder-profile", async Task<NoContent> (BreederProfileDto input, ApplicationContext db) =>
{
var s = await LoadAsync(db, track: true);
s.ZuchtName = input.ZuchtName ?? "";
s.Name = input.Name ?? "";
s.Address = input.Address ?? "";
s.Phone = input.Phone ?? "";
s.Email = input.Email ?? "";
s.Homepage = input.Homepage ?? "";
s.City = input.City ?? "";
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
/// <summary>Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt.</summary>
private static async Task<BreederSettings> LoadAsync(ApplicationContext db, bool track)
{
var query = track ? db.BreederSettings : db.BreederSettings.AsNoTracking();
var s = await query.FirstOrDefaultAsync(x => x.Id == BreederSettings.SingletonId);
if (s is null)
{
s = new BreederSettings { Id = BreederSettings.SingletonId };
db.BreederSettings.Add(s);
await db.SaveChangesAsync();
}
return s;
}
internal static BreederProfileDto ToDto(BreederSettings s) =>
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
}
}

View File

@@ -0,0 +1,212 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Export
{
/// <summary>
/// EXPORT-1: user-facing Datenexport — builds a zip containing
/// - export.json (full fidelity: every entity incl. genotypes + import provenance)
/// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
/// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
/// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
///
/// Pure function over <see cref="ExportData"/> (no DbContext) so the round-trip
/// and escaping tests run without a database.
/// </summary>
public static class ExportService
{
public sealed record ExportData(
List<Gerbil> Gerbils,
List<Litter> Litters,
List<Contact> Contacts,
List<Enclosure> Enclosures,
List<ColorVariety> ColorVarieties,
List<HealthRecord> HealthRecords,
List<WeightRecord> WeightRecords,
List<GerbilPhoto> Photos);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
WriteIndented = true,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
};
// German labels for enum values (CSV is the wife-facing backup format;
// the JSON keeps the enum member names for lossless re-import).
private static readonly Dictionary<Gender, string> GenderDe = new()
{
[Gender.unknown] = "Unbekannt",
[Gender.male] = "Männlich",
[Gender.female] = "Weiblich",
};
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
{
[HealthRecordType.Examination] = "Untersuchung",
[HealthRecordType.Treatment] = "Behandlung",
[HealthRecordType.Injury] = "Verletzung",
[HealthRecordType.Vaccination] = "Impfung",
[HealthRecordType.Other] = "Sonstiges",
};
public static byte[] BuildZip(ExportData data, DateOnly exportDate)
{
var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
string? Lookup<TKey>(Dictionary<TKey, string> map, TKey? key) where TKey : struct =>
key is null ? null : map.GetValueOrDefault(key.Value);
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
AddText(zip, "export.json", JsonSerializer.Serialize(new
{
exportedAt = exportDate.ToString("yyyy-MM-dd"),
data.Gerbils,
data.Litters,
data.Contacts,
data.Enclosures,
data.ColorVarieties,
data.HealthRecords,
data.WeightRecords,
data.Photos,
}, JsonOptions), bom: false);
AddCsv(zip, "tiere.csv",
["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
"Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
"Genotyp", "Notizen"],
data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
{
g.Name, GenderDe[g.Gender], StatusDe[g.Status],
De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
g.Genotype, g.Notes,
}));
AddCsv(zip, "wuerfe.csv",
["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
"Voraussichtliches Abgabedatum", "Notizen"],
data.Litters.OrderBy(l => l.Date).Select(l => new[]
{
l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
De(l.ExpectedGoHomeDate), l.Notes,
}));
AddCsv(zip, "kontakte.csv",
["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
data.Contacts.OrderBy(c => c.Name).Select(c => new[]
{
c.Name, c.Email, c.Phone, c.Address, c.Notes,
}));
AddCsv(zip, "gesundheit.csv",
["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
{
gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
HealthTypeDe[h.Type], h.Description, h.Veterinarian,
}));
AddCsv(zip, "gewichte.csv",
["Tier", "Datum", "Gewicht (g)", "Notizen"],
data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
{
gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
}));
AddText(zip, "LIESMICH.txt", $"""
Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
Enthalten:
- export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
- tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
- wuerfe.csv : alle Würfe.
- kontakte.csv : alle Kontakte.
- gesundheit.csv: alle Gesundheitseinträge.
- gewichte.csv : alle Gewichtseinträge.
Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
steht in export.json (Abschnitt "photos").
""", bom: false);
}
return stream.ToArray();
}
// ── CSV building blocks ────────────────────────────────────────────
/// <summary>"2024-03-12" -> "12.03.2024"; null -> empty.</summary>
private static string? De(DateOnly? date) =>
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
private static string De(DateOnly date) =>
date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
/// <summary>
/// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
/// they contain separator/quote/newline; quotes doubled.
/// </summary>
internal static string BuildCsv(string[] header, IEnumerable<string?[]> rows)
{
var sb = new StringBuilder();
sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
foreach (var row in rows)
sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
return sb.ToString();
}
internal static string Escape(string? field)
{
if (string.IsNullOrEmpty(field)) return "";
return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
? $"\"{field.Replace("\"", "\"\"")}\""
: field;
}
private static void AddCsv(ZipArchive zip, string name, string[] header,
IEnumerable<string?[]> rows) =>
AddText(zip, name, BuildCsv(header, rows), bom: true);
private static void AddText(ZipArchive zip, string name, string content, bool bom)
{
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
using var entryStream = entry.Open();
if (bom)
{
// UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
entryStream.Write(Encoding.UTF8.GetPreamble());
}
var bytes = Encoding.UTF8.GetBytes(content);
entryStream.Write(bytes);
}
}
}

View File

@@ -8,6 +8,8 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
@@ -24,4 +26,14 @@
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
namespace GerbilManagerWebAPI.Import
{
// ---- Source shapes (tools/import/output JSON, produced by extract.py) ----
public sealed class SourceAnimal
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public List<string> NameVariants { get; set; } = new();
public string Dob { get; set; } = "";
public string Death { get; set; } = "";
public string? Gender { get; set; }
public string Farbschlag { get; set; } = "";
public List<string> FarbschlagVariants { get; set; } = new();
public SourceGenotype Genotype { get; set; } = new();
public string Zucht { get; set; } = "";
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
public List<SourceParentRef> ParentRefs { get; set; } = new();
public List<string> Photos { get; set; } = new();
public List<string> SourceFiles { get; set; } = new();
public bool Conflict { get; set; }
public SourceLitterRef? LitterRef { get; set; }
// GEN-3b normalization: hearing/deaf phenotype flag (null = not stated) and
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
public bool? Deaf { get; set; }
public List<string> Tags { get; set; } = new();
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
// this animal (its genotype/farbschlag are then authoritative). For reporting.
public bool ResolvedByDecision { get; set; }
}
public sealed class SourceGenotype
{
public Dictionary<string, List<string>> Mapped8locus { get; set; } = new();
public string RawGenotype { get; set; } = "";
public List<string> UnmappedTokens { get; set; } = new();
}
public sealed class SourceParentRef
{
public string Name { get; set; } = "";
public string Dob { 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 string LitterId { get; set; } = "";
public string Method { get; set; } = "";
public string Confidence { get; set; } = ""; // "hoch" | "niedrig" | (ambiguous => candidates)
public List<string>? Candidates { get; set; }
}
public sealed class SourceLitter
{
public string Id { get; set; } = "";
public string LitterId { get; set; } = "";
public string Date { get; set; } = "";
public string DamName { get; set; } = "";
public string SireName { get; set; } = "";
public int? TotalBorn { get; set; }
public string Zuchtnummer { get; set; } = "";
public string Note { get; set; } = "";
public List<string> Warnings { get; set; } = new();
}
// ---- Report shapes (Julian-readable: counts per category + samples) ----
public sealed record ImportReport(
bool Executed,
LitterSummary Litters,
AnimalSummary Animals,
PhotoSummary Photos,
IReadOnlyList<string> Samples,
IReadOnlyList<string> Notes,
ResidencySummary? Residency = null);
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
public sealed record AnimalSummary(
int InSource,
int Created,
int LinkedToLitter,
int FarbschlagMatched,
int FarbschlagUnmatched,
int AlreadyImported,
QuarantineSummary Quarantined,
int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0);
public sealed record QuarantineSummary(
int Conflicts,
int Stubs,
int DateOnlyLinks,
int AmbiguousLinks,
int Total);
public sealed record PhotoSummary(int Attached, int SourceFilesMissing);
}

View File

@@ -0,0 +1,517 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
/// <summary>
/// FEAT-8c import loader. Consumes tools/import/output (animals.json + litters.json
/// produced by extract.py) and loads conflict-free data into the database:
/// litters first (Wurfchronik = authoritative), then animals matched onto them.
///
/// Load policy:
/// - Litters: all created (idempotent by ExternalRef = source litter id).
/// - Animals: created when they have a DOB and are NOT in conflict. The birth-litter
/// link is set only for HIGH-confidence matches (litterRef.confidence == "hoch");
/// date-only/ambiguous links are quarantined (animal loads with LitterId = null).
/// - QUARANTINED (never loaded): conflicts + stubs (no DOB) — await the wife's review.
/// Idempotent: re-running matches on ExternalRef and skips existing rows.
/// Execute is gated by the endpoint; this service only acts when asked.
/// </summary>
public sealed class ImportService
{
private static readonly string[] LocusOrder = { "A", "C", "D", "E", "G", "P", "Sp", "Re" };
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
};
private const string ImportSourceTag = "FEAT-8 Stammbaum/Wurfchronik";
private readonly ApplicationContext _db;
private readonly string _sourceDir;
private readonly string _photoRoot;
/// <summary>Test-friendly constructor with explicit paths.</summary>
public ImportService(ApplicationContext db, string sourceDir, string photoRoot)
{
_db = db;
_sourceDir = sourceDir;
_photoRoot = photoRoot;
}
public ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
: this(db,
config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")),
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage"))
{
}
public async Task<ImportReport> RunAsync(bool execute)
{
var notes = new List<string>();
var samples = new List<string>();
var animals = Load<List<SourceAnimal>>("animals.json") ?? new();
var litters = Load<List<SourceLitter>>("litters.json") ?? new();
if (animals.Count == 0 && litters.Count == 0)
notes.Add($"Keine Quelldaten gefunden in {_sourceDir} (animals.json/litters.json). extract.py zuerst ausführen.");
// ---- categorise animals ----
var loadable = new List<SourceAnimal>();
int conflicts = 0, stubs = 0, dateOnly = 0, ambiguous = 0;
foreach (var a in animals)
{
if (a.Conflict) { conflicts++; continue; }
if (string.IsNullOrEmpty(a.Dob)) { stubs++; continue; }
loadable.Add(a);
var conf = a.LitterRef?.Confidence;
if (a.LitterRef?.Candidates is { Count: > 0 }) ambiguous++;
else if (conf == "niedrig") dateOnly++;
}
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
var existingGerbilExtRefs = await _db.Gerbils
.Where(g => g.ExternalRef != null)
.Select(g => g.ExternalRef!).ToListAsync();
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
var existingLitterKeys = await _db.Litters
.Select(l => new { l.Name, l.Date }).ToListAsync();
var existingLitterKeySet = existingLitterKeys
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
// colour-variety name -> id (case-insensitive)
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
var varietyByName = varieties
.GroupBy(v => v.Name.Trim().ToLowerInvariant())
.ToDictionary(g => g.Key, g => g.First().Id);
// gender inference from litter roles (sire -> male, dam -> female; both -> unknown)
var sireNames = litters.Select(l => Normalize(StripZucht(l.SireName))).Where(s => s.Length > 0).ToHashSet();
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
int littersCreated = 0, littersExisting = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters)
{
var date = ParseDate(sl.Date);
var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}";
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
var id = Guid.NewGuid();
litterIdMap[sl.Id] = id;
littersCreated++;
if (execute && date is DateOnly d)
{
_db.Litters.Add(new Litter
{
Id = id,
Name = name,
Date = d,
TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
});
}
if (samples.Count < 8 && date is not null)
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
}
if (execute) await _db.SaveChangesAsync();
// ---- animals ----
int animalsCreated = 0, linked = 0, fbMatched = 0, fbUnmatched = 0, animalsExisting = 0;
int photosAttached = 0, photosMissing = 0;
var createdAnimalByName = new Dictionary<string, Guid>(); // normalized name -> gerbil id (for litter back-link)
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
var existingRows = await _db.Gerbils
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).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)
{
bool exists = existingGerbilSet.Contains(a.Id);
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
Guid? wurfLitterId = null;
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
wurfLitterId = lid;
Guid? colorVarietyId = null;
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
.Where(s => !string.IsNullOrWhiteSpace(s));
foreach (var fb in fbCandidates)
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
{ colorVarietyId = vid; break; }
var gender = InferGender(a, sireNames, damNames);
var norm = Normalize(StripZucht(a.Name));
if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid);
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
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)
samples.Add($"Tier: {p.A.Name} (*{p.A.Dob}), Genotyp {ComposeGenotype(p.A.Genotype)}"
+ (litterId is not null ? (p.WurfLitterId is not null ? ", Wurf-verknüpft" : ", Eltern aus Diagramm") : "")
+ (p.ColorVarietyId is not null ? $", Farbschlag „{p.A.Farbschlag}\"" : ""));
if (p.Exists)
{
animalsExisting++;
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
if (row is not null)
{
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
row.IsResident = isResident;
}
}
continue;
}
animalsCreated++;
if (execute)
{
_db.Gerbils.Add(new Gerbil
{
Id = p.Gid,
Name = p.A.Name,
Gender = p.Gender,
Status = GerbilStatus.Active,
DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId,
ColorVarietyId = p.ColorVarietyId,
Genotype = ComposeGenotype(p.A.Genotype),
IsDeaf = p.A.Deaf,
IsResident = isResident,
ImportSource = ImportSourceTag,
ExternalRef = p.A.Id,
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
RawImportData = JsonSerializer.Serialize(new
{
p.A.Genotype.RawGenotype,
p.A.Genotype.UnmappedTokens,
// GEN-3b: Sls (2nd spotting locus) preserved here until Kevin's GEN-3a
// parser adopts it into the compact Genotype contract; tags + deaf too.
Sls = p.A.Genotype.Mapped8locus.TryGetValue("Sls", out var sls) ? sls : null,
p.A.Tags,
p.A.Deaf,
p.A.Zucht,
p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag,
}),
});
}
// photos
foreach (var rel in p.A.Photos)
{
var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(src)) { photosMissing++; continue; }
photosAttached++;
if (execute)
{
Directory.CreateDirectory(_photoRoot);
var fileName = $"{Guid.NewGuid():N}{Path.GetExtension(src)}";
File.Copy(src, Path.Combine(_photoRoot, fileName), overwrite: true);
_db.GerbilPhotos.Add(new GerbilPhoto
{
Id = Guid.NewGuid(),
GerbilId = p.Gid,
FileName = fileName,
SortOrder = 0,
CreatedAt = DateTimeOffset.UtcNow,
});
}
}
}
if (execute) await _db.SaveChangesAsync();
// ---- back-link Wurfchronik litter parents by name (best effort) ----
if (execute)
{
foreach (var sl in litters)
{
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
if (litter is null) continue;
// guard: only link parents that are actually persisted (avoid an orphan FK).
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId))
litter.FatherId = fId;
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId))
litter.MotherId = mId;
}
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.");
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.");
return new ImportReport(
Executed: execute,
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
parentLinksAdded, conflictsResolvedByDecision),
Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples,
Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
}
private T? Load<T>(string file)
{
var path = Path.Combine(_sourceDir, file);
if (!File.Exists(path)) return default;
using var fs = File.OpenRead(path);
return JsonSerializer.Deserialize<T>(fs, Json);
}
public static string ComposeGenotype(SourceGenotype g)
{
var tokens = LocusOrder.Select(locus =>
{
if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2)
return StripCaret(pair[0]) + StripCaret(pair[1]);
return "??";
}).ToList();
// GEN-3a contract (Kevin): Sls is appended LAST and ONLY for carriers — the
// wild-type sl/sl is omitted so existing 8-locus strings stay unchanged. WP het
// renders as the trailing token "Slsl"; S(l)S(l) is lethal so never appears.
if (g.Mapped8locus.TryGetValue("Sls", out var sls) && sls.Count == 2
&& !(sls[0] == "sl" && sls[1] == "sl"))
{
tokens.Add(StripCaret(sls[0]) + StripCaret(sls[1]));
}
return string.Join(' ', tokens);
}
private static string StripCaret(string allele) => allele.Replace("^", "");
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
{
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
// over sire/dam name inference (PEDIGREE-LINK, Julian 2026-06-06).
if (string.Equals(a.Gender, "male", StringComparison.OrdinalIgnoreCase)) return Gender.male;
if (string.Equals(a.Gender, "female", StringComparison.OrdinalIgnoreCase)) return Gender.female;
var n = Normalize(StripZucht(a.Name));
bool isSire = sires.Contains(n), isDam = dams.Contains(n);
if (isSire && !isDam) return Gender.male;
if (isDam && !isSire) return Gender.female;
return Gender.unknown;
}
public static DateOnly? ParseDate(string s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
var m = Regex.Match(s, @"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
if (!m.Success) return null;
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
int y = int.Parse(m.Groups[3].Value);
if (y < 100) y += 2000;
try { return new DateOnly(y, mo, d); }
catch { return null; }
}
private static string StripZucht(string name)
{
var n = Regex.Replace(name ?? "", @"\[.*?\]", " ");
n = Regex.Replace(n, @"\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+.*$", "", RegexOptions.IgnoreCase);
return n.Trim();
}
private static string Normalize(string name)
{
var n = (name ?? "").ToLowerInvariant();
n = Regex.Replace(n, @"\bgen\.\b", " ");
n = Regex.Replace(n, @"[^a-z0-9äöüß]", "");
return n;
}
/// <summary>Dedup identity for parent resolution: normalized call-name + DOB.</summary>
private static string NameDobKey(string name, DateOnly? dob) =>
$"{Normalize(StripZucht(name))}|{dob:yyyy-MM-dd}";
/// <summary>Resolve a chart-position parentRef (by role) to a known gerbil id, or null.</summary>
private static Guid? ResolveParentGid(SourceAnimal a, string role, Dictionary<string, Guid> gidByNameDob)
{
var pr = a.ParentRefs.FirstOrDefault(p =>
string.Equals(p.RoleGuess, role, StringComparison.OrdinalIgnoreCase));
if (pr is null || string.IsNullOrWhiteSpace(pr.Name)) return null;
return gidByNameDob.TryGetValue(NameDobKey(pr.Name, ParseDate(pr.Dob)), out var id) ? id : null;
}
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
private sealed record AnimalPlan(
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
/// <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,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class SaleContractsAndBreederSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BreederSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ZuchtName = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Address = table.Column<string>(type: "text", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
Homepage = table.Column<string>(type: "text", nullable: false),
City = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BreederSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SaleContracts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ContactId = table.Column<Guid>(type: "uuid", nullable: false),
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
HandoverDate = table.Column<DateOnly>(type: "date", nullable: false),
ContractDate = table.Column<DateOnly>(type: "date", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SaleContracts", x => x.Id);
table.ForeignKey(
name: "FK_SaleContracts_Contacts_ContactId",
column: x => x.ContactId,
principalTable: "Contacts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "SaleContractAnimal",
columns: table => new
{
SaleContractId = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SaleContractAnimal", x => new { x.SaleContractId, x.GerbilId });
table.ForeignKey(
name: "FK_SaleContractAnimal_Gerbils_GerbilId",
column: x => x.GerbilId,
principalTable: "Gerbils",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SaleContractAnimal_SaleContracts_SaleContractId",
column: x => x.SaleContractId,
principalTable: "SaleContracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "BreederSettings",
columns: new[] { "Id", "Address", "City", "Email", "Homepage", "Name", "Phone", "ZuchtName" },
values: new object[] { new Guid("11111111-1111-1111-1111-000000000001"), "", "", "", "", "", "", "" });
migrationBuilder.CreateIndex(
name: "IX_SaleContractAnimal_GerbilId",
table: "SaleContractAnimal",
column: "GerbilId");
migrationBuilder.CreateIndex(
name: "IX_SaleContracts_ContactId",
table: "SaleContracts",
column: "ContactId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BreederSettings");
migrationBuilder.DropTable(
name: "SaleContractAnimal");
migrationBuilder.DropTable(
name: "SaleContracts");
}
}
}

View File

@@ -0,0 +1,897 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
[DbContext(typeof(ApplicationContext))]
[Migration("20260606052952_AddImportProvenanceFields")]
partial class AddImportProvenanceFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CanonicalGenotype")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("ColorVarieties");
b.HasData(
new
{
Id = new Guid("00000000-0000-0000-0000-000000000001"),
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
Name = "Pink Eyed White (PEW)",
SortOrder = 0
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000002"),
CanonicalGenotype = "aa chch DD EE GG PP spsp rere",
Name = "Hermelin",
SortOrder = 1
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000003"),
CanonicalGenotype = "AA chch DD EE GG PP spsp rere",
Name = "Himalaya",
SortOrder = 2
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000004"),
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
Name = "Zobel",
SortOrder = 3
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000005"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Schwarzschimmel",
SortOrder = 4
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000006"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
Name = "Rotaugenschimmel",
SortOrder = 5
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000007"),
CanonicalGenotype = "AA CC DD EE GG PP spsp rere",
Name = "Agouti",
SortOrder = 6
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000008"),
CanonicalGenotype = "aa CC DD EE GG PP spsp rere",
Name = "Schwarz",
SortOrder = 7
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000009"),
CanonicalGenotype = "AA CC DD EE gg PP spsp rere",
Name = "Silberagouti",
SortOrder = 8
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000010"),
CanonicalGenotype = "aa CC DD EE gg PP spsp rere",
Name = "Anthrazit",
SortOrder = 9
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000011"),
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
Name = "Algierfuchs",
SortOrder = 10
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000012"),
CanonicalGenotype = "aa CC dd EE GG PP spsp rere",
Name = "Blau",
SortOrder = 11
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000013"),
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Gold",
SortOrder = 12
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000014"),
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
Name = "Platin",
SortOrder = 13
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000015"),
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
Name = "Goldfuchs",
SortOrder = 14
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000016"),
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
Name = "Rotfuchs",
SortOrder = 15
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000017"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "dd Gold",
SortOrder = 16
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000018"),
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
Name = "dd Platin",
SortOrder = 17
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000019"),
CanonicalGenotype = "aa CC DD EE gg pp spsp rere",
Name = "Altweiss (REW)",
SortOrder = 18
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000020"),
CanonicalGenotype = "AA CC DD ee gg pp spsp rere",
Name = "Apricot (Blassfuchs)",
SortOrder = 19
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000021"),
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
Name = "Blaufuchs",
SortOrder = 20
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000022"),
CanonicalGenotype = "aa CC DD ee gg pp spsp rere",
Name = "C-Separator",
SortOrder = 21
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000023"),
CanonicalGenotype = "AA CC DD EE gg pp spsp rere",
Name = "Elfenbein",
SortOrder = 22
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000024"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs",
SortOrder = 23
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000025"),
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
Name = "Marder",
SortOrder = 24
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000026"),
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
Name = "Siam (Marder-Hell)",
SortOrder = 25
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000027"),
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
Name = "Polarfuchs",
SortOrder = 26
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000028"),
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
Name = "Saphir",
SortOrder = 27
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000029"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Schimmel (Orangeschimmel)",
SortOrder = 28
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000030"),
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Topas",
SortOrder = 29
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000031"),
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
Name = "Platin-Hell",
SortOrder = 30
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000032"),
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
Name = "Agouti dd",
SortOrder = 31
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000033"),
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
Name = "Silberagouti dd",
SortOrder = 32
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
Name = "Kohlfuchs dd",
SortOrder = 33
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
Name = "Anthrazit dd",
SortOrder = 34
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
Name = "Agouti CP-Hell",
SortOrder = 35
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000037"),
CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere",
Name = "Blaufuchs CP",
SortOrder = 36
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000038"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Polarfuchsschimmel",
SortOrder = 37
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000039"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Silberschimmel",
SortOrder = 38
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Algierfuchsschimmel",
SortOrder = 39
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000041"),
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
Name = "Polarfuchs-Hell CP",
SortOrder = 40
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000042"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel",
SortOrder = 41
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
Name = "Blaufuchsschimmel",
SortOrder = 42
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000044"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs, hell",
SortOrder = 43
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000045"),
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
Name = "Goldfuchs, hell",
SortOrder = 44
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000046"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
Name = "Goldfuchsschimmel",
SortOrder = 45
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000047"),
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Gold-Hell",
SortOrder = 46
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000048"),
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
Name = "Siam (Marder-Hell) dd",
SortOrder = 47
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000049"),
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
Name = "Marder dd",
SortOrder = 48
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000050"),
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
Name = "Zobel-Hell",
SortOrder = 49
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000051"),
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
Name = "Silberagouti dd CP",
SortOrder = 50
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000052"),
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
Name = "Silberagouti-Hell dd CP",
SortOrder = 51
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000053"),
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
Name = "Agouti dd CP",
SortOrder = 52
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000054"),
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
Name = "Agouti-Hell dd CP",
SortOrder = 53
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000055"),
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
Name = "Blaufuchs, hell",
SortOrder = 54
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000056"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
Name = "Rotfuchsschimmel",
SortOrder = 55
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000057"),
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
Name = "Polarfuchs, hell",
SortOrder = 56
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000058"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel, hell",
SortOrder = 57
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000059"),
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
Name = "Rotfuchs, hell",
SortOrder = 58
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000060"),
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
Name = "Zobel dd",
SortOrder = 59
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000061"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs-Hell",
SortOrder = 60
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
Name = "Kohlfuchs CP",
SortOrder = 61
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "Algierfuchs CP",
SortOrder = 62
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
Name = "Silberagouti CP",
SortOrder = 63
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000065"),
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
Name = "Agouti CP",
SortOrder = 64
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "Algierfuchs-Hell CP",
SortOrder = 65
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000067"),
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
Name = "Kohlfuchs,hell CP",
SortOrder = 66
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000068"),
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
Name = "Polarfuchs CP",
SortOrder = 67
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000069"),
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
Name = "Algierfuchs, hell",
SortOrder = 68
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000070"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "Topas dd",
SortOrder = 69
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000071"),
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
Name = "Zobel-Hell dd",
SortOrder = 70
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000072"),
CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel CP",
SortOrder = 71
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000073"),
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
Name = "Blaufuchs dd",
SortOrder = 72
});
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Phone")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Contacts");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Enclosures");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CauseOfDeath")
.HasColumnType("text");
b.Property<Guid?>("ColorVarietyId")
.HasColumnType("uuid");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date");
b.Property<DateOnly?>("DateOfDeath")
.HasColumnType("date");
b.Property<Guid?>("EnclosureId")
.HasColumnType("uuid");
b.Property<string>("ExternalRef")
.HasColumnType("text");
b.Property<string>("Gender")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Genotype")
.HasColumnType("text");
b.Property<DateOnly?>("GoHomeDate")
.HasColumnType("date");
b.Property<string>("ImportSource")
.HasColumnType("text");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<string>("RawImportData")
.HasColumnType("text");
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ColorVarietyId");
b.HasIndex("EnclosureId");
b.HasIndex("LitterId");
b.HasIndex("OriginContactId");
b.HasIndex("ReceiverContactId");
b.ToTable("Gerbils");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Caption")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("GerbilPhotos");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Veterinarian")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("HealthRecords");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date");
b.Property<Guid?>("FatherId")
.HasColumnType("uuid");
b.Property<Guid?>("MotherId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FatherId");
b.HasIndex("MotherId");
b.ToTable("Litters");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<int>("WeightGrams")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("WeightRecords");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
.WithMany()
.HasForeignKey("ColorVarietyId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure")
.WithMany("Gerbils")
.HasForeignKey("EnclosureId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter")
.WithMany()
.HasForeignKey("LitterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact")
.WithMany()
.HasForeignKey("OriginContactId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact")
.WithMany()
.HasForeignKey("ReceiverContactId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ColorVariety");
b.Navigation("Enclosure");
b.Navigation("Litter");
b.Navigation("OriginContact");
b.Navigation("ReceiverContact");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
.WithMany()
.HasForeignKey("GerbilId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
.WithMany()
.HasForeignKey("GerbilId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father")
.WithMany()
.HasForeignKey("FatherId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother")
.WithMany()
.HasForeignKey("MotherId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Father");
b.Navigation("Mother");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
.WithMany()
.HasForeignKey("GerbilId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
{
b.Navigation("Gerbils");
});
#pragma warning restore 612, 618
}
}
}

View File

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

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,36 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeilen-Entity
/// (Singleton, fixe Id, leer geseedet). Die Züchterin pflegt ihre Daten in
/// der App unter /einstellungen statt in einer JSON-Datei; FEAT-13 phase B
/// ersetzt damit die appsettings-Variante aus phase A.
/// </summary>
public class BreederSettings
{
/// <summary>Fixe Id der einzigen Zeile (per HasData geseedet).</summary>
public static readonly Guid SingletonId = new("11111111-1111-1111-1111-000000000001");
[Key]
public Guid Id { get; set; }
public string ZuchtName { get; set; } = "";
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
public string Name { get; set; } = "";
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
public string Address { get; set; } = "";
public string Phone { get; set; } = "";
public string Email { get; set; } = "";
public string Homepage { get; set; } = "";
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
public string City { get; set; } = "";
}
}

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

@@ -46,5 +46,51 @@ namespace GerbilManagerWebAPI.Models
// Provenance (for the FEAT-8 spreadsheet import).
public string? ImportSource { get; set; }
public string? ExternalRef { get; set; }
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; }
/// <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

@@ -21,5 +21,9 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Computed ~35 days after Date by default; editable.</summary>
public DateOnly? ExpectedGoHomeDate { get; set; }
public string? Notes { get; set; }
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
public string? PairingCode { get; set; }
}
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// Ein erzeugter Abgabevertrag (FEAT-13). Die .docx liegt wie die Fotos im
/// Datei-Root (Contracts:RootPath bzw. contract-storage/), in der DB steht
/// nur der Dateiname. Tiere hängen über <see cref="SaleContractAnimal"/>
/// (Join-Entity statt uuid[]-Spalte: referenzielle Integrität, „Verträge
/// eines Tieres“ bleibt abfragbar, und pro Tier-Zeile ist später Platz für
/// Zusatzdaten wie den Einzelpreis).
/// </summary>
public class SaleContract
{
[Key]
public Guid Id { get; set; }
/// <summary>Abnehmer (Käufer-Block des Vertrags).</summary>
public Guid ContactId { get; set; }
public Contact? Contact { get; set; }
/// <summary>Kaufpreis in Euro (gesamt).</summary>
public decimal Price { get; set; }
public DateOnly HandoverDate { get; set; }
/// <summary>Datum der Unterschriftszeile (Standard: Übergabedatum).</summary>
public DateOnly ContractDate { get; set; }
/// <summary>Dateiname der erzeugten .docx im Vertrags-Dateiroot.</summary>
public required string FileName { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public List<SaleContractAnimal> Animals { get; set; } = [];
}
/// <summary>Join-Zeile Vertrag ↔ Tier (zusammengesetzter Schlüssel).</summary>
public class SaleContractAnimal
{
public Guid SaleContractId { get; set; }
public Guid GerbilId { get; set; }
public Gerbil? Gerbil { get; set; }
}
}

View File

@@ -35,6 +35,24 @@ builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
// OpenAPI document for the Scalar API reference (Swashbuckle removed).
builder.Services.AddOpenApi();
// FEAT-12a: provider-agnostic AI config (env vars AI__BaseUrl/AI__ApiKey/AI__Model
// or user-secrets — never committed) + typed client for sale-ad generation.
builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// 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();
app.MapDefaultEndpoints();
@@ -43,9 +61,11 @@ app.MapDefaultEndpoints();
app.MapOpenApi();
app.MapScalarApiReference();
if (app.Environment.IsDevelopment())
// Apply EF migrations at startup (no-op if schema is current; safe for single-instance deploy).
// Unter "Testing" übersprungen: die Endpoint-Tests laufen auf SQLite in-memory
// (EnsureCreated) — die Npgsql-Migrationen sind dort nicht anwendbar.
if (!app.Environment.IsEnvironment("Testing"))
{
// Aspire provisions an empty database in dev — bring the schema up to date.
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
}
@@ -62,5 +82,15 @@ app.MapHealthRecordEndpoints();
app.MapWeightRecordEndpoints();
app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.MapSaleAdEndpoints();
app.MapImportEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.Run();
// Sichtbarer Programmtyp für WebApplicationFactory<Program> (Endpoint-Tests).
public partial class Program { }

View File

@@ -0,0 +1,30 @@
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// FEAT-12a: provider-agnostic AI configuration (section "AI").
///
/// Values come from ENVIRONMENT VARIABLES or user-secrets — NEVER from a
/// committed appsettings file (Julian's directive). Env-var names:
/// AI__BaseUrl, AI__ApiKey, AI__Model
///
/// Any endpoint that speaks the OpenAI-compatible chat-completions shape works:
/// Google Gemini : https://generativelanguage.googleapis.com/v1beta/openai
/// Groq : https://api.groq.com/openai/v1
/// Mistral : https://api.mistral.ai/v1
/// Ollama (lokal): http://&lt;host&gt;:11434/v1 (ApiKey beliebig, z. B. "ollama")
/// </summary>
public sealed class AiOptions
{
public const string SectionName = "AI";
public string? BaseUrl { get; set; }
public string? ApiKey { get; set; }
public string? Model { get; set; }
/// <summary>All three settings present -> the sale-ad endpoint is live.</summary>
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(BaseUrl)
&& !string.IsNullOrWhiteSpace(ApiKey)
&& !string.IsNullOrWhiteSpace(Model);
}
}

View File

@@ -0,0 +1,43 @@
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// FEAT-12a: request/response contract of POST /gerbils/sale-ad — FROZEN,
/// mirror of the frontend (gerbil-manager-web/src/api/saleAd.ts).
/// </summary>
public sealed record SaleAdAnimal(
string Name,
string? Farbschlag,
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
string? DateOfBirth,
string? Notes,
/// <summary>FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt.</summary>
List<string>? Traits = null,
/// <summary>FEAT-14: free-text character note for the prompt.</summary>
string? CharacterNote = null);
public sealed record SaleAdRequest(
List<SaleAdAnimal> Animals,
/// <summary>e.g. "FREI" / "LOCKER RESERVIERT Anna" / "RESERVIERT".</summary>
string StatusLine,
/// <summary>Free-text style hints/wishes from the user.</summary>
string Hints);
public sealed record SaleAdResponse(string Text);
/// <summary>
/// Error body for the non-200 paths. The code string "AiKeyMissing" is FROZEN —
/// Kevin's UI maps it to the German "API-Schlüssel noch nicht konfiguriert" hint.
/// </summary>
public sealed record SaleAdError(string Code, string Message);
public enum SaleAdStatus
{
Ok,
/// <summary>AI section not (fully) configured -> HTTP 503, code "AiKeyMissing".</summary>
NotConfigured,
/// <summary>Provider call failed -> HTTP 502, code "AiUpstreamError".</summary>
UpstreamError,
}
public sealed record SaleAdResult(SaleAdStatus Status, string? Text, string? Error = null);
}

View File

@@ -0,0 +1,118 @@
using System.Globalization;
using System.Text;
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// FEAT-12a: assembles the German prompt for the sale-ad (Abgabe-Inserat)
/// generation. Style and rules follow the breeder's real Jimdo listings
/// (analysed in hive/agents/god/FEAT12-jimdo-publish-research.md):
/// group-based listings, status line, bold emotive tagline, per-animal
/// Farbschlag + "geboren am DD.MM.YYYY" + personality prose, NO PRICES.
/// </summary>
public static class SaleAdPromptBuilder
{
/// <summary>
/// Two few-shot examples derived from the documented listing template.
/// NOTE: the research file documents the format and one real tagline;
/// these examples are synthesized to that template. Swap in verbatim
/// listings from kleine-chaoten.jimdofree.com when available.
/// </summary>
private const string ExampleListing1 = """
Status: FREI
Großer und kleiner Bruder Dynamik Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
Balu CP-Agouti, geboren am 12.03.2024
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
Benny Schwarz Schecke, geboren am 12.03.2024
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest getrennt werden die beiden deshalb nicht.
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
""";
private const string ExampleListing2 = """
Status: LOCKER RESERVIERT Anna
Zwei Schwestern, ein Herz und ganz viel Neugier Weibliches Duo sucht seine Menschen
Frieda Gold, geboren am 28.06.2024
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
Fine Agouti, geboren am 28.06.2024
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
Die Schwestern ziehen selbstverständlich nur zusammen um.
""";
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
public static string BuildSystemPrompt() => $"""
Du schreibst Abgabe-Inserate für Mongolische Rennmäuse im Stil der „Zucht der kleinen Chaoten“.
Stil und Aufbau (verbindlich):
- Erste Zeile: die Status-Zeile, exakt wie vorgegeben (z. B. „Status: FREI“).
- Danach eine fette, emotionale Überschrift (Tagline) in Anführungszeichen mit kurzem Untertitel, die den Charakter der Gruppe einfängt.
- Danach pro Tier ein Absatz: Name Farbschlag, geboren am TT.MM.JJJJ, gefolgt von warmherziger Persönlichkeits-Prosa auf Basis der mitgelieferten Notizen.
- Abschluss: ein Satz, dass die Tiere nur gemeinsam in ein artgerechtes Zuhause abgegeben werden.
Harte Regeln:
- NIEMALS Preise oder Schutzgebühren nennen.
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Charakter-Angaben, Notizen). Fehlt eine Angabe, lässt du sie weg.
- Charakter-Eigenschaften (Stichworte wie „zutraulich“, „buddelt gern“) und die Charakter-Notiz sind die Grundlage der Persönlichkeits-Prosa: Verwebe sie zu FLIESSENDEM Text — niemals als Aufzählung oder Stichwortliste ausgeben.
- Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben.
- Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke.
Beispiel 1:
{ExampleListing1}
Beispiel 2:
{ExampleListing2}
""";
/// <summary>User prompt: the actual group data + the user's free-text hints.</summary>
public static string BuildUserPrompt(SaleAdRequest request)
{
var sb = new StringBuilder();
sb.AppendLine("Erstelle ein Abgabe-Inserat für folgende Gruppe:");
sb.AppendLine();
sb.AppendLine($"Status-Zeile: {request.StatusLine}");
sb.AppendLine();
sb.AppendLine("Tiere:");
foreach (var animal in request.Animals)
{
sb.Append($"- Name: {animal.Name}");
if (!string.IsNullOrWhiteSpace(animal.Farbschlag))
sb.Append($" | Farbschlag: {animal.Farbschlag}");
if (!string.IsNullOrWhiteSpace(animal.DateOfBirth))
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
// FEAT-14c: Charakterbogen (Traits + Notiz) ist die BEVORZUGTE
// Charakterquelle; die generischen Notizen dienen nur als
// Fallback, wenn kein Charakterbogen gepflegt ist (sie enthalten
// oft Verwaltungs-Infos, die nicht ins Inserat gehören).
var hasCharacter = animal.Traits is { Count: > 0 }
|| !string.IsNullOrWhiteSpace(animal.CharacterNote);
if (animal.Traits is { Count: > 0 })
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
if (!hasCharacter && !string.IsNullOrWhiteSpace(animal.Notes))
sb.Append($" | Notizen: {animal.Notes}");
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(request.Hints))
{
sb.AppendLine();
sb.AppendLine($"Wünsche/Hinweise: {request.Hints}");
}
return sb.ToString();
}
/// <summary>ISO "YYYY-MM-DD" -> "TT.MM.JJJJ"; anything else passes through verbatim.</summary>
internal static string FormatGermanDate(string isoDate) =>
DateOnly.TryParseExact(isoDate, "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)
? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)
: isoDate;
}
}

View File

@@ -0,0 +1,35 @@
using GerbilManagerWebAPI.Ai;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
/// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
/// future AI features reuse the SAME implementation); this service contributes
/// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
/// </summary>
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
{
private readonly OpenAiChatClient _client = new(http, options);
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
{
var result = await _client.CompleteAsync(
SaleAdPromptBuilder.BuildSystemPrompt(),
SaleAdPromptBuilder.BuildUserPrompt(request),
ct);
var status = result.Status switch
{
AiCallStatus.Ok => SaleAdStatus.Ok,
AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
_ => SaleAdStatus.UpstreamError,
};
return new SaleAdResult(status, result.Text, result.Error);
}
/// <summary>Forwarder kept for the existing config-matrix tests.</summary>
internal static Uri BuildCompletionsUri(string baseUrl) =>
OpenAiChatClient.BuildCompletionsUri(baseUrl);
}
}

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.*

View File

@@ -0,0 +1,23 @@
# GerbilManager Produktionskonfiguration
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
POSTGRES_PASSWORD=aendere_mich_bitte
# Externer Port fuer das Frontend (Standard: 80)
PORT=80
# Gitea Container Registry (Standard: truenas:13000/gulum)
REGISTRY=truenas:13000/gulum
TAG=latest
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups
# Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
ANTHROPIC_API_KEY=

109
deploy/truenas/compose.yaml Normal file
View File

@@ -0,0 +1,109 @@
# GerbilManager — TrueNAS Custom Application
# ==============================================
# Vor dem ersten Start:
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
# 2. Lege die Dataset-Pfade auf der NAS an (pgdata, photos, backups).
# 3. docker compose -f deploy/truenas/compose.yaml up -d
#
# Zugriff: http://<NAS-IP>:${PORT:-80}
# API-Doku: http://<NAS-IP>:${PORT:-80}/scalar
services:
# --- PostgreSQL-Datenbank ---
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: gerbilmanager
POSTGRES_USER: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d gerbilmanager"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# --- .NET API (GerbilManagerWebAPI) ---
api:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
build:
context: ../..
dockerfile: GerbilManagerWebAPI/Dockerfile
restart: unless-stopped
environment:
ASPNETCORE_ENVIRONMENT: Production
# Verbindung zur Postgres-DB im selben Compose-Netz
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
Photos__RootPath: /data/photos
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
volumes:
- photos:/data/photos
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
# --- nginx Frontend (React SPA + API-Proxy) ---
frontend:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
build:
context: ../..
dockerfile: gerbil-manager-web/Dockerfile
restart: unless-stopped
ports:
- "${PORT:-80}:80"
depends_on:
api:
condition: service_healthy
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
backup:
image: postgres:17-alpine
restart: unless-stopped
environment:
PGPASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_HOST: db
POSTGRES_USER: postgres
POSTGRES_DB: gerbilmanager
BACKUP_KEEP_DAYS: "${BACKUP_KEEP_DAYS:-7}"
volumes:
- photos:/data/photos:ro
- backups:/backups
- ./scripts:/scripts:ro
depends_on:
db:
condition: service_healthy
entrypoint: ["/bin/sh", "/scripts/entrypoint.sh"]
volumes:
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
pgdata:
driver: local
driver_opts:
type: none
o: bind
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
photos:
driver: local
driver_opts:
type: none
o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
backups:
driver: local
driver_opts:
type: none
o: bind
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"

View File

@@ -0,0 +1,56 @@
#!/bin/sh
# GerbilManager Backup-Skript (Container-Welt)
# Laeuft als Cron-Job im backup-Sidecar-Container.
# Volumes: /data/photos (read-only), /backups (read-write)
# Umgebungsvariablen: PGPASSWORD, POSTGRES_HOST, POSTGRES_USER, POSTGRES_DB, BACKUP_KEEP_DAYS
set -e
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
BACKUP_DIR="/backups/$TIMESTAMP"
DB_DUMP="$BACKUP_DIR/gerbilmanager_${TIMESTAMP}.sql"
PHOTO_ARCHIVE="$BACKUP_DIR/photos_${TIMESTAMP}.tar.gz"
KEEP_DAYS="${BACKUP_KEEP_DAYS:-7}"
HOST="${POSTGRES_HOST:-db}"
USER="${POSTGRES_USER:-postgres}"
DB="${POSTGRES_DB:-gerbilmanager}"
log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"; }
log "=== Backup gestartet ($TIMESTAMP) ==="
mkdir -p "$BACKUP_DIR"
# --- Datenbank-Dump ---
log "Erstelle pg_dump fuer $DB@$HOST..."
if ! pg_dump -h "$HOST" -U "$USER" -d "$DB" --clean --if-exists --format=plain \
> "$DB_DUMP" 2>/tmp/pg_err; then
log "FEHLER bei pg_dump: $(cat /tmp/pg_err)"
exit 1
fi
DUMP_SIZE=$(du -k "$DB_DUMP" | cut -f1)
log "Dump erstellt: $DB_DUMP (${DUMP_SIZE} KB)"
# Validierung: mindestens CREATE TABLE muss vorhanden sein
if ! grep -q "CREATE TABLE" "$DB_DUMP" 2>/dev/null; then
log "WARNUNG: Dump sieht ungueltig aus -- bitte $DB_DUMP pruefen"
fi
# --- Fotos archivieren ---
if [ -d /data/photos ] && [ "$(ls -A /data/photos 2>/dev/null)" ]; then
PHOTO_COUNT=$(find /data/photos -type f | wc -l)
log "Archiviere $PHOTO_COUNT Fotos..."
tar czf "$PHOTO_ARCHIVE" -C /data photos/ 2>/tmp/tar_err || {
log "WARNUNG: Foto-Archiv fehlgeschlagen: $(cat /tmp/tar_err)"
}
PHOTO_SIZE=$(du -k "$PHOTO_ARCHIVE" 2>/dev/null | cut -f1)
log "Foto-Archiv erstellt: $PHOTO_ARCHIVE (${PHOTO_SIZE} KB)"
else
log "Keine Fotos gefunden -- Foto-Backup uebersprungen"
fi
# --- Rotation: alte Backups loeschen ---
log "Rotiere Backups (behalte $KEEP_DAYS Tage)..."
find /backups -maxdepth 1 -type d -name '????-??-??_??-??' \
-mtime "+$KEEP_DAYS" -exec rm -rf {} + 2>/dev/null || true
REMAINING=$(find /backups -maxdepth 1 -type d -name '????-??-??_??-??' | wc -l)
log "Backup abgeschlossen. Vorhandene Sicherungen: $REMAINING"
log "=== Ende ==="

View File

@@ -0,0 +1,16 @@
#!/bin/sh
# Backup-Sidecar Entrypoint
# Installiert den Cron-Job und startet crond im Vordergrund.
set -e
# Cron-Job: taeglich um 03:00 Uhr
CRON_SCHEDULE="${BACKUP_CRON:-0 3 * * *}"
echo "$CRON_SCHEDULE /bin/sh /scripts/backup.sh >> /backups/backup.log 2>&1" > /etc/crontabs/root
echo "[$(date)] Backup-Sidecar gestartet. Naechste Sicherung: $CRON_SCHEDULE"
echo "[$(date)] Backup-Verzeichnis: /backups Fotos: /data/photos"
# Ersten Backup-Lauf direkt beim Start ausfuehren (optional, auskommentieren wenn unerwuenscht)
# /bin/sh /scripts/backup.sh
exec crond -f -l 6

View File

@@ -0,0 +1,77 @@
#!/bin/sh
# GerbilManager Wiederherstellung (Container-Welt)
# Aufruf: docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/restore.sh
# Optional: .../restore.sh 2026-06-06_03-00 (bestimmtes Backup)
#
# WARNUNG: Ueberschreibt alle aktuellen Datenbankdaten und Fotos!
set -e
HOST="${POSTGRES_HOST:-db}"
USER="${POSTGRES_USER:-postgres}"
DB="${POSTGRES_DB:-gerbilmanager}"
BACKUP_ROOT=/backups
log() { echo "[$(date +'%H:%M:%S')] $1"; }
# --- Backup-Verzeichnis bestimmen ---
if [ -n "$1" ]; then
BACKUP_DIR="$BACKUP_ROOT/$1"
else
BACKUP_DIR=$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name '????-??-??_??-??' \
| sort -r | head -1)
fi
if [ -z "$BACKUP_DIR" ] || [ ! -d "$BACKUP_DIR" ]; then
log "FEHLER: Backup-Verzeichnis nicht gefunden: ${BACKUP_DIR:-$BACKUP_ROOT}"
log "Verfuegbare Backups:"
find "$BACKUP_ROOT" -maxdepth 1 -type d -name '????-??-??_??-??' | sort -r
exit 1
fi
SQL_FILE=$(find "$BACKUP_DIR" -name '*.sql' | head -1)
PHOTO_ARCHIVE=$(find "$BACKUP_DIR" -name '*.tar.gz' | head -1)
if [ -z "$SQL_FILE" ]; then
log "FEHLER: Kein SQL-Dump in $BACKUP_DIR gefunden."
exit 1
fi
log "Wiederherstellung aus: $BACKUP_DIR"
log " Datenbank : $(basename $SQL_FILE)"
[ -n "$PHOTO_ARCHIVE" ] && log " Fotos : $(basename $PHOTO_ARCHIVE)"
# --- Bestaetigung (interaktiv; mit -f ueberspringen) ---
if [ "$1" != "-f" ] && [ "$2" != "-f" ]; then
printf "WARNUNG: Aktuelle Daten werden unwiderruflich ueberschrieben!\nFortfahren? (ja/nein): "
read -r CONFIRM
if [ "$CONFIRM" != "ja" ]; then
log "Abgebrochen."
exit 0
fi
fi
# --- Datenbankwiederherstellung ---
log "Trenne laufende DB-Verbindungen..."
psql -h "$HOST" -U "$USER" -d postgres -c \
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB' AND pid<>pg_backend_pid();" \
> /dev/null 2>&1 || true
log "Spiele SQL-Dump ein..."
if ! psql -h "$HOST" -U "$USER" -d "$DB" < "$SQL_FILE" > /tmp/restore_out 2>&1; then
log "FEHLER bei der DB-Wiederherstellung:"
cat /tmp/restore_out
exit 1
fi
log "Datenbankwiederherstellung erfolgreich."
# --- Fotos wiederherstellen ---
if [ -n "$PHOTO_ARCHIVE" ]; then
log "Stelle Fotos wieder her..."
rm -rf /data/photos/* 2>/dev/null || true
tar xzf "$PHOTO_ARCHIVE" -C /data
PHOTO_COUNT=$(find /data/photos -type f 2>/dev/null | wc -l)
log "Fotos wiederhergestellt: $PHOTO_COUNT Dateien."
fi
log "Wiederherstellung abgeschlossen."
log "Starte API-Container neu: docker compose ... restart api"

50
docs/ai-provider.md Normal file
View File

@@ -0,0 +1,50 @@
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
gesetzt als **Umgebungsvariablen oder user-secrets, niemals in appsettings
committen**. Fehlt eine der drei, antwortet der Endpunkt mit
`503 {code:"AiKeyMissing"}` und die Oberfläche zeigt den deutschen Hinweis.
| Variable | Bedeutung |
|---|---|
| `AI__BaseUrl` | Basis-URL des Anbieters (ohne `/chat/completions`) |
| `AI__ApiKey` | API-Schlüssel (Bearer) |
| `AI__Model` | Modellname des Anbieters |
## Anbieter-Matrix (alle mit derselben Implementierung getestet)
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|---|---|---|---|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
(die Einstellung darf nur nicht leer sein).
## Setzen der Variablen
**Entwicklung (PowerShell, vor dem AppHost-Start):**
```powershell
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
$env:AI__ApiKey = '<schlüssel>'
$env:AI__Model = 'gemini-2.0-flash'
```
oder per user-secrets im API-Projekt:
`dotnet user-secrets set "AI:ApiKey" "<schlüssel>" --project GerbilManagerWebAPI` (usw.).
**TrueNAS / Docker Compose** (für Dwights Ops-Doku): im `environment:`-Block des
API-Containers —
```yaml
services:
webapi:
environment:
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
AI__Model: gemini-2.0-flash
```

381
docs/ops.md Normal file
View File

@@ -0,0 +1,381 @@
# GerbilManager — Betriebsanleitung (TrueNAS)
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
---
## Inhaltsverzeichnis
1. [Übersicht & Architektur](#1-übersicht--architektur)
2. [Voraussetzungen](#2-voraussetzungen)
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
9. [Fehlerbehebung](#9-fehlerbehebung)
---
## 1. Übersicht & Architektur
```
Browser / Handy
| HTTP :80
v
┌──────────────────┐
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
└──────┬───────────┘
│ /api/* → http://api:8080/*
│ /scalar → http://api:8080/scalar
v
┌──────────────────┐
│ api (.NET 10) │ GerbilManagerWebAPI, Minimal API
└──────┬───────────┘
│ ConnectionStrings__gerbilmanager
v
┌──────────────────┐ ┌──────────────────────┐
│ db (Postgres 17)│ │ backup (Sidecar) │
└──────────────────┘ │ pg_dump + tar + cron │
│ └──────────────────────┘
└─ pgdata-Volume (NAS-Dataset)
photos-Volume (NAS-Dataset)
backups-Volume (NAS-Dataset)
```
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
Alles andere läuft intern im Docker-Netz.
---
## 2. Voraussetzungen
| Was | Details |
|-----|---------|
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
---
## 3. Erstinstallation auf TrueNAS
### 3.1 ZFS-Datasets anlegen
In TrueNAS → **Datasets****Dataset hinzufügen** (je einmal wiederholen):
| Dataset-Name | Empfohlener Pfad | Verwendung |
|---|---|---|
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
### 3.2 Repository klonen
```bash
# SSH in TrueNAS oder lokale Shell
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
cd /opt/gerbilmanager
```
### 3.3 Konfiguration anlegen
```bash
cp deploy/truenas/.env.example deploy/truenas/.env
# Jetzt .env bearbeiten:
nano deploy/truenas/.env
```
Mindestens setzen:
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
### 3.4 Images bauen und App starten
```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml build
docker compose -f deploy/truenas/compose.yaml up -d
```
Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen).
### 3.5 Prüfen
```bash
# Alle Container laufen?
docker compose -f deploy/truenas/compose.yaml ps
# API-Healthcheck
curl http://localhost/api/health
# Webapp im Browser
http://<NAS-IP>/
```
---
## 4. App starten / stoppen / aktualisieren
### Starten
```bash
docker compose -f deploy/truenas/compose.yaml up -d
```
### Stoppen
```bash
docker compose -f deploy/truenas/compose.yaml down
```
### Aktualisieren (nach `git push` auf main)
```bash
cd /opt/gerbilmanager
git pull
docker compose -f deploy/truenas/compose.yaml build
docker compose -f deploy/truenas/compose.yaml up -d
```
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
### Mit Gitea CI (wenn Actions aktiviert)
Push auf `main` triggert automatisch Build → Test → Image-Push.
Danach auf der NAS:
```bash
docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d
```
---
## 5. Backup & Wiederherstellung
### Automatisches Backup
Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00 Uhr**:
- Kompletter `pg_dump` der Datenbank als `.sql`
- Komprimiertes Foto-Archiv als `.tar.gz`
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
```
/mnt/SSD/gerbil/backups/
2026-06-06_03-00/
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
photos_2026-06-06_03-00.tar.gz (Fotos)
backup.log (Protokoll)
```
### Manuelles Backup auslösen
```bash
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
```
### Backup-Log prüfen
```bash
tail -50 /mnt/SSD/gerbil/backups/backup.log
```
### Wiederherstellung — Runbook
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
**Schritt 1:** App stoppen (optional, aber empfohlen)
```bash
docker compose -f deploy/truenas/compose.yaml stop api frontend
```
**Schritt 2:** Restore ausführen
```bash
# Neuestes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh
# Bestimmtes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh 2026-06-05_03-00
```
**Schritt 3:** API neu starten
```bash
docker compose -f deploy/truenas/compose.yaml start api frontend
```
**Schritt 4:** Prüfen
```bash
curl http://localhost/api/color-varieties | grep -c '"id"'
# Erwarteter Wert: 73
```
### Restore-Nachweis (Round-Trip-Test)
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
```
73 ColorVarieties vorhanden
→ DELETE 12 Zeilen → 61 verbleibend
→ pg_restore eingespielt
→ 73 ColorVarieties bestätigt
Exit-Code: 0
```
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
---
## 6. ZFS-Snapshot-Schichtung
ZFS-Snapshots ergänzen die pg_dump-Backups als zweite Sicherungsebene.
Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
### Empfohlene Snapshot-Konfiguration
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots****Regelmäßige Snapshots**:
| Dataset | Häufigkeit | Aufbewahrung |
|---------|-----------|--------------|
| `gerbil/photos` | Stündlich | 24 Stunden |
| `gerbil/photos` | Täglich | 30 Tage |
| `gerbil/pgdata` | Stündlich | 24 Stunden |
| `gerbil/pgdata` | Täglich | 30 Tage |
| `gerbil/backups` | Täglich | 90 Tage |
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
> nicht den ZFS-Snapshot von `pgdata`.
### Snapshot manuell erstellen (z. B. vor Update)
```bash
# TrueNAS CLI
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
```
### Aus ZFS-Snapshot wiederherstellen (Fotos)
```bash
# Snapshot auflisten
zfs list -t snapshot SSD/gerbil/photos
# Datei aus Snapshot kopieren
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
```
---
## 7. CI/CD via Gitea Actions
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
### Aktivierung
1. **Gitea Actions aktivieren:**
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
```bash
# Gitea Runner Container (einfachste Variante für TrueNAS)
docker run -d --name gitea-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/gitea-runner:/data \
-e GITEA_INSTANCE_URL=http://truenas:13000 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
gitea/act_runner:latest
```
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
3. **Registry-Secrets konfigurieren:**
Gitea → Repository → Einstellungen → Secrets:
- `REGISTRY_USER` — dein Gitea-Benutzername
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
### Workflow nach Aktivierung
```
git push origin main
→ Gitea Actions: dotnet test + npm test + npm run build
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
→ Auf NAS: docker compose pull + up -d
```
---
## 8. Offene Fragen
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
| # | Frage | Auswirkung |
|---|-------|-----------|
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
---
## 9. Fehlerbehebung
### App startet nicht
```bash
# Logs aller Container
docker compose -f deploy/truenas/compose.yaml logs
# Logs eines bestimmten Containers
docker compose -f deploy/truenas/compose.yaml logs api
docker compose -f deploy/truenas/compose.yaml logs db
```
### Datenbank nicht erreichbar
```bash
# DB-Container läuft?
docker compose -f deploy/truenas/compose.yaml ps db
# Verbindung testen
docker compose -f deploy/truenas/compose.yaml exec db \
psql -U postgres -d gerbilmanager -c "\dt"
```
### Backup-Fehler
```bash
# Backup-Log prüfen
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
# Backup manuell starten (mit Fehlerausgabe)
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/backup.sh
```
### Fotos werden nicht angezeigt
Prüfe ob das `photos`-Volume korrekt gemounted ist:
```bash
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
```
### Container-Status zurücksetzen (Neustart)
```bash
docker compose -f deploy/truenas/compose.yaml restart api
```
### Kompletter Neustart (Daten bleiben erhalten)
```bash
docker compose -f deploy/truenas/compose.yaml down
docker compose -f deploy/truenas/compose.yaml up -d
```

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

@@ -22,3 +22,9 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# QA-1: Playwright-Artefakte
test-results/
playwright-report/
blob-report/
playwright/.cache/

View File

@@ -1,25 +1,48 @@
# Build-Stufe: Vite-Produktionsbuild
# Build context: repo root (Dockerfile liest aus gerbil-manager-web/).
# docker build -f gerbil-manager-web/Dockerfile -t gerbilmanager-frontend .
#
# Produktion: kein VITE_API_BASE_URL nötig — client.ts fällt auf '/api' zurück,
# nginx proxyt /api/* transparent zum API-Container (keine Host-Kopplung zur Build-Zeit).
FROM node:22-alpine AS build
WORKDIR /app
COPY gerbil-manager-web/package.json gerbil-manager-web/package-lock.json ./
RUN npm ci
COPY gerbil-manager-web/ .
# Basis-URL der API (zur Build-Zeit eingebettet; Browser erreicht die API über den Host)
ARG VITE_API_BASE_URL=http://localhost:80
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build
# Laufzeit-Stufe: statische Auslieferung über nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
# SPA-Fallback: alle Pfade auf index.html umleiten (react-router)
# nginx: SPA-Fallback + Reverse-Proxy für /api, /scalar, /openapi zum API-Container.
RUN printf 'server {\n\
listen 3000;\n\
listen 80;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
\n\
# API-Aufrufe: /api/* -> API-Container /* (Praefix wird entfernt)\n\
location /api/ {\n\
proxy_pass http://api:8080/;\n\
proxy_set_header Host $host;\n\
proxy_set_header X-Real-IP $remote_addr;\n\
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
}\n\
\n\
# Scalar API-Doku und OpenAPI-Spec direkt vom Backend\n\
location /scalar {\n\
proxy_pass http://api:8080/scalar;\n\
proxy_set_header Host $host;\n\
}\n\
location /openapi/ {\n\
proxy_pass http://api:8080/openapi/;\n\
proxy_set_header Host $host;\n\
}\n\
\n\
# SPA-Fallback: alle anderen Pfade laden index.html (React Router)\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
}\n\
}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 3000
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -39,3 +39,21 @@ Erwartet die laufende GerbilManagerWebAPI unter `http://localhost:5179`
npm run build
npm run preview
```
## E2E-Tests (QA-1, Playwright)
```bash
npm run e2e # Mock-Modus: startet Vite auf :5199, API wird gemockt
npx playwright test --project=phone # nur Smartphone-Viewport (390px)
```
- **Mock-Modus (Standard):** kein Backend nötig. `e2e/mock-api.ts` fängt alle
API-Aufrufe ab (DATA-2-Vertrag: Gridify-Paging, camelCase, 409-Konflikte)
mit frischem Datenbestand pro Test (`e2e/mock-data.ts`).
- **Live-Modus:** dieselbe Suite gegen den echten Stack —
`$env:E2E_BASE_URL = 'http://localhost:5173'; npm run e2e`
(vorher AppHost starten). Tests, die Mock-Seed-Daten voraussetzen,
überspringen sich selbst; die übrigen legen eigene Datensätze an.
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
`src/strings/de.ts`.

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,103 @@
/** QA-1: Becken + Kontakte (FEAT-2) — CRUD, Belegung, 409-Konfliktpfad. */
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
const tb = de.pages.becken
const tk = de.pages.kontakte
test.describe('Becken', () => {
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
skipUnlessMock()
await page.goto('/becken')
await page.getByRole('link', { name: /Großbecken/ }).click()
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
await expect(page.getByText(tb.occupancy.title)).toBeVisible()
await page.getByRole('link', { name: /Krümel/ }).click()
await expect(page.getByText(de.pages.gerbils.detail.masterData)).toBeVisible()
})
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
skipUnlessMock()
await page.goto('/becken')
await page.getByRole('link', { name: /Großbecken/ }).click()
acceptNextDialog(page)
await page.getByRole('button', { name: tb.delete.action }).click()
await expect(page.getByText(tb.delete.conflict)).toBeVisible()
})
test('Becken anlegen, bearbeiten und (leer) löschen', async ({ page }) => {
await gotoSection(page, de.nav.enclosures)
await page.getByRole('link', { name: tb.newButton }).click()
// Pflichtfeld-Validierung
await page.getByRole('button', { name: tb.form.save, exact: true }).click()
await expect(page.getByText(tb.validation.nameRequired)).toBeVisible()
const name = uniqueName('Becken')
await page.getByLabel(`${tb.fields.name} *`).fill(name)
await page.getByLabel(tb.fields.notes).fill('Übergangsbecken')
await page.getByRole('button', { name: tb.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
await expect(page.getByText(tb.occupancy.empty)).toBeVisible()
// Leeres Becken löschen -> zurück zur Liste
acceptNextDialog(page)
await page.getByRole('button', { name: tb.delete.action }).click()
await expect(page.getByRole('heading', { name: tb.title, exact: true })).toBeVisible()
await expect(page.getByRole('link', { name })).toBeHidden()
})
})
test.describe('Kontakte', () => {
test('Detail zeigt verknüpfte Tiere mit Rollen-Beschriftung', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte')
await page.getByRole('link', { name: /Zoohandlung Meier/ }).click()
await expect(page.getByRole('heading', { name: 'Zoohandlung Meier' })).toBeVisible()
await expect(page.getByText(tk.linked.title)).toBeVisible()
// "Herkunft von Krümel"
await expect(page.getByText(`${tk.linked.originRole} Krümel`)).toBeVisible()
})
test('Löschen eines referenzierten Kontakts meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte')
await page.getByRole('link', { name: /Familie Huber/ }).click()
// Pauli (abgegeben) referenziert Familie Huber als Abnehmer
await expect(page.getByText(`${tk.linked.receiverRole} Pauli`)).toBeVisible()
acceptNextDialog(page)
await page.getByRole('button', { name: tk.delete.action }).click()
await expect(page.getByText(tk.delete.conflict)).toBeVisible()
})
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
await gotoSection(page, de.nav.contacts)
await page.getByRole('link', { name: tk.newButton }).click()
const name = uniqueName('Kontakt')
await page.getByLabel(`${tk.fields.name} *`).fill(name)
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
await page.getByLabel(tk.fields.email).fill('test@example.de')
await page.getByLabel(tk.fields.phone).fill('0151 9876543')
await page.getByRole('button', { name: tk.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
await expect(page.getByText(tk.linked.empty)).toBeVisible()
// die gespeicherten Felder erscheinen auf der Detailseite
await expect(page.getByText('test@example.de')).toBeVisible()
await expect(page.getByText('0151 9876543')).toBeVisible()
acceptNextDialog(page)
await page.getByRole('button', { name: tk.delete.action }).click()
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
})
// FEAT-13: Abdeckung der neuen strukturierten Kontaktfelder
test('Detail zeigt E-Mail, Telefon und Adresse mit deutschen Beschriftungen', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte')
await page.getByRole('link', { name: /Zoohandlung Meier/ }).click()
await expect(page.getByText(tk.fields.email, { exact: true })).toBeVisible()
await expect(page.getByText('meier@example.de')).toBeVisible()
await expect(page.getByText(tk.fields.address, { exact: true })).toBeVisible()
await expect(page.getByText('Hauptstraße 1, 12345 Musterstadt')).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

@@ -0,0 +1,20 @@
/** EXPORT-1: Einstellungen — Datenexport-Karte + Download-Smoke. */
import { de, expect, gotoSection, test } from './fixtures'
const t = de.pages.datenexport
test('Einstellungen zeigt die Datenexport-Karte und der Download startet', async ({ page }) => {
await gotoSection(page, de.nav.settings)
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title })).toBeVisible()
// Datenexport-Karte mit deutschem Erklärtext
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
await expect(page.getByText(t.intro)).toBeVisible()
await expect(page.getByText(t.photoNote)).toBeVisible()
// Klick startet den Zip-Download
const downloadPromise = page.waitForEvent('download')
await page.getByRole('link', { name: t.button }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('rennmaus-export')
})

View File

@@ -0,0 +1,68 @@
/**
* QA-1: Test-Fixtures.
*
* - MOCK-Modus (Standard): installiert vor jedem Test den In-Memory-Mock
* (frischer Datenbestand pro Test).
* - LIVE-Modus (E2E_BASE_URL gesetzt): kein Mock; fixture-gebundene Tests
* überspringen sich über `skipUnlessMock()`.
*
* Deutsche Oberflächen-Texte werden DIREKT aus src/strings/de.ts importiert —
* die Suite bleibt damit automatisch synchron zur App.
*/
import { test as base, expect, type Page } from '@playwright/test'
import { de } from '../src/strings/de'
import { installMockApi } from './mock-api'
import type { MockDb } from './mock-data'
export const LIVE = Boolean(process.env.E2E_BASE_URL)
export { expect }
export { de }
interface Fixtures {
/** Im Mock-Modus der pro Test frische Datenbestand, sonst null. */
mockDb: MockDb | null
}
export const test = base.extend<Fixtures>({
mockDb: [
async ({ page }, use) => {
const db = LIVE ? null : await installMockApi(page)
await use(db)
},
{ auto: true },
],
})
/** Test überspringen, wenn er Seed-Daten des Mocks voraussetzt. */
export function skipUnlessMock() {
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
}
/**
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
*/
export async function gotoSection(page: Page, label: string) {
const nav = page.getByRole('navigation')
// App noch nicht geladen (about:blank) -> erst zur Startseite
if (!(await nav.isVisible())) {
await page.goto('/')
await expect(nav).toBeVisible()
}
const link = nav.getByRole('link', { name: label })
if (!(await link.isVisible())) {
await nav.getByRole('button', { name: de.nav.more }).click()
await expect(link).toBeVisible()
}
await link.click()
}
/** window.confirm des nächsten Dialogs annehmen. */
export function acceptNextDialog(page: Page) {
page.once('dialog', (d) => void d.accept())
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

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