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 @@ +