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>
This commit is contained in:
2026-06-06 00:53:49 +02:00
parent a47fbef785
commit 180d53b203
46 changed files with 1070 additions and 750 deletions

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] GridifyQuery 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] GridifyQuery 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, ContactInfo = input.ContactInfo, 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.ContactInfo = input.ContactInfo; 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.ContactInfo, 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] GridifyQuery 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] GridifyQuery 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] GridifyQuery 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] GridifyQuery 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] GridifyQuery 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);
}
}