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:
67
GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs
Normal file
67
GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
59
GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs
Normal file
59
GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
59
GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs
Normal file
59
GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
97
GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
Normal file
97
GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs
Normal file
33
GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
Normal file
91
GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
Normal 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);
|
||||
}
|
||||
104
GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs
Normal file
104
GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
121
GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs
Normal file
121
GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user