Add unit-of-measure catalog foundation
Phase 1 of structured quantities + UoM. Introduces a global UnitOfMeasure catalog (Code-keyed for stable backend lookup of canonical units like "each") and FamilyUnitOfMeasure for family-scoped customs, mirroring the product-catalog pattern. Endpoints expose the merged effective catalog plus CRUD for family customs. Abbreviation uniqueness is enforced per table at the DB layer and across tables at the API layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YesChef.Api.Auth;
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
namespace YesChef.Api.Features.Units;
|
||||
|
||||
public static class UnitEndpoints
|
||||
{
|
||||
/// <summary>Discriminator so the client can route subsequent PUT/DELETE
|
||||
/// calls to the right code path. Global units cannot be edited.</summary>
|
||||
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();
|
||||
|
||||
// No usage check yet — RecipeIngredient/ShoppingListItem don't
|
||||
// reference units in Phase 1. Phase 2/3 will need to block delete
|
||||
// when rows reference this unit.
|
||||
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<bool> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user