diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index ad30eed..9b84c5d 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -1,28 +1,107 @@ using GerbilManagerWebAPI.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; public class ApplicationContext : DbContext { - public ApplicationContext(DbContextOptions options) - : base(options) + public ApplicationContext(DbContextOptions options) : base(options) { } + public DbSet Gerbils => Set(); + public DbSet Litters => Set(); + public DbSet Contacts => Set(); + public DbSet Enclosures => Set(); + public DbSet ColorVarieties => Set(); + public DbSet GerbilPhotos => Set(); + public DbSet HealthRecords => Set(); + public DbSet WeightRecords => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity().ToTable("Gerbils"); - modelBuilder.Entity().ToTable("Litters"); - modelBuilder.Entity().ToTable("Breeders"); + modelBuilder.Entity(e => + { + // Enums persisted as their string names (readable, Gridify-friendly). + e.Property(g => g.Gender).HasConversion(); + e.Property(g => g.Status).HasConversion(); - modelBuilder.Entity().HasOne(entity => entity.Litter); - modelBuilder.Entity().HasOne(entity => entity.Father); - modelBuilder.Entity().HasOne(entity => entity.Mother); + e.HasOne(g => g.Litter).WithMany() + .HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(g => g.OriginContact).WithMany() + .HasForeignKey(g => g.OriginContactId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(g => g.ReceiverContact).WithMany() + .HasForeignKey(g => g.ReceiverContactId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(g => g.Enclosure).WithMany(en => en.Gerbils) + .HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(g => g.ColorVariety).WithMany() + .HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull); + }); - modelBuilder - .Entity() - .Property(d => d.Gender) - .HasConversion(new EnumToStringConverter()); + modelBuilder.Entity(e => + { + e.HasOne(l => l.Father).WithMany() + .HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(l => l.Mother).WithMany() + .HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.Property(h => h.Type).HasConversion(); + e.HasOne().WithMany() + .HasForeignKey(h => h.GerbilId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + e.HasOne().WithMany() + .HasForeignKey(w => w.GerbilId).OnDelete(DeleteBehavior.Cascade)); + + modelBuilder.Entity(e => + e.HasOne().WithMany() + .HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade)); + + SeedColorVarieties(modelBuilder); + } + + /// + /// 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). + /// + 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().HasData(rows); } } - diff --git a/GerbilManagerWebAPI/Common/PagedResult.cs b/GerbilManagerWebAPI/Common/PagedResult.cs new file mode 100644 index 0000000..3194e7d --- /dev/null +++ b/GerbilManagerWebAPI/Common/PagedResult.cs @@ -0,0 +1,30 @@ +using Gridify; +using Gridify.EntityFramework; + +namespace GerbilManagerWebAPI.Common +{ + /// Standard paged list envelope returned by every list endpoint. + public record PagedResult(IReadOnlyList Items, int TotalCount, int Page, int PageSize); + + public static class QueryableExtensions + { + /// + /// 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==...". + /// + public static async Task> ToPagedResultAsync( + this IQueryable source, + GridifyQuery query, + Func map) + { + query.Page = query.Page <= 0 ? 1 : query.Page; + query.PageSize = query.PageSize <= 0 ? 20 : query.PageSize; + + Paging paging = await source.GridifyAsync(query); + var items = paging.Data.Select(map).ToList(); + return new PagedResult(items, paging.Count, query.Page, query.PageSize); + } + } +} diff --git a/GerbilManagerWebAPI/Controllers/BreedersController.cs b/GerbilManagerWebAPI/Controllers/BreedersController.cs deleted file mode 100644 index 5bbff92..0000000 --- a/GerbilManagerWebAPI/Controllers/BreedersController.cs +++ /dev/null @@ -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 GetBreeders() - { - var items = unitOfWork.BreederRepository.Get().Select( breeder => breeder.AsDto()); - return items; - } - - //GET /gerbils - [HttpGet("{id}")] - public ActionResult GetBreeder(Guid id) - { - var item = unitOfWork.BreederRepository.GetByID(id).AsDto(); - - if(item is null) - { - return NotFound(); - } - return item; - } - - [HttpPost()] - public ActionResult 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(); - } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Controllers/GerbilsController.cs b/GerbilManagerWebAPI/Controllers/GerbilsController.cs deleted file mode 100644 index 46977df..0000000 --- a/GerbilManagerWebAPI/Controllers/GerbilsController.cs +++ /dev/null @@ -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 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 GetGerbil(Guid id) - { - var item = unitOfWork.GerbilRepository.GetByID(id).AsDto(); - - if(item is null) - { - return NotFound(); - } - return item; - } - - [HttpPost()] - public ActionResult 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(); - } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Controllers/InbreedingController.cs b/GerbilManagerWebAPI/Controllers/InbreedingController.cs deleted file mode 100644 index 45a7a76..0000000 --- a/GerbilManagerWebAPI/Controllers/InbreedingController.cs +++ /dev/null @@ -1,39 +0,0 @@ -using GerbilManagerWebAPI.DAL; -using GerbilManagerWebAPI.Dtos; -using GerbilManagerWebAPI.Genetics; -using Microsoft.AspNetCore.Mvc; - -namespace GerbilManagerWebAPI.Controllers -{ - /// - /// Inbreeding-coefficient (Inzuchtkoeffizient) endpoints — both for an existing - /// gerbil and for a hypothetical pairing (Probeverpaarung). - /// - [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 GetForGerbil(Guid id) - { - var result = service.ForGerbil(id); - return result is null ? NotFound() : result; - } - - // POST /genetics/test-inbreeding - [HttpPost("genetics/test-inbreeding")] - public ActionResult TestPairing(TestInbreedingDto dto) - { - return service.ForPairing(dto.FatherId, dto.MotherId); - } - } -} diff --git a/GerbilManagerWebAPI/Controllers/LittersController.cs b/GerbilManagerWebAPI/Controllers/LittersController.cs deleted file mode 100644 index c50cc6c..0000000 --- a/GerbilManagerWebAPI/Controllers/LittersController.cs +++ /dev/null @@ -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 GetLitters() - { - var items = unitOfWork.LitterRepository.Get(includeProperties: "Father,Mother").Select( litter => litter.AsDto()); - return items; - } - - //GET /gerbils - [HttpGet("{id}")] - public ActionResult GetLitter(Guid id) - { - var item = unitOfWork.LitterRepository.GetByID(id).AsDto(); - - if(item is null) - { - return NotFound(); - } - return item; - } - - [HttpPost()] - public ActionResult 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(); - } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Converter/BreederConveter.cs b/GerbilManagerWebAPI/Converter/BreederConveter.cs deleted file mode 100644 index c933dc8..0000000 --- a/GerbilManagerWebAPI/Converter/BreederConveter.cs +++ /dev/null @@ -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 - }; - } - - } -} - diff --git a/GerbilManagerWebAPI/Converter/GerbilConverter.cs b/GerbilManagerWebAPI/Converter/GerbilConverter.cs deleted file mode 100644 index ab31f77..0000000 --- a/GerbilManagerWebAPI/Converter/GerbilConverter.cs +++ /dev/null @@ -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() - }; - } - } -} - diff --git a/GerbilManagerWebAPI/Converter/LitterConverter.cs b/GerbilManagerWebAPI/Converter/LitterConverter.cs deleted file mode 100644 index 6af7caa..0000000 --- a/GerbilManagerWebAPI/Converter/LitterConverter.cs +++ /dev/null @@ -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 - }; - } - } -} - diff --git a/GerbilManagerWebAPI/DAL/GenericRepository.cs b/GerbilManagerWebAPI/DAL/GenericRepository.cs deleted file mode 100644 index a09807d..0000000 --- a/GerbilManagerWebAPI/DAL/GenericRepository.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Linq.Expressions; -using GerbilManagerWebAPI.Models; -using Microsoft.EntityFrameworkCore; - -namespace GerbilManagerWebAPI.DAL -{ - public class GenericRepository where TEntity : class - { - internal ApplicationContext context; - internal DbSet dbSet; - - public GenericRepository(ApplicationContext context) - { - this.context = context; - this.dbSet = context.Set(); - } - - public virtual IEnumerable Get( - Expression> filter = null!, - Func, IOrderedQueryable> orderBy = null!, - string includeProperties = "") - { - IQueryable 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; - } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/DAL/UnitOfWork.cs b/GerbilManagerWebAPI/DAL/UnitOfWork.cs deleted file mode 100644 index 4128dd0..0000000 --- a/GerbilManagerWebAPI/DAL/UnitOfWork.cs +++ /dev/null @@ -1,76 +0,0 @@ -using GerbilManagerWebAPI.Models; -using Microsoft.EntityFrameworkCore; - -namespace GerbilManagerWebAPI.DAL -{ - public class UnitOfWork : IDisposable - { - private ApplicationContext context; - private GenericRepository gerbilRepository = null!; - private GenericRepository litterRepository = null!; - private GenericRepository breederRepository = null!; - - public GenericRepository GerbilRepository - { - get - { - if (this.gerbilRepository == null) - { - this.gerbilRepository = new GenericRepository(context); - } - return gerbilRepository; - } - } - - public GenericRepository LitterRepository - { - get - { - if (this.litterRepository == null) - { - this.litterRepository = new GenericRepository(context); - } - return litterRepository; - } - } - - public GenericRepository BreederRepository - { - get - { - if (this.breederRepository == null) - { - this.breederRepository = new GenericRepository(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); - } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs new file mode 100644 index 0000000..f25744a --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs @@ -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); + + /// Hypothetical pairing request for POST /genetics/test-inbreeding. + public record TestInbreedingDto(Guid? FatherId, Guid? MotherId); +} diff --git a/GerbilManagerWebAPI/Dtos/BreederDto.cs b/GerbilManagerWebAPI/Dtos/BreederDto.cs deleted file mode 100644 index 5e27f3c..0000000 --- a/GerbilManagerWebAPI/Dtos/BreederDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace GerbilManagerWebAPI.Dtos -{ - public record BreederDto - { - public Guid Id { get; init; } - public required string Name { get; init; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/CreateBreederDto.cs b/GerbilManagerWebAPI/Dtos/CreateBreederDto.cs deleted file mode 100644 index d066fbe..0000000 --- a/GerbilManagerWebAPI/Dtos/CreateBreederDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace GerbilManagerWebAPI.Dtos -{ - public record CreateBreederDto - { - public required string Name { get; init; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/CreateGerbilDto.cs b/GerbilManagerWebAPI/Dtos/CreateGerbilDto.cs deleted file mode 100644 index c2e6af5..0000000 --- a/GerbilManagerWebAPI/Dtos/CreateGerbilDto.cs +++ /dev/null @@ -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; } - } - -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/CreateLitterDto.cs b/GerbilManagerWebAPI/Dtos/CreateLitterDto.cs deleted file mode 100644 index 08440f3..0000000 --- a/GerbilManagerWebAPI/Dtos/CreateLitterDto.cs +++ /dev/null @@ -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; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/GenderDto.cs b/GerbilManagerWebAPI/Dtos/GenderDto.cs deleted file mode 100644 index 8481907..0000000 --- a/GerbilManagerWebAPI/Dtos/GenderDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace GerbilManagerWebAPI.Dtos -{ - public enum GenderDto - { - unknown = 0, - male = 1, - female = 2 - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/GerbilDto.cs b/GerbilManagerWebAPI/Dtos/GerbilDto.cs deleted file mode 100644 index b6368bb..0000000 --- a/GerbilManagerWebAPI/Dtos/GerbilDto.cs +++ /dev/null @@ -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; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/LitterDto.cs b/GerbilManagerWebAPI/Dtos/LitterDto.cs deleted file mode 100644 index 3cd479a..0000000 --- a/GerbilManagerWebAPI/Dtos/LitterDto.cs +++ /dev/null @@ -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; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs b/GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs deleted file mode 100644 index dacdf96..0000000 --- a/GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace GerbilManagerWebAPI.Dtos -{ - /// - /// 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. - /// - public record TestInbreedingDto(Guid? FatherId, Guid? MotherId); -} diff --git a/GerbilManagerWebAPI/Dtos/UpdateBreederDto.cs b/GerbilManagerWebAPI/Dtos/UpdateBreederDto.cs deleted file mode 100644 index 6002d6b..0000000 --- a/GerbilManagerWebAPI/Dtos/UpdateBreederDto.cs +++ /dev/null @@ -1,8 +0,0 @@ - -namespace GerbilManagerWebAPI.Dtos -{ - public record UpdateBreederDto - { - public string? Name { get; init; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/UpdateGerbilDto.cs b/GerbilManagerWebAPI/Dtos/UpdateGerbilDto.cs deleted file mode 100644 index a132d68..0000000 --- a/GerbilManagerWebAPI/Dtos/UpdateGerbilDto.cs +++ /dev/null @@ -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; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Dtos/UpdateLitterDto.cs b/GerbilManagerWebAPI/Dtos/UpdateLitterDto.cs deleted file mode 100644 index 1817c38..0000000 --- a/GerbilManagerWebAPI/Dtos/UpdateLitterDto.cs +++ /dev/null @@ -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; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs new file mode 100644 index 0000000..630b345 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs @@ -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, 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> (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>> (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); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs new file mode 100644 index 0000000..bdef185 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs @@ -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, 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> (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>> (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); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs b/GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs new file mode 100644 index 0000000..9b15b2d --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs @@ -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, 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> (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>> (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); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs new file mode 100644 index 0000000..f895266 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs @@ -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, 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, ValidationProblem>> (GerbilInput input, ApplicationContext db) => + { + if (string.IsNullOrWhiteSpace(input.Name)) + return TypedResults.ValidationProblem(new Dictionary { ["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> (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>> (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); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs b/GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs new file mode 100644 index 0000000..5e2d181 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs @@ -0,0 +1,33 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Genetics; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// Inzuchtkoeffizient endpoints (FEAT-1b). Routes/response shapes are IDENTICAL to + /// the former InbreedingController — only the hosting style changed to Minimal API. + /// + public static class InbreedingEndpoints + { + public static IEndpointRouteBuilder MapInbreedingEndpoints(this IEndpointRouteBuilder app) + { + // GET /gerbils/{id}/inbreeding-coefficient + app.MapGet("/gerbils/{id:guid}/inbreeding-coefficient", + Results, 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 (TestInbreedingDto dto, ApplicationContext db) => + TypedResults.Ok(new InbreedingService(db).ForPairing(dto.FatherId, dto.MotherId))) + .WithTags("Inbreeding"); + + return app; + } + } +} diff --git a/GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs b/GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs new file mode 100644 index 0000000..a66b1f1 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs @@ -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, 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, BadRequest>> (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>> (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> (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 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); + } + + /// 400 body for a father×mother gender mismatch; frontend localises by Code. + public record ParentGenderError(string Code, Gender FatherGender, Gender MotherGender); +} diff --git a/GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs b/GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs new file mode 100644 index 0000000..d4347a4 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs @@ -0,0 +1,104 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// 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. + /// + public static class PhotoEndpoints + { + public static IEndpointRouteBuilder MapPhotoEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/gerbils/{id:guid}/photos", + async Task>, 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, NotFound, BadRequest>> ( + 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> (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> (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}"); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs new file mode 100644 index 0000000..5515573 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs @@ -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, 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> (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> (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, 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> (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> (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); + } +} diff --git a/GerbilManagerWebAPI/Genetics/InbreedingService.cs b/GerbilManagerWebAPI/Genetics/InbreedingService.cs index 7ba7c19..a3729ee 100644 --- a/GerbilManagerWebAPI/Genetics/InbreedingService.cs +++ b/GerbilManagerWebAPI/Genetics/InbreedingService.cs @@ -32,22 +32,12 @@ namespace GerbilManagerWebAPI.Genetics private PedigreeCalculator BuildCalculator() { - var litters = _db.Set() - .Select(l => new - { - l.Id, - FatherId = EF.Property(l, "FatherId"), - MotherId = EF.Property(l, "MotherId"), - }) + var litters = _db.Litters + .Select(l => new { l.Id, l.FatherId, l.MotherId }) .ToDictionary(l => l.Id, l => (l.FatherId, l.MotherId)); - var gerbils = _db.Set() - .Select(g => new - { - g.Id, - g.Name, - LitterId = EF.Property(g, "LitterId"), - }) + var gerbils = _db.Gerbils + .Select(g => new { g.Id, g.Name, g.LitterId }) .ToList(); var parents = new Dictionary(gerbils.Count); diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index 6c68de5..da3989d 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -15,11 +15,13 @@ all - + + + - - + + diff --git a/GerbilManagerWebAPI/Models/Breeder.cs b/GerbilManagerWebAPI/Models/Breeder.cs deleted file mode 100644 index 42f88a7..0000000 --- a/GerbilManagerWebAPI/Models/Breeder.cs +++ /dev/null @@ -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; } - } -} \ No newline at end of file diff --git a/GerbilManagerWebAPI/Models/ColorVariety.cs b/GerbilManagerWebAPI/Models/ColorVariety.cs new file mode 100644 index 0000000..4efbd47 --- /dev/null +++ b/GerbilManagerWebAPI/Models/ColorVariety.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// A Farbschlag (colour variety). Seeded from the GEN-1 catalog (source of truth) + /// and user-extendable. Gerbils reference it via Gerbil.ColorVarietyId. + /// + public class ColorVariety + { + [Key] + public Guid Id { get; set; } + public required string Name { get; set; } + /// Reference (representative) compact genotype string for this variety. + public string? CanonicalGenotype { get; set; } + public int SortOrder { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Models/Contact.cs b/GerbilManagerWebAPI/Models/Contact.cs new file mode 100644 index 0000000..bee831f --- /dev/null +++ b/GerbilManagerWebAPI/Models/Contact.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// A person/cattery a gerbil came from (Herkunft) or was given to (Abnehmer). + /// Was "Breeder"; the role is relational (see Gerbil.OriginContactId / ReceiverContactId). + /// + 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; } + } +} diff --git a/GerbilManagerWebAPI/Models/Enclosure.cs b/GerbilManagerWebAPI/Models/Enclosure.cs new file mode 100644 index 0000000..825eb68 --- /dev/null +++ b/GerbilManagerWebAPI/Models/Enclosure.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// A tank/cage (Becken) that gerbils currently live in. + public class Enclosure + { + [Key] + public Guid Id { get; set; } + public required string Name { get; set; } + public string? Notes { get; set; } + + public ICollection Gerbils { get; } = new List(); + } +} diff --git a/GerbilManagerWebAPI/Models/Gerbil.cs b/GerbilManagerWebAPI/Models/Gerbil.cs index 53f68d5..0839591 100644 --- a/GerbilManagerWebAPI/Models/Gerbil.cs +++ b/GerbilManagerWebAPI/Models/Gerbil.cs @@ -2,13 +2,49 @@ using System.ComponentModel.DataAnnotations; namespace GerbilManagerWebAPI.Models { - public record Gerbil + /// + /// 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. + /// + public class Gerbil { [Key] - public Guid Id { get; init; } // init => properties are immutable by default, they are only mutable in the constructor and initializer. - public required string Name { get; set; } //required => parameter is necessary in constructor and object iniilization list. So it cannot be intantiated without it - public required Gender Gender { get; set; } - public Litter? Litter { get; set; } // Any variable where the ? isn't appended to the type name is a non-nullable reference type. - public Breeder? Breeder { get; set; } // You use the null-forgiving operator ! following a variable name to force the null-state to be not-null. For example, if you know the name variable isn't null but the compiler issues a warning, you can write the following code to override the compiler's analysis: + public Guid Id { get; set; } + public required string Name { get; set; } + public Gender Gender { get; set; } + public GerbilStatus Status { get; set; } = GerbilStatus.Active; + + // Birth litter (the litter this gerbil was born in). + public Guid? LitterId { get; set; } + public Litter? Litter { get; set; } + + // Contacts: Herkunft (origin) and Abnehmer (receiver, when given away). + public Guid? OriginContactId { get; set; } + public Contact? OriginContact { get; set; } + public Guid? ReceiverContactId { get; set; } + public Contact? ReceiverContact { get; set; } + + // Current home. + public Guid? EnclosureId { get; set; } + public Enclosure? Enclosure { get; set; } + + // Farbschlag. + public Guid? ColorVarietyId { get; set; } + public ColorVariety? ColorVariety { get; set; } + + public DateOnly? DateOfBirth { get; set; } + public DateOnly? DateOfDeath { get; set; } + public string? CauseOfDeath { get; set; } + public DateOnly? GoHomeDate { get; set; } + + /// Compact genotype string (frozen GEN-1 contract). Null = not genotyped. + 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; } } -} \ No newline at end of file +} diff --git a/GerbilManagerWebAPI/Models/GerbilPhoto.cs b/GerbilManagerWebAPI/Models/GerbilPhoto.cs new file mode 100644 index 0000000..4987612 --- /dev/null +++ b/GerbilManagerWebAPI/Models/GerbilPhoto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// 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. + 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; } + } +} diff --git a/GerbilManagerWebAPI/Models/GerbilStatus.cs b/GerbilManagerWebAPI/Models/GerbilStatus.cs new file mode 100644 index 0000000..df7df7a --- /dev/null +++ b/GerbilManagerWebAPI/Models/GerbilStatus.cs @@ -0,0 +1,10 @@ +namespace GerbilManagerWebAPI.Models +{ + /// Lifecycle status of a gerbil. Serialised as the string name on the wire. + public enum GerbilStatus + { + Active = 0, + Deceased = 1, + GivenAway = 2 + } +} diff --git a/GerbilManagerWebAPI/Models/HealthRecord.cs b/GerbilManagerWebAPI/Models/HealthRecord.cs new file mode 100644 index 0000000..ea6c41f --- /dev/null +++ b/GerbilManagerWebAPI/Models/HealthRecord.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// A medical-log entry for a gerbil (Gesundheitseintrag). + 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; } + } +} diff --git a/GerbilManagerWebAPI/Models/HealthRecordType.cs b/GerbilManagerWebAPI/Models/HealthRecordType.cs new file mode 100644 index 0000000..3a6aca9 --- /dev/null +++ b/GerbilManagerWebAPI/Models/HealthRecordType.cs @@ -0,0 +1,12 @@ +namespace GerbilManagerWebAPI.Models +{ + /// Kind of health-record entry. Serialised as the string name on the wire. + public enum HealthRecordType + { + Examination = 0, + Treatment = 1, + Injury = 2, + Vaccination = 3, + Other = 4 + } +} diff --git a/GerbilManagerWebAPI/Models/Litter.cs b/GerbilManagerWebAPI/Models/Litter.cs index a079f3a..89b68af 100644 --- a/GerbilManagerWebAPI/Models/Litter.cs +++ b/GerbilManagerWebAPI/Models/Litter.cs @@ -2,15 +2,24 @@ using System.ComponentModel.DataAnnotations; namespace GerbilManagerWebAPI.Models { - public record Litter + /// A litter (Wurf). Parents are gerbils; juveniles are Gerbils linked via Gerbil.LitterId. + public class Litter { [Key] - public Guid Id { get; init; } + public Guid Id { get; set; } public required string Name { get; set; } - public required DateTime Date { get; set; } - public int? Strength { get; set; } + public DateOnly Date { get; set; } + + /// Total born count (was "Strength"). + 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; } + + /// Computed ~35 days after Date by default; editable. + public DateOnly? ExpectedGoHomeDate { get; set; } + public string? Notes { get; set; } } } - diff --git a/GerbilManagerWebAPI/Models/WeightRecord.cs b/GerbilManagerWebAPI/Models/WeightRecord.cs new file mode 100644 index 0000000..6492393 --- /dev/null +++ b/GerbilManagerWebAPI/Models/WeightRecord.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// A weight measurement for a gerbil (Gewichtseintrag), in grams. + 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; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index a0c5594..c4bfc3e 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -1,13 +1,14 @@ -using GerbilManagerWebAPI.DAL; +using System.Text.Json.Serialization; +using GerbilManagerWebAPI.Endpoints; using Microsoft.EntityFrameworkCore; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); -// LAN reachability: bind on all interfaces so devices on the home network (the -// SPA on a phone/laptop) can reach the API. We keep whatever port Aspire / -// launchSettings assigned and only widen the host from localhost to 0.0.0.0. +// LAN reachability: bind on all interfaces (keep the Aspire/launch-assigned port), +// so phones/laptops on the home network can reach the API (trusted LAN, no auth). var aspnetUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); if (!string.IsNullOrWhiteSpace(aspnetUrls)) { @@ -17,43 +18,49 @@ if (!string.IsNullOrWhiteSpace(aspnetUrls)) 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("gerbilmanager"); -builder.Services.AddScoped(); -// Permissive CORS for the trusted home LAN (no auth — see hive scope rule). -// The SPA calls the API cross-origin from other devices on the network. +// JSON: camelCase property names + enums serialised as their string names (frontend contract). +builder.Services.ConfigureHttpJsonOptions(o => +{ + o.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + +// Permissive CORS for the trusted home LAN (no auth — see board constraint). const string LanCorsPolicy = "lan"; builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); +// OpenAPI document for the Scalar API reference (Swashbuckle removed). +builder.Services.AddOpenApi(); + var app = builder.Build(); 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()) { - app.UseSwagger(); - app.UseSwaggerUI(); - // Aspire provisions an empty database in dev — bring the schema up to date. using var scope = app.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().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.UseAuthorization(); - -app.MapControllers(); +// Endpoint groups (Minimal API, no controllers). +app.MapGerbilEndpoints(); +app.MapLitterEndpoints(); +app.MapContactEndpoints(); +app.MapEnclosureEndpoints(); +app.MapColorVarietyEndpoints(); +app.MapHealthRecordEndpoints(); +app.MapWeightRecordEndpoints(); +app.MapInbreedingEndpoints(); +app.MapPhotoEndpoints(); app.Run(); diff --git a/GerbilManagerWebAPI/Properties/launchSettings.json b/GerbilManagerWebAPI/Properties/launchSettings.json index 49dc5c6..90aa7f0 100644 --- a/GerbilManagerWebAPI/Properties/launchSettings.json +++ b/GerbilManagerWebAPI/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "scalar", "applicationUrl": "http://localhost:5179", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -23,7 +23,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "scalar", "applicationUrl": "https://localhost:7191;http://localhost:5179", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -32,7 +32,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "scalar", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }