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:
@@ -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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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" }
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,10 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
||||
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
||||
public DbSet<Invite> Invites => Set<Invite>();
|
||||
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)
|
||||
{
|
||||
@@ -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.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.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);
|
||||
});
|
||||
|
||||
@@ -111,6 +117,54 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
||||
e.Property(i => i.Name).HasMaxLength(200);
|
||||
e.Property(i => i.Quantity).HasMaxLength(50);
|
||||
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 string? Quantity { 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 int? SectionId { 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? RemovedAt { 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.Data;
|
||||
using YesChef.Api.Entities;
|
||||
using YesChef.Api.Features.ShoppingLists;
|
||||
|
||||
namespace YesChef.Api.Features.Recipes;
|
||||
|
||||
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 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) =>
|
||||
{
|
||||
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
|
||||
{
|
||||
FamilyId = familyId,
|
||||
@@ -50,7 +58,9 @@ public static class RecipeEndpoints
|
||||
FamilyId = familyId,
|
||||
Name = i.Name,
|
||||
Quantity = i.Quantity,
|
||||
SortOrder = i.SortOrder
|
||||
SortOrder = i.SortOrder,
|
||||
ProductId = i.ProductId,
|
||||
FamilyProductId = i.FamilyProductId
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
@@ -80,7 +90,7 @@ public static class RecipeEndpoints
|
||||
recipe.SourceUrl,
|
||||
CreatedBy = recipe.CreatedByUser.Name,
|
||||
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();
|
||||
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.Description = request.Description;
|
||||
recipe.Instructions = request.Instructions;
|
||||
@@ -106,7 +122,9 @@ public static class RecipeEndpoints
|
||||
FamilyId = familyId,
|
||||
Name = i.Name,
|
||||
Quantity = i.Quantity,
|
||||
SortOrder = i.SortOrder
|
||||
SortOrder = i.SortOrder,
|
||||
ProductId = i.ProductId,
|
||||
FamilyProductId = i.FamilyProductId
|
||||
}).ToList();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -10,7 +10,7 @@ public static class ShoppingListEndpoints
|
||||
{
|
||||
public record CreateListRequest(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);
|
||||
|
||||
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
|
||||
@@ -127,7 +127,9 @@ public static class ShoppingListEndpoints
|
||||
CheckedByUserName = i.CheckedByUser?.Name,
|
||||
i.SortOrder,
|
||||
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();
|
||||
|
||||
// Reject section IDs that don't belong to the list's store/family.
|
||||
if (request.SectionId is int sectionId &&
|
||||
!await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == list.StoreId && s.FamilyId == familyId))
|
||||
if (request.SectionId is int explicitSectionId &&
|
||||
!await db.StoreSections.AnyAsync(s => s.Id == explicitSectionId && s.StoreId == list.StoreId && s.FamilyId == familyId))
|
||||
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
|
||||
{
|
||||
FamilyId = familyId,
|
||||
ShoppingListId = listId,
|
||||
Name = request.Name,
|
||||
SortOrder = request.SortOrder,
|
||||
SectionId = request.SectionId,
|
||||
SectionId = resolvedSectionId,
|
||||
ProductId = request.ProductId,
|
||||
FamilyProductId = request.FamilyProductId,
|
||||
};
|
||||
db.ShoppingListItems.Add(item);
|
||||
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 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);
|
||||
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) =>
|
||||
@@ -208,6 +228,9 @@ public static class ShoppingListEndpoints
|
||||
return Results.BadRequest(new { error = "Unknown section." });
|
||||
|
||||
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 hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId });
|
||||
@@ -272,7 +295,9 @@ public static class ShoppingListEndpoints
|
||||
CheckedByUserName = item.CheckedByUser?.Name,
|
||||
item.SortOrder,
|
||||
item.SectionId,
|
||||
RecipeTitle = item.Recipe?.Title
|
||||
RecipeTitle = item.Recipe?.Title,
|
||||
item.ProductId,
|
||||
item.FamilyProductId
|
||||
});
|
||||
await BroadcastListSummary(hub, db, listId, familyId);
|
||||
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 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)
|
||||
{
|
||||
FamilyId = familyId,
|
||||
ShoppingListId = listId,
|
||||
Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}",
|
||||
SortOrder = maxSort + idx + 1,
|
||||
RecipeId = recipeId
|
||||
}).ToList();
|
||||
// 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,
|
||||
ShoppingListId = listId,
|
||||
Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}",
|
||||
SortOrder = maxSort + idx + 1,
|
||||
RecipeId = recipeId,
|
||||
ProductId = ing.ProductId,
|
||||
FamilyProductId = ing.FamilyProductId,
|
||||
SectionId = rememberedSectionId,
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
|
||||
db.ShoppingListItems.AddRange(newItems);
|
||||
list.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -307,7 +345,7 @@ public static class ShoppingListEndpoints
|
||||
|
||||
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);
|
||||
@@ -316,4 +354,71 @@ public static class ShoppingListEndpoints
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+784
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+832
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+920
@@ -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");
|
||||
});
|
||||
|
||||
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")
|
||||
@@ -157,6 +222,85 @@ namespace YesChef.Api.Migrations
|
||||
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")
|
||||
@@ -214,11 +358,17 @@ namespace YesChef.Api.Migrations
|
||||
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)");
|
||||
@@ -233,6 +383,10 @@ namespace YesChef.Api.Migrations
|
||||
|
||||
b.HasIndex("FamilyId");
|
||||
|
||||
b.HasIndex("FamilyProductId");
|
||||
|
||||
b.HasIndex("ProductId");
|
||||
|
||||
b.HasIndex("RecipeId");
|
||||
|
||||
b.ToTable("RecipeIngredients");
|
||||
@@ -297,6 +451,9 @@ namespace YesChef.Api.Migrations
|
||||
b.Property<int>("FamilyId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("FamilyProductId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsChecked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -305,6 +462,9 @@ namespace YesChef.Api.Migrations
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<int?>("ProductId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("RecipeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -329,6 +489,10 @@ namespace YesChef.Api.Migrations
|
||||
|
||||
b.HasIndex("FamilyId");
|
||||
|
||||
b.HasIndex("FamilyProductId");
|
||||
|
||||
b.HasIndex("ProductId");
|
||||
|
||||
b.HasIndex("RecipeId");
|
||||
|
||||
b.HasIndex("RemovedByUserId");
|
||||
@@ -460,6 +624,36 @@ namespace YesChef.Api.Migrations
|
||||
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")
|
||||
@@ -497,6 +691,47 @@ namespace YesChef.Api.Migrations
|
||||
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")
|
||||
@@ -524,6 +759,16 @@ namespace YesChef.Api.Migrations
|
||||
.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")
|
||||
@@ -532,6 +777,10 @@ namespace YesChef.Api.Migrations
|
||||
|
||||
b.Navigation("Family");
|
||||
|
||||
b.Navigation("FamilyProduct");
|
||||
|
||||
b.Navigation("Product");
|
||||
|
||||
b.Navigation("Recipe");
|
||||
});
|
||||
|
||||
@@ -575,6 +824,16 @@ namespace YesChef.Api.Migrations
|
||||
.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")
|
||||
@@ -600,6 +859,10 @@ namespace YesChef.Api.Migrations
|
||||
|
||||
b.Navigation("Family");
|
||||
|
||||
b.Navigation("FamilyProduct");
|
||||
|
||||
b.Navigation("Product");
|
||||
|
||||
b.Navigation("Recipe");
|
||||
|
||||
b.Navigation("RemovedByUser");
|
||||
|
||||
@@ -8,9 +8,11 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using YesChef.Api.Auth;
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Data.Seed;
|
||||
using YesChef.Api.Email;
|
||||
using YesChef.Api.Entities;
|
||||
using YesChef.Api.Features.Families;
|
||||
using YesChef.Api.Features.Products;
|
||||
using YesChef.Api.Features.Recipes;
|
||||
using YesChef.Api.Features.ShoppingLists;
|
||||
using YesChef.Api.Features.Stores;
|
||||
@@ -194,6 +196,15 @@ using (var scope = app.Services.CreateScope())
|
||||
}
|
||||
if (familiesNeedingAdmin.Count > 0)
|
||||
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();
|
||||
@@ -213,6 +224,7 @@ var storesGroup = app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthori
|
||||
storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints();
|
||||
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
|
||||
app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
|
||||
app.MapGroup("/api/products").MapProductEndpoints().RequireAuthorization();
|
||||
app.MapHub<ShoppingListHub>("/hubs/shopping-list");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Data/Seed/products.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<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 { startConnection, stopConnection } from '$lib/signalr';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
||||
import type { HubConnection } from '@microsoft/signalr';
|
||||
|
||||
interface ListItem {
|
||||
@@ -39,6 +40,8 @@
|
||||
let sections = $state<Section[]>([]);
|
||||
let newItemName = $state('');
|
||||
let newItemSectionId = $state<number | null>(null);
|
||||
let newItemProductId = $state<number | null>(null);
|
||||
let newItemFamilyProductId = $state<number | null>(null);
|
||||
let loading = $state(true);
|
||||
let connection: HubConnection | null = null;
|
||||
|
||||
@@ -149,10 +152,27 @@
|
||||
body: JSON.stringify({
|
||||
name: newItemName,
|
||||
sortOrder: maxSort + 1,
|
||||
sectionId: newItemSectionId
|
||||
sectionId: newItemSectionId,
|
||||
productId: newItemProductId,
|
||||
familyProductId: newItemFamilyProductId
|
||||
})
|
||||
});
|
||||
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) {
|
||||
@@ -210,12 +230,16 @@
|
||||
</div>
|
||||
|
||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newItemName}
|
||||
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"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<ProductTypeahead
|
||||
bind:value={newItemName}
|
||||
placeholder="Add an item..."
|
||||
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}
|
||||
<select
|
||||
bind:value={newItemSectionId}
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
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 description = $state('');
|
||||
let instructions = $state('');
|
||||
let servings = $state<number | undefined>();
|
||||
let ingredients = $state<{ name: string; quantity: string }[]>([{ name: '', quantity: '' }]);
|
||||
let ingredients = $state<IngredientForm[]>([emptyIngredient()]);
|
||||
let saving = $state(false);
|
||||
|
||||
function emptyIngredient(): IngredientForm {
|
||||
return { name: '', quantity: '', productId: null, familyProductId: null };
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
ingredients = [...ingredients, { name: '', quantity: '' }];
|
||||
ingredients = [...ingredients, emptyIngredient()];
|
||||
}
|
||||
|
||||
function removeIngredient(idx: number) {
|
||||
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() {
|
||||
if (!title.trim()) return;
|
||||
saving = true;
|
||||
@@ -31,7 +57,13 @@
|
||||
sourceUrl: null,
|
||||
ingredients: ingredients
|
||||
.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}`);
|
||||
@@ -84,12 +116,15 @@
|
||||
placeholder="Qty"
|
||||
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={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"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<ProductTypeahead
|
||||
bind:value={ingredient.name}
|
||||
placeholder="Ingredient name"
|
||||
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}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user