Files
YesChef/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs
T
Josh Rogers 6d84aad94b Pre-fill list section on product pick; tighten backend warnings
- Adds GET /api/products/{kind}/{id}/section?storeId=... exposing the
  per-store memory the list page mirrors when a product is picked, so the
  section dropdown reflects what the backend would auto-assign on POST.
- Treats backend warnings as errors via Directory.Build.props; fixes the
  surfaced warnings (obsolete PostgreSqlBuilder ctor, nullable string[]
  in IsEquivalentTo, redundant nullable flow).
- Annotates wire-exposed enums (ProductKind, UnitKind, UnitCategory,
  UnitCategoryFlags) with JsonStringEnumConverter so they round-trip as
  strings regardless of caller options. Unblocks the integration tests
  that deserialize DTOs via GetFromJsonAsync without the global converter.
2026-05-15 21:30:00 -05:00

165 lines
7.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>Discriminator so the client can route subsequent PUT/DELETE
/// calls to the right code path. Global units cannot be edited.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<UnitKind>))]
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<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);
}