Compare commits

...

74 Commits

Author SHA1 Message Date
e4b7558abd FEAT-12a: ForSale scaffolding — status+ForSale, abgabe strings, /abgabe route+nav, sale-ad API stub, detail action, jszip 2026-06-06 01:07:43 +02:00
188b33618e DATA-2: amendments — GerbilStatus.ForSale, Contact structured fields, 73-variety seed
- GerbilStatus += ForSale ("Abzugeben", FEAT-12 sale listing); enum-as-string, no DB change.
- Contact: contactInfo -> structured email/phone/address (FEAT-13 sale contracts); name/notes kept.
- ColorVariety seed 18 -> 73 from Kevin's GEN-2 colorVarietySeed.generated.json (18 frozen first).
- Regenerated InitialCreate migration (Contacts email/phone/address cols, 73 seed rows).
2026-06-06 01:07:19 +02:00
f3b1ecb7a3 FEAT-13: Abgabevertrag placeholder template from her sample contract (18 {{tokens}}, PII scrubbed incl. docProps/mailto-rel, animal photos removed, Zucht logo kept, boilerplate verbatim)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:03:25 +02:00
7627468b82 DATA-2: optional list params + normalize frontend's '==' Gridify operator
- GridifyParams wrapper: nullable page/pageSize/filter/orderBy so list endpoints
  work with no query params (Gridify's non-nullable int Page made [AsParameters]
  treat them as required -> 400). Defaults page=1,pageSize=20.
