Pre-fill list section on product pick; tighten backend warnings

- Adds GET /api/products/{kind}/{id}/section?storeId=... exposing the
  per-store memory the list page mirrors when a product is picked, so the
  section dropdown reflects what the backend would auto-assign on POST.
- Treats backend warnings as errors via Directory.Build.props; fixes the
  surfaced warnings (obsolete PostgreSqlBuilder ctor, nullable string[]
  in IsEquivalentTo, redundant nullable flow).
- Annotates wire-exposed enums (ProductKind, UnitKind, UnitCategory,
  UnitCategoryFlags) with JsonStringEnumConverter so they round-trip as
  strings regardless of caller options. Unblocks the integration tests
  that deserialize DTOs via GetFromJsonAsync without the global converter.
This commit is contained in:
Josh Rogers
2026-05-15 21:30:00 -05:00
parent f38530cf81
commit 6d84aad94b
11 changed files with 205 additions and 28 deletions
+2 -15
View File
@@ -55,10 +55,10 @@ The product-catalog foundation has shipped. A `Product` is the canonical thing b
- **Catalog management page (`/products`):** search the effective catalog, add a family product, edit a global product (writes an override) or family product, "Reset to catalog default" for an overridden global, and delete a family product. Linked from the lists page. - **Catalog management page (`/products`):** search the effective catalog, add a family product, edit a global product (writes an override) or family product, "Reset to catalog default" for an overridden global, and delete a family product. Linked from the lists page.
- **Override indicator:** "Edited" pill on overridden rows in both the catalog page and the typeahead dropdown. - **Override indicator:** "Edited" pill on overridden rows in both the catalog page and the typeahead dropdown.
- **`ProductStoreSection` write path:** when an item is saved/checked with `(productId, sectionId)`, the mapping is remembered for `(family, store, product)`. - **`ProductStoreSection` write path:** when an item is saved/checked with `(productId, sectionId)`, the mapping is remembered for `(family, store, product)`.
- **Auto-assign section from product on add.** `GET /api/products/{kind}/{id}/section?storeId=...` exposes the remembered mapping; `onItemProductChange` on the list page calls it and pre-fills the section dropdown so the user can see (and override) what the backend would auto-assign. The recipe → list copy path already lands ingredients in remembered sections server-side.
- **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`). - **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`).
#### Remaining #### Remaining
- **Auto-assign section from product on add.** The write path remembers `(product, store) → section` and the backend has a helper that reads it back, but the add-item form doesn't call it on product pick yet. Described in detail in *Auto-assign section from product* further down.
- **"Add '<text>' as a new product" affordance in the typeahead.** Today the typeahead only suggests existing catalog rows; an unmatched name becomes a pure-text item. Adding an explicit affordance to promote that name into a `FamilyProduct` from the same dropdown is still open. - **"Add '<text>' as a new product" affordance in the typeahead.** Today the typeahead only suggests existing catalog rows; an unmatched name becomes a pure-text item. Adding an explicit affordance to promote that name into a `FamilyProduct` from the same dropdown is still open.
- **Seed expansion to ~23k curated items.** Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep `products.json` the source of truth, keep the seeder idempotent on `Name`. - **Seed expansion to ~23k curated items.** Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep `products.json` the source of truth, keep the seeder idempotent on `Name`.
- **Catalog ingestion tooling (future).** When the curated list starts feeling limiting, build re-runnable importers for public datasets so we don't grow by hand-typing. - **Catalog ingestion tooling (future).** When the curated list starts feeling limiting, build re-runnable importers for public datasets so we don't grow by hand-typing.
@@ -133,22 +133,9 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure
- "A few", "to taste", "some" — these are real recipe quantities that don't fit `(decimal, unit)`. Probably modeled as a special `IsApproximate` flag with an optional `QuantityNote` rather than forcing them into the structured shape. - "A few", "to taste", "some" — these are real recipe quantities that don't fit `(decimal, unit)`. Probably modeled as a special `IsApproximate` flag with an optional `QuantityNote` rather than forcing them into the structured shape.
### Per-store sections — remaining polish ### Per-store sections — remaining polish
The base feature is shipped (entity, default seed on store create, list view groups by section). Remaining nice-to-haves: The base feature is shipped (entity, default seed on store create, list view groups by section, per-store ingredient memory via `ProductStoreSection` auto-assigning on item add and recipe-to-list copy). Remaining nice-to-haves:
- **Per-store ingredient memory:** remember "last time `Bananas` was bought at Kroger it was in Produce" and auto-assign on next add at that store. Adds an `IngredientSection` mapping table per store. Pairs naturally with the product catalog.
- **Recipes → sections:** when pulling recipe ingredients into a list, map them to the list's store's sections (only meaningful once the per-store ingredient memory or product catalog lands).
- **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually. - **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually.
### Auto-assign section from product
When a user picks a product from the typeahead on the shopping list add form, pre-populate the section dropdown with that product's known section for the current store — rather than leaving it as "Uncategorized".
This is the "per-store ingredient memory" item above, stated from the user's perspective: choosing "Spaghetti" should already know it belongs in Pasta/Dry Goods at this store.
**State of play:** `ProductStoreSection` is shipped — table, indexes, and the write path that records `(family, store, product) → section` whenever an item with a product link is saved with a chosen section. A backend helper in `ShoppingListEndpoints` already reads the effective section back. **The missing piece is the frontend:** `onItemProductChange` in `lists/[id]/+page.svelte` doesn't call the read path on product pick yet, so the section dropdown still defaults to "Uncategorized" until the user sets it.
**To finish:** expose the lookup via an endpoint (`GET /api/products/{id}/section?storeId=...` or roll it into the typeahead response when a `storeId` is supplied), call it on `onItemProductChange`, and pre-select the returned section. Same treatment for the recipe → list copy path so adding a recipe to a list lands ingredients in the right sections.
**Scope note:** the section pre-fill is family-scoped memory (`ProductStoreSection` rows they've created), not a global default — different families organize differently.
## Recipes ## Recipes
### Structured multi-step instructions ### Structured multi-step instructions
+5
View File
@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using YesChef.Api.Entities; using YesChef.Api.Entities;
using YesChef.Api.Features.Products; using YesChef.Api.Features.Products;
using YesChef.Api.IntegrationTests.Infrastructure; using YesChef.Api.IntegrationTests.Infrastructure;
@@ -343,9 +344,9 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q="); var results = (await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q="))!;
var milk = results!.Single(r => r.Name == "Milk"); var milk = results.Single(r => r.Name == "Milk");
await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume); await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
var eggs = results.Single(r => r.Name == "Eggs"); var eggs = results.Single(r => r.Name == "Eggs");
@@ -362,4 +363,132 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
var response = await AnonymousClient.GetAsync("/api/products"); var response = await AnonymousClient.GetAsync("/api/products");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
} }
[Test]
public async Task Get_global_product_section_returns_remembered_section()
{
var familyId = await GetFamilyIdAsync();
var store = await Data.CreateStoreAsync();
var (product, section) = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Lookup" };
var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Produce", SortOrder = 1 };
db.Products.Add(p);
db.StoreSections.Add(s);
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = store.Id,
ProductId = p.Id,
StoreSectionId = s.Id,
});
await db.SaveChangesAsync();
return (p, s);
});
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/global/{product.Id}/section?storeId={store.Id}");
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id);
}
[Test]
public async Task Get_global_product_section_returns_null_when_no_memory()
{
var store = await Data.CreateStoreAsync();
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Unknown-Lookup" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/global/{product.Id}/section?storeId={store.Id}");
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
}
[Test]
public async Task Get_global_product_section_is_scoped_to_store()
{
var familyId = await GetFamilyIdAsync();
var storeA = await Data.CreateStoreAsync(b => b.Named("A"));
var storeB = await Data.CreateStoreAsync(b => b.Named("B"));
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Apples-Lookup" };
var sectionA = new StoreSection { FamilyId = familyId, StoreId = storeA.Id, Name = "Produce A", SortOrder = 1 };
db.Products.Add(p);
db.StoreSections.Add(sectionA);
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = storeA.Id,
ProductId = p.Id,
StoreSectionId = sectionA.Id,
});
await db.SaveChangesAsync();
return p;
});
// Lookup at store B — no memory there even though store A has one.
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/global/{product.Id}/section?storeId={storeB.Id}");
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
}
[Test]
public async Task Get_family_product_section_returns_remembered_section()
{
var familyId = await GetFamilyIdAsync();
var store = await Data.CreateStoreAsync();
var (product, section) = await UseDbAsync(async db =>
{
var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" };
var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Bakery", SortOrder = 1 };
db.FamilyProducts.Add(p);
db.StoreSections.Add(s);
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = store.Id,
FamilyProductId = p.Id,
StoreSectionId = s.Id,
});
await db.SaveChangesAsync();
return (p, s);
});
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/family/{product.Id}/section?storeId={store.Id}");
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id);
}
[Test]
public async Task Get_family_product_section_returns_404_for_other_family_product()
{
var store = await Data.CreateStoreAsync();
var otherFamilyProductId = 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 = "Their Bread" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p.Id;
});
var response = await Client.GetAsync(
$"/api/products/family/{otherFamilyProductId}/section?storeId={store.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
} }
@@ -111,7 +111,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(body.GetProperty("title").GetString()).IsEqualTo("Soup"); await Assert.That(body.GetProperty("title").GetString()).IsEqualTo("Soup");
await Assert.That(body.GetProperty("createdBy").GetString()).IsEqualTo(User.Name); await Assert.That(body.GetProperty("createdBy").GetString()).IsEqualTo(User.Name);
var ingredientNames = body.GetProperty("ingredients").EnumerateArray() var ingredientNames = body.GetProperty("ingredients").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray(); .Select(i => i.GetProperty("name").GetString()!).ToArray();
await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" }); await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" });
} }
@@ -131,7 +131,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
var hits = await Client.GetFromJsonAsync<List<JsonElement>>("/api/recipes?q=Pa"); var hits = await Client.GetFromJsonAsync<List<JsonElement>>("/api/recipes?q=Pa");
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString())) await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()!))
.IsEquivalentTo(new[] { "Pancakes", "Pad Thai" }); .IsEquivalentTo(new[] { "Pancakes", "Pad Thai" });
} }
@@ -77,7 +77,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries"); await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries");
var items = body.GetProperty("items").EnumerateArray() var items = body.GetProperty("items").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray(); .Select(i => i.GetProperty("name").GetString()!).ToArray();
await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" }); await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" });
} }
@@ -377,7 +377,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}"); var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
var items = body.GetProperty("items").EnumerateArray() var items = body.GetProperty("items").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray(); .Select(i => i.GetProperty("name").GetString()!).ToArray();
await Assert.That(items).IsEquivalentTo(new[] { "kept" }); await Assert.That(items).IsEquivalentTo(new[] { "kept" });
} }
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Npgsql; using Npgsql;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;
using TUnit.Core.Interfaces; using TUnit.Core.Interfaces;
@@ -16,8 +15,7 @@ public sealed class PostgresFixture : IAsyncInitializer, IAsyncDisposable
{ {
private const string TemplateDbName = "yeschef_template"; private const string TemplateDbName = "yeschef_template";
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17")
.WithImage("postgres:17")
.WithDatabase("postgres") .WithDatabase("postgres")
.WithUsername("postgres") .WithUsername("postgres")
.WithPassword("postgres") .WithPassword("postgres")
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization;
namespace YesChef.Api.Entities; namespace YesChef.Api.Entities;
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategory>))]
public enum UnitCategory public enum UnitCategory
{ {
Count = 0, Count = 0,
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace YesChef.Api.Entities; namespace YesChef.Api.Entities;
/// <summary> /// <summary>
@@ -7,6 +9,7 @@ namespace YesChef.Api.Entities;
/// 32-bit integer so families can OR in additional categories over time. /// 32-bit integer so families can OR in additional categories over time.
/// </summary> /// </summary>
[System.Flags] [System.Flags]
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategoryFlags>))]
public enum UnitCategoryFlags public enum UnitCategoryFlags
{ {
None = 0, None = 0,
@@ -1,7 +1,9 @@
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth; using YesChef.Api.Auth;
using YesChef.Api.Data; using YesChef.Api.Data;
using YesChef.Api.Entities; using YesChef.Api.Entities;
using YesChef.Api.Features.ShoppingLists;
namespace YesChef.Api.Features.Products; namespace YesChef.Api.Features.Products;
@@ -10,6 +12,7 @@ public static class ProductEndpoints
/// <summary>Discriminator on product DTOs so the client can route subsequent /// <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 /// 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> /// need to surface this — both kinds render the same way.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ProductKind>))]
public enum ProductKind { Global, Family } public enum ProductKind { Global, Family }
public record ProductDto( public record ProductDto(
@@ -239,6 +242,30 @@ public static class ProductEndpoints
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories)); ovr.AllowedUnitCategories ?? product.AllowedUnitCategories));
}); });
// Look up the per-store section memory for a product so the add-item
// UI can pre-fill the dropdown the moment the user picks a product
// from the typeahead. The backend already applies this memory on POST
// when no section is supplied — this endpoint just lets the client
// mirror that decision so the user can see (and override) it.
// Returns { sectionId: int | null }. No memory → null, not 404.
group.MapGet("/global/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, id, null);
return Results.Ok(new { sectionId });
});
group.MapGet("/family/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
// FamilyProducts are tenant-scoped — refuse to leak even the
// existence of another family's product id.
if (!await db.FamilyProducts.AnyAsync(p => p.Id == id && p.FamilyId == familyId))
return Results.NotFound();
var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, null, id);
return Results.Ok(new { sectionId });
});
return group; return group;
} }
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth; using YesChef.Api.Auth;
using YesChef.Api.Data; using YesChef.Api.Data;
@@ -9,6 +10,7 @@ public static class UnitEndpoints
{ {
/// <summary>Discriminator so the client can route subsequent PUT/DELETE /// <summary>Discriminator so the client can route subsequent PUT/DELETE
/// calls to the right code path. Global units cannot be edited.</summary> /// calls to the right code path. Global units cannot be edited.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<UnitKind>))]
public enum UnitKind { Global, Family } public enum UnitKind { Global, Family }
public record UnitDto( public record UnitDto(
@@ -56,6 +56,9 @@
let newItemAllowedUnitCategories = $state(0); let newItemAllowedUnitCategories = $state(0);
let loading = $state(true); let loading = $state(true);
let connection: HubConnection | null = null; let connection: HubConnection | null = null;
// Seq guard so a slow section lookup can't overwrite the section a
// later (faster) product pick already set.
let sectionLookupSeq = 0;
const listId = $derived(Number(page.params.id)); const listId = $derived(Number(page.params.id));
const uncheckedItems = $derived(items.filter((i) => !i.isChecked)); const uncheckedItems = $derived(items.filter((i) => !i.isChecked));
@@ -203,19 +206,39 @@
} }
} }
function onItemProductChange(product: ProductSuggestion | null) { async function onItemProductChange(product: ProductSuggestion | null) {
const seq = ++sectionLookupSeq;
if (product === null) { if (product === null) {
newItemProductId = null; newItemProductId = null;
newItemFamilyProductId = null; newItemFamilyProductId = null;
newItemAllowedUnitCategories = 0; newItemAllowedUnitCategories = 0;
} else if (product.kind === 'Global') { // Don't clobber the user's section choice when they edit the
// name and the link clears — they may have set it deliberately.
return;
}
if (product.kind === 'Global') {
newItemProductId = product.id; newItemProductId = product.id;
newItemFamilyProductId = null; newItemFamilyProductId = null;
newItemAllowedUnitCategories = product.allowedUnitCategories;
} else { } else {
newItemProductId = null; newItemProductId = null;
newItemFamilyProductId = product.id; newItemFamilyProductId = product.id;
newItemAllowedUnitCategories = product.allowedUnitCategories; }
newItemAllowedUnitCategories = product.allowedUnitCategories;
// Mirror the backend's auto-assign rule: pre-fill the section dropdown
// from per-store memory so the user can see (and override) what's
// about to happen. Null result means no memory — reset to "No section".
if (!list) return;
const path = product.kind === 'Global' ? 'global' : 'family';
try {
const res = await api<{ sectionId: number | null }>(
`/api/products/${path}/${product.id}/section?storeId=${list.store.id}`
);
if (seq !== sectionLookupSeq) return;
newItemSectionId = res.sectionId;
} catch {
// Lookup failure shouldn't block adding an item — leave the
// section alone and let the backend auto-assign on POST.
} }
} }