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:
@@ -1,28 +1,107 @@
|
|||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
public class ApplicationContext : DbContext
|
public class ApplicationContext : DbContext
|
||||||
{
|
{
|
||||||
public ApplicationContext(DbContextOptions options)
|
public ApplicationContext(DbContextOptions options) : base(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)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<Gerbil>().ToTable("Gerbils");
|
modelBuilder.Entity<Gerbil>(e =>
|
||||||
modelBuilder.Entity<Litter>().ToTable("Litters");
|
{
|
||||||
modelBuilder.Entity<Breeder>().ToTable("Breeders");
|
// 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);
|
e.HasOne(g => g.Litter).WithMany()
|
||||||
modelBuilder.Entity<Litter>().HasOne(entity => entity.Father);
|
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
|
||||||
modelBuilder.Entity<Litter>().HasOne(entity => entity.Mother);
|
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
|
modelBuilder.Entity<Litter>(e =>
|
||||||
.Entity<Gerbil>()
|
{
|
||||||
.Property(d => d.Gender)
|
e.HasOne(l => l.Father).WithMany()
|
||||||
.HasConversion(new EnumToStringConverter<Gender>());
|
.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 18 base varieties from the GEN-1 catalog (gerbil-manager-web/src/genetics/catalog.ts).
|
||||||
|
/// Source of truth for names; the baseportal 78-variety extension lands in GEN-2 (re-seed later).
|
||||||
|
/// </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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
30
GerbilManagerWebAPI/Common/PagedResult.cs
Normal file
30
GerbilManagerWebAPI/Common/PagedResult.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
GridifyQuery query,
|
||||||
|
Func<TEntity, TDto> map)
|
||||||
|
{
|
||||||
|
query.Page = query.Page <= 0 ? 1 : query.Page;
|
||||||
|
query.PageSize = query.PageSize <= 0 ? 20 : query.PageSize;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using GerbilManagerWebAPI.DAL;
|
|
||||||
using GerbilManagerWebAPI.Dtos;
|
|
||||||
using GerbilManagerWebAPI.Genetics;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Controllers
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Inbreeding-coefficient (Inzuchtkoeffizient) endpoints — both for an existing
|
|
||||||
/// gerbil and for a hypothetical pairing (Probeverpaarung).
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
public class InbreedingController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly InbreedingService service;
|
|
||||||
|
|
||||||
// ApplicationContext is already registered in DI; the service is a thin,
|
|
||||||
// dependency-free wrapper so it needs no separate registration in Program.cs.
|
|
||||||
public InbreedingController(ApplicationContext db)
|
|
||||||
{
|
|
||||||
service = new InbreedingService(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /gerbils/{id}/inbreeding-coefficient
|
|
||||||
[HttpGet("gerbils/{id}/inbreeding-coefficient")]
|
|
||||||
public ActionResult<InbreedingResult> GetForGerbil(Guid id)
|
|
||||||
{
|
|
||||||
var result = service.ForGerbil(id);
|
|
||||||
return result is null ? NotFound() : result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /genetics/test-inbreeding
|
|
||||||
[HttpPost("genetics/test-inbreeding")]
|
|
||||||
public ActionResult<InbreedingResult> TestPairing(TestInbreedingDto dto)
|
|
||||||
{
|
|
||||||
return service.ForPairing(dto.FatherId, dto.MotherId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
96
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal file
96
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal 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? ContactInfo, 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? ContactInfo, 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);
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace GerbilManagerWebAPI.Dtos
|
|
||||||
{
|
|
||||||
public record BreederDto
|
|
||||||
{
|
|
||||||
public Guid Id { get; init; }
|
|
||||||
public required string Name { get; init; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace GerbilManagerWebAPI.Dtos
|
|
||||||
{
|
|
||||||
public record CreateBreederDto
|
|
||||||
{
|
|
||||||
public required string Name { get; init; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace GerbilManagerWebAPI.Dtos
|
|
||||||
{
|
|
||||||
public enum GenderDto
|
|
||||||
{
|
|
||||||
unknown = 0,
|
|
||||||
male = 1,
|
|
||||||
female = 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace GerbilManagerWebAPI.Dtos
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Request body for a hypothetical pairing's inbreeding coefficient
|
|
||||||
/// (Inzuchtkoeffizient der geplanten Verpaarung). Either parent may be omitted,
|
|
||||||
/// in which case the coefficient is 0.
|
|
||||||
/// </summary>
|
|
||||||
public record TestInbreedingDto(Guid? FatherId, Guid? MotherId);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
namespace GerbilManagerWebAPI.Dtos
|
|
||||||
{
|
|
||||||
public record UpdateBreederDto
|
|
||||||
{
|
|
||||||
public string? Name { get; init; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,22 +32,12 @@ namespace GerbilManagerWebAPI.Genetics
|
|||||||
|
|
||||||
private PedigreeCalculator BuildCalculator()
|
private PedigreeCalculator BuildCalculator()
|
||||||
{
|
{
|
||||||
var litters = _db.Set<Litter>()
|
var litters = _db.Litters
|
||||||
.Select(l => new
|
.Select(l => new { l.Id, l.FatherId, l.MotherId })
|
||||||
{
|
|
||||||
l.Id,
|
|
||||||
FatherId = EF.Property<Guid?>(l, "FatherId"),
|
|
||||||
MotherId = EF.Property<Guid?>(l, "MotherId"),
|
|
||||||
})
|
|
||||||
.ToDictionary(l => l.Id, l => (l.FatherId, l.MotherId));
|
.ToDictionary(l => l.Id, l => (l.FatherId, l.MotherId));
|
||||||
|
|
||||||
var gerbils = _db.Set<Gerbil>()
|
var gerbils = _db.Gerbils
|
||||||
.Select(g => new
|
.Select(g => new { g.Id, g.Name, g.LitterId })
|
||||||
{
|
|
||||||
g.Id,
|
|
||||||
g.Name,
|
|
||||||
LitterId = EF.Property<Guid?>(g, "LitterId"),
|
|
||||||
})
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var parents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>(gerbils.Count);
|
var parents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>(gerbils.Count);
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
GerbilManagerWebAPI/Models/ColorVariety.cs
Normal file
18
GerbilManagerWebAPI/Models/ColorVariety.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
17
GerbilManagerWebAPI/Models/Contact.cs
Normal file
17
GerbilManagerWebAPI/Models/Contact.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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? ContactInfo { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
15
GerbilManagerWebAPI/Models/Enclosure.cs
Normal file
15
GerbilManagerWebAPI/Models/Enclosure.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,49 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace GerbilManagerWebAPI.Models
|
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]
|
[Key]
|
||||||
public Guid Id { get; init; } // init => properties are immutable by default, they are only mutable in the constructor and initializer.
|
public Guid Id { get; set; }
|
||||||
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 string Name { get; set; }
|
||||||
public required Gender Gender { get; set; }
|
public 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 GerbilStatus Status { get; set; } = GerbilStatus.Active;
|
||||||
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:
|
|
||||||
|
// 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
17
GerbilManagerWebAPI/Models/GerbilPhoto.cs
Normal file
17
GerbilManagerWebAPI/Models/GerbilPhoto.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
10
GerbilManagerWebAPI/Models/GerbilStatus.cs
Normal file
10
GerbilManagerWebAPI/Models/GerbilStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
17
GerbilManagerWebAPI/Models/HealthRecord.cs
Normal file
17
GerbilManagerWebAPI/Models/HealthRecord.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
GerbilManagerWebAPI/Models/HealthRecordType.cs
Normal file
12
GerbilManagerWebAPI/Models/HealthRecordType.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,24 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace GerbilManagerWebAPI.Models
|
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]
|
[Key]
|
||||||
public Guid Id { get; init; }
|
public Guid Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required DateTime Date { get; set; }
|
public DateOnly Date { get; set; }
|
||||||
public int? Strength { get; set; }
|
|
||||||
public Gerbil? Father { get; set; }
|
|
||||||
public Gerbil? Mother { 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
15
GerbilManagerWebAPI/Models/WeightRecord.cs
Normal file
15
GerbilManagerWebAPI/Models/WeightRecord.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
using GerbilManagerWebAPI.DAL;
|
using System.Text.Json.Serialization;
|
||||||
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.AddServiceDefaults();
|
builder.AddServiceDefaults();
|
||||||
|
|
||||||
// LAN reachability: bind on all interfaces so devices on the home network (the
|
// LAN reachability: bind on all interfaces (keep the Aspire/launch-assigned port),
|
||||||
// SPA on a phone/laptop) can reach the API. We keep whatever port Aspire /
|
// so phones/laptops on the home network can reach the API (trusted LAN, no auth).
|
||||||
// launchSettings assigned and only widen the host from localhost to 0.0.0.0.
|
|
||||||
var aspnetUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
var aspnetUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
||||||
if (!string.IsNullOrWhiteSpace(aspnetUrls))
|
if (!string.IsNullOrWhiteSpace(aspnetUrls))
|
||||||
{
|
{
|
||||||
@@ -17,43 +18,49 @@ if (!string.IsNullOrWhiteSpace(aspnetUrls))
|
|||||||
builder.WebHost.UseUrls(lanUrls);
|
builder.WebHost.UseUrls(lanUrls);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add services to the container.
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
builder.AddNpgsqlDbContext<ApplicationContext>("gerbilmanager");
|
builder.AddNpgsqlDbContext<ApplicationContext>("gerbilmanager");
|
||||||
builder.Services.AddScoped<UnitOfWork>();
|
|
||||||
|
|
||||||
// Permissive CORS for the trusted home LAN (no auth — see hive scope rule).
|
// JSON: camelCase property names + enums serialised as their string names (frontend contract).
|
||||||
// The SPA calls the API cross-origin from other devices on the network.
|
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";
|
const string LanCorsPolicy = "lan";
|
||||||
builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
|
builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
|
||||||
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
||||||
|
|
||||||
|
// OpenAPI document for the Scalar API reference (Swashbuckle removed).
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// API reference at /scalar (built on the OpenAPI doc at /openapi/v1.json).
|
||||||
|
app.MapOpenApi();
|
||||||
|
app.MapScalarApiReference();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
|
|
||||||
// Aspire provisions an empty database in dev — bring the schema up to date.
|
// Aspire provisions an empty database in dev — bring the schema up to date.
|
||||||
using var scope = app.Services.CreateScope();
|
using var scope = app.Services.CreateScope();
|
||||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// No HTTPS redirection: the LAN clients (phone) talk plain HTTP to avoid
|
|
||||||
// dev-certificate trust issues on the device.
|
|
||||||
|
|
||||||
app.UseCors(LanCorsPolicy);
|
app.UseCors(LanCorsPolicy);
|
||||||
|
|
||||||
app.UseAuthorization();
|
// Endpoint groups (Minimal API, no controllers).
|
||||||
|
app.MapGerbilEndpoints();
|
||||||
app.MapControllers();
|
app.MapLitterEndpoints();
|
||||||
|
app.MapContactEndpoints();
|
||||||
|
app.MapEnclosureEndpoints();
|
||||||
|
app.MapColorVarietyEndpoints();
|
||||||
|
app.MapHealthRecordEndpoints();
|
||||||
|
app.MapWeightRecordEndpoints();
|
||||||
|
app.MapInbreedingEndpoints();
|
||||||
|
app.MapPhotoEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "scalar",
|
||||||
"applicationUrl": "http://localhost:5179",
|
"applicationUrl": "http://localhost:5179",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "scalar",
|
||||||
"applicationUrl": "https://localhost:7191;http://localhost:5179",
|
"applicationUrl": "https://localhost:7191;http://localhost:5179",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"IIS Express": {
|
"IIS Express": {
|
||||||
"commandName": "IISExpress",
|
"commandName": "IISExpress",
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "scalar",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user