6d84aad94b
- 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.
165 lines
7.0 KiB
C#
165 lines
7.0 KiB
C#
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);
|
||
}
|