- Normalize incoming filter '==' -> '=' : the frontend gridify.ts emits '==' for
  equals (its convention) but Gridify's equals is '='. Safe (values are escaped;
  != >= <= =* contain no '=='). Fixes status==Active (frontend default that 500'd).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:00:35 +02:00
c937b6d5a8 GEN-2: add generated ColorVariety seed (73 rows: name, canonicalGenotype, sortOrder, image) for DATA-2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:00:33 +02:00
6cc007ba55 GEN-2: surface Farbschlag images (FarbschlagImage component) in breeding results, animal detail, form picker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:58:28 +02:00
7ea2f97e72 FEAT-8b: bake Julian's authoritative Wurfchronik semantics into the extractor
- survivedToGoHome: unlabeled Tabelle1 col E detected positionally; value-
  adaptive row parsing recovers it from schema-shifted Tabelle2 rows too
  (118 recovered, 79 confirmed by the E=F-TG-s identity)
- breakdown G -> males/females/stillborn/diedLater ('s' = died before Abgabe)
- validation: E = F - TG - s; 113 mismatches as German review-report warnings
- (name, Zucht) canonicalisation: [brackets] == of/von suffix ([ZdkC] ==
  von den Kleinen Chaoten); Zucht = dedup discriminator (0 splits in data)
- animal->litter matching via DOB+(Vater,Mutter): 95 high-confidence,
  40 date-only, 9 ambiguous; litterRef in animals.json
- regenerated report: 889 raw -> 574 unique (279 dated), 32 conflicts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:56:14 +02:00
a9778ad1d2 GEN-2: expand Farbschlag catalog 18->73 (baseportal.de) + images + collision-aware tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:55:35 +02:00
263ba32e3c DATA-2: fresh Npgsql InitialCreate migration + ColorVariety seed (18)
8 tables (Gerbils, Litters, Contacts, Enclosures, ColorVarieties, GerbilPhotos,
HealthRecords, WeightRecords); enums as text columns; ColorVariety HasData seed
of the 18 GEN-1 base varieties. Replaces the prior pre-DATA-2 InitialCreate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:54:56 +02:00
180d53b203 DATA-2: new schema entities + Minimal API endpoints + Scalar + DAL teardown
- Entities: Breeder->Contact, +Enclosure/ColorVariety/GerbilPhoto/HealthRecord/WeightRecord;
  Gerbil expanded (single Genotype text col, Status/Gender enums-as-string, FK ids,
  ImportSource/ExternalRef provenance); Litter Strength->TotalBorn +ExpectedGoHomeDate/Notes.
  ColorVariety HasData seed = 18 from GEN-1 catalog. Gerbil<->Litter cycle handled
  (SetNull/Restrict). Enums stored as strings.
- Minimal API (no controllers): Endpoints/*.cs MapGroup+TypedResults for gerbils, litters,
  contacts, enclosures, color-varieties, health/weight-records, inbreeding (converted from
  controller, same routes/shapes), photos (Oscar contract: GET array/POST multipart/DELETE,
  url /photos/files/{fileName}). Gridify paged {items,totalCount,page,pageSize}, camelCase,
  409 conflict-deletes, flat FK ids, litter parent-gender validation (400 {code,...}).
- Scalar replaces Swashbuckle (AddOpenApi/MapOpenApi + MapScalarApiReference at /scalar);
  launchUrl swagger->scalar. GenericRepository/UnitOfWork/Converters deleted; DbContext direct.
- InbreedingService reads real FK props now; pure calculator + 8 tests untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:53:49 +02:00
a47fbef785 FEAT-7: AppShell 'Mehr' overflow nav (phone: 4 tabs + Mehr sheet; desktop: all 7) + de.nav.more
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:47:07 +02:00
7ab463d47e UI: hand-drawn gerbil favicon (sitting Agouti, warm palette, tufted tail) 2026-06-06 00:46:46 +02:00
27299e225c HOTFIX: useMutation.run returns MutationOutcome (no throw); sweep all 7 call sites + README convention
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:44:56 +02:00
3476b71525 FEAT-7: thin population curve to <=60 points so the line is not beaded over long date ranges
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:43:48 +02:00
34911fdee1 FEAT-7: Statistik page - Wuerfe/Jahr + avg Wurfstaerke bars, Farbschlag h-bars (FEAT-4 chip colors), Bestandsentwicklung line, Verluste/Jahr + /statistik route & nav entry (Mehr overflow follows via Kevin)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:41:21 +02:00
1b776cd994 FEAT-8b: spreadsheet import tooling (stages 1-2) + review report
tools/import/ (Python, zero-dep migration tooling, not product code):
- xlsx_util.py: dependency-free .xlsx reader (shared strings, cells, drawing anchors)
- genotype.py: notation -> frozen 8-locus mapping + verbatim rawGenotype + unmappedTokens; '-' -> '?'
- extract.py: 10 Stammbaum charts + Wurfchronik -> animals.json/litters.json + anchor-mapped photos;
  dedup on normalise(name)+DOB -> German review-report.md (no DB load)

Run: 889 raw -> 587 unique animals, 24 conflicts, 310 ambiguous, 123 photos, 752 litters.
Output gitignored except review-report.md. Re-runnable per file (Wurfchronik Teil2+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:40:54 +02:00
016b5a1521 FEAT-7: statistics aggregation module - litters/year, avg litter size, Farbschlag distribution, population curve, losses (9 tests)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:37:58 +02:00
c6699a9592 FEAT-3: fix unhandled ApiError rejections (catch in form submits + dev unhandledrejection guard, ApiError carries path)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:36:14 +02:00
e76554342b FEAT-3: Zuchtpaar-Übersicht tab (derive pairs from litters) + ?vater/&mutter pair prefill in Probeverpaarung
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:32:44 +02:00
79f260d62e FEAT-3: Wurf detail workspace (parents, juveniles, Jungtier erfassen prefill, expected Farbschlag via breed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:31:19 +02:00
74ac6ddb5a FEAT-4: sync inbreeding DTO to FEAT-1b InbreedingResult + anchor root card left so parents are visible on phone at load
Verified end-to-end against a DATA-2-contract mock (5-gen family, shared ancestor): phone/desktop, re-root, lazy expand, zoom/fit, print Ahnentafel, 404 state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:30:14 +02:00
d3550bbc16 FEAT-3: Wurf create/edit form (parent pickers, auto +35d go-home date, validation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:29:52 +02:00
784d3d3f40 FEAT-3: Würfe list page (year filter, sort by date, paging, parent names)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:28:50 +02:00
cd4328cef8 FEAT-6: wire tab content into animal detail (Fotos/Gesundheit/Gewicht) + profile photo in header + tab styles 2026-06-06 00:28:31 +02:00
87250ba007 FEAT-3: litter type+CRUD, Zucht strings, /wuerfe nested routes (retire LittersPage)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:27:29 +02:00
d7be812ad0 FEAT-6: Gesundheit/Gewicht/Fotos tab components + hand-rolled SVG LineChart (no chart dependency) 2026-06-06 00:27:09 +02:00
1bf94c2040 FEAT-6: fix main build - align FEAT-4 makeLitter test factory with FEAT-3 Litter type (expectedGoHomeDate, notes) 2026-06-06 00:27:09 +02:00
17879b1cfb FEAT-6: typed API clients for health-records, weight-records, gerbil photos (multipart upload) 2026-06-06 00:25:00 +02:00
f772464401 FEAT-6: add German strings for Tier-Detail-Tabs (Gesundheit/Gewicht/Fotos) 2026-06-06 00:24:30 +02:00
e12588c338 FEAT-3: extract reusable BreedingResultView from GenetikPage (no behavior change)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:23:53 +02:00
9c0a2fec56 FEAT-4: interactive Stammbaum viewer - foreignObject cards (photo slot, sex, Farbschlag chip, year), tap-to-re-root, lazy expand, zoom controls + CSS-grid print pedigree & Inzuchtkoeffizient slot
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:22:45 +02:00
61cfab046a FEAT-4: pedigree data module - depth-limited ancestor traversal, lazy expand, cycle guard, rd3t conversion (15 tests)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:17:49 +02:00
01923d1b87 FEAT-1b: add first test project (xUnit) for Inzuchtkoeffizient
GerbilManager.Tests (net10.0), added to slnx. Reference pedigrees verified
against textbook F: parent-offspring 0.25, full sibs 0.25, half sibs 0.125,
first cousins 0.0625, unknown 0, plus deep multi-path (0.375) and
generationsAvailable checks. 8/8 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:13:22 +02:00
caba259a28 FEAT-4: add Stammbaum button to animal detail head-actions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:12:09 +02:00
5ef27a70d3 IDE: launch.json starts the full stack via Aspire AppHost from the VS Code play button 2026-06-06 00:11:32 +02:00
16d3cefe68 FEAT-2: wire /becken + /kontakte routes and nav (Kontakte supersedes Zuechter placeholder) 2026-06-06 00:11:11 +02:00
0c94a6ecd3 FEAT-1b: Inzuchtkoeffizient endpoints via Wright path method
- PedigreeCalculator: pure, EF-free Wright path-method engine (recursive F_A,
  per-common-ancestor contributions, generationsAvailable, cycle guards)
- InbreedingService: loads pedigree from ApplicationContext shadow FKs
  (Gerbils.LitterId, Litters.FatherId/MotherId) into in-memory maps
- InbreedingController: GET /gerbils/{id}/inbreeding-coefficient,
  POST /genetics/test-inbreeding (hypothetical pairing for Probeverpaarung)
- Injects ApplicationContext directly; no Program.cs change (Dwight owns it)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:10:19 +02:00
bca97ed10a FEAT-4: install react-d3-tree + /rennmaeuse/:id/stammbaum route (page scaffold)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:10:10 +02:00
92c930c165 FEAT-2: Kontakte pages - list (Gridify search+paging), detail w/ linked animals (Herkunft/Abnehmer), form, delete w/ 409 conflict message 2026-06-06 00:09:43 +02:00
d8e94f6d05 FEAT-5: Probeverpaarung entry point from animal detail (?parent=) + stored-vs-engine Farbschlag mismatch hint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:09:22 +02:00
5ee4b9874b FEAT-2: Becken pages - list (Gridify search+paging), detail w/ occupancy, form, delete w/ 409 conflict message 2026-06-06 00:08:06 +02:00
16a0016de9 FEAT-5: add gender-filtered animal pickers to Probeverpaarung parents
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:07:32 +02:00
a1d02c0d4e FEAT-2: typed CRUD API clients for enclosures + contacts 2026-06-06 00:07:01 +02:00
12e787671c FEAT-2: add German strings for Becken + Kontakte sections (append-only de.ts) 2026-06-06 00:06:42 +02:00
5a6326a967 FEAT-5: Probeverpaarung page — free genotype entry, client-side breed(), Farbschlag cards + warnings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:06:22 +02:00
f7ba1fa70e FEAT-5: add /genetik route + nav entry + German strings (page stub)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:04:03 +02:00
e077766cc5 FEAT-1: Tiere create/edit form (German labels, validation, GEN-1 genotype check) + nested routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:58:10 +02:00
e9f88b7889 ARCH-2b: add frontend (Vite) as Aspire resource with LAN wiring
- AppHost: AddViteApp(frontend) via Aspire.Hosting.JavaScript 13.4.2,
  WithReference(webapi)+WaitFor, inject VITE_API_BASE_URL=http://<lan-ip>:5179
  at launch (computed from the active physical adapter, follows DHCP)
- Pin proxyless ports: webapi http=5179 (matches SPA default), vite=5173,
  both bind 0.0.0.0 for LAN reachability
- vite.config: server.host=true so the dev server is LAN-visible

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:57:31 +02:00
0237f5de5e FEAT-1: Tiere detail page — Stammdaten, GEN-1 genotype+Farbschlag, placeholder tabs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:55:54 +02:00
9b586dd7fa FEAT-1: Tiere list page — Gridify filters (Status/Geschlecht/Farbschlag), sort, paging + German strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:54:44 +02:00
f162ef4fed FEAT-1: add typed gerbils/lookups API + useApi/useMutation hooks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:52:39 +02:00
e04b69ce49 FEAT-1: add Gridify query builder + API DTO types
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:50:30 +02:00
3629e89e87 GEN-1: expose CATALOG seed view (Name/CanonicalGenotype/SortOrder) for DATA-2 + round-trip test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:47:11 +02:00
3cf6413fcc ARCH-2b: permissive LAN CORS + bind API on 0.0.0.0, drop HTTPS redirect
- AddCors lan policy (AllowAnyOrigin/Header/Method) — trusted home LAN, no auth
- Widen ASPNETCORE_URLS host localhost/127.0.0.1 -> 0.0.0.0 (keep Aspire-assigned port)
- Remove dev HTTPS redirection so phones reach the API over plain HTTP

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:46:59 +02:00
0408a482cf GEN-1: export genotypeToFarbschlag for DATA-1 denormalization contract
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:45:07 +02:00
0553ceda66 GEN-1: add German genetics warning texts to de.ts (code -> text mapping)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:43:41 +02:00
3561b435d5 GEN-1: add vitest suite (worked example, SpSp renorm, wildcards) + erasable-enum fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:43:21 +02:00
2bee5caaa2 GEN-1: add Probeverpaarung API (breed) + public index
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:42:05 +02:00
d99294e30e GEN-1: add phenotype tokens + Farbschlag catalog (18 varieties + Schecke/Rex modifiers)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:41:34 +02:00
f641334982 ARCH-2: move Aspire resource service port 22112 -> 22113
ASUS ROGLiveService squats on 22112 on this machine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:41:06 +02:00
ebc61acd18 GEN-1: add lethality rules + warning codes (SpSp lethal+renormalize, ReRe semi)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:40:06 +02:00
76d8380ea1 GEN-1: add Punnett engine (per-locus + full genotype distribution, wildcard support)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:39:31 +02:00
3fc430f1a0 GEN-1: add Genotype type + canonical serialization (JSON + display string)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:38:49 +02:00
9d5608f46e GEN-1: add locus/allele model (A C D E G P Sp Re, multi-allele C/E)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:38:18 +02:00
2b9a0cd648 GEN-1: add vitest + exact Fraction utility
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:37:53 +02:00
9b9cf2b35a ARCH-2: apply EF migrations on startup in Development
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:37:42 +02:00
cc589b13d0 ARCH-2: fresh Npgsql initial migration
uuid keys, text columns, Gender persisted as text via EnumToStringConverter,
Litter.Date as timestamptz. Replaces deleted SqlServer initialCreate (no prod data).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:37:21 +02:00
84de905469 ARCH-2: wire PostgreSQL through Aspire
- AppHost: AddPostgres(postgres).WithDataVolume() + AddDatabase(gerbilmanager),
  WebAPI project resource WithReference/WaitFor on the db (Aspire-generated credentials)
- WebAPI: AddNpgsqlDbContext<ApplicationContext>(gerbilmanager) replaces manual
  AddDbContext/UseNpgsql + appsettings connection string

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:36:06 +02:00
4d541fe9cd ARCH-2: add Aspire AppHost + ServiceDefaults (Aspire 13.4.2)
- New projects GerbilManager.AppHost / GerbilManager.ServiceDefaults (templates)
- Added both to GerbilManager.slnx
- WebAPI references ServiceDefaults; AddServiceDefaults + MapDefaultEndpoints wired

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:35:15 +02:00
8820cc7751 ARCH-2: swap EF provider to Npgsql, drop SqlServer migrations + conn strings
- Microsoft.EntityFrameworkCore.SqlServer -> Npgsql.EntityFrameworkCore.PostgreSQL 10.0.2
- UseSqlServer -> UseNpgsql (Aspire-injected gerbilmanager connection)
- Removed hardcoded sa connection strings from appsettings*.json
- Deleted SqlServer-flavored initial migration (fresh Npgsql one follows; no prod data)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:34:00 +02:00
b041d84b18 ARCH-2: bump TFM to net10.0, EF Core/OpenApi to 10.0.8, Swashbuckle to 10.2.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:32:47 +02:00
6ee85bc909 Replace Next.js app with Vite React app (gerbil-manager-web)
- Remove gerbil-manager-nextjs (unused scaffold; recoverable from history)
- Scaffold gerbil-manager-web: Vite + React 19 + TypeScript
- German-only UI (lang=de, central strings in src/strings/de.ts)
- Mobile-first shell: bottom tab bar on phones, sidebar on >=768px
- react-router with skeleton routes (Start, Rennmaeuse, Wuerfe, Zuechter)
- API client (src/api/client.ts) pointing at GerbilManagerWebAPI,
  configurable via VITE_API_BASE_URL (default http://localhost:5179)
- nginx-based Dockerfile with SPA fallback; docker-compose frontend
  service now builds gerbil-manager-web

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:30:34 +02:00
42a43ae24e Migrate GerbilManager.sln to slnx format
- Generated GerbilManager.slnx via dotnet sln migrate (SDK 10.0.300)
- Removed legacy GerbilManager.sln after verified build
- Updated .vscode/tasks.json references to the new file

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:24:42 +02:00
a7157a5a2e baseline before slnx migration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:23:46 +02:00
259 changed files with 18713 additions and 5807 deletions

4
.gitignore vendored
View File

@@ -127,3 +127,7 @@ $RECYCLE.BIN/
# Mac desktop service store files
.DS_Store
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json

74
.vscode/launch.json vendored
View File

@@ -1,42 +1,32 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/GerbilManagerWebAPI/bin/Debug/net7.0/GerbilManagerWebAPI.dll",
"args": [],
"cwd": "${workspaceFolder}/GerbilManagerWebAPI",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/GerbilManagerWebAPI/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
},
{
"name": "Docker .NET Launch",
"type": "docker",
"request": "launch",
"preLaunchTask": "docker-run: debug",
"netCore": {
"appProject": "${workspaceFolder}/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
}
}
]
}
{
// Play button (F5) starts the WHOLE stack via .NET Aspire:
// dashboard + Postgres container + WebAPI + React frontend (Vite).
// The Aspire dashboard (with login token) opens automatically.
"version": "0.2.0",
"configurations": [
{
"name": "GerbilManager (Aspire — alles starten)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/GerbilManager.AppHost/bin/Debug/net10.0/GerbilManager.AppHost.dll",
"args": [],
"cwd": "${workspaceFolder}/GerbilManager.AppHost",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development"
},
"serverReadyAction": {
"action": "openExternally",
"pattern": "Login to the dashboard at\\s+(https?://\\S+)",
"uriFormat": "%s"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

6
.vscode/tasks.json vendored
View File

@@ -7,7 +7,7 @@
"type": "process",
"args": [
"build",
"${workspaceFolder}/GerbilManager.sln",
"${workspaceFolder}/GerbilManager.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
@@ -19,7 +19,7 @@
"type": "process",
"args": [
"publish",
"${workspaceFolder}/GerbilManager.sln",
"${workspaceFolder}/GerbilManager.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
@@ -33,7 +33,7 @@
"watch",
"run",
"--project",
"${workspaceFolder}/GerbilManager.sln"
"${workspaceFolder}/GerbilManager.slnx"
],
"problemMatcher": "$msCompile"
},

View File

@@ -0,0 +1,73 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithDataVolume();
var gerbilDb = postgres.AddDatabase("gerbilmanager");
// Stable, LAN-facing HTTP port for the API (matches the SPA's default base URL).
const int ApiHttpPort = 5179;
var webapi = builder.AddProject<Projects.GerbilManagerWebAPI>("webapi")
.WithReference(gerbilDb)
.WaitFor(gerbilDb)
// Pin the port so the phone-facing URL doesn't change between launches.
// Proxyless so the app binds the port directly — Program.cs then widens the
// host to 0.0.0.0 for LAN access (the DCP proxy binds localhost only).
.WithEndpoint("http", e => { e.Port = ApiHttpPort; e.IsProxied = false; }, createIfNotExists: false)
.WithExternalHttpEndpoints();
// The machine's LAN IP — the SPA runs in the phone's browser, so it must reach
// the API at this address, not "localhost" (which would be the phone itself).
var lanIp = GetLanIpAddress();
builder.AddViteApp("frontend", "../gerbil-manager-web")
.WithReference(webapi)
.WaitFor(webapi)
// Pin a stable port for the phone bookmark (Vite default 5173). Proxyless so
// the Vite dev server binds the port directly; vite.config host:true makes it
// bind 0.0.0.0 for LAN access.
.WithEndpoint("http", e => { e.Port = 5173; e.IsProxied = false; }, createIfNotExists: false)
.WithExternalHttpEndpoints()
.WithEnvironment(context =>
{
// VITE_API_BASE_URL is read by the Vite dev server at launch (see
// src/api/client.ts). Point it at the API's LAN-reachable URL.
context.EnvironmentVariables["VITE_API_BASE_URL"] = $"http://{lanIp}:{ApiHttpPort}";
});
builder.Build().Run();
// Picks the IPv4 address of the active physical LAN adapter (Wi-Fi/Ethernet),
// skipping loopback and virtual adapters (WSL/Hyper-V). Recomputed each launch
// so it follows DHCP changes.
static string GetLanIpAddress()
{
var candidates = NetworkInterface.GetAllNetworkInterfaces()
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
.Where(ni => ni.NetworkInterfaceType is NetworkInterfaceType.Wireless80211
or NetworkInterfaceType.Ethernet)
.Where(ni => !ni.Description.Contains("Hyper-V", StringComparison.OrdinalIgnoreCase)
&& !ni.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase)
&& !ni.Name.Contains("WSL", StringComparison.OrdinalIgnoreCase)
&& !ni.Name.Contains("vEthernet", StringComparison.OrdinalIgnoreCase))
// Prefer adapters that have a default gateway (i.e. a real network path).
.OrderByDescending(ni => ni.GetIPProperties().GatewayAddresses.Count > 0);
foreach (var ni in candidates)
{
var ip = ni.GetIPProperties().UnicastAddresses
.FirstOrDefault(a => a.Address.AddressFamily == AddressFamily.InterNetwork
&& !IPAddress.IsLoopback(a.Address));
if (ip is not null)
{
return ip.Address.ToString();
}
}
return "localhost";
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.2">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>b153f50c-bec1-401e-9893-f9df7cff2ebd</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManagerWebAPI\GerbilManagerWebAPI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17004;http://localhost:15265",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21284",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22113"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15265",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19171",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20259"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}

View File

@@ -0,0 +1,5 @@
{
"appHost": {
"path": "GerbilManager.AppHost.csproj"
}
}

View File

@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ServiceDiscovery;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/aspire/service-defaults
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(tracing =>
// Exclude health check requests from tracing
tracing.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
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())
{
// 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")
});
}
return app;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<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" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManagerWebAPI\GerbilManagerWebAPI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,192 @@
using GerbilManagerWebAPI.Genetics;
namespace GerbilManager.Tests
{
/// <summary>
/// Exact-math checks for the inbreeding coefficient (Inzuchtkoeffizient) against
/// the textbook reference values for canonical pedigrees.
/// </summary>
public class PedigreeCalculatorTests
{
private const int Precision = 10;
[Fact]
public void UnknownAncestry_IsZero()
{
var ped = new PedigreeBuilder();
var lonely = ped.Add("Lonely");
var calc = ped.Build();
var result = calc.ForIndividual(lonely);
Assert.Equal(0.0, result.Coefficient, Precision);
Assert.Equal(0, result.GenerationsAvailable);
Assert.Empty(result.CommonAncestors);
}
[Fact]
public void OneKnownParent_IsZero()
{
var ped = new PedigreeBuilder();
var sire = ped.Add("Sire");
var calc = ped.Build();
// Only one parent known → no inbreeding can be established.
var result = calc.ForOffspringOf(sire, null);
Assert.Equal(0.0, result.Coefficient, Precision);
Assert.Empty(result.CommonAncestors);
}
[Fact]
public void ParentOffspring_IsQuarter()
{
// Z is the offspring of P mated with its own daughter C.
var ped = new PedigreeBuilder();
var p = ped.Add("P");
var mate = ped.Add("Mate");
var c = ped.AddChild("C", p, mate);
var calc = ped.Build();
var result = calc.ForOffspringOf(p, c);
Assert.Equal(0.25, result.Coefficient, Precision);
Assert.Equal(25.0, result.Percent, Precision);
var ancestor = Assert.Single(result.CommonAncestors);
Assert.Equal(p, ancestor.Id);
Assert.Equal(0.25, ancestor.Contribution, Precision);
}
[Fact]
public void FullSiblings_IsQuarter()
{
// Parents S and D are full siblings (both children of GF and GM).
var ped = new PedigreeBuilder();
var gf = ped.Add("GF");
var gm = ped.Add("GM");
var s = ped.AddChild("S", gf, gm);
var d = ped.AddChild("D", gf, gm);
var calc = ped.Build();
var result = calc.ForOffspringOf(s, d);
Assert.Equal(0.25, result.Coefficient, Precision);
Assert.Equal(2, result.CommonAncestors.Count);
Assert.All(result.CommonAncestors, a => Assert.Equal(0.125, a.Contribution, Precision));
}
[Fact]
public void HalfSiblings_IsEighth()
{
// Parents S and D share only GF (other parents unrelated).
var ped = new PedigreeBuilder();
var gf = ped.Add("GF");
var x = ped.Add("X");
var y = ped.Add("Y");
var s = ped.AddChild("S", gf, x);
var d = ped.AddChild("D", gf, y);
var calc = ped.Build();
var result = calc.ForOffspringOf(s, d);
Assert.Equal(0.125, result.Coefficient, Precision);
var ancestor = Assert.Single(result.CommonAncestors);
Assert.Equal(gf, ancestor.Id);
Assert.Equal(0.125, ancestor.Contribution, Precision);
}
[Fact]
public void FirstCousins_IsSixteenth()
{
// Parents S and D are first cousins: their respective parents A1 and B1
// are full siblings (children of GF and GM).
var ped = new PedigreeBuilder();
var gf = ped.Add("GF");
var gm = ped.Add("GM");
var a1 = ped.AddChild("A1", gf, gm);
var b1 = ped.AddChild("B1", gf, gm);
var x = ped.Add("X");
var y = ped.Add("Y");
var s = ped.AddChild("S", a1, x);
var d = ped.AddChild("D", b1, y);
var calc = ped.Build();
var result = calc.ForOffspringOf(s, d);
Assert.Equal(0.0625, result.Coefficient, Precision);
Assert.Equal(2, result.CommonAncestors.Count);
Assert.All(result.CommonAncestors, a => Assert.Equal(0.03125, a.Contribution, Precision));
}
[Fact]
public void DeepCommonAncestry_SumsAllDisjointPaths()
{
// Full siblings S and D whose shared parents (GF, GM) are themselves full
// siblings (children of GGF, GGM). GF and GM are NOT inbred (their parents
// are unrelated founders, so F_GF = F_GM = 0), but the great-grandparents
// are reached by additional disjoint paths:
// GF + GM: 2 * (1/2)^3 = 0.25
// GGF: 2 * (1/2)^5 = 0.0625 (S-GF-GGF / D-GM-GGF and swap)
// GGM: 2 * (1/2)^5 = 0.0625
// ---------------------------------------------
// F = 0.375 (verified against the recursive kinship method)
var ped = new PedigreeBuilder();
var ggf = ped.Add("GGF");
var ggm = ped.Add("GGM");
var gf = ped.AddChild("GF", ggf, ggm);
var gm = ped.AddChild("GM", ggf, ggm);
var s = ped.AddChild("S", gf, gm);
var d = ped.AddChild("D", gf, gm);
var calc = ped.Build();
var result = calc.ForOffspringOf(s, d);
Assert.Equal(0.375, result.Coefficient, Precision);
// Four common ancestors: GF, GM, GGF, GGM.
Assert.Equal(4, result.CommonAncestors.Count);
Assert.Equal(0.375, result.CommonAncestors.Sum(a => a.Contribution), Precision);
// Deepest known generation above the offspring: S/D (1) → GF/GM (2) → GGF/GGM (3).
Assert.Equal(3, result.GenerationsAvailable);
}
[Fact]
public void GenerationsAvailable_CountsDeepestKnownGeneration()
{
var ped = new PedigreeBuilder();
var gf = ped.Add("GF");
var gm = ped.Add("GM");
var father = ped.AddChild("Father", gf, gm);
var mother = ped.Add("Mother");
var calc = ped.Build();
var result = calc.ForOffspringOf(father, mother);
// Father side reaches grandparents (2 generations); mother side reaches 1.
Assert.Equal(2, result.GenerationsAvailable);
}
/// <summary>Small helper to assemble a pedigree of plain GUIDs for the calculator.</summary>
private sealed class PedigreeBuilder
{
private readonly Dictionary<Guid, (Guid? Father, Guid? Mother)> _parents = new();
private readonly Dictionary<Guid, string> _names = new();
public Guid Add(string name)
{
var id = Guid.NewGuid();
_parents[id] = (null, null);
_names[id] = name;
return id;
}
public Guid AddChild(string name, Guid father, Guid mother)
{
var id = Add(name);
_parents[id] = (father, mother);
return id;
}
public PedigreeCalculator Build() => new(_parents, _names);
}
}
}

View File

@@ -1,25 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GerbilManagerWebAPI", "GerbilManagerWebAPI\GerbilManagerWebAPI.csproj", "{48875578-7112-4F6F-A4C0-F1E7637B513D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7F573F67-6F28-4056-8D14-0E04C8C54B7E}
EndGlobalSection
EndGlobal

6
GerbilManager.slnx Normal file
View File

@@ -0,0 +1,6 @@
<Solution>
<Project Path="GerbilManager.AppHost/GerbilManager.AppHost.csproj" />
<Project Path="GerbilManager.ServiceDefaults/GerbilManager.ServiceDefaults.csproj" />
<Project Path="GerbilManager.Tests/GerbilManager.Tests.csproj" />
<Project Path="GerbilManagerWebAPI/GerbilManagerWebAPI.csproj" />
</Solution>

View File

@@ -1,28 +1,164 @@
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
public class ApplicationContext : DbContext
{
public ApplicationContext(DbContextOptions options)
: base(options)
public ApplicationContext(DbContextOptions options) : base(options)
{
}
public DbSet<Gerbil> Gerbils => Set<Gerbil>();
public DbSet<Litter> Litters => Set<Litter>();
public DbSet<Contact> Contacts => Set<Contact>();
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Gerbil>().ToTable("Gerbils");
modelBuilder.Entity<Litter>().ToTable("Litters");
modelBuilder.Entity<Breeder>().ToTable("Breeders");
modelBuilder.Entity<Gerbil>(e =>
{
// Enums persisted as their string names (readable, Gridify-friendly).
e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>();
modelBuilder.Entity<Gerbil>().HasOne(entity => entity.Litter);
modelBuilder.Entity<Litter>().HasOne(entity => entity.Father);
modelBuilder.Entity<Litter>().HasOne(entity => entity.Mother);
e.HasOne(g => g.Litter).WithMany()
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.OriginContact).WithMany()
.HasForeignKey(g => g.OriginContactId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(g => g.ReceiverContact).WithMany()
.HasForeignKey(g => g.ReceiverContactId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(g => g.Enclosure).WithMany(en => en.Gerbils)
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.ColorVariety).WithMany()
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
});
modelBuilder
.Entity<Gerbil>()
.Property(d => d.Gender)
.HasConversion(new EnumToStringConverter<Gender>());
modelBuilder.Entity<Litter>(e =>
{
e.HasOne(l => l.Father).WithMany()
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(l => l.Mother).WithMany()
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<HealthRecord>(e =>
{
e.Property(h => h.Type).HasConversion<string>();
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(h => h.GerbilId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<WeightRecord>(e =>
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(w => w.GerbilId).OnDelete(DeleteBehavior.Cascade));
modelBuilder.Entity<GerbilPhoto>(e =>
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
SeedColorVarieties(modelBuilder);
}
/// <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
/// (the 18 frozen-contract names first, then the baseportal.de extension), in stable
/// SortOrder. Names are UI filter keys — spelling changes route through god.
/// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder)
{
(string Name, string Genotype)[] catalog =
{
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
("Hermelin", "aa chch DD EE GG PP spsp rere"),
("Himalaya", "AA chch DD EE GG PP spsp rere"),
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
("Schwarzschimmel", "AA CC DD efef GG PP spsp rere"),
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
("Agouti", "AA CC DD EE GG PP spsp rere"),
("Schwarz", "aa CC DD EE GG PP spsp rere"),
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
("Blau", "aa CC dd EE GG PP spsp rere"),
("Gold", "AA CC DD EE GG pp spsp rere"),
("Platin", "aa CC DD EE GG pp spsp rere"),
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
("dd Gold", "AA CC dd EE GG pp spsp rere"),
("dd Platin", "aa CC dd EE GG pp spsp rere"),
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
("C-Separator", "aa CC DD ee gg pp spsp rere"),
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere"),
("Schimmel (Orangeschimmel)", "AA CC DD efef GG PP spsp rere"),
("Topas", "AA CC DD EE GG pp spsp rere"),
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Polarfuchs-Hell CP", "AA cchmcchm DD ee gg PP spsp rere"),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
("Agouti-Hell dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Polarfuchs CP", "AA cchmcchm DD ee gg PP spsp rere"),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Topas dd", "AA CC dd EE GG pp spsp rere"),
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg PP spsp rere"),
};
var rows = new ColorVariety[catalog.Length];
for (int i = 0; i < catalog.Length; i++)
{
rows[i] = new ColorVariety
{
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
Name = catalog[i].Name,
CanonicalGenotype = catalog[i].Genotype,
SortOrder = i,
};
}
modelBuilder.Entity<ColorVariety>().HasData(rows);
}
}

View File

@@ -0,0 +1,57 @@
using Gridify;
using Gridify.EntityFramework;
namespace GerbilManagerWebAPI.Common
{
/// <summary>Standard paged list envelope returned by every list endpoint.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize);
/// <summary>
/// Query-string parameters for list endpoints. All optional (nullable) so the
/// frontend can call a list with no params; Gridify's own GridifyQuery has
/// non-nullable int Page/PageSize which [AsParameters] would make REQUIRED.
/// Bound via [AsParameters]: ?filter=…&orderBy=…&page=1&pageSize=20.
/// </summary>
public class GridifyParams
{
public string? Filter { get; set; }
public string? OrderBy { get; set; }
public int? Page { get; set; }
public int? PageSize { get; set; }
public GridifyQuery ToQuery() => new()
{
Filter = NormalizeFilter(Filter),
OrderBy = OrderBy,
Page = Page is > 0 ? Page.Value : 1,
PageSize = PageSize is > 0 ? PageSize.Value : 20,
};
// The frontend's gridify.ts emits "==" for equals (its documented convention),
// but Gridify's equals operator is a single "=". Translate "==" -> "=". This is
// safe because the frontend backslash-escapes any "=" inside values, and the
// other operators it uses ( != >= <= =* ) contain no literal "==".
private static string? NormalizeFilter(string? filter) =>
string.IsNullOrEmpty(filter) ? filter : filter.Replace("==", "=");
}
public static class QueryableExtensions
{
/// <summary>
/// Apply a Gridify query (filter/order/page) to an EF query and project each
/// row to a DTO, returning the standard paged envelope. Filter/orderBy names
/// are the ENTITY property names (case-insensitive), e.g. "status==Active",
/// "orderBy=dateOfBirth", "litterId==…".
/// </summary>
public static async Task<PagedResult<TDto>> ToPagedResultAsync<TEntity, TDto>(
this IQueryable<TEntity> source,
GridifyParams parameters,
Func<TEntity, TDto> map)
{
var query = parameters.ToQuery();
Paging<TEntity> paging = await source.GridifyAsync(query);
var items = paging.Data.Select(map).ToList();
return new PagedResult<TDto>(items, paging.Count, query.Page, query.PageSize);
}
}
}

View File

@@ -1,92 +0,0 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.DAL;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Converter;
using Microsoft.AspNetCore.Mvc;
using System.Xml;
namespace GerbilManagerWebAPI.Controllers
{
[ApiController]
[Route("breeders")]
public class BreederController : ControllerBase
{
private readonly UnitOfWork unitOfWork;
public BreederController(UnitOfWork unitOfWork)
{
this.unitOfWork = unitOfWork;
}
//GET /gerbils
[HttpGet]
public IEnumerable<BreederDto> GetBreeders()
{
var items = unitOfWork.BreederRepository.Get().Select( breeder => breeder.AsDto());
return items;
}
//GET /gerbils
[HttpGet("{id}")]
public ActionResult<BreederDto> GetBreeder(Guid id)
{
var item = unitOfWork.BreederRepository.GetByID(id).AsDto();
if(item is null)
{
return NotFound();
}
return item;
}
[HttpPost()]
public ActionResult<BreederDto> CreateBreeder(CreateBreederDto dto)
{
Breeder breeder = new Breeder{
Id = Guid.NewGuid(),
Name = dto.Name,
};
this.unitOfWork.BreederRepository.Insert(breeder);
this.unitOfWork.Save();
return CreatedAtAction(nameof(GetBreeder), new { id = breeder.Id}, breeder.AsDto());
}
[HttpPut("{id}")]
public ActionResult UpdateBreeder(Guid id, UpdateBreederDto breederDto)
{
var existingItem = unitOfWork.BreederRepository.GetByID(id);
if(existingItem is null)
{
return NotFound();
}
Breeder updatedBreeder = existingItem with {
Name = breederDto?.Name!
};
unitOfWork.BreederRepository.Update(updatedBreeder);
this.unitOfWork.Save();
return NoContent();
}
[HttpDelete("{id}")]
public ActionResult DeleteBreeder(Guid id)
{
var existingLitter = unitOfWork.BreederRepository.GetByID(id);
if(existingLitter is null)
{
return NotFound();
}
unitOfWork.BreederRepository.Delete(existingLitter);
this.unitOfWork.Save();
return NoContent();
}
}
}

View File

@@ -1,110 +0,0 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.DAL;
using GerbilManagerWebAPI.Dtos;
using Microsoft.AspNetCore.Mvc;
using GerbilManagerWebAPI.Converter;
namespace GerbilManagerWebAPI.Controllers
{
[ApiController]
[Route("gerbils")]
public class GerbilsController : ControllerBase
{
private readonly UnitOfWork unitOfWork;
public GerbilsController(UnitOfWork unitOfWork)
{
this.unitOfWork = unitOfWork;
}
//GET /gerbils
[HttpGet]
public IEnumerable<GerbilDto> GetGerbils([FromQuery]string? name)
{
if(name != null)
{
return unitOfWork.GerbilRepository.Get(filter: x => x.Name == name).Select( gerbil => gerbil.AsDto());
}
var items = unitOfWork.GerbilRepository.Get().Select( gerbil => gerbil.AsDto());
return items;
}
//GET /gerbils
[HttpGet("{id}")]
public ActionResult<GerbilDto> GetGerbil(Guid id)
{
var item = unitOfWork.GerbilRepository.GetByID(id).AsDto();
if(item is null)
{
return NotFound();
}
return item;
}
[HttpPost()]
public ActionResult<GerbilDto> CreateGerbil(CreateGerbilDto dto)
{
Gerbil gerbil = new Gerbil{
Id = Guid.NewGuid(),
Name = dto.Name,
Gender = (Gender) (int) dto.Gender
};
if(dto.Litter != Guid.Empty)
{
gerbil.Litter = unitOfWork.LitterRepository.GetByID(dto.Litter);
}
if(dto.Breeder != Guid.Empty)
{
gerbil.Breeder = unitOfWork.BreederRepository.GetByID(dto.Breeder);
}
this.unitOfWork.GerbilRepository.Insert(gerbil);
this.unitOfWork.Save();
return CreatedAtAction(nameof(GetGerbil), new { id = gerbil.Id}, gerbil.AsDto());
}
[HttpPut("{id}")]
public ActionResult UpdateGerbil(Guid id, UpdateGerbilDto gerbilDto)
{
var existingItem = unitOfWork.GerbilRepository.GetByID(id);
if(existingItem is null)
{
return NotFound();
}
Gerbil updatedGerbil = existingItem with{
Breeder = unitOfWork.BreederRepository.GetByID(gerbilDto?.Breeder ?? Guid.Empty),
Litter = unitOfWork.LitterRepository.GetByID(gerbilDto?.Litter ?? Guid.Empty),
Gender = (Gender) (int) gerbilDto?.Gender!,
Name = gerbilDto?.Name!
};
unitOfWork.GerbilRepository.Update(updatedGerbil);
this.unitOfWork.Save();
return NoContent();
}
[HttpDelete("{id}")]
public ActionResult DeleteGerbil(Guid id)
{
var existingGerbil = unitOfWork.GerbilRepository.GetByID(id);
if(existingGerbil is null)
{
return NotFound();
}
unitOfWork.GerbilRepository.Delete(existingGerbil);
this.unitOfWork.Save();
return NoContent();
}
}
}

View File

@@ -1,109 +0,0 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.DAL;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Converter;
using Microsoft.AspNetCore.Mvc;
using System.Xml;
namespace GerbilManagerWebAPI.Controllers
{
[ApiController]
[Route("litters")]
public class LittersController : ControllerBase
{
private readonly UnitOfWork unitOfWork;
public LittersController(UnitOfWork unitOfWork)
{
this.unitOfWork = unitOfWork;
}
//GET /gerbils
[HttpGet]
public IEnumerable<LitterDto> GetLitters()
{
var items = unitOfWork.LitterRepository.Get(includeProperties: "Father,Mother").Select( litter => litter.AsDto());
return items;
}
//GET /gerbils
[HttpGet("{id}")]
public ActionResult<LitterDto> GetLitter(Guid id)
{
var item = unitOfWork.LitterRepository.GetByID(id).AsDto();
if(item is null)
{
return NotFound();
}
return item;
}
[HttpPost()]
public ActionResult<LitterDto> CreateLitter(CreateLitterDto dto)
{
//Sanity check if father is male and mother is female
var father = this.unitOfWork.GerbilRepository.GetByID(dto.Father ?? Guid.Empty);
var mother = this.unitOfWork.GerbilRepository.GetByID(dto.Mother ?? Guid.Empty);
if(father?.Gender == Gender.female || mother?.Gender == Gender.male)
{
return BadRequest($"The mothers gender is {mother?.Gender.ToString() ?? "unknown"} and the fathers {father?.Gender.ToString() ?? "unknown"}.");
}
Litter litter = new Litter{
Id = Guid.NewGuid(),
Date = dto.Date,
Father = this.unitOfWork.GerbilRepository.GetByID(dto.Father ?? Guid.Empty),
Mother = this.unitOfWork.GerbilRepository.GetByID(dto.Mother ?? Guid.Empty),
Name = dto.Name,
Strength = dto.Strength
};
this.unitOfWork.LitterRepository.Insert(litter);
this.unitOfWork.Save();
return CreatedAtAction(nameof(GetLitter), new { id = litter.Id}, litter.AsDto());
}
[HttpPut("{id}")]
public ActionResult UpdateLitter(Guid id, UpdateLitterDto litterDto)
{
var existingItem = unitOfWork.LitterRepository.GetByID(id);
if(existingItem is null)
{
return NotFound();
}
Litter updatedLitter = existingItem with{
Date = litterDto.Date ?? DateTime.Now,
Strength = litterDto.Strength,
Father = this.unitOfWork.GerbilRepository.GetByID(litterDto.Father ?? Guid.Empty),
Mother = this.unitOfWork.GerbilRepository.GetByID(litterDto.Mother ?? Guid.Empty),
Name = litterDto?.Name!
};
unitOfWork.LitterRepository.Update(updatedLitter);
this.unitOfWork.Save();
return NoContent();
}
[HttpDelete("{id}")]
public ActionResult DeleteLitter(Guid id)
{
var existingLitter = unitOfWork.LitterRepository.GetByID(id);
if(existingLitter is null)
{
return NotFound();
}
unitOfWork.LitterRepository.Delete(existingLitter);
this.unitOfWork.Save();
return NoContent();
}
}
}

View File

@@ -1,18 +0,0 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Converter
{
public static class BreederConverterExtension
{
public static BreederDto AsDto(this Breeder breeder)
{
return new BreederDto{
Id = breeder.Id,
Name = breeder.Name
};
}
}
}

View File

@@ -1,20 +0,0 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Converter
{
public static class GerbilConverterExtension
{
public static GerbilDto AsDto(this Gerbil gerbil)
{
return new GerbilDto{
Id = gerbil.Id,
Gender = (GenderDto) (int) gerbil.Gender,
Name = gerbil.Name,
Breeder = gerbil.Breeder?.AsDto(),
Litter = gerbil.Litter?.AsDto()
};
}
}
}

View File

@@ -1,21 +0,0 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Converter
{
public static class LitterConverterExtension
{
public static LitterDto AsDto(this Litter litter)
{
return new LitterDto{
Id = litter.Id,
Date = litter.Date,
Father = litter.Father?.AsDto(),
Mother = litter.Mother?.AsDto(),
Name = litter.Name,
Strength = litter.Strength
};
}
}
}

View File

@@ -1,77 +0,0 @@
using System.Linq.Expressions;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.DAL
{
public class GenericRepository<TEntity> where TEntity : class
{
internal ApplicationContext context;
internal DbSet<TEntity> dbSet;
public GenericRepository(ApplicationContext context)
{
this.context = context;
this.dbSet = context.Set<TEntity>();
}
public virtual IEnumerable<TEntity> Get(
Expression<Func<TEntity, bool>> filter = null!,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null!,
string includeProperties = "")
{
IQueryable<TEntity> query = dbSet;
if (filter != null)
{
query = query.Where(filter);
}
foreach (var includeProperty in includeProperties.Split
(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
{
query = query.Include(includeProperty);
}
if (orderBy != null)
{
return orderBy(query).ToList();
}
else
{
return query.ToList();
}
}
public virtual TEntity GetByID(object id)
{
return dbSet?.Find(id)!;
}
public virtual void Insert(TEntity entity)
{
dbSet.Add(entity);
}
public virtual void Delete(object id)
{
TEntity entityToDelete = dbSet?.Find(id)!;
if (entityToDelete is not null) Delete(entityToDelete);
}
public virtual void Delete(TEntity entityToDelete)
{
if (context.Entry(entityToDelete).State == EntityState.Detached)
{
dbSet.Attach(entityToDelete);
}
dbSet.Remove(entityToDelete);
}
public virtual void Update(TEntity entityToUpdate)
{
dbSet.Attach(entityToUpdate);
context.Entry(entityToUpdate).State = EntityState.Modified;
}
}
}

View File

@@ -1,76 +0,0 @@
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.DAL
{
public class UnitOfWork : IDisposable
{
private ApplicationContext context;
private GenericRepository<Gerbil> gerbilRepository = null!;
private GenericRepository<Litter> litterRepository = null!;
private GenericRepository<Breeder> breederRepository = null!;
public GenericRepository<Gerbil> GerbilRepository
{
get
{
if (this.gerbilRepository == null)
{
this.gerbilRepository = new GenericRepository<Gerbil>(context);
}
return gerbilRepository;
}
}
public GenericRepository<Litter> LitterRepository
{
get
{
if (this.litterRepository == null)
{
this.litterRepository = new GenericRepository<Litter>(context);
}
return litterRepository;
}
}
public GenericRepository<Breeder> BreederRepository
{
get
{
if (this.breederRepository == null)
{
this.breederRepository = new GenericRepository<Breeder>(context);
}
return breederRepository;
}
}
public UnitOfWork(ApplicationContext context) => this.context = context;
public void Save()
{
context.SaveChanges();
}
private bool disposed = false;
protected virtual void Dispose(bool disposing)
{
if (!this.disposed)
{
if (disposing)
{
context.Dispose();
}
}
this.disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,96 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Dtos
{
// Response DTOs ----------------------------------------------------------
// Enums serialise as string names; JSON property names are camelCase (configured
// globally in Program.cs). FK references are FLAT ids (no nested objects) per the
// frontend contract (Kelly's pedigree + Oscar's filters depend on this).
public record GerbilDto(
Guid Id,
string Name,
Gender Gender,
GerbilStatus Status,
Guid? LitterId,
Guid? OriginContactId,
Guid? ReceiverContactId,
Guid? EnclosureId,
Guid? ColorVarietyId,
DateOnly? DateOfBirth,
DateOnly? DateOfDeath,
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
public record LitterDto(
Guid Id,
string Name,
DateOnly Date,
int? TotalBorn,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
string? Notes);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
public record EnclosureDto(Guid Id, string Name, string? Notes);
public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder);
public record HealthRecordDto(
Guid Id, Guid GerbilId, DateOnly Date, HealthRecordType Type,
string Description, string? Veterinarian, DateTimeOffset CreatedAt);
public record WeightRecordDto(
Guid Id, Guid GerbilId, DateOnly Date, int WeightGrams, string? Notes);
public record PhotoDto(Guid Id, string FileName, string? Caption, int SortOrder, string Url);
// Request DTOs -----------------------------------------------------------
public record GerbilInput(
string Name,
Gender Gender,
GerbilStatus? Status,
Guid? LitterId,
Guid? OriginContactId,
Guid? ReceiverContactId,
Guid? EnclosureId,
Guid? ColorVarietyId,
DateOnly? DateOfBirth,
DateOnly? DateOfDeath,
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
public record LitterInput(
string Name,
DateOnly Date,
int? TotalBorn,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
public record EnclosureInput(string Name, string? Notes);
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
public record HealthRecordInput(
Guid GerbilId, DateOnly Date, HealthRecordType Type, string Description, string? Veterinarian);
public record WeightRecordInput(Guid GerbilId, DateOnly Date, int WeightGrams, string? Notes);
/// <summary>Hypothetical pairing request for POST /genetics/test-inbreeding.</summary>
public record TestInbreedingDto(Guid? FatherId, Guid? MotherId);
}

View File

@@ -1,8 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record BreederDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
}
}

View File

@@ -1,7 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record CreateBreederDto
{
public required string Name { get; init; }
}
}

View File

@@ -1,11 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record CreateGerbilDto
{
public required string Name { get; init; }
public required GenderDto Gender { get; init; }
public Guid Litter { get; init; }
public Guid Breeder { get; init; }
}
}

View File

@@ -1,11 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record CreateLitterDto
{
public required string Name { get; init; }
public required DateTime Date { get; init; }
public int? Strength { get; init; }
public Guid? Father { get; init; }
public Guid? Mother { get; init; }
}
}

View File

@@ -1,9 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public enum GenderDto
{
unknown = 0,
male = 1,
female = 2
}
}

View File

@@ -1,11 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record GerbilDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
public required GenderDto Gender { get; init; }
public LitterDto? Litter { get; init; }
public BreederDto? Breeder { get; init; }
}
}

View File

@@ -1,12 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record LitterDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
public required DateTime Date { get; init; }
public int? Strength { get; init; }
public GerbilDto? Father { get; init; }
public GerbilDto? Mother { get; init; }
}
}

View File

@@ -1,8 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record UpdateBreederDto
{
public string? Name { get; init; }
}
}

View File

@@ -1,10 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record UpdateGerbilDto
{
public string? Name { get; init; }
public GenderDto? Gender { get; init; }
public Guid? Litter { get; init; }
public Guid? Breeder { get; init; }
}
}

View File

@@ -1,12 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record UpdateLitterDto
{
public string? Name { get; init; }
public DateTime? Date { get; init; }
public int? Strength { get; init; }
public Guid? Father { get; init; }
public Guid? Mother { get; init; }
}
}

View File

@@ -0,0 +1,67 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class ColorVarietyEndpoints
{
public static IEndpointRouteBuilder MapColorVarietyEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/color-varieties").WithTags("ColorVarieties");
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.ColorVarieties.AsNoTracking().OrderBy(v => v.SortOrder)
.ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<ColorVarietyDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var v = await db.ColorVarieties.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return v is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(v));
});
group.MapPost("/", async (ColorVarietyInput input, ApplicationContext db) =>
{
var v = new ColorVariety
{
Id = Guid.NewGuid(),
Name = input.Name,
CanonicalGenotype = input.CanonicalGenotype,
SortOrder = input.SortOrder ?? 1000,
};
db.ColorVarieties.Add(v);
await db.SaveChangesAsync();
return TypedResults.Created($"/color-varieties/{v.Id}", ToDto(v));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ColorVarietyInput input, ApplicationContext db) =>
{
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
if (v is null) return TypedResults.NotFound();
v.Name = input.Name;
v.CanonicalGenotype = input.CanonicalGenotype;
if (input.SortOrder is int so) v.SortOrder = so;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
if (v is null) return TypedResults.NotFound();
bool inUse = await db.Gerbils.AnyAsync(g => g.ColorVarietyId == id);
if (inUse) return TypedResults.Conflict("Color variety is in use by one or more gerbils and cannot be deleted.");
db.ColorVarieties.Remove(v);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static ColorVarietyDto ToDto(ColorVariety v) => new(v.Id, v.Name, v.CanonicalGenotype, v.SortOrder);
}
}

View File

@@ -0,0 +1,59 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class ContactEndpoints
{
public static IEndpointRouteBuilder MapContactEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/contacts").WithTags("Contacts");
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Contacts.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<ContactDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var c = await db.Contacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
});
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
{
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
db.Contacts.Add(c);
await db.SaveChangesAsync();
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ContactInput input, ApplicationContext db) =>
{
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// 409 if any gerbil references this contact as origin or receiver.
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
bool linked = await db.Gerbils.AnyAsync(g => g.OriginContactId == id || g.ReceiverContactId == id);
if (linked) return TypedResults.Conflict("Contact is linked to one or more gerbils and cannot be deleted.");
db.Contacts.Remove(c);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
}
}

View File

@@ -0,0 +1,59 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class EnclosureEndpoints
{
public static IEndpointRouteBuilder MapEnclosureEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/enclosures").WithTags("Enclosures");
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Enclosures.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var e = await db.Enclosures.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return e is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(e));
});
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
{
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
db.Enclosures.Add(e);
await db.SaveChangesAsync();
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, EnclosureInput input, ApplicationContext db) =>
{
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
if (e is null) return TypedResults.NotFound();
e.Name = input.Name; e.Notes = input.Notes;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// 409 if the enclosure still houses gerbils.
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
if (e is null) return TypedResults.NotFound();
bool occupied = await db.Gerbils.AnyAsync(g => g.EnclosureId == id);
if (occupied) return TypedResults.Conflict("Enclosure still contains gerbils and cannot be deleted.");
db.Enclosures.Remove(e);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes);
}
}

View File

@@ -0,0 +1,97 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class GerbilEndpoints
{
public static IEndpointRouteBuilder MapGerbilEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/gerbils").WithTags("Gerbils");
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
// GET /gerbils/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
});
// POST /gerbils
group.MapPost("/", async Task<Results<Created<GerbilDto>, ValidationProblem>> (GerbilInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.Name))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
Apply(g, input, isCreate: true);
db.Gerbils.Add(g);
await db.SaveChangesAsync();
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
});
// PUT /gerbils/{id}
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
g.Name = input.Name;
Apply(g, input, isCreate: false);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
db.Gerbils.Remove(g);
try
{
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
catch (DbUpdateException)
{
return TypedResults.Conflict("Gerbil is referenced as a litter parent and cannot be deleted.");
}
});
return app;
}
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
{
g.Gender = i.Gender;
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
g.LitterId = i.LitterId;
g.OriginContactId = i.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId;
g.EnclosureId = i.EnclosureId;
g.ColorVarietyId = i.ColorVarietyId;
g.DateOfBirth = i.DateOfBirth;
g.DateOfDeath = i.DateOfDeath;
g.CauseOfDeath = i.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate;
g.Genotype = i.Genotype;
g.Notes = i.Notes;
g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef;
}
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);
}
}

View File

@@ -0,0 +1,33 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Genetics;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// Inzuchtkoeffizient endpoints (FEAT-1b). Routes/response shapes are IDENTICAL to
/// the former InbreedingController — only the hosting style changed to Minimal API.
/// </summary>
public static class InbreedingEndpoints
{
public static IEndpointRouteBuilder MapInbreedingEndpoints(this IEndpointRouteBuilder app)
{
// GET /gerbils/{id}/inbreeding-coefficient
app.MapGet("/gerbils/{id:guid}/inbreeding-coefficient",
Results<Ok<InbreedingResult>, NotFound> (Guid id, ApplicationContext db) =>
{
var result = new InbreedingService(db).ForGerbil(id);
return result is null ? TypedResults.NotFound() : TypedResults.Ok(result);
})
.WithTags("Inbreeding");
// POST /genetics/test-inbreeding
app.MapPost("/genetics/test-inbreeding",
Ok<InbreedingResult> (TestInbreedingDto dto, ApplicationContext db) =>
TypedResults.Ok(new InbreedingService(db).ForPairing(dto.FatherId, dto.MotherId)))
.WithTags("Inbreeding");
return app;
}
}
}

View File

@@ -0,0 +1,91 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class LitterEndpoints
{
public static IEndpointRouteBuilder MapLitterEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/litters").WithTags("Litters");
// GET /litters (Gridify: date range + orderBy=date supported on the DateOnly column)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Litters.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<LitterDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var l = await db.Litters.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
});
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
{
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
Apply(l, input);
db.Litters.Add(l);
await db.SaveChangesAsync();
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
{
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound();
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
l.Name = input.Name;
l.Date = input.Date;
Apply(l, input);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound();
db.Litters.Remove(l);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
// father must not be female; mother must not be male (unknown is allowed).
private static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
{
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;
if (father?.Gender == Gender.female || mother?.Gender == Gender.male)
{
return new ParentGenderError("InvalidParentGender",
father?.Gender ?? Gender.unknown, mother?.Gender ?? Gender.unknown);
}
return null;
}
private static void Apply(Litter l, LitterInput i)
{
l.TotalBorn = i.TotalBorn;
l.FatherId = i.FatherId;
l.MotherId = i.MotherId;
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
l.Notes = i.Notes;
}
private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
}
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
public record ParentGenderError(string Code, Gender FatherGender, Gender MotherGender);
}

View File

@@ -0,0 +1,104 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// Gerbil photo endpoints (FEAT-1b phase 2), per the contract agreed with Oscar:
/// GET /gerbils/{id}/photos -> plain array (not paged)
/// POST /gerbils/{id}/photos -> multipart 'file' + optional 'caption' -> 201 PhotoDto
/// DELETE /photos/{id} -> 204
/// GET /photos/files/{fileName} -> the image bytes
/// url = "/photos/files/{fileName}"; profile photo = first by SortOrder.
/// </summary>
public static class PhotoEndpoints
{
public static IEndpointRouteBuilder MapPhotoEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/gerbils/{id:guid}/photos",
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
{
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
var photos = await db.GerbilPhotos.AsNoTracking()
.Where(p => p.GerbilId == id)
.OrderBy(p => p.SortOrder)
.ToListAsync();
return TypedResults.Ok(photos.Select(ToDto).ToList());
}).WithTags("Photos");
app.MapPost("/gerbils/{id:guid}/photos",
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
var ext = Path.GetExtension(file.FileName);
var fileName = $"{Guid.NewGuid():N}{ext}";
var root = PhotoRoot(config, env);
Directory.CreateDirectory(root);
await using (var stream = File.Create(Path.Combine(root, fileName)))
await file.CopyToAsync(stream);
int nextSort = (await db.GerbilPhotos.Where(p => p.GerbilId == id)
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
var photo = new GerbilPhoto
{
Id = Guid.NewGuid(),
GerbilId = id,
FileName = fileName,
Caption = caption,
SortOrder = nextSort,
CreatedAt = DateTimeOffset.UtcNow,
};
db.GerbilPhotos.Add(photo);
await db.SaveChangesAsync();
return TypedResults.Created($"/photos/{photo.Id}", ToDto(photo));
}).WithTags("Photos").DisableAntiforgery();
app.MapDelete("/photos/{id:guid}",
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var photo = await db.GerbilPhotos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return TypedResults.NotFound();
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
if (File.Exists(path)) File.Delete(path);
db.GerbilPhotos.Remove(photo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).WithTags("Photos");
app.MapGet("/photos/files/{fileName}",
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
{
// guard against path traversal: only a bare file name is allowed
if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains(".."))
return TypedResults.BadRequest("Invalid file name.");
var path = Path.Combine(PhotoRoot(config, env), fileName);
if (!File.Exists(path)) return TypedResults.NotFound();
return TypedResults.PhysicalFile(path, ContentType(fileName));
}).WithTags("Photos");
return app;
}
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
private static string ContentType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch
{
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".bmp" => "image/bmp",
_ => "image/jpeg",
};
private static PhotoDto ToDto(GerbilPhoto p) =>
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
}
}

View File

@@ -0,0 +1,121 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class RecordEndpoints
{
public static IEndpointRouteBuilder MapHealthRecordEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/health-records").WithTags("HealthRecords");
// GET /health-records?filter=gerbilId==…
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.HealthRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<HealthRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.HealthRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
group.MapPost("/", async (HealthRecordInput input, ApplicationContext db) =>
{
var r = new HealthRecord
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
Date = input.Date,
Type = input.Type,
Description = input.Description,
Veterinarian = input.Veterinarian,
CreatedAt = DateTimeOffset.UtcNow,
};
db.HealthRecords.Add(r);
await db.SaveChangesAsync();
return TypedResults.Created($"/health-records/{r.Id}", ToDto(r));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, HealthRecordInput input, ApplicationContext db) =>
{
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
r.GerbilId = input.GerbilId; r.Date = input.Date; r.Type = input.Type;
r.Description = input.Description; r.Veterinarian = input.Veterinarian;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
db.HealthRecords.Remove(r);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
public static IEndpointRouteBuilder MapWeightRecordEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/weight-records").WithTags("WeightRecords");
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.WeightRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<WeightRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.WeightRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
group.MapPost("/", async (WeightRecordInput input, ApplicationContext db) =>
{
var r = new WeightRecord
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
Date = input.Date,
WeightGrams = input.WeightGrams,
Notes = input.Notes,
};
db.WeightRecords.Add(r);
await db.SaveChangesAsync();
return TypedResults.Created($"/weight-records/{r.Id}", ToDto(r));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, WeightRecordInput input, ApplicationContext db) =>
{
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
r.GerbilId = input.GerbilId; r.Date = input.Date;
r.WeightGrams = input.WeightGrams; r.Notes = input.Notes;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
db.WeightRecords.Remove(r);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static HealthRecordDto ToDto(HealthRecord r) =>
new(r.Id, r.GerbilId, r.Date, r.Type, r.Description, r.Veterinarian, r.CreatedAt);
private static WeightRecordDto ToDto(WeightRecord r) =>
new(r.Id, r.GerbilId, r.Date, r.WeightGrams, r.Notes);
}
}

View File

@@ -0,0 +1,25 @@
namespace GerbilManagerWebAPI.Genetics
{
/// <summary>
/// Result of an inbreeding-coefficient (Inzuchtkoeffizient) calculation for an
/// individual or a hypothetical pairing.
/// </summary>
/// <param name="Coefficient">Wright's inbreeding coefficient F, in the range 0..1.</param>
/// <param name="Percent">The coefficient expressed as a percentage (F * 100).</param>
/// <param name="GenerationsAvailable">
/// The deepest generation of known ancestry above the individual (parents = 1,
/// grandparents = 2, …). 0 when no parents are recorded.
/// </param>
/// <param name="CommonAncestors">
/// The ancestors common to both parents, each with its additive contribution to F,
/// ordered by contribution descending. Empty when there is no inbreeding.
/// </param>
public record InbreedingResult(
double Coefficient,
double Percent,
int GenerationsAvailable,
IReadOnlyList<CommonAncestorContribution> CommonAncestors);
/// <summary>A single common ancestor and how much it contributes to F.</summary>
public record CommonAncestorContribution(Guid Id, string Name, double Contribution);
}

View File

@@ -0,0 +1,64 @@
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Genetics
{
/// <summary>
/// Loads the pedigree out of the database into in-memory lookups (the home dataset
/// is small) and delegates the actual maths to <see cref="PedigreeCalculator"/>.
///
/// Parent links live in EF shadow foreign keys: a gerbil's birth litter is
/// <c>Gerbils.LitterId</c>; that litter's parents are <c>Litters.FatherId</c> and
/// <c>Litters.MotherId</c>.
/// </summary>
public sealed class InbreedingService
{
private readonly ApplicationContext _db;
public InbreedingService(ApplicationContext db) => _db = db;
/// <summary>F for an existing gerbil, or null if no such gerbil exists.</summary>
public InbreedingResult? ForGerbil(Guid id)
{
var calculator = BuildCalculator();
return calculator.Contains(id) ? calculator.ForIndividual(id) : null;
}
/// <summary>F for the hypothetical offspring of the given sire and dam.</summary>
public InbreedingResult ForPairing(Guid? fatherId, Guid? motherId)
{
return BuildCalculator().ForOffspringOf(fatherId, motherId);
}
private PedigreeCalculator BuildCalculator()
{
var litters = _db.Litters
.Select(l => new { l.Id, l.FatherId, l.MotherId })
.ToDictionary(l => l.Id, l => (l.FatherId, l.MotherId));
var gerbils = _db.Gerbils
.Select(g => new { g.Id, g.Name, g.LitterId })
.ToList();
var parents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>(gerbils.Count);
var names = new Dictionary<Guid, string>(gerbils.Count);
foreach (var g in gerbils)
{
names[g.Id] = g.Name;
Guid? father = null;
Guid? mother = null;
if (g.LitterId is Guid litterId && litters.TryGetValue(litterId, out var litterParents))
{
father = litterParents.FatherId;
mother = litterParents.MotherId;
}
parents[g.Id] = (father, mother);
}
return new PedigreeCalculator(parents, names);
}
}
}

View File

@@ -0,0 +1,219 @@
namespace GerbilManagerWebAPI.Genetics
{
/// <summary>
/// Computes the inbreeding coefficient (Inzuchtkoeffizient) using Wright's path
/// method:
///
/// F = Σ over common ancestors, over every valid path, of
/// (1/2)^(n1 + n2 + 1) * (1 + F_A)
///
/// where, for a given common ancestor A, n1 is the number of links from the sire
/// up to A and n2 the number of links from the dam up to A, and F_A is A's own
/// inbreeding coefficient (computed recursively at full available pedigree depth).
/// A path counts only if the sire-side and dam-side chains share no individual
/// other than A — this is what prevents double counting.
///
/// This type is deliberately free of any database / EF dependency: it works purely
/// from a parent lookup, so it can be unit-tested with hand-built pedigrees.
/// </summary>
public sealed class PedigreeCalculator
{
private readonly IReadOnlyDictionary<Guid, (Guid? Father, Guid? Mother)> _parents;
private readonly IReadOnlyDictionary<Guid, string> _names;
// F_A memo + re-entrancy guard (the latter only matters if the data somehow
// contains a cycle, which is biologically impossible but cheap to defend).
private readonly Dictionary<Guid, double> _fCache = new();
private readonly HashSet<Guid> _fInProgress = new();
private readonly Dictionary<Guid, int> _depthCache = new();
public PedigreeCalculator(
IReadOnlyDictionary<Guid, (Guid? Father, Guid? Mother)> parents,
IReadOnlyDictionary<Guid, string> names)
{
_parents = parents;
_names = names;
}
/// <summary>True if the given individual exists in the pedigree.</summary>
public bool Contains(Guid id) => _parents.ContainsKey(id);
/// <summary>Inbreeding coefficient of an existing individual.</summary>
public InbreedingResult ForIndividual(Guid id)
{
var (father, mother) = ParentsOf(id);
return ForOffspringOf(father, mother);
}
/// <summary>
/// Inbreeding coefficient of the (possibly hypothetical) offspring of the given
/// sire and dam — equivalently, the kinship between the two parents.
/// </summary>
public InbreedingResult ForOffspringOf(Guid? sire, Guid? dam)
{
int generations = GenerationsAvailable(sire, dam);
if (sire is not Guid s || dam is not Guid d)
{
// A parent is unknown → no inbreeding can be established.
return new InbreedingResult(0.0, 0.0, generations, Array.Empty<CommonAncestorContribution>());
}
var contributions = Coefficient(s, d);
double f = contributions.Values.Sum();
var commonAncestors = contributions
.Select(kv => new CommonAncestorContribution(kv.Key, NameOf(kv.Key), kv.Value))
.OrderByDescending(c => c.Contribution)
.ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new InbreedingResult(f, f * 100.0, generations, commonAncestors);
}
/// <summary>
/// Per-common-ancestor contributions to the kinship of <paramref name="sire"/>
/// and <paramref name="dam"/>. The sum of the values is the coefficient.
/// </summary>
private Dictionary<Guid, double> Coefficient(Guid sire, Guid dam)
{
var sirePaths = EnumerateAncestorPaths(sire);
var damPaths = EnumerateAncestorPaths(dam);
// Group the dam's paths by the ancestor they terminate at for quick lookup.
var damPathsByAncestor = damPaths
.GroupBy(p => p[^1])
.ToDictionary(g => g.Key, g => g.ToList());
var contributions = new Dictionary<Guid, double>();
foreach (var sirePath in sirePaths)
{
var ancestor = sirePath[^1];
if (!damPathsByAncestor.TryGetValue(ancestor, out var matchingDamPaths))
{
continue;
}
var sireNodes = new HashSet<Guid>(sirePath);
foreach (var damPath in matchingDamPaths)
{
// The two chains may meet only at the common ancestor itself.
if (damPath.Any(node => node != ancestor && sireNodes.Contains(node)))
{
continue;
}
int n1 = sirePath.Count - 1;
int n2 = damPath.Count - 1;
double term = Math.Pow(0.5, n1 + n2 + 1) * (1.0 + F(ancestor));
contributions[ancestor] = contributions.GetValueOrDefault(ancestor) + term;
}
}
return contributions;
}
/// <summary>Inbreeding coefficient F of a single individual (memoized).</summary>
private double F(Guid id)
{
if (_fCache.TryGetValue(id, out var cached))
{
return cached;
}
// Defend against accidental cycles in the data.
if (!_fInProgress.Add(id))
{
return 0.0;
}
double f = 0.0;
var (father, mother) = ParentsOf(id);
if (father is Guid s && mother is Guid d)
{
f = Coefficient(s, d).Values.Sum();
}
_fInProgress.Remove(id);
_fCache[id] = f;
return f;
}
/// <summary>
/// Every chain of parent links starting at <paramref name="start"/>, including
/// the trivial chain [start] itself. Each returned list runs from the start
/// individual down to one of its ancestors (inclusive). A visited set keeps the
/// walk finite even if the data contains a cycle.
/// </summary>
private List<List<Guid>> EnumerateAncestorPaths(Guid start)
{
var all = new List<List<Guid>>();
var path = new List<Guid>();
void Walk(Guid node)
{
path.Add(node);
all.Add(new List<Guid>(path));
var (father, mother) = ParentsOf(node);
foreach (var parent in new[] { father, mother })
{
if (parent is Guid p && !path.Contains(p))
{
Walk(p);
}
}
path.RemoveAt(path.Count - 1);
}
Walk(start);
return all;
}
/// <summary>
/// Deepest generation of known ancestry for the offspring of the two parents
/// (parents = 1, grandparents = 2, …). 0 when neither parent is known.
/// </summary>
private int GenerationsAvailable(Guid? sire, Guid? dam)
{
int FromParent(Guid? parent) => parent is Guid p ? 1 + AncestorDepth(p) : 0;
return Math.Max(FromParent(sire), FromParent(dam));
}
/// <summary>Number of ancestral generations above <paramref name="id"/> (0 for a founder).</summary>
private int AncestorDepth(Guid id)
{
if (_depthCache.TryGetValue(id, out var cached))
{
return cached;
}
// Temporary 0 breaks any cycle while the real value is computed.
_depthCache[id] = 0;
var (father, mother) = ParentsOf(id);
int depth = 0;
if (father is Guid f)
{
depth = Math.Max(depth, 1 + AncestorDepth(f));
}
if (mother is Guid m)
{
depth = Math.Max(depth, 1 + AncestorDepth(m));
}
_depthCache[id] = depth;
return depth;
}
private (Guid? Father, Guid? Mother) ParentsOf(Guid id) =>
_parents.TryGetValue(id, out var p) ? p : (null, null);
private string NameOf(Guid id) =>
_names.TryGetValue(id, out var name) ? name : id.ToString();
}
}

View File

@@ -1,20 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,134 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
[DbContext(typeof(ApplicationContext))]
[Migration("20231030203950_initialCreate")]
partial class initialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("GerbilManagerWebAPI.Models.Breeder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Breeders", (string)null);
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BreederId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Gender")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("LitterId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("BreederId");
b.HasIndex("LitterId");
b.ToTable("Gerbils", (string)null);
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<Guid?>("FatherId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("MotherId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("Strength")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FatherId");
b.HasIndex("MotherId");
b.ToTable("Litters", (string)null);
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Breeder", "Breeder")
.WithMany()
.HasForeignKey("BreederId");
b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter")
.WithMany()
.HasForeignKey("LitterId");
b.Navigation("Breeder");
b.Navigation("Litter");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father")
.WithMany()
.HasForeignKey("FatherId");
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother")
.WithMany()
.HasForeignKey("MotherId");
b.Navigation("Father");
b.Navigation("Mother");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,121 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class initialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Breeders",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Breeders", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Gerbils",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Gender = table.Column<string>(type: "nvarchar(max)", nullable: false),
LitterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
BreederId = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Gerbils", x => x.Id);
table.ForeignKey(
name: "FK_Gerbils_Breeders_BreederId",
column: x => x.BreederId,
principalTable: "Breeders",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Litters",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
Strength = table.Column<int>(type: "int", nullable: true),
FatherId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
MotherId = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Litters", x => x.Id);
table.ForeignKey(
name: "FK_Litters_Gerbils_FatherId",
column: x => x.FatherId,
principalTable: "Gerbils",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Litters_Gerbils_MotherId",
column: x => x.MotherId,
principalTable: "Gerbils",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Gerbils_BreederId",
table: "Gerbils",
column: "BreederId");
migrationBuilder.CreateIndex(
name: "IX_Gerbils_LitterId",
table: "Gerbils",
column: "LitterId");
migrationBuilder.CreateIndex(
name: "IX_Litters_FatherId",
table: "Litters",
column: "FatherId");
migrationBuilder.CreateIndex(
name: "IX_Litters_MotherId",
table: "Litters",
column: "MotherId");
migrationBuilder.AddForeignKey(
name: "FK_Gerbils_Litters_LitterId",
table: "Gerbils",
column: "LitterId",
principalTable: "Litters",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Gerbils_Breeders_BreederId",
table: "Gerbils");
migrationBuilder.DropForeignKey(
name: "FK_Gerbils_Litters_LitterId",
table: "Gerbils");
migrationBuilder.DropTable(
name: "Breeders");
migrationBuilder.DropTable(
name: "Litters");
migrationBuilder.DropTable(
name: "Gerbils");
}
}
}

View File

@@ -0,0 +1,891 @@
// <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("20260605230614_InitialCreate")]
partial class InitialCreate
{
/// <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<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<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,383 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ColorVarieties",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
CanonicalGenotype = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ColorVarieties", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Contacts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Email = table.Column<string>(type: "text", nullable: true),
Phone = table.Column<string>(type: "text", nullable: true),
Address = table.Column<string>(type: "text", nullable: true),
Notes = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contacts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Enclosures",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Enclosures", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GerbilPhotos",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
Caption = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GerbilPhotos", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Gerbils",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Gender = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
OriginContactId = table.Column<Guid>(type: "uuid", nullable: true),
ReceiverContactId = table.Column<Guid>(type: "uuid", nullable: true),
EnclosureId = table.Column<Guid>(type: "uuid", nullable: true),
ColorVarietyId = table.Column<Guid>(type: "uuid", nullable: true),
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
DateOfDeath = table.Column<DateOnly>(type: "date", nullable: true),
CauseOfDeath = table.Column<string>(type: "text", nullable: true),
GoHomeDate = table.Column<DateOnly>(type: "date", nullable: true),
Genotype = table.Column<string>(type: "text", nullable: true),
Notes = table.Column<string>(type: "text", nullable: true),
ImportSource = table.Column<string>(type: "text", nullable: true),
ExternalRef = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Gerbils", x => x.Id);
table.ForeignKey(
name: "FK_Gerbils_ColorVarieties_ColorVarietyId",
column: x => x.ColorVarietyId,
principalTable: "ColorVarieties",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Gerbils_Contacts_OriginContactId",
column: x => x.OriginContactId,
principalTable: "Contacts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Gerbils_Contacts_ReceiverContactId",
column: x => x.ReceiverContactId,
principalTable: "Contacts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Gerbils_Enclosures_EnclosureId",
column: x => x.EnclosureId,
principalTable: "Enclosures",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "HealthRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
Veterinarian = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthRecords", x => x.Id);
table.ForeignKey(
name: "FK_HealthRecords_Gerbils_GerbilId",
column: x => x.GerbilId,
principalTable: "Gerbils",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Litters",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: false),
TotalBorn = table.Column<int>(type: "integer", nullable: true),
FatherId = table.Column<Guid>(type: "uuid", nullable: true),
MotherId = table.Column<Guid>(type: "uuid", nullable: true),
ExpectedGoHomeDate = table.Column<DateOnly>(type: "date", nullable: true),
Notes = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Litters", x => x.Id);
table.ForeignKey(
name: "FK_Litters_Gerbils_FatherId",
column: x => x.FatherId,
principalTable: "Gerbils",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Litters_Gerbils_MotherId",
column: x => x.MotherId,
principalTable: "Gerbils",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "WeightRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: false),
WeightGrams = table.Column<int>(type: "integer", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WeightRecords", x => x.Id);
table.ForeignKey(
name: "FK_WeightRecords_Gerbils_GerbilId",
column: x => x.GerbilId,
principalTable: "Gerbils",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000001"), "AA chch DD EE GG pp spsp rere", "Pink Eyed White (PEW)", 0 },
{ new Guid("00000000-0000-0000-0000-000000000002"), "aa chch DD EE GG PP spsp rere", "Hermelin", 1 },
{ new Guid("00000000-0000-0000-0000-000000000003"), "AA chch DD EE GG PP spsp rere", "Himalaya", 2 },
{ new Guid("00000000-0000-0000-0000-000000000004"), "aa cchmcchm DD EE gg PP spsp rere", "Zobel", 3 },
{ new Guid("00000000-0000-0000-0000-000000000005"), "AA CC DD efef GG PP spsp rere", "Schwarzschimmel", 4 },
{ new Guid("00000000-0000-0000-0000-000000000006"), "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel", 5 },
{ new Guid("00000000-0000-0000-0000-000000000007"), "AA CC DD EE GG PP spsp rere", "Agouti", 6 },
{ new Guid("00000000-0000-0000-0000-000000000008"), "aa CC DD EE GG PP spsp rere", "Schwarz", 7 },
{ new Guid("00000000-0000-0000-0000-000000000009"), "AA CC DD EE gg PP spsp rere", "Silberagouti", 8 },
{ new Guid("00000000-0000-0000-0000-000000000010"), "aa CC DD EE gg PP spsp rere", "Anthrazit", 9 },
{ new Guid("00000000-0000-0000-0000-000000000011"), "AA CC DD ee GG PP spsp rere", "Algierfuchs", 10 },
{ new Guid("00000000-0000-0000-0000-000000000012"), "aa CC dd EE GG PP spsp rere", "Blau", 11 },
{ new Guid("00000000-0000-0000-0000-000000000013"), "AA CC DD EE GG pp spsp rere", "Gold", 12 },
{ new Guid("00000000-0000-0000-0000-000000000014"), "aa CC DD EE GG pp spsp rere", "Platin", 13 },
{ new Guid("00000000-0000-0000-0000-000000000015"), "AA CC DD ee GG pp spsp rere", "Goldfuchs", 14 },
{ new Guid("00000000-0000-0000-0000-000000000016"), "aa CC DD ee GG pp spsp rere", "Rotfuchs", 15 },
{ new Guid("00000000-0000-0000-0000-000000000017"), "AA CC dd EE GG pp spsp rere", "dd Gold", 16 },
{ new Guid("00000000-0000-0000-0000-000000000018"), "aa CC dd EE GG pp spsp rere", "dd Platin", 17 },
{ new Guid("00000000-0000-0000-0000-000000000019"), "aa CC DD EE gg pp spsp rere", "Altweiss (REW)", 18 },
{ new Guid("00000000-0000-0000-0000-000000000020"), "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)", 19 },
{ new Guid("00000000-0000-0000-0000-000000000021"), "aa CC DD ee gg PP spsp rere", "Blaufuchs", 20 },
{ new Guid("00000000-0000-0000-0000-000000000022"), "aa CC DD ee gg pp spsp rere", "C-Separator", 21 },
{ new Guid("00000000-0000-0000-0000-000000000023"), "AA CC DD EE gg pp spsp rere", "Elfenbein", 22 },
{ new Guid("00000000-0000-0000-0000-000000000024"), "aa CC DD ee GG PP spsp rere", "Kohlfuchs", 23 },
{ new Guid("00000000-0000-0000-0000-000000000025"), "aa cchmcchm DD EE GG PP spsp rere", "Marder", 24 },
{ new Guid("00000000-0000-0000-0000-000000000026"), "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)", 25 },
{ new Guid("00000000-0000-0000-0000-000000000027"), "AA CC DD ee gg PP spsp rere", "Polarfuchs", 26 },
{ new Guid("00000000-0000-0000-0000-000000000028"), "aa CC DD EE GG pp spsp rere", "Saphir", 27 },
{ new Guid("00000000-0000-0000-0000-000000000029"), "AA CC DD efef GG PP spsp rere", "Schimmel (Orangeschimmel)", 28 },
{ new Guid("00000000-0000-0000-0000-000000000030"), "AA CC DD EE GG pp spsp rere", "Topas", 29 },
{ new Guid("00000000-0000-0000-0000-000000000031"), "aa CC DD EE GG pp spsp rere", "Platin-Hell", 30 },
{ new Guid("00000000-0000-0000-0000-000000000032"), "AA CC dd EE GG PP spsp rere", "Agouti dd", 31 },
{ new Guid("00000000-0000-0000-0000-000000000033"), "AA CC dd EE gg PP spsp rere", "Silberagouti dd", 32 },
{ new Guid("00000000-0000-0000-0000-000000000034"), "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd", 33 },
{ new Guid("00000000-0000-0000-0000-000000000035"), "aa CC dd EE gg PP spsp rere", "Anthrazit dd", 34 },
{ new Guid("00000000-0000-0000-0000-000000000036"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell", 35 },
{ new Guid("00000000-0000-0000-0000-000000000037"), "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP", 36 },
{ new Guid("00000000-0000-0000-0000-000000000038"), "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel", 37 },
{ new Guid("00000000-0000-0000-0000-000000000039"), "AA CC DD efef gg PP spsp rere", "Silberschimmel", 38 },
{ new Guid("00000000-0000-0000-0000-000000000040"), "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel", 39 },
{ new Guid("00000000-0000-0000-0000-000000000041"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP", 40 },
{ new Guid("00000000-0000-0000-0000-000000000042"), "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel", 41 },
{ new Guid("00000000-0000-0000-0000-000000000043"), "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel", 42 },
{ new Guid("00000000-0000-0000-0000-000000000044"), "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell", 43 },
{ new Guid("00000000-0000-0000-0000-000000000045"), "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell", 44 },
{ new Guid("00000000-0000-0000-0000-000000000046"), "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel", 45 },
{ new Guid("00000000-0000-0000-0000-000000000047"), "AA CC DD EE GG pp spsp rere", "Gold-Hell", 46 },
{ new Guid("00000000-0000-0000-0000-000000000048"), "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd", 47 },
{ new Guid("00000000-0000-0000-0000-000000000049"), "aa cchmcchm dd EE GG PP spsp rere", "Marder dd", 48 },
{ new Guid("00000000-0000-0000-0000-000000000050"), "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell", 49 },
{ new Guid("00000000-0000-0000-0000-000000000051"), "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP", 50 },
{ new Guid("00000000-0000-0000-0000-000000000052"), "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP", 51 },
{ new Guid("00000000-0000-0000-0000-000000000053"), "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP", 52 },
{ new Guid("00000000-0000-0000-0000-000000000054"), "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP", 53 },
{ new Guid("00000000-0000-0000-0000-000000000055"), "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell", 54 },
{ new Guid("00000000-0000-0000-0000-000000000056"), "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel", 55 },
{ new Guid("00000000-0000-0000-0000-000000000057"), "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell", 56 },
{ new Guid("00000000-0000-0000-0000-000000000058"), "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell", 57 },
{ new Guid("00000000-0000-0000-0000-000000000059"), "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell", 58 },
{ new Guid("00000000-0000-0000-0000-000000000060"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd", 59 },
{ new Guid("00000000-0000-0000-0000-000000000061"), "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell", 60 },
{ new Guid("00000000-0000-0000-0000-000000000062"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP", 61 },
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 62 },
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 63 },
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 64 },
{ new Guid("00000000-0000-0000-0000-000000000066"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 65 },
{ new Guid("00000000-0000-0000-0000-000000000067"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 66 },
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 67 },
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 68 },
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd EE GG pp spsp rere", "Topas dd", 69 },
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 70 },
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 71 },
{ new Guid("00000000-0000-0000-0000-000000000073"), "aa CC dd ee gg PP spsp rere", "Blaufuchs dd", 72 }
});
migrationBuilder.CreateIndex(
name: "IX_GerbilPhotos_GerbilId",
table: "GerbilPhotos",
column: "GerbilId");
migrationBuilder.CreateIndex(
name: "IX_Gerbils_ColorVarietyId",
table: "Gerbils",
column: "ColorVarietyId");
migrationBuilder.CreateIndex(
name: "IX_Gerbils_EnclosureId",
table: "Gerbils",
column: "EnclosureId");
migrationBuilder.CreateIndex(
name: "IX_Gerbils_LitterId",
table: "Gerbils",
column: "LitterId");
migrationBuilder.CreateIndex(
name: "IX_Gerbils_OriginContactId",
table: "Gerbils",
column: "OriginContactId");
migrationBuilder.CreateIndex(
name: "IX_Gerbils_ReceiverContactId",
table: "Gerbils",
column: "ReceiverContactId");
migrationBuilder.CreateIndex(
name: "IX_HealthRecords_GerbilId",
table: "HealthRecords",
column: "GerbilId");
migrationBuilder.CreateIndex(
name: "IX_Litters_FatherId",
table: "Litters",
column: "FatherId");
migrationBuilder.CreateIndex(
name: "IX_Litters_MotherId",
table: "Litters",
column: "MotherId");
migrationBuilder.CreateIndex(
name: "IX_WeightRecords_GerbilId",
table: "WeightRecords",
column: "GerbilId");
migrationBuilder.AddForeignKey(
name: "FK_GerbilPhotos_Gerbils_GerbilId",
table: "GerbilPhotos",
column: "GerbilId",
principalTable: "Gerbils",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Gerbils_Litters_LitterId",
table: "Gerbils",
column: "LitterId",
principalTable: "Litters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Litters_Gerbils_FatherId",
table: "Litters");
migrationBuilder.DropForeignKey(
name: "FK_Litters_Gerbils_MotherId",
table: "Litters");
migrationBuilder.DropTable(
name: "GerbilPhotos");
migrationBuilder.DropTable(
name: "HealthRecords");
migrationBuilder.DropTable(
name: "WeightRecords");
migrationBuilder.DropTable(
name: "Gerbils");
migrationBuilder.DropTable(
name: "ColorVarieties");
migrationBuilder.DropTable(
name: "Contacts");
migrationBuilder.DropTable(
name: "Enclosures");
migrationBuilder.DropTable(
name: "Litters");
}
}
}

View File

@@ -2,8 +2,8 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
@@ -16,76 +16,751 @@ namespace GerbilManagerWebAPI.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GerbilManagerWebAPI.Models.Breeder", b =>
modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
.HasColumnType("uuid");
b.Property<string>("CanonicalGenotype")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Breeders", (string)null);
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("uniqueidentifier");
.HasColumnType("uuid");
b.Property<Guid?>("BreederId")
.HasColumnType("uniqueidentifier");
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("nvarchar(max)");
.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("uniqueidentifier");
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("BreederId");
b.HasIndex("ColorVarietyId");
b.HasIndex("EnclosureId");
b.HasIndex("LitterId");
b.ToTable("Gerbils", (string)null);
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("uniqueidentifier");
.HasColumnType("uuid");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date");
b.Property<Guid?>("FatherId")
.HasColumnType("uniqueidentifier");
.HasColumnType("uuid");
b.Property<Guid?>("MotherId")
.HasColumnType("uniqueidentifier");
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("text");
b.Property<int?>("Strength")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");
b.HasKey("Id");
@@ -93,38 +768,120 @@ namespace GerbilManagerWebAPI.Migrations
b.HasIndex("MotherId");
b.ToTable("Litters", (string)null);
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.Breeder", "Breeder")
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
.WithMany()
.HasForeignKey("BreederId");
.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");
.HasForeignKey("LitterId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Breeder");
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");
.HasForeignKey("FatherId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother")
.WithMany()
.HasForeignKey("MotherId");
.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

@@ -1,11 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
public record Breeder
{
[Key]
public Guid Id { get; init; }
public required string Name { get; set; }
}
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// A Farbschlag (colour variety). Seeded from the GEN-1 catalog (source of truth)
/// and user-extendable. Gerbils reference it via Gerbil.ColorVarietyId.
/// </summary>
public class ColorVariety
{
[Key]
public Guid Id { get; set; }
public required string Name { get; set; }
/// <summary>Reference (representative) compact genotype string for this variety.</summary>
public string? CanonicalGenotype { get; set; }
public int SortOrder { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// A person/cattery a gerbil came from (Herkunft) or was given to (Abnehmer).
/// Was "Breeder"; the role is relational (see Gerbil.OriginContactId / ReceiverContactId).
/// </summary>
public class Contact
{
[Key]
public Guid Id { get; set; }
public required string Name { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string? Notes { get; set; }
}
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>A tank/cage (Becken) that gerbils currently live in.</summary>
public class Enclosure
{
[Key]
public Guid Id { get; set; }
public required string Name { get; set; }
public string? Notes { get; set; }
public ICollection<Gerbil> Gerbils { get; } = new List<Gerbil>();
}
}

View File

@@ -2,13 +2,49 @@ using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
public record Gerbil
/// <summary>
/// A gerbil (Rennmaus). Pedigree is traversed via Litter → Father/Mother (no redundant
/// parent FKs here). Genotype is one compact frozen-contract string (e.g.
/// "Aa CC Dd EE GG Pp Spsp rere", "?" wildcards allowed); never SQL-filtered.
/// </summary>
public class Gerbil
{
[Key]
public Guid Id { get; init; } // init => properties are immutable by default, they are only mutable in the constructor and initializer.
public required string Name { get; set; } //required => parameter is necessary in constructor and object iniilization list. So it cannot be intantiated without it
public required Gender Gender { get; set; }
public Litter? Litter { get; set; } // Any variable where the ? isn't appended to the type name is a non-nullable reference type.
public Breeder? Breeder { get; set; } // You use the null-forgiving operator ! following a variable name to force the null-state to be not-null. For example, if you know the name variable isn't null but the compiler issues a warning, you can write the following code to override the compiler's analysis:
public Guid Id { get; set; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
// Birth litter (the litter this gerbil was born in).
public Guid? LitterId { get; set; }
public Litter? Litter { get; set; }
// Contacts: Herkunft (origin) and Abnehmer (receiver, when given away).
public Guid? OriginContactId { get; set; }
public Contact? OriginContact { get; set; }
public Guid? ReceiverContactId { get; set; }
public Contact? ReceiverContact { get; set; }
// Current home.
public Guid? EnclosureId { get; set; }
public Enclosure? Enclosure { get; set; }
// Farbschlag.
public Guid? ColorVarietyId { get; set; }
public ColorVariety? ColorVariety { get; set; }
public DateOnly? DateOfBirth { get; set; }
public DateOnly? DateOfDeath { get; set; }
public string? CauseOfDeath { get; set; }
public DateOnly? GoHomeDate { get; set; }
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
public string? Genotype { get; set; }
public string? Notes { get; set; }
// Provenance (for the FEAT-8 spreadsheet import).
public string? ImportSource { get; set; }
public string? ExternalRef { get; set; }
}
}
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>A photo of a gerbil. The file lives under a configured FS root; only the
/// file name is stored in the DB. The first photo by SortOrder is the profile photo.</summary>
public class GerbilPhoto
{
[Key]
public Guid Id { get; set; }
public Guid GerbilId { get; set; }
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
namespace GerbilManagerWebAPI.Models
{
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
public enum GerbilStatus
{
Active = 0,
Deceased = 1,
GivenAway = 2,
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
ForSale = 3
}
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>A medical-log entry for a gerbil (Gesundheitseintrag).</summary>
public class HealthRecord
{
[Key]
public Guid Id { get; set; }
public Guid GerbilId { get; set; }
public DateOnly Date { get; set; }
public HealthRecordType Type { get; set; }
public required string Description { get; set; }
public string? Veterinarian { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
namespace GerbilManagerWebAPI.Models
{
/// <summary>Kind of health-record entry. Serialised as the string name on the wire.</summary>
public enum HealthRecordType
{
Examination = 0,
Treatment = 1,
Injury = 2,
Vaccination = 3,
Other = 4
}
}

View File

@@ -2,15 +2,24 @@ using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
public record Litter
/// <summary>A litter (Wurf). Parents are gerbils; juveniles are Gerbils linked via Gerbil.LitterId.</summary>
public class Litter
{
[Key]
public Guid Id { get; init; }
public Guid Id { get; set; }
public required string Name { get; set; }
public required DateTime Date { get; set; }
public int? Strength { get; set; }
public DateOnly Date { get; set; }
/// <summary>Total born count (was "Strength").</summary>
public int? TotalBorn { get; set; }
public Guid? FatherId { get; set; }
public Gerbil? Father { get; set; }
public Guid? MotherId { get; set; }
public Gerbil? Mother { get; set; }
/// <summary>Computed ~35 days after Date by default; editable.</summary>
public DateOnly? ExpectedGoHomeDate { get; set; }
public string? Notes { get; set; }
}
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>A weight measurement for a gerbil (Gewichtseintrag), in grams.</summary>
public class WeightRecord
{
[Key]
public Guid Id { get; set; }
public Guid GerbilId { get; set; }
public DateOnly Date { get; set; }
public int WeightGrams { get; set; }
public string? Notes { get; set; }
}
}

View File

@@ -1,33 +1,66 @@
using GerbilManagerWebAPI.DAL;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.AddServiceDefaults();
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<ApplicationContext>(opts => opts.UseSqlServer(builder.Configuration.GetConnectionString("sqlConnection")));
builder.Services.AddScoped<UnitOfWork>();
// LAN reachability: bind on all interfaces (keep the Aspire/launch-assigned port),
// so phones/laptops on the home network can reach the API (trusted LAN, no auth).
var aspnetUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
if (!string.IsNullOrWhiteSpace(aspnetUrls))
{
var lanUrls = string.Join(';', aspnetUrls
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(u => u.Replace("localhost", "0.0.0.0").Replace("127.0.0.1", "0.0.0.0")));
builder.WebHost.UseUrls(lanUrls);
}
builder.AddNpgsqlDbContext<ApplicationContext>("gerbilmanager");
// JSON: camelCase property names + enums serialised as their string names (frontend contract).
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Permissive CORS for the trusted home LAN (no auth — see board constraint).
const string LanCorsPolicy = "lan";
builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
// OpenAPI document for the Scalar API reference (Swashbuckle removed).
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapDefaultEndpoints();
// API reference at /scalar (built on the OpenAPI doc at /openapi/v1.json).
app.MapOpenApi();
app.MapScalarApiReference();
if (app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
// 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();
}
app.UseAuthorization();
app.UseCors(LanCorsPolicy);
app.MapControllers();
// Endpoint groups (Minimal API, no controllers).
app.MapGerbilEndpoints();
app.MapLitterEndpoints();
app.MapContactEndpoints();
app.MapEnclosureEndpoints();
app.MapColorVarietyEndpoints();
app.MapHealthRecordEndpoints();
app.MapWeightRecordEndpoints();
app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.Run();

View File

@@ -13,7 +13,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "scalar",
"applicationUrl": "http://localhost:5179",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -23,7 +23,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "scalar",
"applicationUrl": "https://localhost:7191;http://localhost:5179",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -32,7 +32,7 @@
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "scalar",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -4,8 +4,5 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"sqlConnection": "server=.; database=GerbilManagerDebug; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True"
}
}

View File

@@ -5,8 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"sqlConnection": "server=.; database=GerbilManagerDebug; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True"
},
"AllowedHosts": "*"
}

View File

@@ -1,3 +1,8 @@
# GerbilManager
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
# Requirements
Install ef:
@@ -15,6 +20,11 @@ dotnet ef migrations add <name>
### force compose rebuild
docker-compose build --no-cache
## Frontend (gerbil-manager-web)
cd gerbil-manager-web
npm install
npm run dev
TODO:
- Backup docker volume database

View File

@@ -5,10 +5,13 @@ version: '3.4'
services:
frontend:
image: gerbilmanagernextjs
image: gerbilmanagerweb
build:
context: .
dockerfile: gerbil-manager-nextjs/Dockerfile
dockerfile: gerbil-manager-web/Dockerfile
args:
# Der Browser erreicht die API über den am Host veröffentlichten Port
- VITE_API_BASE_URL=http://localhost:80
ports:
- 3000:3000
networks:

View File

@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

View File

@@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,63 +0,0 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY gerbil-manager-nextjs/package.json gerbil-manager-nextjs/package-lock.json* ./
RUN \
if [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY gerbil-manager-nextjs/ .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
# RUN yarn build
# If using npm comment out above and use below instead
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,40 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
## Inspiration
[SideBar](https://tailwindcomponents.com/component/sub-menu-sidebar)

View File

@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = {
output: 'standalone'
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{
"name": "gerbil-manager-nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.0.1"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"eslint": "^8",
"eslint-config-next": "14.0.1"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,27 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}

View File

@@ -1,22 +0,0 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@@ -1,121 +0,0 @@
import Image from 'next/image'
export default function Home() {
return (
<aside className="flex">
<div className="flex flex-col items-center w-16 h-screen py-8 space-y-8 bg-white dark:bg-gray-900 dark:border-gray-700">
<a href="#">
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
</a>
<a href="#" className="p-1.5 text-blue-500 transition-colors duration-200 bg-blue-100 rounded-lg dark:text-blue-400 dark:bg-gray-800">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6a7.5 7.5 0 107.5 7.5h-7.5V6z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 10.5H21A7.5 7.5 0 0013.5 3v7.5z" />
</svg>
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
</div>
<div className="h-screen py-8 overflow-y-auto bg-white border-l border-r sm:w-64 w-60 dark:bg-gray-900 dark:border-gray-700">
<h2 className="px-5 text-lg font-medium text-gray-800 dark:text-white">Accounts</h2>
<div className="mt-8 space-y-4">
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Mia John</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">11.2 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">arthur melo</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">1.2 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 bg-gray-100 dark:bg-gray-800 gap-x-2 focus:outline-none">
<div className="relative">
<span className="h-2 w-2 rounded-full bg-emerald-500 absolute right-0.5 ring-1 ring-white bottom-0"></span>
</div>
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Jane Doe</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">15.6 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Amelia. Anderson</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">32.9 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Joseph Gonzalez</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">100.2 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-800 gap-x-2 focus:outline-none">
<div className="relative">
<span className="h-2 w-2 rounded-full bg-emerald-500 absolute right-0.5 ring-1 ring-white bottom-0"></span>
</div>
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Olivia Wathan</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">8.6 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-800 gap-x-2 focus:outline-none">
<div className="relative">
<span className="h-2 w-2 rounded-full bg-emerald-500 absolute right-0.5 ring-1 ring-white bottom-0"></span>
</div>
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Junior REIS</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">56.6 Followers</p>
</div>
</button>
</div>
</div>
</aside>
)
}

View File

@@ -1,20 +0,0 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [],
}
export default config

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

24
gerbil-manager-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,25 @@
# Build-Stufe: Vite-Produktionsbuild
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)
RUN printf 'server {\n\
listen 3000;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
}\n\
}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,41 @@
# gerbil-manager-web
Frontend des Rennmaus-Managers — React + TypeScript + Vite, mobile-first (Smartphone und Laptop).
## Entwicklung
```bash
npm install
npm run dev
```
Erwartet die laufende GerbilManagerWebAPI unter `http://localhost:5179`
(Profil `http` in `GerbilManagerWebAPI/Properties/launchSettings.json`).
## Konfiguration
| Variable | Standard | Beschreibung |
|---|---|---|
| `VITE_API_BASE_URL` | `http://localhost:5179` | Basis-URL der GerbilManagerWebAPI |
## Konventionen
- **Alle für Benutzer sichtbaren Texte sind Deutsch** und liegen zentral in
`src/strings/de.ts` — keine Texte direkt in Komponenten hartkodieren.
- API-Zugriffe laufen über `src/api/client.ts`.
- Routen werden in `src/App.tsx` registriert, Seiten liegen in `src/pages/`.
- **Mutationen** (`useMutation` aus `src/hooks/useApi.ts`): `run()` wirft NIE,
sondern liefert ein `MutationOutcome<T>``{ ok: true, value }` oder
`{ ok: false, error, cause }`. Aufrufer MÜSSEN verzweigen:
`const r = await m.run(x); if (r.ok) navigate(r.value.id)`. `m.error` treibt
die Inline-Anzeige; für Spezialfälle (z. B. HTTP 409) `r.cause` prüfen
(`r.cause instanceof ApiError && r.cause.status === 409`). So sind unbehandelte
Promise-Rejections an Aufrufstellen ausgeschlossen (siehe auch
`src/dev/unhandledRejectionGuard.ts`).
## Build
```bash
npm run build
npm run preview
```

View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
<title>Rennmaus-Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3608
gerbil-manager-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{
"name": "gerbil-manager-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"jszip": "^3.10.1",
"react": "^19.2.6",
"react-d3-tree": "^3.6.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

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