From 559d80c1046e4819f9bad90ebc6a55258ee2dfd7 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Tue, 12 May 2026 21:17:30 -0500 Subject: [PATCH] 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) --- .../Features/UnitEndpointsTests.cs | 206 ++++ .../YesChef.Api/Data/Seed/UnitSeeder.cs | 68 ++ src/backend/YesChef.Api/Data/Seed/units.json | 34 + src/backend/YesChef.Api/Data/YesChefDb.cs | 25 + .../Entities/FamilyUnitOfMeasure.cs | 18 + .../YesChef.Api/Entities/UnitCategory.cs | 9 + .../YesChef.Api/Entities/UnitOfMeasure.cs | 22 + .../Features/Units/UnitEndpoints.cs | 158 +++ ...230122_AddUnitOfMeasureCatalog.Designer.cs | 1025 +++++++++++++++++ .../20260512230122_AddUnitOfMeasureCatalog.cs | 89 ++ .../Migrations/YesChefDbModelSnapshot.cs | 105 ++ src/backend/YesChef.Api/Program.cs | 3 + src/backend/YesChef.Api/YesChef.Api.csproj | 1 + 13 files changed, 1763 insertions(+) create mode 100644 src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs create mode 100644 src/backend/YesChef.Api/Data/Seed/UnitSeeder.cs create mode 100644 src/backend/YesChef.Api/Data/Seed/units.json create mode 100644 src/backend/YesChef.Api/Entities/FamilyUnitOfMeasure.cs create mode 100644 src/backend/YesChef.Api/Entities/UnitCategory.cs create mode 100644 src/backend/YesChef.Api/Entities/UnitOfMeasure.cs create mode 100644 src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.Designer.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.cs diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs new file mode 100644 index 0000000..3c005bf --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs @@ -0,0 +1,206 @@ +using System.Net; +using System.Net.Http.Json; +using YesChef.Api.Entities; +using YesChef.Api.Features.Units; +using YesChef.Api.IntegrationTests.Infrastructure; + +namespace YesChef.Api.IntegrationTests.Features; + +public class UnitEndpointsTests : AuthenticatedIntegrationTest +{ + private Task GetFamilyIdAsync() => UseDbAsync(db => + db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); + + [Test] + public async Task List_returns_global_units_and_family_customs_merged() + { + var familyId = await GetFamilyIdAsync(); + await UseDbAsync(async db => + { + db.UnitsOfMeasure.Add(new UnitOfMeasure + { + Code = "each", SingularName = "each", PluralName = "each", + Abbreviation = "ea", Category = UnitCategory.Count, IsBase = true, SortOrder = 0, + }); + db.FamilyUnitsOfMeasure.Add(new FamilyUnitOfMeasure + { + FamilyId = familyId, + SingularName = "scoop", PluralName = "scoops", + Abbreviation = "scp", Category = UnitCategory.Volume, SortOrder = 50, + }); + await db.SaveChangesAsync(); + }); + + var results = await Client.GetFromJsonAsync>("/api/units"); + + await Assert.That(results!.Select(r => r.Abbreviation)).IsEquivalentTo(new[] { "ea", "scp" }); + var each = results!.Single(r => r.Abbreviation == "ea"); + await Assert.That(each.Kind).IsEqualTo(UnitEndpoints.UnitKind.Global); + await Assert.That(each.Code).IsEqualTo("each"); + var scoop = results!.Single(r => r.Abbreviation == "scp"); + await Assert.That(scoop.Kind).IsEqualTo(UnitEndpoints.UnitKind.Family); + await Assert.That(scoop.Code).IsNull(); + } + + [Test] + public async Task List_does_not_leak_other_family_units() + { + await UseDbAsync(async db => + { + var other = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(other); + await db.SaveChangesAsync(); + db.FamilyUnitsOfMeasure.Add(new FamilyUnitOfMeasure + { + FamilyId = other.Id, + SingularName = "pinch", PluralName = "pinches", + Abbreviation = "pn", Category = UnitCategory.Volume, + }); + await db.SaveChangesAsync(); + }); + + var results = await Client.GetFromJsonAsync>("/api/units"); + + await Assert.That(results!.Any(r => r.Abbreviation == "pn")).IsFalse(); + } + + [Test] + public async Task Create_persists_family_unit_and_returns_201() + { + var response = await Client.PostAsJsonAsync("/api/units", + new UnitEndpoints.CreateUnitRequest("sleeve", "sleeves", "slv", UnitCategory.Packaging)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var dto = await response.Content.ReadFromJsonAsync(); + await Assert.That(dto!.Kind).IsEqualTo(UnitEndpoints.UnitKind.Family); + await Assert.That(dto.Abbreviation).IsEqualTo("slv"); + + var persisted = await UseDbAsync(db => db.FamilyUnitsOfMeasure.SingleAsync()); + await Assert.That(persisted.SingularName).IsEqualTo("sleeve"); + await Assert.That(persisted.Category).IsEqualTo(UnitCategory.Packaging); + } + + [Test] + public async Task Create_returns_409_when_abbreviation_collides_with_global() + { + await UseDbAsync(async db => + { + db.UnitsOfMeasure.Add(new UnitOfMeasure + { + Code = "lb", SingularName = "pound", PluralName = "pounds", + Abbreviation = "lb", Category = UnitCategory.Weight, + }); + await db.SaveChangesAsync(); + }); + + var response = await Client.PostAsJsonAsync("/api/units", + new UnitEndpoints.CreateUnitRequest("librapound", "librapounds", "lb", UnitCategory.Weight)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Create_returns_409_when_abbreviation_collides_within_family() + { + await Client.PostAsJsonAsync("/api/units", + new UnitEndpoints.CreateUnitRequest("sleeve", "sleeves", "slv", UnitCategory.Packaging)); + + var response = await Client.PostAsJsonAsync("/api/units", + new UnitEndpoints.CreateUnitRequest("slab", "slabs", "slv", UnitCategory.Packaging)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Create_returns_400_when_required_field_missing() + { + var response = await Client.PostAsJsonAsync("/api/units", + new UnitEndpoints.CreateUnitRequest(" ", "sleeves", "slv", UnitCategory.Packaging)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Update_family_unit_changes_fields() + { + var familyId = await GetFamilyIdAsync(); + var unit = await UseDbAsync(async db => + { + var u = new FamilyUnitOfMeasure + { + FamilyId = familyId, + SingularName = "old", PluralName = "olds", + Abbreviation = "od", Category = UnitCategory.Count, SortOrder = 1, + }; + db.FamilyUnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u; + }); + + var response = await Client.PutAsJsonAsync($"/api/units/family/{unit.Id}", + new UnitEndpoints.UpdateUnitRequest("new", "news", "nw", UnitCategory.Weight, 2)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var refreshed = await UseDbAsync(db => db.FamilyUnitsOfMeasure.SingleAsync(u => u.Id == unit.Id)); + await Assert.That(refreshed.SingularName).IsEqualTo("new"); + await Assert.That(refreshed.Abbreviation).IsEqualTo("nw"); + await Assert.That(refreshed.Category).IsEqualTo(UnitCategory.Weight); + await Assert.That(refreshed.SortOrder).IsEqualTo(2); + } + + [Test] + public async Task Update_family_unit_404_for_other_family_id() + { + var foreignId = await UseDbAsync(async db => + { + var other = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(other); + await db.SaveChangesAsync(); + var u = new FamilyUnitOfMeasure + { + FamilyId = other.Id, + SingularName = "foreign", PluralName = "foreigns", + Abbreviation = "fg", Category = UnitCategory.Count, + }; + db.FamilyUnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); + + var response = await Client.PutAsJsonAsync($"/api/units/family/{foreignId}", + new UnitEndpoints.UpdateUnitRequest("x", "xs", "xx", UnitCategory.Count, 0)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Delete_family_unit_removes_row() + { + var familyId = await GetFamilyIdAsync(); + var unit = await UseDbAsync(async db => + { + var u = new FamilyUnitOfMeasure + { + FamilyId = familyId, + SingularName = "doomed", PluralName = "doomeds", + Abbreviation = "dm", Category = UnitCategory.Count, + }; + db.FamilyUnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u; + }); + + var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + var remaining = await UseDbAsync(db => db.FamilyUnitsOfMeasure.CountAsync()); + await Assert.That(remaining).IsEqualTo(0); + } + + [Test] + public async Task Endpoints_require_authentication() + { + var response = await AnonymousClient.GetAsync("/api/units"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/src/backend/YesChef.Api/Data/Seed/UnitSeeder.cs b/src/backend/YesChef.Api/Data/Seed/UnitSeeder.cs new file mode 100644 index 0000000..8f3db05 --- /dev/null +++ b/src/backend/YesChef.Api/Data/Seed/UnitSeeder.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Entities; + +namespace YesChef.Api.Data.Seed; + +/// +/// Loads the unit-of-measure catalog from the embedded JSON resource and +/// inserts any entries that aren't already present (matched by Code). +/// Idempotent — safe to call on every startup. +/// +public static class UnitSeeder +{ + private const string ResourceName = "YesChef.Api.Data.Seed.units.json"; + + private record SeedEntry( + string Code, + string SingularName, + string PluralName, + string Abbreviation, + UnitCategory Category, + bool IsBase, + int SortOrder); + + private record SeedFile(List Units); + + public static async Task SeedAsync(YesChefDb db, ILogger logger, CancellationToken ct = default) + { + var entries = LoadEntries(); + if (entries.Count == 0) return; + + var existingCodes = await db.UnitsOfMeasure + .Select(u => u.Code) + .ToListAsync(ct); + var missing = entries.Where(e => !existingCodes.Contains(e.Code)).ToList(); + if (missing.Count == 0) return; + + var now = DateTime.UtcNow; + db.UnitsOfMeasure.AddRange(missing.Select(e => new UnitOfMeasure + { + Code = e.Code, + SingularName = e.SingularName, + PluralName = e.PluralName, + Abbreviation = e.Abbreviation, + Category = e.Category, + IsBase = e.IsBase, + SortOrder = e.SortOrder, + CreatedAt = now, + })); + await db.SaveChangesAsync(ct); + + logger.LogInformation("Unit seed inserted {Count} new units of measure.", missing.Count); + } + + private static List LoadEntries() + { + using var stream = typeof(UnitSeeder).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"Embedded seed resource '{ResourceName}' not found."); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + var seed = JsonSerializer.Deserialize(stream, options); + return seed?.Units ?? []; + } +} diff --git a/src/backend/YesChef.Api/Data/Seed/units.json b/src/backend/YesChef.Api/Data/Seed/units.json new file mode 100644 index 0000000..3df1f13 --- /dev/null +++ b/src/backend/YesChef.Api/Data/Seed/units.json @@ -0,0 +1,34 @@ +{ + "units": [ + { "code": "each", "singularName": "each", "pluralName": "each", "abbreviation": "ea", "category": "Count", "isBase": true, "sortOrder": 0 }, + { "code": "dozen", "singularName": "dozen", "pluralName": "dozen", "abbreviation": "doz", "category": "Count", "isBase": false, "sortOrder": 1 }, + + { "code": "oz", "singularName": "ounce", "pluralName": "ounces", "abbreviation": "oz", "category": "Weight", "isBase": false, "sortOrder": 10 }, + { "code": "lb", "singularName": "pound", "pluralName": "pounds", "abbreviation": "lb", "category": "Weight", "isBase": false, "sortOrder": 11 }, + { "code": "g", "singularName": "gram", "pluralName": "grams", "abbreviation": "g", "category": "Weight", "isBase": true, "sortOrder": 12 }, + { "code": "kg", "singularName": "kilogram", "pluralName": "kilograms", "abbreviation": "kg", "category": "Weight", "isBase": false, "sortOrder": 13 }, + + { "code": "tsp", "singularName": "teaspoon", "pluralName": "teaspoons", "abbreviation": "tsp", "category": "Volume", "isBase": false, "sortOrder": 20 }, + { "code": "tbsp", "singularName": "tablespoon", "pluralName": "tablespoons", "abbreviation": "tbsp", "category": "Volume", "isBase": false, "sortOrder": 21 }, + { "code": "fl-oz", "singularName": "fluid ounce","pluralName": "fluid ounces","abbreviation": "fl oz", "category": "Volume", "isBase": false, "sortOrder": 22 }, + { "code": "cup", "singularName": "cup", "pluralName": "cups", "abbreviation": "cup", "category": "Volume", "isBase": false, "sortOrder": 23 }, + { "code": "pt", "singularName": "pint", "pluralName": "pints", "abbreviation": "pt", "category": "Volume", "isBase": false, "sortOrder": 24 }, + { "code": "qt", "singularName": "quart", "pluralName": "quarts", "abbreviation": "qt", "category": "Volume", "isBase": false, "sortOrder": 25 }, + { "code": "gal", "singularName": "gallon", "pluralName": "gallons", "abbreviation": "gal", "category": "Volume", "isBase": false, "sortOrder": 26 }, + { "code": "ml", "singularName": "milliliter", "pluralName": "milliliters", "abbreviation": "ml", "category": "Volume", "isBase": true, "sortOrder": 27 }, + { "code": "L", "singularName": "liter", "pluralName": "liters", "abbreviation": "L", "category": "Volume", "isBase": false, "sortOrder": 28 }, + + { "code": "box", "singularName": "box", "pluralName": "boxes", "abbreviation": "bx", "category": "Packaging", "isBase": false, "sortOrder": 30 }, + { "code": "bag", "singularName": "bag", "pluralName": "bags", "abbreviation": "bag", "category": "Packaging", "isBase": false, "sortOrder": 31 }, + { "code": "case", "singularName": "case", "pluralName": "cases", "abbreviation": "cs", "category": "Packaging", "isBase": false, "sortOrder": 32 }, + { "code": "bottle", "singularName": "bottle", "pluralName": "bottles", "abbreviation": "btl", "category": "Packaging", "isBase": false, "sortOrder": 33 }, + { "code": "can", "singularName": "can", "pluralName": "cans", "abbreviation": "can", "category": "Packaging", "isBase": false, "sortOrder": 34 }, + { "code": "jar", "singularName": "jar", "pluralName": "jars", "abbreviation": "jar", "category": "Packaging", "isBase": false, "sortOrder": 35 }, + { "code": "pack", "singularName": "pack", "pluralName": "packs", "abbreviation": "pk", "category": "Packaging", "isBase": false, "sortOrder": 36 }, + { "code": "bunch", "singularName": "bunch", "pluralName": "bunches", "abbreviation": "bn", "category": "Packaging", "isBase": false, "sortOrder": 37 }, + { "code": "head", "singularName": "head", "pluralName": "heads", "abbreviation": "hd", "category": "Packaging", "isBase": false, "sortOrder": 38 }, + { "code": "loaf", "singularName": "loaf", "pluralName": "loaves", "abbreviation": "lf", "category": "Packaging", "isBase": false, "sortOrder": 39 }, + { "code": "carton", "singularName": "carton", "pluralName": "cartons", "abbreviation": "ctn", "category": "Packaging", "isBase": false, "sortOrder": 40 }, + { "code": "roll", "singularName": "roll", "pluralName": "rolls", "abbreviation": "rl", "category": "Packaging", "isBase": false, "sortOrder": 41 } + ] +} diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 3226739..035a519 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -20,6 +20,8 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) public DbSet FamilyProducts => Set(); public DbSet FamilyProductOverrides => Set(); public DbSet ProductStoreSections => Set(); + public DbSet UnitsOfMeasure => Set(); + public DbSet FamilyUnitsOfMeasure => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -148,6 +150,29 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(e => + { + e.Property(u => u.Code).HasMaxLength(50); + e.Property(u => u.SingularName).HasMaxLength(50); + e.Property(u => u.PluralName).HasMaxLength(50); + e.Property(u => u.Abbreviation).HasMaxLength(20); + e.Property(u => u.Category).HasConversion(); + e.HasIndex(u => u.Code).IsUnique(); + e.HasIndex(u => u.Abbreviation).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.Property(u => u.SingularName).HasMaxLength(50); + e.Property(u => u.PluralName).HasMaxLength(50); + e.Property(u => u.Abbreviation).HasMaxLength(20); + e.Property(u => u.Category).HasConversion(); + e.HasOne(u => u.Family).WithMany().HasForeignKey(u => u.FamilyId).OnDelete(DeleteBehavior.Cascade); + // Per-family abbreviation uniqueness. Cross-table collision with the + // global catalog is enforced in the API layer on create/update. + e.HasIndex(u => new { u.FamilyId, u.Abbreviation }).IsUnique(); + }); + modelBuilder.Entity(e => { e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade); diff --git a/src/backend/YesChef.Api/Entities/FamilyUnitOfMeasure.cs b/src/backend/YesChef.Api/Entities/FamilyUnitOfMeasure.cs new file mode 100644 index 0000000..63d1348 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/FamilyUnitOfMeasure.cs @@ -0,0 +1,18 @@ +namespace YesChef.Api.Entities; + +/// +/// Family-scoped unit of measure. Visible only to the owning family and +/// merged with the global catalog on read. +/// +public class FamilyUnitOfMeasure +{ + public int Id { get; set; } + public int FamilyId { get; set; } + public Family Family { get; set; } = null!; + public required string SingularName { get; set; } + public required string PluralName { get; set; } + public required string Abbreviation { get; set; } + public UnitCategory Category { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Entities/UnitCategory.cs b/src/backend/YesChef.Api/Entities/UnitCategory.cs new file mode 100644 index 0000000..ab97b3a --- /dev/null +++ b/src/backend/YesChef.Api/Entities/UnitCategory.cs @@ -0,0 +1,9 @@ +namespace YesChef.Api.Entities; + +public enum UnitCategory +{ + Count = 0, + Weight = 1, + Volume = 2, + Packaging = 3, +} diff --git a/src/backend/YesChef.Api/Entities/UnitOfMeasure.cs b/src/backend/YesChef.Api/Entities/UnitOfMeasure.cs new file mode 100644 index 0000000..fe660f1 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/UnitOfMeasure.cs @@ -0,0 +1,22 @@ +namespace YesChef.Api.Entities; + +/// +/// Global, read-only unit-of-measure catalog entry. Visible to every family. +/// Family-only units live in . The effective +/// catalog for a family is global ∪ that family's customs. +/// +public class UnitOfMeasure +{ + public int Id { get; set; } + // Stable lookup key (e.g. "each", "lb"). Lets the backend reference + // canonical units without depending on auto-generated ids. + public required string Code { get; set; } + public required string SingularName { get; set; } + public required string PluralName { get; set; } + public required string Abbreviation { get; set; } + public UnitCategory Category { get; set; } + // Canonical-in-category marker, reserved for future conversion math. + public bool IsBase { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs new file mode 100644 index 0000000..0ca1ab5 --- /dev/null +++ b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs @@ -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 +{ + /// Discriminator so the client can route subsequent PUT/DELETE + /// calls to the right code path. Global units cannot be edited. + 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 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); +} diff --git a/src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.Designer.cs b/src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.Designer.cs new file mode 100644 index 0000000..7ee113d --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.Designer.cs @@ -0,0 +1,1025 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260512230122_AddUnitOfMeasureCatalog")] + partial class AddUnitOfMeasureCatalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("FamilyProducts"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b => + { + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FamilyId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("FamilyProductOverrides"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Abbreviation") + .IsUnique(); + + b.ToTable("FamilyUnitsOfMeasure"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ConsumedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("FamilyId", "ConsumedAt"); + + b.ToTable("Invites"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedAt"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("StoreSectionId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("StoreSectionId"); + + b.HasIndex("FamilyId", "StoreId", "FamilyProductId") + .IsUnique() + .HasFilter("\"FamilyProductId\" IS NOT NULL"); + + b.HasIndex("FamilyId", "StoreId", "ProductId") + .IsUnique() + .HasFilter("\"ProductId\" IS NOT NULL"); + + b.ToTable("ProductStoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBase") + .HasColumnType("boolean"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Abbreviation") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("UnitsOfMeasure"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser") + .WithMany() + .HasForeignKey("ConsumedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ConsumedByUser"); + + b.Navigation("Family"); + + b.Navigation("IssuedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.StoreSection", "StoreSection") + .WithMany() + .HasForeignKey("StoreSectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Store"); + + b.Navigation("StoreSection"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.cs b/src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.cs new file mode 100644 index 0000000..07496d2 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260512230122_AddUnitOfMeasureCatalog.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddUnitOfMeasureCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FamilyUnitsOfMeasure", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FamilyId = table.Column(type: "integer", nullable: false), + SingularName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PluralName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Abbreviation = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Category = table.Column(type: "integer", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FamilyUnitsOfMeasure", x => x.Id); + table.ForeignKey( + name: "FK_FamilyUnitsOfMeasure_Families_FamilyId", + column: x => x.FamilyId, + principalTable: "Families", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UnitsOfMeasure", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + SingularName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PluralName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Abbreviation = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Category = table.Column(type: "integer", nullable: false), + IsBase = table.Column(type: "boolean", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UnitsOfMeasure", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_FamilyUnitsOfMeasure_FamilyId_Abbreviation", + table: "FamilyUnitsOfMeasure", + columns: new[] { "FamilyId", "Abbreviation" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UnitsOfMeasure_Abbreviation", + table: "UnitsOfMeasure", + column: "Abbreviation", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UnitsOfMeasure_Code", + table: "UnitsOfMeasure", + column: "Code", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FamilyUnitsOfMeasure"); + + migrationBuilder.DropTable( + name: "UnitsOfMeasure"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 1e02da1..26ea0f8 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -137,6 +137,49 @@ namespace YesChef.Api.Migrations b.ToTable("FamilyProductOverrides"); }); + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Abbreviation") + .IsUnique(); + + b.ToTable("FamilyUnitsOfMeasure"); + }); + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => { b.Property("Id") @@ -566,6 +609,57 @@ namespace YesChef.Api.Migrations b.ToTable("StoreSections"); }); + modelBuilder.Entity("YesChef.Api.Entities.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBase") + .HasColumnType("boolean"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Abbreviation") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("UnitsOfMeasure"); + }); + modelBuilder.Entity("YesChef.Api.Entities.User", b => { b.Property("Id") @@ -654,6 +748,17 @@ namespace YesChef.Api.Migrations b.Navigation("Product"); }); + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => { b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser") diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index 02e71b5..f423e82 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -16,6 +16,7 @@ using YesChef.Api.Features.Products; using YesChef.Api.Features.Recipes; using YesChef.Api.Features.ShoppingLists; using YesChef.Api.Features.Stores; +using YesChef.Api.Features.Units; var builder = WebApplication.CreateBuilder(args); @@ -204,6 +205,7 @@ using (var scope = app.Services.CreateScope()) { var seedLogger = scope.ServiceProvider.GetRequiredService>(); await CatalogSeeder.SeedAsync(db, seedLogger); + await UnitSeeder.SeedAsync(db, seedLogger); } } @@ -225,6 +227,7 @@ storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints(); app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization(); app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); app.MapGroup("/api/products").MapProductEndpoints().RequireAuthorization(); +app.MapGroup("/api/units").MapUnitEndpoints().RequireAuthorization(); app.MapHub("/hubs/shopping-list"); app.Run(); diff --git a/src/backend/YesChef.Api/YesChef.Api.csproj b/src/backend/YesChef.Api/YesChef.Api.csproj index 542a7de..eacee06 100644 --- a/src/backend/YesChef.Api/YesChef.Api.csproj +++ b/src/backend/YesChef.Api/YesChef.Api.csproj @@ -8,6 +8,7 @@ +