using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using YesChef.Api.Auth; using YesChef.Api.Data; using YesChef.Api.Entities; namespace YesChef.Api.Features.Units; public static class UnitEndpoints { /// Discriminator so the client can route subsequent PUT/DELETE /// calls to the right code path. Global units cannot be edited. [JsonConverter(typeof(JsonStringEnumConverter))] public enum UnitKind { Global, Family } public record UnitDto( int Id, UnitKind Kind, string? Code, string SingularName, string PluralName, string Abbreviation, UnitCategory Category, bool IsBase, int SortOrder); public record CreateUnitRequest( string SingularName, string PluralName, string Abbreviation, UnitCategory Category, int SortOrder = 100); public record UpdateUnitRequest( string SingularName, string PluralName, string Abbreviation, UnitCategory Category, int SortOrder); public static RouteGroupBuilder MapUnitEndpoints(this RouteGroupBuilder group) { // Effective catalog: global ∪ this family's customs. Client caches. group.MapGet("/", async (YesChefDb db, HttpContext http) => { var familyId = http.User.GetFamilyId(); var globalRows = await db.UnitsOfMeasure.AsNoTracking() .OrderBy(u => u.SortOrder).ThenBy(u => u.SingularName) .ToListAsync(); var familyRows = await db.FamilyUnitsOfMeasure.AsNoTracking() .Where(u => u.FamilyId == familyId) .OrderBy(u => u.SortOrder).ThenBy(u => u.SingularName) .ToListAsync(); var dtos = globalRows.Select(u => new UnitDto( u.Id, UnitKind.Global, u.Code, u.SingularName, u.PluralName, u.Abbreviation, u.Category, u.IsBase, u.SortOrder)) .Concat(familyRows.Select(u => new UnitDto( u.Id, UnitKind.Family, null, u.SingularName, u.PluralName, u.Abbreviation, u.Category, IsBase: false, u.SortOrder))); return Results.Ok(dtos); }); group.MapPost("/", async (CreateUnitRequest request, YesChefDb db, HttpContext http) => { var familyId = http.User.GetFamilyId(); if (ValidateNames(request.SingularName, request.PluralName, request.Abbreviation) is { } badRequest) return badRequest; var singular = request.SingularName.Trim(); var plural = request.PluralName.Trim(); var abbrev = request.Abbreviation.Trim(); if (await AbbreviationCollidesAsync(db, familyId, abbrev, excludeFamilyUnitId: null)) return Results.Conflict(new { error = $"A unit with abbreviation \"{abbrev}\" already exists." }); var unit = new FamilyUnitOfMeasure { FamilyId = familyId, SingularName = singular, PluralName = plural, Abbreviation = abbrev, Category = request.Category, SortOrder = request.SortOrder, }; db.FamilyUnitsOfMeasure.Add(unit); await db.SaveChangesAsync(); return Results.Created($"/api/units/family/{unit.Id}", ToDto(unit)); }); group.MapPut("/family/{id:int}", async (int id, UpdateUnitRequest request, YesChefDb db, HttpContext http) => { var familyId = http.User.GetFamilyId(); var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId); if (unit is null) return Results.NotFound(); if (ValidateNames(request.SingularName, request.PluralName, request.Abbreviation) is { } badRequest) return badRequest; var abbrev = request.Abbreviation.Trim(); if (!string.Equals(abbrev, unit.Abbreviation, StringComparison.Ordinal) && await AbbreviationCollidesAsync(db, familyId, abbrev, excludeFamilyUnitId: id)) return Results.Conflict(new { error = $"A unit with abbreviation \"{abbrev}\" already exists." }); unit.SingularName = request.SingularName.Trim(); unit.PluralName = request.PluralName.Trim(); unit.Abbreviation = abbrev; unit.Category = request.Category; unit.SortOrder = request.SortOrder; await db.SaveChangesAsync(); return Results.Ok(ToDto(unit)); }); group.MapDelete("/family/{id:int}", async (int id, YesChefDb db, HttpContext http) => { var familyId = http.User.GetFamilyId(); var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId); if (unit is null) return Results.NotFound(); // Block deletion when any recipe ingredient or shopping list item // still references the unit. var inUseByRecipe = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id); if (inUseByRecipe) return Results.Conflict(new { error = "Unit is in use by one or more recipes." }); var inUseByList = await db.ShoppingListItems.AnyAsync(i => i.FamilyUnitOfMeasureId == id); if (inUseByList) return Results.Conflict(new { error = "Unit is in use by one or more shopping lists." }); db.FamilyUnitsOfMeasure.Remove(unit); await db.SaveChangesAsync(); return Results.NoContent(); }); return group; } private static IResult? ValidateNames(string singular, string plural, string abbreviation) { if (string.IsNullOrWhiteSpace(singular)) return Results.BadRequest(new { error = "Singular name is required." }); if (string.IsNullOrWhiteSpace(plural)) return Results.BadRequest(new { error = "Plural name is required." }); if (string.IsNullOrWhiteSpace(abbreviation)) return Results.BadRequest(new { error = "Abbreviation is required." }); return null; } private static async Task AbbreviationCollidesAsync( YesChefDb db, int familyId, string abbreviation, int? excludeFamilyUnitId) { // Effective uniqueness spans the global catalog AND this family's // customs. DB indexes guard each table individually; this check // catches cross-table collisions before they get to the user. if (await db.UnitsOfMeasure.AnyAsync(u => u.Abbreviation == abbreviation)) return true; return await db.FamilyUnitsOfMeasure.AnyAsync(u => u.FamilyId == familyId && u.Abbreviation == abbreviation && (excludeFamilyUnitId == null || u.Id != excludeFamilyUnitId)); } private static UnitDto ToDto(FamilyUnitOfMeasure u) => new( u.Id, UnitKind.Family, Code: null, u.SingularName, u.PluralName, u.Abbreviation, u.Category, IsBase: false, u.SortOrder); }