Add product catalog with per-store section memory

Introduces a global Products catalog plus per-family overrides and
private FamilyProducts, exposed via /api/products with a merged
search. Shopping list items and recipe ingredients gain optional
ProductId/FamilyProductId links, and a new ProductStoreSection
table remembers which section a product was last placed in at a
given store so future adds auto-assign the right section.

Frontend gets a reusable ProductTypeahead component, wired into
list-item add and recipe ingredient entry with free-form fallback.

A startup CatalogSeeder loads ~115 curated staples from an embedded
JSON resource via INSERT ... ON CONFLICT DO NOTHING; skipped under
the Testing environment so integration tests keep a clean slate.
This commit is contained in:
Josh Rogers
2026-05-09 21:29:51 -05:00
parent 5c6abc1e43
commit 6c8f0167e5
27 changed files with 4621 additions and 36 deletions
@@ -0,0 +1,256 @@
using System.Net;
using System.Net.Http.Json;
using YesChef.Api.Entities;
using YesChef.Api.Features.Products;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
public class ProductEndpointsTests : AuthenticatedIntegrationTest
{
private Task<int> GetFamilyIdAsync() => UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
[Test]
public async Task Search_returns_global_and_family_products_merged()
{
var familyId = await GetFamilyIdAsync();
await UseDbAsync(async db =>
{
db.Products.Add(new Product { Name = "Bananas" });
db.Products.Add(new Product { Name = "Carrots" });
db.FamilyProducts.Add(new FamilyProduct { FamilyId = familyId, Name = "House Bread" });
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=");
await Assert.That(results!.Select(r => r.Name)).IsEquivalentTo(new[] { "Bananas", "Carrots", "House Bread" });
}
[Test]
public async Task Search_filters_case_insensitively_on_effective_name()
{
await UseDbAsync(async db =>
{
db.Products.Add(new Product { Name = "Bananas" });
db.Products.Add(new Product { Name = "Apples" });
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=ban");
await Assert.That(results!.Select(r => r.Name)).IsEquivalentTo(new[] { "Bananas" });
}
[Test]
public async Task Search_applies_family_override_to_global_product()
{
var familyId = await GetFamilyIdAsync();
Product apples = null!;
await UseDbAsync(async db =>
{
apples = new Product { Name = "Apples", Brand = "Generic" };
db.Products.Add(apples);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new FamilyProductOverride
{
FamilyId = familyId,
ProductId = apples.Id,
Brand = "Honeycrisp",
});
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=apple");
var dto = results!.Single();
await Assert.That(dto.Name).IsEqualTo("Apples");
await Assert.That(dto.Brand).IsEqualTo("Honeycrisp");
await Assert.That(dto.IsOverridden).IsTrue();
}
[Test]
public async Task Search_does_not_leak_other_family_private_products()
{
await UseDbAsync(async db =>
{
var otherFamily = new Family { Name = "Other", InviteCode = "other-code" };
db.Families.Add(otherFamily);
await db.SaveChangesAsync();
db.FamilyProducts.Add(new FamilyProduct { FamilyId = otherFamily.Id, Name = "Other Family Bread" });
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=bread");
await Assert.That(results!).IsEmpty();
}
[Test]
public async Task Search_does_not_leak_other_family_overrides()
{
await UseDbAsync(async db =>
{
var apples = new Product { Name = "Apples", Brand = "Generic" };
db.Products.Add(apples);
var otherFamily = new Family { Name = "Other", InviteCode = "other-code" };
db.Families.Add(otherFamily);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new FamilyProductOverride
{
FamilyId = otherFamily.Id,
ProductId = apples.Id,
Brand = "Their Honeycrisp",
});
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=apple");
var dto = results!.Single();
await Assert.That(dto.Brand).IsEqualTo("Generic");
await Assert.That(dto.IsOverridden).IsFalse();
}
[Test]
public async Task Create_persists_family_product_and_returns_201()
{
var response = await Client.PostAsJsonAsync("/api/products",
new ProductEndpoints.CreateProductRequest("Sourdough", "Local Bakery", null));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var dto = await response.Content.ReadFromJsonAsync<ProductEndpoints.ProductDto>();
await Assert.That(dto!.Kind).IsEqualTo(ProductEndpoints.ProductKind.Family);
await Assert.That(dto.Brand).IsEqualTo("Local Bakery");
var persisted = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
await Assert.That(persisted.Name).IsEqualTo("Sourdough");
}
[Test]
public async Task Create_returns_409_for_duplicate_name_within_family()
{
await Client.PostAsJsonAsync("/api/products",
new ProductEndpoints.CreateProductRequest("Sourdough", null, null));
var response = await Client.PostAsJsonAsync("/api/products",
new ProductEndpoints.CreateProductRequest("Sourdough", null, null));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
[Test]
public async Task Create_returns_400_when_name_missing()
{
var response = await Client.PostAsJsonAsync("/api/products",
new ProductEndpoints.CreateProductRequest(" ", null, null));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Update_family_product_changes_fields()
{
var familyId = await GetFamilyIdAsync();
var product = await UseDbAsync(async db =>
{
var p = new FamilyProduct { FamilyId = familyId, Name = "Old", Brand = "OldBrand" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}",
new ProductEndpoints.UpdateProductRequest("New", "NewBrand", "New notes"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == product.Id));
await Assert.That(refreshed.Name).IsEqualTo("New");
await Assert.That(refreshed.Brand).IsEqualTo("NewBrand");
await Assert.That(refreshed.Notes).IsEqualTo("New notes");
}
[Test]
public async Task Update_family_product_404_for_unknown_id()
{
var response = await Client.PutAsJsonAsync("/api/products/family/99999",
new ProductEndpoints.UpdateProductRequest("x", null, null));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Update_global_product_creates_override_for_this_family_only()
{
var familyId = await GetFamilyIdAsync();
var apples = await UseDbAsync(async db =>
{
var p = new Product { Name = "Apples", Brand = "Generic" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}",
new ProductEndpoints.UpdateProductRequest(null, "Honeycrisp", null));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var dto = await response.Content.ReadFromJsonAsync<ProductEndpoints.ProductDto>();
await Assert.That(dto!.Brand).IsEqualTo("Honeycrisp");
await Assert.That(dto.IsOverridden).IsTrue();
// Global row untouched.
var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == apples.Id));
await Assert.That(global.Brand).IsEqualTo("Generic");
// Override stored under the caller's family.
var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync());
await Assert.That(ovr.FamilyId).IsEqualTo(familyId);
await Assert.That(ovr.Brand).IsEqualTo("Honeycrisp");
}
[Test]
public async Task Update_global_product_upserts_existing_override()
{
var familyId = await GetFamilyIdAsync();
var apples = await UseDbAsync(async db =>
{
var p = new Product { Name = "Apples", Brand = "Generic" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
Brand = "Honeycrisp",
});
await db.SaveChangesAsync();
return p;
});
var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}",
new ProductEndpoints.UpdateProductRequest(null, "Pink Lady", "Family favorite"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync());
await Assert.That(ovr.Brand).IsEqualTo("Pink Lady");
await Assert.That(ovr.Notes).IsEqualTo("Family favorite");
}
[Test]
public async Task Update_global_product_404_for_unknown_id()
{
var response = await Client.PutAsJsonAsync("/api/products/global/99999",
new ProductEndpoints.UpdateProductRequest(null, "x", null));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Endpoints_require_authentication()
{
var response = await AnonymousClient.GetAsync("/api/products");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
}
@@ -37,6 +37,67 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(recipe.Ingredients.Count).IsEqualTo(2); await Assert.That(recipe.Ingredients.Count).IsEqualTo(2);
} }
[Test]
public async Task Create_links_ingredient_to_global_product()
{
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Pasta" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
var request = new RecipeEndpoints.CreateRecipeRequest(
Title: "Pasta",
Description: null,
Instructions: null,
Servings: null,
SourceUrl: null,
Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1, ProductId: product.Id)]);
var response = await Client.PostAsJsonAsync("/api/recipes", request);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var ingredient = await UseDbAsync(db => db.RecipeIngredients.SingleAsync());
await Assert.That(ingredient.ProductId).IsEqualTo(product.Id);
}
[Test]
public async Task Create_rejects_ingredient_with_both_product_ids()
{
var request = new RecipeEndpoints.CreateRecipeRequest(
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, ProductId: 1, FamilyProductId: 1)]);
var response = await Client.PostAsJsonAsync("/api/recipes", request);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Create_rejects_ingredient_with_other_familys_family_product()
{
var foreignProductId = await UseDbAsync(async db =>
{
var otherFamily = new Family { Name = "Other", InviteCode = "other-code" };
db.Families.Add(otherFamily);
await db.SaveChangesAsync();
var p = new FamilyProduct { FamilyId = otherFamily.Id, Name = "Theirs" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p.Id;
});
var request = new RecipeEndpoints.CreateRecipeRequest(
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, FamilyProductId: foreignProductId)]);
var response = await Client.PostAsJsonAsync("/api/recipes", request);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test] [Test]
public async Task Get_by_id_returns_full_recipe_including_creator_and_ingredients() public async Task Get_by_id_returns_full_recipe_including_creator_and_ingredients()
{ {
@@ -140,6 +140,199 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
} }
[Test]
public async Task Add_item_links_global_product_when_id_supplied()
{
var list = await CreateListAsync();
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
await Assert.That(item.ProductId).IsEqualTo(product.Id);
await Assert.That(item.FamilyProductId).IsNull();
}
[Test]
public async Task Add_item_links_family_product_when_id_supplied()
{
var list = await CreateListAsync();
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var familyProduct = await UseDbAsync(async db =>
{
var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("House Bread", FamilyProductId: familyProduct.Id));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
await Assert.That(item.FamilyProductId).IsEqualTo(familyProduct.Id);
await Assert.That(item.ProductId).IsNull();
}
[Test]
public async Task Add_item_rejects_setting_both_product_ids()
{
var list = await CreateListAsync();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("eggs", ProductId: 1, FamilyProductId: 1));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Add_item_rejects_unknown_product_id()
{
var list = await CreateListAsync();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("eggs", ProductId: 99999));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Add_item_records_section_memory_and_auto_assigns_on_next_add()
{
var list = await CreateListAsync();
var section = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Memory" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// First add: explicit section + product → memory recorded.
var first = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", SectionId: section.Id, ProductId: product.Id));
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created);
// Second add: same product, no section → auto-assigned from memory.
var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("More Bananas", ProductId: product.Id));
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Created);
var items = await UseDbAsync(db => db.ShoppingListItems
.Where(i => i.ShoppingListId == list.Id).OrderBy(i => i.Id).ToListAsync());
await Assert.That(items.Count).IsEqualTo(2);
await Assert.That(items[1].SectionId).IsEqualTo(section.Id);
}
[Test]
public async Task Patch_item_section_updates_memory_for_next_add()
{
var list = await CreateListAsync();
var (originalSection, correctedSection) = await UseDbAsync(async db =>
{
var s1 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Pantry", SortOrder = 1 };
var s2 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 };
db.StoreSections.AddRange(s1, s2);
await db.SaveChangesAsync();
return (s1, s2);
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bread-Memory" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// Add with section A; that records (Product → A) memory.
var addResponse = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bread", SectionId: originalSection.Id, ProductId: product.Id));
var addBody = await addResponse.Content.ReadFromJsonAsync<JsonElement>();
var firstItemId = addBody.GetProperty("id").GetInt32();
// User corrects to section B → memory should update.
var patchResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{firstItemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(correctedSection.Id));
await Assert.That(patchResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
// Next add (no section) should now pull section B.
var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("More Bread", ProductId: product.Id));
var secondBody = await second.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(secondBody.GetProperty("sectionId").GetInt32()).IsEqualTo(correctedSection.Id);
}
[Test]
public async Task Section_memory_is_scoped_per_store()
{
var listA = await CreateListAsync();
var otherStore = await Data.CreateStoreAsync(b => b.Named("Other Store"));
var listB = await Data.CreateListAsync(b => b.ForStore(otherStore).CreatedBy(User));
var (sectionA, sectionB) = await UseDbAsync(async db =>
{
var a = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce A", SortOrder = 1 };
var b = new StoreSection { FamilyId = otherStore.FamilyId, StoreId = otherStore.Id, Name = "Produce B", SortOrder = 1 };
db.StoreSections.AddRange(a, b);
await db.SaveChangesAsync();
return (a, b);
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Apples-Memory" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// Memorize at store A only.
await Client.PostAsJsonAsync($"/api/lists/{listA.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Apples", SectionId: sectionA.Id, ProductId: product.Id));
// Add at store B with no section → no memory yet for store B.
var response = await Client.PostAsJsonAsync($"/api/lists/{listB.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Apples", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
}
[Test]
public async Task Add_item_rejects_other_familys_family_product()
{
var list = await CreateListAsync();
var foreignProductId = await UseDbAsync(async db =>
{
var otherFamily = new Family { Name = "Other", InviteCode = "other-code" };
db.Families.Add(otherFamily);
await db.SaveChangesAsync();
var p = new FamilyProduct { FamilyId = otherFamily.Id, Name = "Theirs" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p.Id;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("eggs", FamilyProductId: foreignProductId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test] [Test]
public async Task Check_toggles_item_state_and_attributes_to_user() public async Task Check_toggles_item_state_and_attributes_to_user()
{ {
@@ -0,0 +1,66 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
namespace YesChef.Api.Data.Seed;
/// <summary>
/// Loads the curated product catalog from the embedded JSON resource and
/// inserts any rows that aren't already present (matched by Name). Idempotent
/// — safe to call on every startup; only the first call does real work.
/// </summary>
public static class CatalogSeeder
{
private const string ResourceName = "YesChef.Api.Data.Seed.products.json";
private record SeedEntry(string Name, string? Brand, string? Notes);
private record SeedFile(List<SeedEntry> Products);
public static async Task SeedAsync(YesChefDb db, ILogger logger, CancellationToken ct = default)
{
var entries = LoadEntries();
if (entries.Count == 0) return;
// Insert names + any provided brand/notes via two passes:
// 1) bulk insert names (one statement, ON CONFLICT DO NOTHING).
// 2) per-entry update to fill brand/notes when present.
// The two-step shape sidesteps EF's ExecuteSqlRawAsync rejecting
// DBNull in mixed-nullability parameter lists, while still giving
// future seed entries somewhere to put brand/notes data.
var nameParams = entries.Select(e => (object)e.Name).ToList();
var nameValues = string.Join(", ",
Enumerable.Range(0, entries.Count).Select(i => $"({{{i}}}, NOW())"));
var nameSql = $"""
INSERT INTO "Products" ("Name", "CreatedAt")
VALUES {nameValues}
ON CONFLICT ("Name") DO NOTHING;
""";
var inserted = await db.Database.ExecuteSqlRawAsync(nameSql, nameParams, ct);
// Apply brand/notes only for entries that have them; harmless if the
// global row was overridden by a different name in a prior seed.
foreach (var entry in entries.Where(e => e.Brand is not null || e.Notes is not null))
{
await db.Database.ExecuteSqlInterpolatedAsync($"""
UPDATE "Products"
SET "Brand" = COALESCE("Brand", {entry.Brand}),
"Notes" = COALESCE("Notes", {entry.Notes})
WHERE "Name" = {entry.Name};
""", ct);
}
if (inserted > 0)
logger.LogInformation("Catalog seed inserted {Count} new products.", inserted);
}
private static List<SeedEntry> LoadEntries()
{
using var stream = typeof(CatalogSeeder).Assembly.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException($"Embedded seed resource '{ResourceName}' not found.");
var seed = JsonSerializer.Deserialize<SeedFile>(stream, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return seed?.Products ?? [];
}
}
@@ -0,0 +1,142 @@
{
"$schema-comment": "Seed catalog of common grocery products. Loaded once into the global Products table by CatalogSeeder. Add new items here; CatalogSeeder is idempotent on Name. Keep names short, generic, and singular where possible (let the user add quantity).",
"products": [
{ "name": "Apples" },
{ "name": "Avocado" },
{ "name": "Bananas" },
{ "name": "Blueberries" },
{ "name": "Strawberries" },
{ "name": "Raspberries" },
{ "name": "Grapes" },
{ "name": "Lemons" },
{ "name": "Limes" },
{ "name": "Oranges" },
{ "name": "Pears" },
{ "name": "Pineapple" },
{ "name": "Watermelon" },
{ "name": "Bell Peppers" },
{ "name": "Broccoli" },
{ "name": "Carrots" },
{ "name": "Cauliflower" },
{ "name": "Celery" },
{ "name": "Cucumber" },
{ "name": "Garlic" },
{ "name": "Green Beans" },
{ "name": "Lettuce" },
{ "name": "Mushrooms" },
{ "name": "Onions" },
{ "name": "Potatoes" },
{ "name": "Spinach" },
{ "name": "Sweet Potatoes" },
{ "name": "Tomatoes" },
{ "name": "Zucchini" },
{ "name": "Kale" },
{ "name": "Cabbage" },
{ "name": "Basil" },
{ "name": "Cilantro" },
{ "name": "Parsley" },
{ "name": "Mint" },
{ "name": "Rosemary" },
{ "name": "Thyme" },
{ "name": "Milk" },
{ "name": "Whole Milk" },
{ "name": "Skim Milk" },
{ "name": "Heavy Cream" },
{ "name": "Half and Half" },
{ "name": "Butter" },
{ "name": "Eggs" },
{ "name": "Yogurt" },
{ "name": "Greek Yogurt" },
{ "name": "Sour Cream" },
{ "name": "Cream Cheese" },
{ "name": "Cheddar Cheese" },
{ "name": "Mozzarella" },
{ "name": "Parmesan" },
{ "name": "Feta" },
{ "name": "Chicken Breast" },
{ "name": "Chicken Thighs" },
{ "name": "Whole Chicken" },
{ "name": "Ground Beef" },
{ "name": "Ground Turkey" },
{ "name": "Bacon" },
{ "name": "Pork Chops" },
{ "name": "Sausage" },
{ "name": "Salmon" },
{ "name": "Shrimp" },
{ "name": "Tuna" },
{ "name": "Bread" },
{ "name": "Sourdough Bread" },
{ "name": "Whole Wheat Bread" },
{ "name": "Bagels" },
{ "name": "Tortillas" },
{ "name": "Hamburger Buns" },
{ "name": "Hot Dog Buns" },
{ "name": "All-Purpose Flour" },
{ "name": "Sugar" },
{ "name": "Brown Sugar" },
{ "name": "Salt" },
{ "name": "Black Pepper" },
{ "name": "Olive Oil" },
{ "name": "Vegetable Oil" },
{ "name": "Vinegar" },
{ "name": "Soy Sauce" },
{ "name": "Honey" },
{ "name": "Maple Syrup" },
{ "name": "Peanut Butter" },
{ "name": "Jelly" },
{ "name": "Pasta" },
{ "name": "Spaghetti" },
{ "name": "Rice" },
{ "name": "Brown Rice" },
{ "name": "Oats" },
{ "name": "Cereal" },
{ "name": "Granola" },
{ "name": "Black Beans" },
{ "name": "Chickpeas" },
{ "name": "Diced Tomatoes" },
{ "name": "Tomato Sauce" },
{ "name": "Chicken Broth" },
{ "name": "Beef Broth" },
{ "name": "Frozen Peas" },
{ "name": "Frozen Corn" },
{ "name": "Frozen Berries" },
{ "name": "Ice Cream" },
{ "name": "Frozen Pizza" },
{ "name": "Coffee" },
{ "name": "Tea" },
{ "name": "Orange Juice" },
{ "name": "Apple Juice" },
{ "name": "Sparkling Water" },
{ "name": "Chips" },
{ "name": "Tortilla Chips" },
{ "name": "Salsa" },
{ "name": "Crackers" },
{ "name": "Popcorn" },
{ "name": "Chocolate" },
{ "name": "Ketchup" },
{ "name": "Mustard" },
{ "name": "Mayonnaise" },
{ "name": "Hot Sauce" },
{ "name": "Ranch Dressing" },
{ "name": "Paper Towels" },
{ "name": "Toilet Paper" },
{ "name": "Dish Soap" },
{ "name": "Laundry Detergent" },
{ "name": "Trash Bags" },
{ "name": "Aluminum Foil" },
{ "name": "Plastic Wrap" }
]
}
+54
View File
@@ -16,6 +16,10 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>(); public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
public DbSet<Invite> Invites => Set<Invite>(); public DbSet<Invite> Invites => Set<Invite>();
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>(); public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
public DbSet<Product> Products => Set<Product>();
public DbSet<FamilyProduct> FamilyProducts => Set<FamilyProduct>();
public DbSet<FamilyProductOverride> FamilyProductOverrides => Set<FamilyProductOverride>();
public DbSet<ProductStoreSection> ProductStoreSections => Set<ProductStoreSection>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -94,6 +98,8 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(i => i.FamilyId); e.HasIndex(i => i.FamilyId);
}); });
@@ -111,6 +117,54 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(i => i.Name).HasMaxLength(200); e.Property(i => i.Name).HasMaxLength(200);
e.Property(i => i.Quantity).HasMaxLength(50); e.Property(i => i.Quantity).HasMaxLength(50);
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade); e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Product>(e =>
{
e.Property(p => p.Name).HasMaxLength(200);
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.HasIndex(p => p.Name).IsUnique();
});
modelBuilder.Entity<FamilyProduct>(e =>
{
e.Property(p => p.Name).HasMaxLength(200);
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
});
modelBuilder.Entity<FamilyProductOverride>(e =>
{
e.HasKey(o => new { o.FamilyId, o.ProductId });
e.Property(o => o.Name).HasMaxLength(200);
e.Property(o => o.Brand).HasMaxLength(200);
e.Property(o => o.Notes).HasMaxLength(1000);
e.HasOne(o => o.Family).WithMany().HasForeignKey(o => o.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ProductStoreSection>(e =>
{
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.Store).WithMany().HasForeignKey(p => p.StoreId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.StoreSection).WithMany().HasForeignKey(p => p.StoreSectionId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.Product).WithMany().HasForeignKey(p => p.ProductId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.FamilyProduct).WithMany().HasForeignKey(p => p.FamilyProductId).OnDelete(DeleteBehavior.Cascade);
// Filtered unique indexes — at most one row per (Family, Store, Product)
// and one per (Family, Store, FamilyProduct). Postgres treats NULLs
// as distinct, so the partial WHERE keeps the index from caring
// about the inactive variant on each row.
e.HasIndex(p => new { p.FamilyId, p.StoreId, p.ProductId })
.IsUnique()
.HasFilter(@"""ProductId"" IS NOT NULL");
e.HasIndex(p => new { p.FamilyId, p.StoreId, p.FamilyProductId })
.IsUnique()
.HasFilter(@"""FamilyProductId"" IS NOT NULL");
}); });
} }
} }
@@ -0,0 +1,16 @@
namespace YesChef.Api.Entities;
/// <summary>
/// Family-owned product not present in the global catalog. Only visible to
/// the owning family.
/// </summary>
public class FamilyProduct
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public required string Name { get; set; }
public string? Brand { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,19 @@
namespace YesChef.Api.Entities;
/// <summary>
/// A family's override of a global <see cref="Product"/>. Each non-null field
/// replaces the global value when the family views the catalog. Composite key
/// (<see cref="FamilyId"/>, <see cref="ProductId"/>) — at most one override
/// per family per product.
/// </summary>
public class FamilyProductOverride
{
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public int ProductId { get; set; }
public Product Product { get; set; } = null!;
public string? Name { get; set; }
public string? Brand { get; set; }
public string? Notes { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,15 @@
namespace YesChef.Api.Entities;
/// <summary>
/// Global, read-only product catalog entry. Visible to every family.
/// Per-family edits go in <see cref="FamilyProductOverride"/>; family-only
/// products live in <see cref="FamilyProduct"/>.
/// </summary>
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public string? Brand { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,27 @@
namespace YesChef.Api.Entities;
/// <summary>
/// Remembers which section a particular product was last placed in at a
/// particular store, scoped per-family. Used to auto-assign sections when
/// the same product is added to a list at the same store later.
///
/// Exactly one of <see cref="ProductId"/> / <see cref="FamilyProductId"/> is
/// set on a row, mirroring the at-most-one product link on items and
/// ingredients. A surrogate Id keeps the row addressable; uniqueness is
/// enforced by filtered indexes (see DbContext config).
/// </summary>
public class ProductStoreSection
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public int StoreId { get; set; }
public Store Store { get; set; } = null!;
public int? ProductId { get; set; }
public Product? Product { get; set; }
public int? FamilyProductId { get; set; }
public FamilyProduct? FamilyProduct { get; set; }
public int StoreSectionId { get; set; }
public StoreSection StoreSection { get; set; } = null!;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -10,4 +10,9 @@ public class RecipeIngredient
public required string Name { get; set; } public required string Name { get; set; }
public string? Quantity { get; set; } public string? Quantity { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
// At most one of ProductId / FamilyProductId is set; both null = free-form.
public int? ProductId { get; set; }
public Product? Product { get; set; }
public int? FamilyProductId { get; set; }
public FamilyProduct? FamilyProduct { get; set; }
} }
@@ -16,6 +16,12 @@ public class ShoppingListItem
public Recipe? Recipe { get; set; } public Recipe? Recipe { get; set; }
public int? SectionId { get; set; } public int? SectionId { get; set; }
public StoreSection? Section { get; set; } public StoreSection? Section { get; set; }
// At most one of ProductId / FamilyProductId is set on a given row.
// Both null = free-form text entry (no catalog linkage).
public int? ProductId { get; set; }
public Product? Product { get; set; }
public int? FamilyProductId { get; set; }
public FamilyProduct? FamilyProduct { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RemovedAt { get; set; } public DateTime? RemovedAt { get; set; }
public int? RemovedByUserId { get; set; } public int? RemovedByUserId { get; set; }
@@ -0,0 +1,194 @@
using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
using YesChef.Api.Entities;
namespace YesChef.Api.Features.Products;
public static class ProductEndpoints
{
/// <summary>Discriminator on product DTOs so the client can route subsequent
/// PUT/POST calls to the right code path. The catalog UI itself doesn't
/// need to surface this — both kinds render the same way.</summary>
public enum ProductKind { Global, Family }
public record ProductDto(
int Id,
ProductKind Kind,
string Name,
string? Brand,
string? Notes,
bool IsOverridden);
public record CreateProductRequest(string Name, string? Brand, string? Notes);
public record UpdateProductRequest(string? Name, string? Brand, string? Notes);
private const int SearchResultLimit = 50;
public static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group)
{
// Search the effective catalog: global products with this family's
// overrides applied, unioned with the family's private products.
// Substring match (case-insensitive) on the *effective* name.
group.MapGet("/", async (string? q, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var query = (q ?? string.Empty).Trim();
var pattern = $"%{query}%";
// Two separate queries merged in memory. EF can't translate a
// union over the global+override join (the projection is treated
// as client-side), and the result set is bounded by SearchResultLimit
// so doing it client-side is fine.
var globalRows = await (
from p in db.Products.AsNoTracking()
join o in db.FamilyProductOverrides.Where(o => o.FamilyId == familyId)
on p.Id equals o.ProductId into overrides
from o in overrides.DefaultIfEmpty()
where query == string.Empty
|| EF.Functions.ILike(p.Name, pattern)
|| (o != null && o.Name != null && EF.Functions.ILike(o.Name, pattern))
select new
{
p.Id,
GlobalName = p.Name,
GlobalBrand = p.Brand,
GlobalNotes = p.Notes,
OverrideName = o != null ? o.Name : null,
OverrideBrand = o != null ? o.Brand : null,
OverrideNotes = o != null ? o.Notes : null,
HasOverride = o != null,
})
.Take(SearchResultLimit)
.ToListAsync();
var familyRows = await db.FamilyProducts.AsNoTracking()
.Where(p => p.FamilyId == familyId)
.Where(p => query == string.Empty || EF.Functions.ILike(p.Name, pattern))
.Take(SearchResultLimit)
.ToListAsync();
var globalDtos = globalRows.Select(r =>
{
var name = r.OverrideName ?? r.GlobalName;
// After applying overrides, drop rows that no longer match
// the query — guards against the case where the global name
// matched but the override renames it away from the query.
if (query.Length > 0 && !name.Contains(query, StringComparison.OrdinalIgnoreCase))
return null;
return new ProductDto(
r.Id,
ProductKind.Global,
name,
r.OverrideBrand ?? r.GlobalBrand,
r.OverrideNotes ?? r.GlobalNotes,
r.HasOverride);
}).Where(d => d is not null).Cast<ProductDto>();
var familyDtos = familyRows.Select(p =>
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false));
var results = globalDtos.Concat(familyDtos)
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
.Take(SearchResultLimit)
.ToList();
return Results.Ok(results);
});
// Always creates a FamilyProduct. Editing a global product is done
// via PUT /global/{id}, which writes a FamilyProductOverride.
group.MapPost("/", async (CreateProductRequest request, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var name = request.Name?.Trim();
if (string.IsNullOrEmpty(name))
return Results.BadRequest(new { error = "Name is required." });
if (await db.FamilyProducts.AnyAsync(p => p.FamilyId == familyId && p.Name == name))
return Results.Conflict(new { error = $"A product named \"{name}\" already exists." });
var product = new FamilyProduct
{
FamilyId = familyId,
Name = name,
Brand = request.Brand,
Notes = request.Notes,
};
db.FamilyProducts.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/api/products/family/{product.Id}", ToDto(product));
});
// Update a family-owned product directly.
group.MapPut("/family/{id:int}", async (int id, UpdateProductRequest request, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var product = await db.FamilyProducts.FirstOrDefaultAsync(p => p.Id == id && p.FamilyId == familyId);
if (product is null) return Results.NotFound();
if (request.Name is not null)
{
var name = request.Name.Trim();
if (string.IsNullOrEmpty(name))
return Results.BadRequest(new { error = "Name cannot be empty." });
if (name != product.Name &&
await db.FamilyProducts.AnyAsync(p => p.FamilyId == familyId && p.Name == name && p.Id != id))
return Results.Conflict(new { error = $"A product named \"{name}\" already exists." });
product.Name = name;
}
product.Brand = request.Brand;
product.Notes = request.Notes;
await db.SaveChangesAsync();
return Results.Ok(ToDto(product));
});
// Update a global product for this family by upserting an override.
// Each non-null field becomes the override; null fields fall back to
// the global value. To "reset" a field to the global, send null —
// this matches how the merged search projects fields.
group.MapPut("/global/{id:int}", async (int id, UpdateProductRequest request, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var product = await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
if (product is null) return Results.NotFound();
var ovr = await db.FamilyProductOverrides
.FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == id);
if (ovr is null)
{
ovr = new FamilyProductOverride { FamilyId = familyId, ProductId = id };
db.FamilyProductOverrides.Add(ovr);
}
// A name override that collides with the global name is meaningless;
// disallow rather than silently storing a redundant value.
var trimmedName = request.Name?.Trim();
if (trimmedName is not null && trimmedName.Length == 0)
return Results.BadRequest(new { error = "Name cannot be empty." });
ovr.Name = trimmedName;
ovr.Brand = request.Brand;
ovr.Notes = request.Notes;
ovr.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(new ProductDto(
product.Id,
ProductKind.Global,
ovr.Name ?? product.Name,
ovr.Brand ?? product.Brand,
ovr.Notes ?? product.Notes,
IsOverridden: true));
});
return group;
}
private static ProductDto ToDto(FamilyProduct p) =>
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false);
}
@@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth; using YesChef.Api.Auth;
using YesChef.Api.Data; using YesChef.Api.Data;
using YesChef.Api.Entities; using YesChef.Api.Entities;
using YesChef.Api.Features.ShoppingLists;
namespace YesChef.Api.Features.Recipes; namespace YesChef.Api.Features.Recipes;
public static class RecipeEndpoints public static class RecipeEndpoints
{ {
public record IngredientRequest(string Name, string? Quantity, int SortOrder); public record IngredientRequest(string Name, string? Quantity, int SortOrder, int? ProductId = null, int? FamilyProductId = null);
public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients); public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients); public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
@@ -36,6 +37,13 @@ public static class RecipeEndpoints
group.MapPost("/", async (CreateRecipeRequest request, YesChefDb db, HttpContext http) => group.MapPost("/", async (CreateRecipeRequest request, YesChefDb db, HttpContext http) =>
{ {
var familyId = http.User.GetFamilyId(); var familyId = http.User.GetFamilyId();
foreach (var ing in request.Ingredients)
{
if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error)
return error;
}
var recipe = new Recipe var recipe = new Recipe
{ {
FamilyId = familyId, FamilyId = familyId,
@@ -50,7 +58,9 @@ public static class RecipeEndpoints
FamilyId = familyId, FamilyId = familyId,
Name = i.Name, Name = i.Name,
Quantity = i.Quantity, Quantity = i.Quantity,
SortOrder = i.SortOrder SortOrder = i.SortOrder,
ProductId = i.ProductId,
FamilyProductId = i.FamilyProductId
}).ToList() }).ToList()
}; };
@@ -80,7 +90,7 @@ public static class RecipeEndpoints
recipe.SourceUrl, recipe.SourceUrl,
CreatedBy = recipe.CreatedByUser.Name, CreatedBy = recipe.CreatedByUser.Name,
recipe.UpdatedAt, recipe.UpdatedAt,
Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder }) Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder, i.ProductId, i.FamilyProductId })
}); });
}); });
@@ -93,6 +103,12 @@ public static class RecipeEndpoints
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (recipe is null) return Results.NotFound(); if (recipe is null) return Results.NotFound();
foreach (var ing in request.Ingredients)
{
if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error)
return error;
}
recipe.Title = request.Title; recipe.Title = request.Title;
recipe.Description = request.Description; recipe.Description = request.Description;
recipe.Instructions = request.Instructions; recipe.Instructions = request.Instructions;
@@ -106,7 +122,9 @@ public static class RecipeEndpoints
FamilyId = familyId, FamilyId = familyId,
Name = i.Name, Name = i.Name,
Quantity = i.Quantity, Quantity = i.Quantity,
SortOrder = i.SortOrder SortOrder = i.SortOrder,
ProductId = i.ProductId,
FamilyProductId = i.FamilyProductId
}).ToList(); }).ToList();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -10,7 +10,7 @@ public static class ShoppingListEndpoints
{ {
public record CreateListRequest(string Name, int StoreId); public record CreateListRequest(string Name, int StoreId);
public record UpdateListRequest(string Name, int StoreId); public record UpdateListRequest(string Name, int StoreId);
public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null); public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null, int? ProductId = null, int? FamilyProductId = null);
public record SetItemSectionRequest(int? SectionId); public record SetItemSectionRequest(int? SectionId);
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}"; private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
@@ -127,7 +127,9 @@ public static class ShoppingListEndpoints
CheckedByUserName = i.CheckedByUser?.Name, CheckedByUserName = i.CheckedByUser?.Name,
i.SortOrder, i.SortOrder,
i.SectionId, i.SectionId,
RecipeTitle = i.Recipe?.Title RecipeTitle = i.Recipe?.Title,
i.ProductId,
i.FamilyProductId
}) })
}); });
}); });
@@ -174,25 +176,43 @@ public static class ShoppingListEndpoints
if (list is null) return Results.NotFound(); if (list is null) return Results.NotFound();
// Reject section IDs that don't belong to the list's store/family. // Reject section IDs that don't belong to the list's store/family.
if (request.SectionId is int sectionId && if (request.SectionId is int explicitSectionId &&
!await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == list.StoreId && s.FamilyId == familyId)) !await db.StoreSections.AnyAsync(s => s.Id == explicitSectionId && s.StoreId == list.StoreId && s.FamilyId == familyId))
return Results.BadRequest(new { error = "Unknown section." }); return Results.BadRequest(new { error = "Unknown section." });
if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError)
return productError;
// Auto-assign a section from memory when caller didn't pick one
// but supplied a product link — "we put bananas in Produce last
// time we shopped here, do it again."
var resolvedSectionId = request.SectionId
?? await LookupRememberedSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId);
var item = new ShoppingListItem var item = new ShoppingListItem
{ {
FamilyId = familyId, FamilyId = familyId,
ShoppingListId = listId, ShoppingListId = listId,
Name = request.Name, Name = request.Name,
SortOrder = request.SortOrder, SortOrder = request.SortOrder,
SectionId = request.SectionId, SectionId = resolvedSectionId,
ProductId = request.ProductId,
FamilyProductId = request.FamilyProductId,
}; };
db.ShoppingListItems.Add(item); db.ShoppingListItems.Add(item);
list.UpdatedAt = DateTime.UtcNow; list.UpdatedAt = DateTime.UtcNow;
// If the caller explicitly chose a section, record/update memory
// for next time. Auto-assigned sections don't need a write back —
// the existing memory row already says exactly this.
if (request.SectionId.HasValue)
await RememberSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId, request.SectionId);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId }); await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId });
await BroadcastListSummary(hub, db, listId, familyId); await BroadcastListSummary(hub, db, listId, familyId);
return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId }); return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId });
}); });
group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) => group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
@@ -208,6 +228,9 @@ public static class ShoppingListEndpoints
return Results.BadRequest(new { error = "Unknown section." }); return Results.BadRequest(new { error = "Unknown section." });
item.SectionId = request.SectionId; item.SectionId = request.SectionId;
// Manual section change is the user correcting the memory: persist
// the new mapping so future adds at this store learn from it.
await RememberSectionAsync(db, familyId, item.ShoppingList.StoreId, item.ProductId, item.FamilyProductId, request.SectionId);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId }); await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId });
@@ -272,7 +295,9 @@ public static class ShoppingListEndpoints
CheckedByUserName = item.CheckedByUser?.Name, CheckedByUserName = item.CheckedByUser?.Name,
item.SortOrder, item.SortOrder,
item.SectionId, item.SectionId,
RecipeTitle = item.Recipe?.Title RecipeTitle = item.Recipe?.Title,
item.ProductId,
item.FamilyProductId
}); });
await BroadcastListSummary(hub, db, listId, familyId); await BroadcastListSummary(hub, db, listId, familyId);
return Results.NoContent(); return Results.NoContent();
@@ -292,14 +317,27 @@ public static class ShoppingListEndpoints
var maxSort = await db.ShoppingListItems.Where(i => i.ShoppingListId == listId).MaxAsync(i => (int?)i.SortOrder) ?? 0; var maxSort = await db.ShoppingListItems.Where(i => i.ShoppingListId == listId).MaxAsync(i => (int?)i.SortOrder) ?? 0;
var newItems = recipe.Ingredients.Select((ing, idx) => new ShoppingListItem var newItems = new List<ShoppingListItem>(recipe.Ingredients.Count);
var idx = 0;
foreach (var ing in recipe.Ingredients)
{
// Carry the ingredient's product link onto the list item, and
// use the per-store memory to assign a section if we have one.
var rememberedSectionId = await LookupRememberedSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId);
newItems.Add(new ShoppingListItem
{ {
FamilyId = familyId, FamilyId = familyId,
ShoppingListId = listId, ShoppingListId = listId,
Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}", Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}",
SortOrder = maxSort + idx + 1, SortOrder = maxSort + idx + 1,
RecipeId = recipeId RecipeId = recipeId,
}).ToList(); ProductId = ing.ProductId,
FamilyProductId = ing.FamilyProductId,
SectionId = rememberedSectionId,
});
idx++;
}
db.ShoppingListItems.AddRange(newItems); db.ShoppingListItems.AddRange(newItems);
list.UpdatedAt = DateTime.UtcNow; list.UpdatedAt = DateTime.UtcNow;
@@ -307,7 +345,7 @@ public static class ShoppingListEndpoints
foreach (var item in newItems) foreach (var item in newItems)
{ {
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, RecipeTitle = recipe.Title }); await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId, RecipeTitle = recipe.Title });
} }
await BroadcastListSummary(hub, db, listId, familyId); await BroadcastListSummary(hub, db, listId, familyId);
@@ -316,4 +354,71 @@ public static class ShoppingListEndpoints
return group; return group;
} }
/// <summary>
/// Validates a (ProductId, FamilyProductId) pair on an item or ingredient
/// payload. Enforces at-most-one and tenant scoping. Returns null on success
/// or a 400 result describing the problem.
/// </summary>
internal static async Task<IResult?> ValidateProductLink(YesChefDb db, int familyId, int? productId, int? familyProductId)
{
if (productId.HasValue && familyProductId.HasValue)
return Results.BadRequest(new { error = "An item can link to a global product or a family product, not both." });
if (productId.HasValue && !await db.Products.AnyAsync(p => p.Id == productId.Value))
return Results.BadRequest(new { error = "Unknown product." });
if (familyProductId.HasValue && !await db.FamilyProducts.AnyAsync(p => p.Id == familyProductId.Value && p.FamilyId == familyId))
return Results.BadRequest(new { error = "Unknown product." });
return null;
}
/// <summary>
/// Look up the section a product was last placed in at this store, for
/// this family. Returns null if no memory exists or no product link was
/// supplied.
/// </summary>
internal static async Task<int?> LookupRememberedSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId)
{
if (!productId.HasValue && !familyProductId.HasValue) return null;
return await db.ProductStoreSections
.Where(p => p.FamilyId == familyId && p.StoreId == storeId
&& p.ProductId == productId && p.FamilyProductId == familyProductId)
.Select(p => (int?)p.StoreSectionId)
.FirstOrDefaultAsync();
}
/// <summary>
/// Upsert the (Family, Store, Product) → Section memory. No-op if no
/// product link or no section is supplied. Caller must ensure section
/// belongs to the same store and family.
/// </summary>
internal static async Task RememberSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId, int? sectionId)
{
if (!sectionId.HasValue) return;
if (!productId.HasValue && !familyProductId.HasValue) return;
var existing = await db.ProductStoreSections
.FirstOrDefaultAsync(p => p.FamilyId == familyId && p.StoreId == storeId
&& p.ProductId == productId && p.FamilyProductId == familyProductId);
if (existing is null)
{
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = storeId,
ProductId = productId,
FamilyProductId = familyProductId,
StoreSectionId = sectionId.Value,
});
}
else if (existing.StoreSectionId != sectionId.Value)
{
existing.StoreSectionId = sectionId.Value;
existing.UpdatedAt = DateTime.UtcNow;
}
}
} }
@@ -0,0 +1,784 @@
// <auto-generated />
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("20260509044440_AddProductCatalog")]
partial class AddProductCatalog
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<int>("FamilyId")
.HasColumnType("integer");
b.Property<int>("ProductId")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("FamilyId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("FamilyProductOverrides");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ConsumedByUserId")
.HasColumnType("integer");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("IssuedByUserId")
.HasColumnType("integer");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Products");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<DateTime?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RemovedByUserId")
.HasColumnType("integer");
b.Property<int?>("SectionId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CheckedByUserId");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.HasIndex("RemovedByUserId");
b.HasIndex("SectionId");
b.HasIndex("ShoppingListId");
b.ToTable("ShoppingListItems");
});
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("StoreId", "Name")
.IsUnique();
b.ToTable("StoreSections");
});
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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.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.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.Recipe", "Recipe")
.WithMany("Ingredients")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
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.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("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
}
}
}
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddProductCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FamilyProducts",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FamilyId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Brand = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FamilyProducts", x => x.Id);
table.ForeignKey(
name: "FK_FamilyProducts_Families_FamilyId",
column: x => x.FamilyId,
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Brand = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
});
migrationBuilder.CreateTable(
name: "FamilyProductOverrides",
columns: table => new
{
FamilyId = table.Column<int>(type: "integer", nullable: false),
ProductId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Brand = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FamilyProductOverrides", x => new { x.FamilyId, x.ProductId });
table.ForeignKey(
name: "FK_FamilyProductOverrides_Families_FamilyId",
column: x => x.FamilyId,
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_FamilyProductOverrides_Products_ProductId",
column: x => x.ProductId,
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FamilyProductOverrides_ProductId",
table: "FamilyProductOverrides",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_FamilyProducts_FamilyId_Name",
table: "FamilyProducts",
columns: new[] { "FamilyId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Products_Name",
table: "Products",
column: "Name",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FamilyProductOverrides");
migrationBuilder.DropTable(
name: "FamilyProducts");
migrationBuilder.DropTable(
name: "Products");
}
}
}
@@ -0,0 +1,832 @@
// <auto-generated />
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("20260510012603_LinkItemsToProductCatalog")]
partial class LinkItemsToProductCatalog
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<int>("FamilyId")
.HasColumnType("integer");
b.Property<int>("ProductId")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("FamilyId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("FamilyProductOverrides");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ConsumedByUserId")
.HasColumnType("integer");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("IssuedByUserId")
.HasColumnType("integer");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Products");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<DateTime?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RemovedByUserId")
.HasColumnType("integer");
b.Property<int?>("SectionId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("StoreId", "Name")
.IsUnique();
b.ToTable("StoreSections");
});
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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.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.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
}
}
}
@@ -0,0 +1,142 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class LinkItemsToProductCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FamilyProductId",
table: "ShoppingListItems",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ProductId",
table: "ShoppingListItems",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "FamilyProductId",
table: "RecipeIngredients",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ProductId",
table: "RecipeIngredients",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ShoppingListItems_FamilyProductId",
table: "ShoppingListItems",
column: "FamilyProductId");
migrationBuilder.CreateIndex(
name: "IX_ShoppingListItems_ProductId",
table: "ShoppingListItems",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_RecipeIngredients_FamilyProductId",
table: "RecipeIngredients",
column: "FamilyProductId");
migrationBuilder.CreateIndex(
name: "IX_RecipeIngredients_ProductId",
table: "RecipeIngredients",
column: "ProductId");
migrationBuilder.AddForeignKey(
name: "FK_RecipeIngredients_FamilyProducts_FamilyProductId",
table: "RecipeIngredients",
column: "FamilyProductId",
principalTable: "FamilyProducts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_RecipeIngredients_Products_ProductId",
table: "RecipeIngredients",
column: "ProductId",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_ShoppingListItems_FamilyProducts_FamilyProductId",
table: "ShoppingListItems",
column: "FamilyProductId",
principalTable: "FamilyProducts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_ShoppingListItems_Products_ProductId",
table: "ShoppingListItems",
column: "ProductId",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RecipeIngredients_FamilyProducts_FamilyProductId",
table: "RecipeIngredients");
migrationBuilder.DropForeignKey(
name: "FK_RecipeIngredients_Products_ProductId",
table: "RecipeIngredients");
migrationBuilder.DropForeignKey(
name: "FK_ShoppingListItems_FamilyProducts_FamilyProductId",
table: "ShoppingListItems");
migrationBuilder.DropForeignKey(
name: "FK_ShoppingListItems_Products_ProductId",
table: "ShoppingListItems");
migrationBuilder.DropIndex(
name: "IX_ShoppingListItems_FamilyProductId",
table: "ShoppingListItems");
migrationBuilder.DropIndex(
name: "IX_ShoppingListItems_ProductId",
table: "ShoppingListItems");
migrationBuilder.DropIndex(
name: "IX_RecipeIngredients_FamilyProductId",
table: "RecipeIngredients");
migrationBuilder.DropIndex(
name: "IX_RecipeIngredients_ProductId",
table: "RecipeIngredients");
migrationBuilder.DropColumn(
name: "FamilyProductId",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "ProductId",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "FamilyProductId",
table: "RecipeIngredients");
migrationBuilder.DropColumn(
name: "ProductId",
table: "RecipeIngredients");
}
}
}
@@ -0,0 +1,920 @@
// <auto-generated />
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("20260510021855_AddProductStoreSection")]
partial class AddProductStoreSection
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<int>("FamilyId")
.HasColumnType("integer");
b.Property<int>("ProductId")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("FamilyId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("FamilyProductOverrides");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ConsumedByUserId")
.HasColumnType("integer");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("IssuedByUserId")
.HasColumnType("integer");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<int>("StoreSectionId")
.HasColumnType("integer");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<DateTime?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RemovedByUserId")
.HasColumnType("integer");
b.Property<int?>("SectionId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("StoreId", "Name")
.IsUnique();
b.ToTable("StoreSections");
});
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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.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
}
}
}
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddProductStoreSection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ProductStoreSections",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FamilyId = table.Column<int>(type: "integer", nullable: false),
StoreId = table.Column<int>(type: "integer", nullable: false),
ProductId = table.Column<int>(type: "integer", nullable: true),
FamilyProductId = table.Column<int>(type: "integer", nullable: true),
StoreSectionId = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductStoreSections", x => x.Id);
table.ForeignKey(
name: "FK_ProductStoreSections_Families_FamilyId",
column: x => x.FamilyId,
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProductStoreSections_FamilyProducts_FamilyProductId",
column: x => x.FamilyProductId,
principalTable: "FamilyProducts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProductStoreSections_Products_ProductId",
column: x => x.ProductId,
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProductStoreSections_StoreSections_StoreSectionId",
column: x => x.StoreSectionId,
principalTable: "StoreSections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProductStoreSections_Stores_StoreId",
column: x => x.StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ProductStoreSections_FamilyId_StoreId_FamilyProductId",
table: "ProductStoreSections",
columns: new[] { "FamilyId", "StoreId", "FamilyProductId" },
unique: true,
filter: "\"FamilyProductId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ProductStoreSections_FamilyId_StoreId_ProductId",
table: "ProductStoreSections",
columns: new[] { "FamilyId", "StoreId", "ProductId" },
unique: true,
filter: "\"ProductId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ProductStoreSections_FamilyProductId",
table: "ProductStoreSections",
column: "FamilyProductId");
migrationBuilder.CreateIndex(
name: "IX_ProductStoreSections_ProductId",
table: "ProductStoreSections",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_ProductStoreSections_StoreId",
table: "ProductStoreSections",
column: "StoreId");
migrationBuilder.CreateIndex(
name: "IX_ProductStoreSections_StoreSectionId",
table: "ProductStoreSections",
column: "StoreSectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ProductStoreSections");
}
}
}
@@ -72,6 +72,71 @@ namespace YesChef.Api.Migrations
b.ToTable("FamilyMemberships"); b.ToTable("FamilyMemberships");
}); });
modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<int>("FamilyId")
.HasColumnType("integer");
b.Property<int>("ProductId")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("FamilyId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("FamilyProductOverrides");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b => modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -157,6 +222,85 @@ namespace YesChef.Api.Migrations
b.ToTable("PasswordResetTokens"); b.ToTable("PasswordResetTokens");
}); });
modelBuilder.Entity("YesChef.Api.Entities.Product", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<int>("StoreSectionId")
.HasColumnType("integer");
b.Property<DateTime>("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 => modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -214,11 +358,17 @@ namespace YesChef.Api.Migrations
b.Property<int>("FamilyId") b.Property<int>("FamilyId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<string>("Quantity") b.Property<string>("Quantity")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
@@ -233,6 +383,10 @@ namespace YesChef.Api.Migrations
b.HasIndex("FamilyId"); b.HasIndex("FamilyId");
b.HasIndex("FamilyProductId");
b.HasIndex("ProductId");
b.HasIndex("RecipeId"); b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients"); b.ToTable("RecipeIngredients");
@@ -297,6 +451,9 @@ namespace YesChef.Api.Migrations
b.Property<int>("FamilyId") b.Property<int>("FamilyId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<bool>("IsChecked") b.Property<bool>("IsChecked")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -305,6 +462,9 @@ namespace YesChef.Api.Migrations
.HasMaxLength(300) .HasMaxLength(300)
.HasColumnType("character varying(300)"); .HasColumnType("character varying(300)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<int?>("RecipeId") b.Property<int?>("RecipeId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -329,6 +489,10 @@ namespace YesChef.Api.Migrations
b.HasIndex("FamilyId"); b.HasIndex("FamilyId");
b.HasIndex("FamilyProductId");
b.HasIndex("ProductId");
b.HasIndex("RecipeId"); b.HasIndex("RecipeId");
b.HasIndex("RemovedByUserId"); b.HasIndex("RemovedByUserId");
@@ -460,6 +624,36 @@ namespace YesChef.Api.Migrations
b.Navigation("User"); 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.Invite", b => modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{ {
b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser") b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser")
@@ -497,6 +691,47 @@ namespace YesChef.Api.Migrations
b.Navigation("User"); 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 => modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{ {
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
@@ -524,6 +759,16 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .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") b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany("Ingredients") .WithMany("Ingredients")
.HasForeignKey("RecipeId") .HasForeignKey("RecipeId")
@@ -532,6 +777,10 @@ namespace YesChef.Api.Migrations
b.Navigation("Family"); b.Navigation("Family");
b.Navigation("FamilyProduct");
b.Navigation("Product");
b.Navigation("Recipe"); b.Navigation("Recipe");
}); });
@@ -575,6 +824,16 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .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") b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany() .WithMany()
.HasForeignKey("RecipeId") .HasForeignKey("RecipeId")
@@ -600,6 +859,10 @@ namespace YesChef.Api.Migrations
b.Navigation("Family"); b.Navigation("Family");
b.Navigation("FamilyProduct");
b.Navigation("Product");
b.Navigation("Recipe"); b.Navigation("Recipe");
b.Navigation("RemovedByUser"); b.Navigation("RemovedByUser");
+12
View File
@@ -8,9 +8,11 @@ using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using YesChef.Api.Auth; using YesChef.Api.Auth;
using YesChef.Api.Data; using YesChef.Api.Data;
using YesChef.Api.Data.Seed;
using YesChef.Api.Email; using YesChef.Api.Email;
using YesChef.Api.Entities; using YesChef.Api.Entities;
using YesChef.Api.Features.Families; using YesChef.Api.Features.Families;
using YesChef.Api.Features.Products;
using YesChef.Api.Features.Recipes; using YesChef.Api.Features.Recipes;
using YesChef.Api.Features.ShoppingLists; using YesChef.Api.Features.ShoppingLists;
using YesChef.Api.Features.Stores; using YesChef.Api.Features.Stores;
@@ -194,6 +196,15 @@ using (var scope = app.Services.CreateScope())
} }
if (familiesNeedingAdmin.Count > 0) if (familiesNeedingAdmin.Count > 0)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Seed the global product catalog. Idempotent (ON CONFLICT DO NOTHING),
// so re-runs are cheap. Skipped under Testing so integration tests get
// a clean slate to insert their own catalog entries without collisions.
if (!app.Environment.IsEnvironment("Testing"))
{
var seedLogger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
await CatalogSeeder.SeedAsync(db, seedLogger);
}
} }
app.UseRateLimiter(); app.UseRateLimiter();
@@ -213,6 +224,7 @@ var storesGroup = app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthori
storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints(); storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints();
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization(); app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
app.MapGroup("/api/products").MapProductEndpoints().RequireAuthorization();
app.MapHub<ShoppingListHub>("/hubs/shopping-list"); app.MapHub<ShoppingListHub>("/hubs/shopping-list");
app.Run(); app.Run();
@@ -6,6 +6,10 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Data/Seed/products.json" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" /> <PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
@@ -0,0 +1,174 @@
<script lang="ts" module>
export interface ProductSuggestion {
id: number;
kind: 'Global' | 'Family';
name: string;
brand: string | null;
notes: string | null;
isOverridden: boolean;
}
</script>
<script lang="ts">
import { api } from '$lib/api';
interface Props {
value: string;
placeholder?: string;
ariaLabel?: string;
inputClass?: string;
onsubmit?: () => void;
// Fires when the user picks a suggestion (with the chosen product),
// or when they edit the input after a selection (with null) — the
// caller uses this to track / clear an associated product link.
onProductChange?: (product: ProductSuggestion | null) => void;
}
let {
value = $bindable(''),
placeholder = '',
ariaLabel = 'Product',
inputClass = '',
onsubmit,
onProductChange,
}: Props = $props();
// Tracks the most recent picked product so we can detect when the user
// edits the input afterward (which invalidates the link).
let lastSelectedName: string | null = null;
let suggestions = $state<ProductSuggestion[]>([]);
let showDropdown = $state(false);
let activeIndex = $state(-1);
const listboxId = `product-typeahead-${crypto.randomUUID().slice(0, 8)}`;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Increment per request; only the most recent response wins to avoid
// out-of-order responses overwriting newer suggestions.
let requestSeq = 0;
async function fetchSuggestions(query: string) {
const seq = ++requestSeq;
try {
const results = await api<ProductSuggestion[]>(
`/api/products?q=${encodeURIComponent(query)}`,
);
if (seq !== requestSeq) return;
suggestions = results;
showDropdown = results.length > 0;
activeIndex = -1;
} catch {
if (seq !== requestSeq) return;
suggestions = [];
showDropdown = false;
}
}
function onInput() {
if (debounceTimer) clearTimeout(debounceTimer);
// Any keystroke that diverges from the last picked product invalidates
// the link — the caller needs to know so it stops sending the stale id.
if (lastSelectedName !== null && value !== lastSelectedName) {
lastSelectedName = null;
onProductChange?.(null);
}
const trimmed = value.trim();
if (trimmed.length === 0) {
suggestions = [];
showDropdown = false;
activeIndex = -1;
return;
}
debounceTimer = setTimeout(() => fetchSuggestions(trimmed), 200);
}
function selectSuggestion(s: ProductSuggestion) {
value = s.name;
lastSelectedName = s.name;
suggestions = [];
showDropdown = false;
activeIndex = -1;
onProductChange?.(s);
}
function onKeydown(e: KeyboardEvent) {
if (!showDropdown || suggestions.length === 0) {
if (e.key === 'Enter' && onsubmit) {
e.preventDefault();
onsubmit();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % suggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = activeIndex <= 0 ? suggestions.length - 1 : activeIndex - 1;
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0) selectSuggestion(suggestions[activeIndex]);
else if (onsubmit) onsubmit();
} else if (e.key === 'Escape') {
showDropdown = false;
activeIndex = -1;
}
}
function onBlur() {
// Delay so that a mousedown-then-mouseup click on a suggestion is
// registered before the dropdown closes.
setTimeout(() => {
showDropdown = false;
activeIndex = -1;
}, 120);
}
</script>
<div class="relative">
<input
type="text"
bind:value
oninput={onInput}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={() => {
if (suggestions.length > 0) showDropdown = true;
}}
{placeholder}
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={showDropdown}
aria-controls={listboxId}
role="combobox"
class={inputClass}
autocomplete="off"
/>
{#if showDropdown}
<ul
id={listboxId}
class="absolute left-0 right-0 z-10 mt-1 max-h-64 overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg"
role="listbox"
>
{#each suggestions as suggestion, i (suggestion.kind + suggestion.id)}
<li
role="option"
aria-selected={i === activeIndex}
class="cursor-pointer px-3 py-2 text-sm {i === activeIndex
? 'bg-primary/10'
: 'hover:bg-gray-50'}"
onmousedown={(e) => {
// mousedown rather than click so we beat the blur handler.
e.preventDefault();
selectSuggestion(suggestion);
}}
>
<span>{suggestion.name}</span>
{#if suggestion.brand}
<span class="ml-2 text-xs text-gray-400">{suggestion.brand}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
@@ -5,6 +5,7 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import { startConnection, stopConnection } from '$lib/signalr'; import { startConnection, stopConnection } from '$lib/signalr';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import type { HubConnection } from '@microsoft/signalr'; import type { HubConnection } from '@microsoft/signalr';
interface ListItem { interface ListItem {
@@ -39,6 +40,8 @@
let sections = $state<Section[]>([]); let sections = $state<Section[]>([]);
let newItemName = $state(''); let newItemName = $state('');
let newItemSectionId = $state<number | null>(null); let newItemSectionId = $state<number | null>(null);
let newItemProductId = $state<number | null>(null);
let newItemFamilyProductId = $state<number | null>(null);
let loading = $state(true); let loading = $state(true);
let connection: HubConnection | null = null; let connection: HubConnection | null = null;
@@ -149,10 +152,27 @@
body: JSON.stringify({ body: JSON.stringify({
name: newItemName, name: newItemName,
sortOrder: maxSort + 1, sortOrder: maxSort + 1,
sectionId: newItemSectionId sectionId: newItemSectionId,
productId: newItemProductId,
familyProductId: newItemFamilyProductId
}) })
}); });
newItemName = ''; newItemName = '';
newItemProductId = null;
newItemFamilyProductId = null;
}
function onItemProductChange(product: ProductSuggestion | null) {
if (product === null) {
newItemProductId = null;
newItemFamilyProductId = null;
} else if (product.kind === 'Global') {
newItemProductId = product.id;
newItemFamilyProductId = null;
} else {
newItemProductId = null;
newItemFamilyProductId = product.id;
}
} }
async function toggleItem(itemId: number) { async function toggleItem(itemId: number) {
@@ -210,12 +230,16 @@
</div> </div>
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2"> <form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
<input <div class="min-w-0 flex-1">
type="text" <ProductTypeahead
bind:value={newItemName} bind:value={newItemName}
placeholder="Add an item..." placeholder="Add an item..."
class="min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none" ariaLabel="Item name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
onsubmit={addItem}
onProductChange={onItemProductChange}
/> />
</div>
{#if sections.length > 0} {#if sections.length > 0}
<select <select
bind:value={newItemSectionId} bind:value={newItemSectionId}
@@ -1,22 +1,48 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
interface IngredientForm {
name: string;
quantity: string;
productId: number | null;
familyProductId: number | null;
}
let title = $state(''); let title = $state('');
let description = $state(''); let description = $state('');
let instructions = $state(''); let instructions = $state('');
let servings = $state<number | undefined>(); let servings = $state<number | undefined>();
let ingredients = $state<{ name: string; quantity: string }[]>([{ name: '', quantity: '' }]); let ingredients = $state<IngredientForm[]>([emptyIngredient()]);
let saving = $state(false); let saving = $state(false);
function emptyIngredient(): IngredientForm {
return { name: '', quantity: '', productId: null, familyProductId: null };
}
function addIngredient() { function addIngredient() {
ingredients = [...ingredients, { name: '', quantity: '' }]; ingredients = [...ingredients, emptyIngredient()];
} }
function removeIngredient(idx: number) { function removeIngredient(idx: number) {
ingredients = ingredients.filter((_, i) => i !== idx); ingredients = ingredients.filter((_, i) => i !== idx);
} }
function onIngredientProductChange(idx: number, product: ProductSuggestion | null) {
const next = ingredients[idx];
if (product === null) {
next.productId = null;
next.familyProductId = null;
} else if (product.kind === 'Global') {
next.productId = product.id;
next.familyProductId = null;
} else {
next.productId = null;
next.familyProductId = product.id;
}
}
async function save() { async function save() {
if (!title.trim()) return; if (!title.trim()) return;
saving = true; saving = true;
@@ -31,7 +57,13 @@
sourceUrl: null, sourceUrl: null,
ingredients: ingredients ingredients: ingredients
.filter((i) => i.name.trim()) .filter((i) => i.name.trim())
.map((i, idx) => ({ name: i.name, quantity: i.quantity || null, sortOrder: idx })) .map((i, idx) => ({
name: i.name,
quantity: i.quantity || null,
sortOrder: idx,
productId: i.productId,
familyProductId: i.familyProductId
}))
}) })
}); });
goto(`/recipes/${res.id}`); goto(`/recipes/${res.id}`);
@@ -84,12 +116,15 @@
placeholder="Qty" placeholder="Qty"
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none" class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/> />
<input <div class="flex-1">
type="text" <ProductTypeahead
bind:value={ingredient.name} bind:value={ingredient.name}
placeholder="Ingredient name" placeholder="Ingredient name"
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none" ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
onProductChange={(p) => onIngredientProductChange(idx, p)}
/> />
</div>
{#if ingredients.length > 1} {#if ingredients.length > 1}
<button <button
type="button" type="button"