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