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:
+2
-15
@@ -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 ~2–3k 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 ~2–3k 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
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user