DATA-2: new schema entities + Minimal API endpoints + Scalar + DAL teardown

- Entities: Breeder->Contact, +Enclosure/ColorVariety/GerbilPhoto/HealthRecord/WeightRecord;
  Gerbil expanded (single Genotype text col, Status/Gender enums-as-string, FK ids,
  ImportSource/ExternalRef provenance); Litter Strength->TotalBorn +ExpectedGoHomeDate/Notes.
  ColorVariety HasData seed = 18 from GEN-1 catalog. Gerbil<->Litter cycle handled
  (SetNull/Restrict). Enums stored as strings.
- Minimal API (no controllers): Endpoints/*.cs MapGroup+TypedResults for gerbils, litters,
  contacts, enclosures, color-varieties, health/weight-records, inbreeding (converted from
  controller, same routes/shapes), photos (Oscar contract: GET array/POST multipart/DELETE,
  url /photos/files/{fileName}). Gridify paged {items,totalCount,page,pageSize}, camelCase,
  409 conflict-deletes, flat FK ids, litter parent-gender validation (400 {code,...}).
- Scalar replaces Swashbuckle (AddOpenApi/MapOpenApi + MapScalarApiReference at /scalar);
  launchUrl swagger->scalar. GenericRepository/UnitOfWork/Converters deleted; DbContext direct.
- InbreedingService reads real FK props now; pure calculator + 8 tests untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 00:53:49 +02:00
parent a47fbef785
commit 180d53b203
46 changed files with 1070 additions and 750 deletions

View File

@@ -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<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)
{
modelBuilder.Entity<Gerbil>().ToTable("Gerbils");
modelBuilder.Entity<Litter>().ToTable("Litters");
modelBuilder.Entity<Breeder>().ToTable("Breeders");
modelBuilder.Entity<Gerbil>(e =>
{
// 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);
modelBuilder.Entity<Litter>().HasOne(entity => entity.Father);
modelBuilder.Entity<Litter>().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<Gerbil>()
.Property(d => d.Gender)
.HasConversion(new EnumToStringConverter<Gender>());
modelBuilder.Entity<Litter>(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<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);
}
}

View 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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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
};
}
}
}

View File

@@ -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()
};
}
}
}

View File

@@ -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
};
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View 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);
}

View File

@@ -1,8 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record BreederDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
}
}

View File

@@ -1,7 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record CreateBreederDto
{
public required string Name { get; init; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -1,9 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public enum GenderDto
{
unknown = 0,
male = 1,
female = 2
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}

View File

@@ -1,8 +0,0 @@
namespace GerbilManagerWebAPI.Dtos
{
public record UpdateBreederDto
{
public string? Name { get; init; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,67 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class ColorVarietyEndpoints
{
public static IEndpointRouteBuilder MapColorVarietyEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/color-varieties").WithTags("ColorVarieties");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) =>
TypedResults.Ok(await db.ColorVarieties.AsNoTracking().OrderBy(v => v.SortOrder)
.ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<ColorVarietyDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var v = await db.ColorVarieties.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return v is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(v));
});
group.MapPost("/", async (ColorVarietyInput input, ApplicationContext db) =>
{
var v = new ColorVariety
{
Id = Guid.NewGuid(),
Name = input.Name,
CanonicalGenotype = input.CanonicalGenotype,
SortOrder = input.SortOrder ?? 1000,
};
db.ColorVarieties.Add(v);
await db.SaveChangesAsync();
return TypedResults.Created($"/color-varieties/{v.Id}", ToDto(v));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ColorVarietyInput input, ApplicationContext db) =>
{
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
if (v is null) return TypedResults.NotFound();
v.Name = input.Name;
v.CanonicalGenotype = input.CanonicalGenotype;
if (input.SortOrder is int so) v.SortOrder = so;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
if (v is null) return TypedResults.NotFound();
bool inUse = await db.Gerbils.AnyAsync(g => g.ColorVarietyId == id);
if (inUse) return TypedResults.Conflict("Color variety is in use by one or more gerbils and cannot be deleted.");
db.ColorVarieties.Remove(v);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static ColorVarietyDto ToDto(ColorVariety v) => new(v.Id, v.Name, v.CanonicalGenotype, v.SortOrder);
}
}

View File

@@ -0,0 +1,59 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class ContactEndpoints
{
public static IEndpointRouteBuilder MapContactEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/contacts").WithTags("Contacts");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) =>
TypedResults.Ok(await db.Contacts.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<ContactDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var c = await db.Contacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
});
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
{
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, ContactInfo = input.ContactInfo, Notes = input.Notes };
db.Contacts.Add(c);
await db.SaveChangesAsync();
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ContactInput input, ApplicationContext db) =>
{
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
c.Name = input.Name; c.ContactInfo = input.ContactInfo; c.Notes = input.Notes;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// 409 if any gerbil references this contact as origin or receiver.
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
bool linked = await db.Gerbils.AnyAsync(g => g.OriginContactId == id || g.ReceiverContactId == id);
if (linked) return TypedResults.Conflict("Contact is linked to one or more gerbils and cannot be deleted.");
db.Contacts.Remove(c);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.ContactInfo, c.Notes);
}
}

View File

@@ -0,0 +1,59 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class EnclosureEndpoints
{
public static IEndpointRouteBuilder MapEnclosureEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/enclosures").WithTags("Enclosures");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) =>
TypedResults.Ok(await db.Enclosures.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var e = await db.Enclosures.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return e is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(e));
});
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
{
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
db.Enclosures.Add(e);
await db.SaveChangesAsync();
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, EnclosureInput input, ApplicationContext db) =>
{
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
if (e is null) return TypedResults.NotFound();
e.Name = input.Name; e.Notes = input.Notes;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// 409 if the enclosure still houses gerbils.
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
if (e is null) return TypedResults.NotFound();
bool occupied = await db.Gerbils.AnyAsync(g => g.EnclosureId == id);
if (occupied) return TypedResults.Conflict("Enclosure still contains gerbils and cannot be deleted.");
db.Enclosures.Remove(e);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes);
}
}

View File

@@ -0,0 +1,97 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class GerbilEndpoints
{
public static IEndpointRouteBuilder MapGerbilEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/gerbils").WithTags("Gerbils");
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
// GET /gerbils/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
});
// POST /gerbils
group.MapPost("/", async Task<Results<Created<GerbilDto>, ValidationProblem>> (GerbilInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.Name))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
Apply(g, input, isCreate: true);
db.Gerbils.Add(g);
await db.SaveChangesAsync();
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
});
// PUT /gerbils/{id}
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
g.Name = input.Name;
Apply(g, input, isCreate: false);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
db.Gerbils.Remove(g);
try
{
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
catch (DbUpdateException)
{
return TypedResults.Conflict("Gerbil is referenced as a litter parent and cannot be deleted.");
}
});
return app;
}
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
{
g.Gender = i.Gender;
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
g.LitterId = i.LitterId;
g.OriginContactId = i.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId;
g.EnclosureId = i.EnclosureId;
g.ColorVarietyId = i.ColorVarietyId;
g.DateOfBirth = i.DateOfBirth;
g.DateOfDeath = i.DateOfDeath;
g.CauseOfDeath = i.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate;
g.Genotype = i.Genotype;
g.Notes = i.Notes;
g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef;
}
internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef);
}
}

View File

@@ -0,0 +1,33 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Genetics;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// Inzuchtkoeffizient endpoints (FEAT-1b). Routes/response shapes are IDENTICAL to
/// the former InbreedingController — only the hosting style changed to Minimal API.
/// </summary>
public static class InbreedingEndpoints
{
public static IEndpointRouteBuilder MapInbreedingEndpoints(this IEndpointRouteBuilder app)
{
// GET /gerbils/{id}/inbreeding-coefficient
app.MapGet("/gerbils/{id:guid}/inbreeding-coefficient",
Results<Ok<InbreedingResult>, NotFound> (Guid id, ApplicationContext db) =>
{
var result = new InbreedingService(db).ForGerbil(id);
return result is null ? TypedResults.NotFound() : TypedResults.Ok(result);
})
.WithTags("Inbreeding");
// POST /genetics/test-inbreeding
app.MapPost("/genetics/test-inbreeding",
Ok<InbreedingResult> (TestInbreedingDto dto, ApplicationContext db) =>
TypedResults.Ok(new InbreedingService(db).ForPairing(dto.FatherId, dto.MotherId)))
.WithTags("Inbreeding");
return app;
}
}
}

View File

@@ -0,0 +1,91 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class LitterEndpoints
{
public static IEndpointRouteBuilder MapLitterEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/litters").WithTags("Litters");
// GET /litters (Gridify: date range + orderBy=date supported on the DateOnly column)
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) =>
TypedResults.Ok(await db.Litters.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<LitterDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var l = await db.Litters.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
});
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
{
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
Apply(l, input);
db.Litters.Add(l);
await db.SaveChangesAsync();
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
{
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound();
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
l.Name = input.Name;
l.Date = input.Date;
Apply(l, input);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound();
db.Litters.Remove(l);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
// father must not be female; mother must not be male (unknown is allowed).
private static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
{
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;
if (father?.Gender == Gender.female || mother?.Gender == Gender.male)
{
return new ParentGenderError("InvalidParentGender",
father?.Gender ?? Gender.unknown, mother?.Gender ?? Gender.unknown);
}
return null;
}
private static void Apply(Litter l, LitterInput i)
{
l.TotalBorn = i.TotalBorn;
l.FatherId = i.FatherId;
l.MotherId = i.MotherId;
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
l.Notes = i.Notes;
}
private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
}
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
public record ParentGenderError(string Code, Gender FatherGender, Gender MotherGender);
}

View File

@@ -0,0 +1,104 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// Gerbil photo endpoints (FEAT-1b phase 2), per the contract agreed with Oscar:
/// GET /gerbils/{id}/photos -> plain array (not paged)
/// POST /gerbils/{id}/photos -> multipart 'file' + optional 'caption' -> 201 PhotoDto
/// DELETE /photos/{id} -> 204
/// GET /photos/files/{fileName} -> the image bytes
/// url = "/photos/files/{fileName}"; profile photo = first by SortOrder.
/// </summary>
public static class PhotoEndpoints
{
public static IEndpointRouteBuilder MapPhotoEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/gerbils/{id:guid}/photos",
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
{
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
var photos = await db.GerbilPhotos.AsNoTracking()
.Where(p => p.GerbilId == id)
.OrderBy(p => p.SortOrder)
.ToListAsync();
return TypedResults.Ok(photos.Select(ToDto).ToList());
}).WithTags("Photos");
app.MapPost("/gerbils/{id:guid}/photos",
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
var ext = Path.GetExtension(file.FileName);
var fileName = $"{Guid.NewGuid():N}{ext}";
var root = PhotoRoot(config, env);
Directory.CreateDirectory(root);
await using (var stream = File.Create(Path.Combine(root, fileName)))
await file.CopyToAsync(stream);
int nextSort = (await db.GerbilPhotos.Where(p => p.GerbilId == id)
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
var photo = new GerbilPhoto
{
Id = Guid.NewGuid(),
GerbilId = id,
FileName = fileName,
Caption = caption,
SortOrder = nextSort,
CreatedAt = DateTimeOffset.UtcNow,
};
db.GerbilPhotos.Add(photo);
await db.SaveChangesAsync();
return TypedResults.Created($"/photos/{photo.Id}", ToDto(photo));
}).WithTags("Photos").DisableAntiforgery();
app.MapDelete("/photos/{id:guid}",
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var photo = await db.GerbilPhotos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return TypedResults.NotFound();
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
if (File.Exists(path)) File.Delete(path);
db.GerbilPhotos.Remove(photo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).WithTags("Photos");
app.MapGet("/photos/files/{fileName}",
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
{
// guard against path traversal: only a bare file name is allowed
if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains(".."))
return TypedResults.BadRequest("Invalid file name.");
var path = Path.Combine(PhotoRoot(config, env), fileName);
if (!File.Exists(path)) return TypedResults.NotFound();
return TypedResults.PhysicalFile(path, ContentType(fileName));
}).WithTags("Photos");
return app;
}
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
private static string ContentType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch
{
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".bmp" => "image/bmp",
_ => "image/jpeg",
};
private static PhotoDto ToDto(GerbilPhoto p) =>
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
}
}

View File

@@ -0,0 +1,121 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
public static class RecordEndpoints
{
public static IEndpointRouteBuilder MapHealthRecordEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/health-records").WithTags("HealthRecords");
// GET /health-records?filter=gerbilId==…
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) =>
TypedResults.Ok(await db.HealthRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<HealthRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.HealthRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
group.MapPost("/", async (HealthRecordInput input, ApplicationContext db) =>
{
var r = new HealthRecord
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
Date = input.Date,
Type = input.Type,
Description = input.Description,
Veterinarian = input.Veterinarian,
CreatedAt = DateTimeOffset.UtcNow,
};
db.HealthRecords.Add(r);
await db.SaveChangesAsync();
return TypedResults.Created($"/health-records/{r.Id}", ToDto(r));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, HealthRecordInput input, ApplicationContext db) =>
{
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
r.GerbilId = input.GerbilId; r.Date = input.Date; r.Type = input.Type;
r.Description = input.Description; r.Veterinarian = input.Veterinarian;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
db.HealthRecords.Remove(r);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
public static IEndpointRouteBuilder MapWeightRecordEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/weight-records").WithTags("WeightRecords");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) =>
TypedResults.Ok(await db.WeightRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<WeightRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.WeightRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
group.MapPost("/", async (WeightRecordInput input, ApplicationContext db) =>
{
var r = new WeightRecord
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
Date = input.Date,
WeightGrams = input.WeightGrams,
Notes = input.Notes,
};
db.WeightRecords.Add(r);
await db.SaveChangesAsync();
return TypedResults.Created($"/weight-records/{r.Id}", ToDto(r));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, WeightRecordInput input, ApplicationContext db) =>
{
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
r.GerbilId = input.GerbilId; r.Date = input.Date;
r.WeightGrams = input.WeightGrams; r.Notes = input.Notes;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
db.WeightRecords.Remove(r);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static HealthRecordDto ToDto(HealthRecord r) =>
new(r.Id, r.GerbilId, r.Date, r.Type, r.Description, r.Veterinarian, r.CreatedAt);
private static WeightRecordDto ToDto(WeightRecord r) =>
new(r.Id, r.GerbilId, r.Date, r.WeightGrams, r.Notes);
}
}

View File

@@ -32,22 +32,12 @@ namespace GerbilManagerWebAPI.Genetics
private PedigreeCalculator BuildCalculator()
{
var litters = _db.Set<Litter>()
.Select(l => new
{
l.Id,
FatherId = EF.Property<Guid?>(l, "FatherId"),
MotherId = EF.Property<Guid?>(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<Gerbil>()
.Select(g => new
{
g.Id,
g.Name,
LitterId = EF.Property<Guid?>(g, "LitterId"),
})
var gerbils = _db.Gerbils
.Select(g => new { g.Id, g.Name, g.LitterId })
.ToList();
var parents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>(gerbils.Count);

View File

@@ -15,11 +15,13 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<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>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
</Project>

View File

@@ -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; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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>();
}
}

View File

@@ -2,13 +2,49 @@ using System.ComponentModel.DataAnnotations;
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]
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; }
/// <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; }
}
}
}

View 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; }
}
}

View 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
}
}

View 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; }
}
}

View 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
}
}

View File

@@ -2,15 +2,24 @@ using System.ComponentModel.DataAnnotations;
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]
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; }
/// <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; }
}
}

View 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; }
}
}

View File

@@ -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<ApplicationContext>("gerbilmanager");
builder.Services.AddScoped<UnitOfWork>();
// 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<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.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();

View File

@@ -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"
}