using GerbilManagerWebAPI.Common; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Services; using Gridify; using Gridify.EntityFramework; 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) => { var q = query.ToQuery(); var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q); var ids = paging.Data.Select(g => g.Id).ToList(); var photoMap = ids.Count == 0 ? new Dictionary() : await db.GerbilPhotos.AsNoTracking() .Where(p => ids.Contains(p.GerbilId)) .GroupBy(p => p.GerbilId) .Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName }) .ToDictionaryAsync(x => x.GerbilId, x => x.FileName); var items = paging.Data .Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null)) .ToList(); return TypedResults.Ok(new PagedResult(items, paging.Count, q.Page, q.PageSize)); }); // GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown group.MapGet("/breeders", async (ApplicationContext db) => TypedResults.Ok(await db.Gerbils.AsNoTracking() .Where(g => g.OriginBreeder != null && g.OriginBreeder != "") .Select(g => g.OriginBreeder!) .Distinct().OrderBy(b => b).ToListAsync())); // GET /gerbils/{id} group.MapGet("/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) => { var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); if (g is null) return TypedResults.NotFound(); var photoFileName = await db.GerbilPhotos.AsNoTracking() .Where(p => p.GerbilId == id) .OrderBy(p => p.SortOrder) .Select(p => p.FileName) .FirstOrDefaultAsync(); var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null; return TypedResults.Ok(ToDto(g, profilePhotoUrl)); }); // POST /gerbils group.MapPost("/", async Task, ValidationProblem>> (GerbilInput input, ApplicationContext db) => { if (string.IsNullOrWhiteSpace(input.Name)) return TypedResults.ValidationProblem(new Dictionary { ["name"] = ["Name is required."] }); // Manually created in the UI → IsManual=true, so the import re-ingest never wipes or // overwrites this animal (and its sub-records). var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name!, IsManual = true }; Apply(g, input, isCreate: true); db.Gerbils.Add(g); await db.SaveChangesAsync(); return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g)); }); // PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value. group.MapPut("/{id:guid}", async Task> (Guid id, GerbilInput input, ApplicationContext db) => { var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id); if (g is null) return TypedResults.NotFound(); // Capture the own-field values BEFORE the edit so manual changes to an IMPORTED animal // can be pinned per-field (survive the next ingest). Manual animals need no override — // they survive the ingest wholesale. var before = GerbilSnapshotService.BuildFreezeObject(g); Apply(g, input, isCreate: false); await db.SaveChangesAsync(); if (!g.IsManual) await RecordEditOverrideAsync(db, g, before); return TypedResults.NoContent(); }); // PUT /gerbils/{id}/parents — QOL (Wunsch der Züchterin): Eltern DIREKT in der // Tier-Akte pflegen, ohne vorher den Wurf suchen zu müssen. Das Datenmodell bleibt // unverändert — Eltern hängen weiterhin am GEBURTSWURF (Litter.FatherId/MotherId), // dieser Endpoint schreibt nur genau dorthin: // • Tier hat einen Wurf → dessen Eltern werden gesetzt. Das gilt zwangsläufig für // ALLE Jungtiere des Wurfs; die UI warnt vorher mit SiblingCount. // • Tier hat keinen Wurf → gibt es einen Wurf mit genau diesem Elternpaar UND // demselben Datum wie das Geburtsdatum (= dieselbe Geburt), wird das Tier dort // eingehängt (LitterAttached). Sonst wird ein Wurf angelegt (LitterCreated) mit // ShowInChronicle=false (nicht in der Wurfchronik, nur zur Abstammung) und // IsManual=true (überlebt den Re-Ingest). group.MapPut("/{id:guid}/parents", async Task, NotFound, BadRequest, BadRequest>> ( Guid id, GerbilParentsInput input, ApplicationContext db) => { var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id); if (g is null) return TypedResults.NotFound(); if (input.FatherId == id || input.MotherId == id) return TypedResults.BadRequest("Ein Tier kann nicht sein eigener Elternteil sein."); var genderErr = await LitterEndpoints.ValidateParents(db, input.FatherId, input.MotherId); if (genderErr is not null) return TypedResults.BadRequest(genderErr); var litter = g.LitterId is Guid lid ? await db.Litters.FirstOrDefaultAsync(l => l.Id == lid) : null; // Nichts zu tun: kein Wurf und keine Eltern angegeben. if (litter is null && input.FatherId is null && input.MotherId is null) return TypedResults.Ok(new GerbilParentsResult(null, null, false, false, 0, null, null)); var created = false; var attached = false; if (litter is null) { // Gleiches Elternpaar + gleiches Datum ⇒ dieselbe Geburt: an den bestehenden // Wurf hängen (macht das Tier korrekt zum Geschwister), statt zu duplizieren. if (g.DateOfBirth is DateOnly dob) { litter = await db.Litters.FirstOrDefaultAsync(l => l.FatherId == input.FatherId && l.MotherId == input.MotherId && l.Date == dob); attached = litter is not null; } litter ??= NewParentLitter(g, await NameOfAsync(db, input.FatherId), await NameOfAsync(db, input.MotherId)); if (!attached) { db.Litters.Add(litter); created = true; } g.LitterId = litter.Id; } litter.FatherId = input.FatherId; litter.MotherId = input.MotherId; await db.SaveChangesAsync(); var siblingCount = await db.Gerbils.CountAsync(x => x.LitterId == litter.Id && x.Id != id); return TypedResults.Ok(new GerbilParentsResult( litter.Id, litter.Name, created, attached, siblingCount, litter.FatherId, litter.MotherId)); }); // DELETE /gerbils/{id} (409 if referenced as a litter parent) group.MapDelete("/{id:guid}", async Task>> (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 async Task NameOfAsync(ApplicationContext db, Guid? gerbilId) => gerbilId is Guid gid ? await db.Gerbils.AsNoTracking().Where(x => x.Id == gid).Select(x => x.Name).FirstOrDefaultAsync() : null; /// Träger-Wurf für die Eltern eines Tiers ohne Geburtswurf. Namensschema wie die /// vom Import erzeugten Stammbaum-Würfe ("Wurf von X + Y"); ShowInChronicle=false, damit die /// Wurfchronik nicht mit Hilfs-Würfen zuwächst; IsManual=true, damit der Re-Ingest ihn nicht wegräumt. private static Litter NewParentLitter(Gerbil g, string? fatherName, string? motherName) => new() { Id = Guid.NewGuid(), Name = $"Wurf von {fatherName ?? "—"} + {motherName ?? "—"}", Date = g.DateOfBirth, ShowInChronicle = false, IsManual = true, Notes = $"Automatisch angelegt, um die Eltern von {g.Name} zu tragen.", }; // CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value. // Prevents silent data loss when the frontend sends partial bodies (ForSale toggle, // Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults // for fields the frontend omits. A non-null input value always wins (including explicit // nulls — callers that want to clear a nullable field must send a full object; a // dedicated PATCH endpoint can be added later if point-clear is needed). private static void Apply(Gerbil g, GerbilInput i, bool isCreate) { if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!; g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender); // Status is applied as a user preference and then overridden by GerbilStatusService. g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status); g.LitterId = i.LitterId ?? g.LitterId; g.OriginContactId = i.OriginContactId ?? g.OriginContactId; g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId; g.EnclosureId = i.EnclosureId ?? g.EnclosureId; g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId; g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth; g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath; g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath; g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate; g.Genotype = i.Genotype ?? g.Genotype; g.SpottingType = i.SpottingType ?? g.SpottingType; g.Notes = i.Notes ?? g.Notes; g.ImportSource = i.ImportSource ?? g.ImportSource; g.ExternalRef = i.ExternalRef ?? g.ExternalRef; g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder; g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits; g.CharacterNote = i.CharacterNote ?? g.CharacterNote; g.IsDeaf = i.IsDeaf ?? g.IsDeaf; g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident); g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated); GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow)); } // EDIT PROTECTION: after a manual edit to an IMPORTED animal, pin the change so the next // ingest can't overwrite it. A VERIFIED animal keeps its certification and its whole golden // is refreshed to the edited state (Frage 5). An unverified animal gets/updates a "geschützt" // override carrying ONLY the changed fields (per-field) — untouched fields keep receiving // import improvements. Called after the edit is already persisted. private static async Task RecordEditOverrideAsync(ApplicationContext db, Gerbil g, System.Text.Json.Nodes.JsonObject before) { var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == g.Id); if (ov is { IsVerified: true }) { ov.EntityName = g.Name; ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g); var snap = await GerbilSnapshotService.BuildSnapshotAsync(db, g.Id); if (snap is not null) ov.SnapshotJson = GerbilSnapshotService.SerializeSnapshot(snap); ov.LastImportSnapshotJson = null; ov.LastImportDiffJson = null; ov.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); return; } var after = GerbilSnapshotService.BuildFreezeObject(g); var changed = GerbilSnapshotService.ChangedFields(before, after); if (changed.Count == 0) return; if (ov is null) { ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = g.Id, IsVerified = false, OverrideJson = "{}" }; db.GerbilOverrides.Add(ov); } ov.EntityName = g.Name; ov.OverrideJson = GerbilSnapshotService.MergeOverrideJson(ov.OverrideJson, changed); ov.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); } internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new( g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated, g.Provenance, g.BirthOrder); } }