feat: D1 — Shopping list (Issue #30) #43

Merged
marcel merged 24 commits from feat/issue-30-shopping-list into master 2026-04-08 22:22:02 +02:00
Owner

Summary

Full-stack implementation of the D1 Shopping List screen (closes #30).

  • Backend: GET /v1/shopping-list?weekStart= endpoint, merge-based regeneration strategy (preserves custom items + check states), reusable @RequiresHouseholdRole annotation, filteredStaplesCount and RecipeRef in response
  • Frontend: Responsive mobile/desktop layout with checklist, checked-off section, add custom item form, recipe reference panel, 3 empty state variants, planner-only generate/regenerate

Backend changes

  • V022 migration: generated_at column on shopping_list
  • ForbiddenException + 403 handler
  • @RequiresHouseholdRole annotation + HouseholdRoleInterceptor (reusable across controllers)
  • ShoppingListResponse extended with generatedAt, filteredStaplesCount, List<RecipeRef>
  • getByWeekStart() — week-based lookup defaulting to current Monday
  • generateFromPlan() refactored to merge strategy
  • GET /v1/shopping-list endpoint, planner guard on generate

Frontend changes

  • +page.server.ts — load (shopping list + week plan) + form actions (check, addItem, generate)
  • ShoppingHeader.svelte — title, eyebrow counts, timestamp, generate button
  • ChecklistItem.svelte — checkbox with strikethrough, recipe source label, quantity
  • AddCustomItem.svelte — expandable inline form
  • RecipeReferencePanel.svelte — desktop sidebar with recipe cards + filtered staples
  • +page.svelte — responsive layout with 3 empty states

Test plan

  • Backend: 277 tests pass (20 shopping-specific)
  • Frontend: npm run check — 0 errors
  • Frontend: 478 tests pass (2 pre-existing failures in recipe search, unrelated)

🤖 Generated with Claude Code

## Summary Full-stack implementation of the D1 Shopping List screen (closes #30). - **Backend**: `GET /v1/shopping-list?weekStart=` endpoint, merge-based regeneration strategy (preserves custom items + check states), reusable `@RequiresHouseholdRole` annotation, `filteredStaplesCount` and `RecipeRef` in response - **Frontend**: Responsive mobile/desktop layout with checklist, checked-off section, add custom item form, recipe reference panel, 3 empty state variants, planner-only generate/regenerate ### Backend changes - V022 migration: `generated_at` column on `shopping_list` - `ForbiddenException` + 403 handler - `@RequiresHouseholdRole` annotation + `HouseholdRoleInterceptor` (reusable across controllers) - `ShoppingListResponse` extended with `generatedAt`, `filteredStaplesCount`, `List<RecipeRef>` - `getByWeekStart()` — week-based lookup defaulting to current Monday - `generateFromPlan()` refactored to merge strategy - `GET /v1/shopping-list` endpoint, planner guard on generate ### Frontend changes - `+page.server.ts` — load (shopping list + week plan) + form actions (check, addItem, generate) - `ShoppingHeader.svelte` — title, eyebrow counts, timestamp, generate button - `ChecklistItem.svelte` — checkbox with strikethrough, recipe source label, quantity - `AddCustomItem.svelte` — expandable inline form - `RecipeReferencePanel.svelte` — desktop sidebar with recipe cards + filtered staples - `+page.svelte` — responsive layout with 3 empty states ## Test plan - [x] Backend: 277 tests pass (20 shopping-specific) - [x] Frontend: `npm run check` — 0 errors - [x] Frontend: 478 tests pass (2 pre-existing failures in recipe search, unrelated) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 16 commits 2026-04-04 19:09:55 +02:00
Replace WebSocket/SSE liveness model with server-authoritative sync
(form actions + page refresh). Remove "members online" indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reusable annotation for planner-only endpoints. Uses a
HandlerInterceptor that resolves the household role from the
authenticated user and throws 403 if the role doesn't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shopping list response now includes generatedAt timestamp, count of
filtered staples, and recipe names (not just UUIDs) in source references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Returns the shopping list for a given week, defaulting to the current
week's Monday when no weekStart is provided. Returns null when no
list exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a shopping list already exists for the week plan, regeneration
now merges: custom items and check states are preserved, existing
generated items are updated, removed recipes' items are deleted,
and new ingredients are added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New week-based lookup endpoint with optional weekStart param (defaults
to current week). Generate endpoint now enforced with @RequiresHouseholdRole.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renamed endpoint to /v1/shopping-list to avoid Springdoc path conflict.
Added @RequiresHouseholdRole("planner") on generate. Regenerated
frontend OpenAPI schema with all new shopping list endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Load function fetches shopping list and week plan for the current week.
Form actions: check (toggle item), addItem (custom item), generate
(planner-only shopping list generation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Displays title, eyebrow counts (remaining/checked), generation
timestamp, and planner-only generate/regenerate button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Checkbox row with name, quantity/unit, recipe source label, and
strikethrough styling when checked. Each toggle submits a form action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Expandable inline form for adding custom items to the shopping list.
Includes name, quantity, and unit fields with cancel/submit actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Desktop right panel showing this week's recipe cards with day
abbreviation, filtered staples count, and link to edit pantry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mobile/desktop responsive shopping list page with:
- Three empty states (no plan, no list, all checked)
- Unchecked/checked item sections with divider
- Add custom item form
- Desktop right panel with recipe references
- Filtered staples info

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Backend Engineer — Senior Spring Boot / PostgreSQL Specialist

Verdict: ⚠️ Approved with concerns

What I checked

Layer boundaries (controller → service → repository), domain modeling, SQL/migration quality, DTO design, transaction management, API design, error handling.

Blockers

None.

Suggestions

  1. ShoppingService is getting large (~330 lines, 7 dependencies). The generateFromPlan() merge logic is the core complexity — consider extracting it into a dedicated ShoppingListGenerator or a domain method on ShoppingList itself. The MergedIngredient inner class is a sign this logic wants its own home. Not blocking for v1, but worth tracking.

  2. HouseholdRoleInterceptor throws ForbiddenException("Not authenticated") when auth == null (line 33). This is arguably a 401, not a 403. An unauthenticated user isn't forbidden — they haven't identified themselves yet. Spring Security should already handle this before the interceptor runs, so it's likely unreachable, but the semantics are worth being precise about.

  3. countFilteredStaples() (line 253–261) iterates all slots and all recipe ingredients on every response. For a read-heavy endpoint like GET /v1/shopping-list, this is an N+1 waiting to happen if the JPA associations aren't eagerly fetched. Consider caching the count on the ShoppingList entity at generation time, or at least verify the fetch plan with a SQL log check.

  4. Merge key construction is duplicated — the string ingredient.getId() + "|" + unit appears in three places (lines 93, 106, 116). A small helper mergeKey(UUID ingredientId, String unit) would prevent subtle drift if one instance is changed but not the others.

  5. @Transactional on the class level (line 28) means generateFromPlan is read-write transactional, which is correct. But getByWeekStart overrides with @Transactional(readOnly = true) — good. Just noting that getShoppingList also has readOnly = true — consistent. 👍

  6. RequiresHouseholdRole annotation uses a plain String value(). A type-safe enum (HouseholdRole.PLANNER, HouseholdRole.MEMBER) would prevent typos like "Planner" vs "planner". Low priority but worth considering as more endpoints use this.

Positives

  • Clean DTO design with records — ShoppingListResponse, ShoppingListItemResponse, nested RecipeRef and CategoryRef.
  • findByHouseholdIdAndWeekPlanWeekStart is a well-named derived query.
  • Merge strategy correctly preserves custom items and check states — the most complex piece and it's well-tested.
  • Household isolation is consistently enforced via findList() helper.
  • V022 migration uses IF NOT EXISTS — safe for re-runs.
## 👨‍💻 Backend Engineer — Senior Spring Boot / PostgreSQL Specialist **Verdict: ⚠️ Approved with concerns** ### What I checked Layer boundaries (controller → service → repository), domain modeling, SQL/migration quality, DTO design, transaction management, API design, error handling. ### Blockers None. ### Suggestions 1. **`ShoppingService` is getting large (~330 lines, 7 dependencies).** The `generateFromPlan()` merge logic is the core complexity — consider extracting it into a dedicated `ShoppingListGenerator` or a domain method on `ShoppingList` itself. The `MergedIngredient` inner class is a sign this logic wants its own home. Not blocking for v1, but worth tracking. 2. **`HouseholdRoleInterceptor` throws `ForbiddenException("Not authenticated")` when `auth == null` (line 33).** This is arguably a 401, not a 403. An unauthenticated user isn't forbidden — they haven't identified themselves yet. Spring Security should already handle this before the interceptor runs, so it's likely unreachable, but the semantics are worth being precise about. 3. **`countFilteredStaples()` (line 253–261) iterates all slots and all recipe ingredients on every response.** For a read-heavy endpoint like `GET /v1/shopping-list`, this is an N+1 waiting to happen if the JPA associations aren't eagerly fetched. Consider caching the count on the `ShoppingList` entity at generation time, or at least verify the fetch plan with a SQL log check. 4. **Merge key construction is duplicated** — the string `ingredient.getId() + "|" + unit` appears in three places (lines 93, 106, 116). A small helper `mergeKey(UUID ingredientId, String unit)` would prevent subtle drift if one instance is changed but not the others. 5. **`@Transactional` on the class level (line 28) means `generateFromPlan` is read-write transactional, which is correct.** But `getByWeekStart` overrides with `@Transactional(readOnly = true)` — good. Just noting that `getShoppingList` also has `readOnly = true` — consistent. 👍 6. **`RequiresHouseholdRole` annotation uses a plain `String value()`.** A type-safe enum (`HouseholdRole.PLANNER`, `HouseholdRole.MEMBER`) would prevent typos like `"Planner"` vs `"planner"`. Low priority but worth considering as more endpoints use this. ### Positives - Clean DTO design with records — `ShoppingListResponse`, `ShoppingListItemResponse`, nested `RecipeRef` and `CategoryRef`. - `findByHouseholdIdAndWeekPlanWeekStart` is a well-named derived query. - Merge strategy correctly preserves custom items and check states — the most complex piece and it's well-tested. - Household isolation is consistently enforced via `findList()` helper. - V022 migration uses `IF NOT EXISTS` — safe for re-runs.
Author
Owner

🎨 Kai — Senior Frontend Engineer (Svelte 5 / SvelteKit 2)

Verdict: ⚠️ Approved with concerns

What I checked

Svelte 5 runes usage, SvelteKit 2 patterns (+page.server.ts / form actions / use:enhance), component architecture, Tailwind usage, reactivity model, TypeScript safety.

Blockers

None.

Suggestions

  1. +page.svelte duplicates the entire layout for mobile and desktop. The mobile block (lines 27–117) and desktop block (lines 120–207) repeat the same empty states, the same checklist rendering, and the same ChecklistItem iteration. This is ~180 lines of near-identical markup. Extract the checklist body (unchecked items + add form + checked items) into a ShoppingChecklist.svelte component and render it in both layouts. The responsive chrome differs, but the content is identical.

  2. No use:enhance on the check form in ChecklistItem.svelte. The use:enhance is there (line 37), which is good — but it doesn't prevent the default full-page reload behavior. Without a custom enhance callback that calls update({ reset: false }), the form will re-render the page and scroll to top on each check/uncheck. For a checklist where users tap items rapidly, this UX is jarring. Consider:

    use:enhance={() => {
      return async ({ update }) => {
        await update({ reset: false });
      };
    }}
    
  3. data.benutzer?.rolle access in +page.svelte line 11. The benutzer object comes from the layout load function. Since this is cast as (data as any).benutzer in the planner page but accessed directly here, verify the type flows correctly from +layout.server.ts. If PageData doesn't include benutzer, this will type-check as any silently. Consider adding the type to App.PageData or use $page.data.benutzer.

  4. ChecklistItem.svelte uses quantity: number | null prop but the schema type is quantity?: number. The ?? null coercion in +page.svelte handles this, but it means the component's interface doesn't match the API type. Minor, but could cause confusion.

  5. AddCustomItem.svelte resets state after successful submission (line 34–38) but doesn't handle errors. If the addItem action returns { success: false }, the form still collapses. Consider checking the action result before resetting.

  6. No loading/pending state on form submissions. When checking an item or generating a list, there's no visual feedback that the action is in progress. For generate (which may take a moment), consider disabling the button during submission.

Positives

  • Correct Svelte 5 runes throughout — $props(), $derived(), $state(). No legacy syntax.
  • Good component decomposition: ShoppingHeader, ChecklistItem, AddCustomItem, RecipeReferencePanel.
  • +page.server.ts follows the established pattern from the planner page — apiClient(fetch), parallel fetches in Promise.all, form actions with role guard.
  • German UI labels consistent with the rest of the app.
  • Three empty states handled cleanly with {#if} chains.
## 🎨 Kai — Senior Frontend Engineer (Svelte 5 / SvelteKit 2) **Verdict: ⚠️ Approved with concerns** ### What I checked Svelte 5 runes usage, SvelteKit 2 patterns (+page.server.ts / form actions / use:enhance), component architecture, Tailwind usage, reactivity model, TypeScript safety. ### Blockers None. ### Suggestions 1. **`+page.svelte` duplicates the entire layout for mobile and desktop.** The mobile block (lines 27–117) and desktop block (lines 120–207) repeat the same empty states, the same checklist rendering, and the same `ChecklistItem` iteration. This is ~180 lines of near-identical markup. Extract the checklist body (unchecked items + add form + checked items) into a `ShoppingChecklist.svelte` component and render it in both layouts. The responsive chrome differs, but the content is identical. 2. **No `use:enhance` on the check form in `ChecklistItem.svelte`.** The `use:enhance` is there (line 37), which is good — but it doesn't prevent the default full-page reload behavior. Without a custom `enhance` callback that calls `update({ reset: false })`, the form will re-render the page and scroll to top on each check/uncheck. For a checklist where users tap items rapidly, this UX is jarring. Consider: ```svelte use:enhance={() => { return async ({ update }) => { await update({ reset: false }); }; }} ``` 3. **`data.benutzer?.rolle` access in `+page.svelte` line 11.** The `benutzer` object comes from the layout load function. Since this is cast as `(data as any).benutzer` in the planner page but accessed directly here, verify the type flows correctly from `+layout.server.ts`. If `PageData` doesn't include `benutzer`, this will type-check as `any` silently. Consider adding the type to `App.PageData` or use `$page.data.benutzer`. 4. **`ChecklistItem.svelte` uses `quantity: number | null` prop but the schema type is `quantity?: number`.** The `?? null` coercion in `+page.svelte` handles this, but it means the component's interface doesn't match the API type. Minor, but could cause confusion. 5. **`AddCustomItem.svelte` resets state after successful submission (line 34–38) but doesn't handle errors.** If the `addItem` action returns `{ success: false }`, the form still collapses. Consider checking the action result before resetting. 6. **No loading/pending state on form submissions.** When checking an item or generating a list, there's no visual feedback that the action is in progress. For generate (which may take a moment), consider disabling the button during submission. ### Positives - Correct Svelte 5 runes throughout — `$props()`, `$derived()`, `$state()`. No legacy syntax. - Good component decomposition: `ShoppingHeader`, `ChecklistItem`, `AddCustomItem`, `RecipeReferencePanel`. - `+page.server.ts` follows the established pattern from the planner page — `apiClient(fetch)`, parallel fetches in `Promise.all`, form actions with role guard. - German UI labels consistent with the rest of the app. - Three empty states handled cleanly with `{#if}` chains.
Author
Owner

🧪 QA Engineer — Backend & Frontend Test Specialist

Verdict: ⚠️ Approved with concerns

What I checked

Test coverage, path analysis (happy/bad/edge), test quality and naming, missing test scenarios, TDD adherence.

Blockers

None.

Missing test coverage — Backend

  1. No controller test for @RequiresHouseholdRole enforcement. The HouseholdRoleInterceptorTest tests the interceptor in isolation, which is good. But there's no integration or controller test that verifies POST /v1/week-plans/{id}/shopping-list actually returns 403 when called by a non-planner. The ShoppingListControllerTest uses MockMvc in standalone mode (no interceptors registered), so the role guard is untested at the HTTP level.

  2. No test for generateFromPlan removing stale generated items. The merge test (generateFromPlanShouldMergeWhenListAlreadyExists) tests updating existing items and preserving custom items, but doesn't verify that a generated item is removed when its ingredient is no longer in the plan. Add a test where the existing list has tomatoes + onions, the new plan only has tomatoes, and verify onions are removed.

  3. No test for duplicate recipe IDs in sourceRecipes. If the same recipe contributes an ingredient via two slots (e.g., a recipe used twice in the week), sourceRecipes should deduplicate. The .distinct() call is there, but no test exercises it.

  4. checkItem — no test for wrong household. There's a household mismatch test for getShoppingList and generateFromPlan, but not for checkItem, addItem, or deleteItem. These all use findList() which enforces it, but the test gap means a regression could go unnoticed.

Missing test coverage — Frontend

  1. No frontend component tests for any of the new Svelte components. Zero test files created for ShoppingHeader, ChecklistItem, AddCustomItem, RecipeReferencePanel, or the page itself. The persona spec says TDD — red/green/refactor — but the frontend was built without tests. This is the biggest gap.

    At minimum, I'd expect:

    • ShoppingHeader.test.ts — renders counts, shows/hides generate button based on isPlanner
    • ChecklistItem.test.ts — renders name, quantity, strikethrough when checked, recipe label
    • AddCustomItem.test.ts — expand/collapse, form submission
    • +page.svelte test — empty states render correctly

Suggestions

  1. ShoppingServiceTest uses reflection to set IDs (setId). This works, but it's fragile — if the field name changes, the test silently breaks. Consider a test factory or builder pattern. Not blocking.

  2. Test names are descriptive and follow conventions. Good: generateFromPlanShouldMergeWhenListAlreadyExists, checkItemShouldUncheckAndClearUser. 👍

Summary

Backend test coverage is solid (20 tests covering most paths) with a few gaps in cross-cutting concerns and edge cases. Frontend has zero component tests, which is a significant gap for a feature of this complexity. I'd recommend adding at least the ShoppingHeader and ChecklistItem tests before merge.

## 🧪 QA Engineer — Backend & Frontend Test Specialist **Verdict: ⚠️ Approved with concerns** ### What I checked Test coverage, path analysis (happy/bad/edge), test quality and naming, missing test scenarios, TDD adherence. ### Blockers None. ### Missing test coverage — Backend 1. **No controller test for `@RequiresHouseholdRole` enforcement.** The `HouseholdRoleInterceptorTest` tests the interceptor in isolation, which is good. But there's no integration or controller test that verifies `POST /v1/week-plans/{id}/shopping-list` actually returns 403 when called by a non-planner. The `ShoppingListControllerTest` uses `MockMvc` in standalone mode (no interceptors registered), so the role guard is untested at the HTTP level. 2. **No test for `generateFromPlan` removing stale generated items.** The merge test (`generateFromPlanShouldMergeWhenListAlreadyExists`) tests updating existing items and preserving custom items, but doesn't verify that a generated item is removed when its ingredient is no longer in the plan. Add a test where the existing list has tomatoes + onions, the new plan only has tomatoes, and verify onions are removed. 3. **No test for duplicate recipe IDs in `sourceRecipes`.** If the same recipe contributes an ingredient via two slots (e.g., a recipe used twice in the week), `sourceRecipes` should deduplicate. The `.distinct()` call is there, but no test exercises it. 4. **`checkItem` — no test for wrong household.** There's a household mismatch test for `getShoppingList` and `generateFromPlan`, but not for `checkItem`, `addItem`, or `deleteItem`. These all use `findList()` which enforces it, but the test gap means a regression could go unnoticed. ### Missing test coverage — Frontend 5. **No frontend component tests for any of the new Svelte components.** Zero test files created for `ShoppingHeader`, `ChecklistItem`, `AddCustomItem`, `RecipeReferencePanel`, or the page itself. The persona spec says TDD — red/green/refactor — but the frontend was built without tests. This is the biggest gap. At minimum, I'd expect: - `ShoppingHeader.test.ts` — renders counts, shows/hides generate button based on `isPlanner` - `ChecklistItem.test.ts` — renders name, quantity, strikethrough when checked, recipe label - `AddCustomItem.test.ts` — expand/collapse, form submission - `+page.svelte` test — empty states render correctly ### Suggestions 6. **`ShoppingServiceTest` uses reflection to set IDs (`setId`).** This works, but it's fragile — if the field name changes, the test silently breaks. Consider a test factory or builder pattern. Not blocking. 7. **Test names are descriptive and follow conventions.** Good: `generateFromPlanShouldMergeWhenListAlreadyExists`, `checkItemShouldUncheckAndClearUser`. 👍 ### Summary Backend test coverage is solid (20 tests covering most paths) with a few gaps in cross-cutting concerns and edge cases. Frontend has zero component tests, which is a significant gap for a feature of this complexity. I'd recommend adding at least the `ShoppingHeader` and `ChecklistItem` tests before merge.
Author
Owner

🔒 Sable — Security Engineer

Verdict: ⚠️ Approved with concerns

What I checked

OWASP Top 10 against all changed code: access control, injection, authentication/authorization, data exposure, multi-tenant isolation, CSRF, input validation.

Blockers

None.

Concerns

  1. ForbiddenException message leaks required role name (line 38 of interceptor): "Requires household role: planner" — this tells an attacker exactly which role they need. In the GlobalExceptionHandler, this message is returned verbatim in the API response body. Recommend a generic message like "Insufficient permissions" in the response, while keeping the specific role in server-side logs.

  2. No input validation on AddItemRequest in the controller. The addItem action accepts customName, quantity, and unit directly from user input. There's no @Valid annotation on the request body, no length constraint on customName, and no range check on quantity. An attacker could submit a 10MB customName string or a negative/extreme quantity. Add @NotBlank @Size(max=255) on customName and @Positive on quantity at minimum.

  3. CheckItemRequest.isChecked is not validated as non-null. If the JSON body omits isChecked, the boolean defaults to false silently. This isn't a security risk per se, but it means any malformed request body will uncheck items — potentially a griefing vector in a shared household.

  4. +page.server.ts form actions don't validate listId/itemId as UUID format. The form data values are passed directly to the API as path parameters. While the backend will likely reject malformed UUIDs, validating format server-side in the SvelteKit action prevents garbage from reaching the backend. This is defense in depth.

  5. Role check in +page.server.ts generate action (line 67) uses frontend-side role. locals.benutzer?.rolle !== 'planer' is a UI guard only — the backend interceptor is the real enforcement. This is correct architecture (defense in depth), but note the role string mismatch: frontend uses 'planer' (German), backend uses 'planner' (English). If these ever diverge, the frontend guard breaks silently. Worth documenting.

Positives

  • Household isolation is consistently enforced — every service method checks householdId match before returning data. IDOR is well-mitigated.
  • generateFromPlan verifies household ownership of the week plan before proceeding.
  • The interceptor pattern for @RequiresHouseholdRole is clean and auditable — single enforcement point.
  • No {@html} usage in any Svelte component — XSS risk from template injection is zero.
  • Form actions use use:enhance with SvelteKit's built-in CSRF protection.
  • No raw SQL — all queries go through JPA repository methods or derived queries.
## 🔒 Sable — Security Engineer **Verdict: ⚠️ Approved with concerns** ### What I checked OWASP Top 10 against all changed code: access control, injection, authentication/authorization, data exposure, multi-tenant isolation, CSRF, input validation. ### Blockers None. ### Concerns 1. **`ForbiddenException` message leaks required role name (line 38 of interceptor):** `"Requires household role: planner"` — this tells an attacker exactly which role they need. In the `GlobalExceptionHandler`, this message is returned verbatim in the API response body. Recommend a generic message like `"Insufficient permissions"` in the response, while keeping the specific role in server-side logs. 2. **No input validation on `AddItemRequest` in the controller.** The `addItem` action accepts `customName`, `quantity`, and `unit` directly from user input. There's no `@Valid` annotation on the request body, no length constraint on `customName`, and no range check on `quantity`. An attacker could submit a 10MB `customName` string or a negative/extreme quantity. Add `@NotBlank @Size(max=255)` on `customName` and `@Positive` on `quantity` at minimum. 3. **`CheckItemRequest.isChecked` is not validated as non-null.** If the JSON body omits `isChecked`, the boolean defaults to `false` silently. This isn't a security risk per se, but it means any malformed request body will uncheck items — potentially a griefing vector in a shared household. 4. **`+page.server.ts` form actions don't validate `listId`/`itemId` as UUID format.** The form data values are passed directly to the API as path parameters. While the backend will likely reject malformed UUIDs, validating format server-side in the SvelteKit action prevents garbage from reaching the backend. This is defense in depth. 5. **Role check in `+page.server.ts` `generate` action (line 67) uses frontend-side role.** `locals.benutzer?.rolle !== 'planer'` is a UI guard only — the backend interceptor is the real enforcement. This is correct architecture (defense in depth), but note the role string mismatch: frontend uses `'planer'` (German), backend uses `'planner'` (English). If these ever diverge, the frontend guard breaks silently. Worth documenting. ### Positives - Household isolation is consistently enforced — every service method checks `householdId` match before returning data. IDOR is well-mitigated. - `generateFromPlan` verifies household ownership of the week plan before proceeding. - The interceptor pattern for `@RequiresHouseholdRole` is clean and auditable — single enforcement point. - No `{@html}` usage in any Svelte component — XSS risk from template injection is zero. - Form actions use `use:enhance` with SvelteKit's built-in CSRF protection. - No raw SQL — all queries go through JPA repository methods or derived queries.
Author
Owner

🎨 Atlas — UI/UX Designer

Verdict: ⚠️ Approved with concerns

What I checked

Design system compliance (tokens, fonts, spacing, radii), responsive layout, accessibility (WCAG 2.2 AA), component consistency with existing screens, German localization.

Blockers

None.

Concerns

  1. Checkbox in ChecklistItem.svelte is a <button>, not an <input type="checkbox">. This works visually but breaks native checkbox semantics for screen readers. The aria-label is present, which helps, but assistive tech won't announce it as a checkbox — it'll announce it as a button. Either use a real <input type="checkbox"> (hidden, with a styled label) or add role="checkbox" and aria-checked={isChecked} to the button.

  2. "Alles erledigt!" empty state (when all items are checked) has no visual weight. It's a single <p> centered in the page. Consider adding a subtle icon or illustration consistent with the other empty states. The "no plan" and "no list" states both have links/actions — the "all done" state should feel like a positive completion, not a missing-data state.

  3. ShoppingHeader.svelte generate button has two visual variants (line 44–47) using a ternary in the class. The filled green bg-[var(--green-dark)] for "Liste generieren" vs bordered for "Neu generieren" is a good distinction. However, the button text size/weight/tracking should match the spec: 13px, weight 500, tracking 0.04em, font-sans. The font-medium class gives 500 — correct. 👍

  4. RecipeReferencePanel.svelte recipe cards use text-[13px] font-medium. Good — matches the spec. But the day abbreviation uses text-[11px] which is below our minimum readable size for UI labels. Consider text-[12px] to match the eyebrow pattern used elsewhere.

  5. Mobile filtered staples info (line 97–103 of +page.svelte) uses bg-[var(--color-surface)] but the desktop version in RecipeReferencePanel uses bg-[var(--color-page)]. These should be consistent — the panel sits on --color-surface bg, so its children should use --color-page for contrast. The mobile version sits on --color-page bg, so its children should use --color-surface. Currently they're swapped.

  6. No focus ring/indicator on the ChecklistItem button. When navigating via keyboard, users won't see which item is focused. Add a focus-visible:ring-2 focus-visible:ring-[var(--green)] or similar.

Positives

  • Consistent use of design tokens throughout — --color-text, --color-text-muted, --color-border, --font-sans, --font-display, --radius-md.
  • Correct German labels: "Einkaufsliste", "Artikel übrig", "abgehakt", "Hinzufügen", "Abbrechen", "Grundzutaten ausgeblendet", "Vorrat bearbeiten".
  • Three empty states handle the UX progression correctly: no plan → link to planner; no list → planner can generate; all done → positive message.
  • Desktop 2-panel layout with 280px right sidebar matches the spec.
  • Strikethrough + muted color on checked items is clear and doesn't distract.
## 🎨 Atlas — UI/UX Designer **Verdict: ⚠️ Approved with concerns** ### What I checked Design system compliance (tokens, fonts, spacing, radii), responsive layout, accessibility (WCAG 2.2 AA), component consistency with existing screens, German localization. ### Blockers None. ### Concerns 1. **Checkbox in `ChecklistItem.svelte` is a `<button>`, not an `<input type="checkbox">`.** This works visually but breaks native checkbox semantics for screen readers. The `aria-label` is present, which helps, but assistive tech won't announce it as a checkbox — it'll announce it as a button. Either use a real `<input type="checkbox">` (hidden, with a styled label) or add `role="checkbox"` and `aria-checked={isChecked}` to the button. 2. **"Alles erledigt!" empty state (when all items are checked) has no visual weight.** It's a single `<p>` centered in the page. Consider adding a subtle icon or illustration consistent with the other empty states. The "no plan" and "no list" states both have links/actions — the "all done" state should feel like a positive completion, not a missing-data state. 3. **`ShoppingHeader.svelte` generate button has two visual variants (line 44–47) using a ternary in the class.** The filled green `bg-[var(--green-dark)]` for "Liste generieren" vs bordered for "Neu generieren" is a good distinction. However, the button text size/weight/tracking should match the spec: `13px, weight 500, tracking 0.04em, font-sans`. The `font-medium` class gives `500` — correct. 👍 4. **`RecipeReferencePanel.svelte` recipe cards use `text-[13px] font-medium`.** Good — matches the spec. But the day abbreviation uses `text-[11px]` which is below our minimum readable size for UI labels. Consider `text-[12px]` to match the eyebrow pattern used elsewhere. 5. **Mobile filtered staples info (line 97–103 of `+page.svelte`) uses `bg-[var(--color-surface)]` but the desktop version in `RecipeReferencePanel` uses `bg-[var(--color-page)]`.** These should be consistent — the panel sits on `--color-surface` bg, so its children should use `--color-page` for contrast. The mobile version sits on `--color-page` bg, so its children should use `--color-surface`. Currently they're swapped. 6. **No focus ring/indicator on the `ChecklistItem` button.** When navigating via keyboard, users won't see which item is focused. Add a `focus-visible:ring-2 focus-visible:ring-[var(--green)]` or similar. ### Positives - Consistent use of design tokens throughout — `--color-text`, `--color-text-muted`, `--color-border`, `--font-sans`, `--font-display`, `--radius-md`. - Correct German labels: "Einkaufsliste", "Artikel übrig", "abgehakt", "Hinzufügen", "Abbrechen", "Grundzutaten ausgeblendet", "Vorrat bearbeiten". - Three empty states handle the UX progression correctly: no plan → link to planner; no list → planner can generate; all done → positive message. - Desktop 2-panel layout with 280px right sidebar matches the spec. - Strikethrough + muted color on checked items is clear and doesn't distract.
Author
Owner

📐 Architecture & DevOps Review

Verdict: Approved

What I checked

Layer boundaries, cross-cutting concern design, migration safety, Docker/deployment readiness, API design consistency.

Notes

  1. @RequiresHouseholdRole + HouseholdRoleInterceptor is a clean cross-cutting pattern. Interceptor-based enforcement is the right choice for Spring Boot 4.x without AOP starter. The WebMvcConfig registration is straightforward. This pattern will scale well to other controller methods.

  2. application-docker.yml adds spring.flyway.out-of-order: true. This is safe for development but should not be enabled in production — out-of-order migrations can mask dependency issues. The Docker profile is dev-only, so this is fine.

  3. API path design: GET /v1/shopping-list (singular, week-based lookup) vs GET /v1/shopping-lists/{id} (plural, ID-based) is slightly inconsistent in convention but functional. The singular form avoids the Springdoc collision that was discovered during implementation — pragmatic choice.

  4. Migration V022 is safe: ADD COLUMN IF NOT EXISTS is idempotent. timestamptz NOT NULL DEFAULT now() is correct for PostgreSQL — uses server time, avoids timezone ambiguity.

  5. No deployment or CI concerns. No new dependencies, no infrastructure changes, no environment variables needed. The feature is purely additive.

LGTM from architecture and devops perspective.

## 📐 Architecture & DevOps Review **Verdict: ✅ Approved** ### What I checked Layer boundaries, cross-cutting concern design, migration safety, Docker/deployment readiness, API design consistency. ### Notes 1. **`@RequiresHouseholdRole` + `HouseholdRoleInterceptor` is a clean cross-cutting pattern.** Interceptor-based enforcement is the right choice for Spring Boot 4.x without AOP starter. The `WebMvcConfig` registration is straightforward. This pattern will scale well to other controller methods. 2. **`application-docker.yml` adds `spring.flyway.out-of-order: true`.** This is safe for development but should not be enabled in production — out-of-order migrations can mask dependency issues. The Docker profile is dev-only, so this is fine. 3. **API path design**: `GET /v1/shopping-list` (singular, week-based lookup) vs `GET /v1/shopping-lists/{id}` (plural, ID-based) is slightly inconsistent in convention but functional. The singular form avoids the Springdoc collision that was discovered during implementation — pragmatic choice. 4. **Migration V022 is safe**: `ADD COLUMN IF NOT EXISTS` is idempotent. `timestamptz NOT NULL DEFAULT now()` is correct for PostgreSQL — uses server time, avoids timezone ambiguity. 5. **No deployment or CI concerns.** No new dependencies, no infrastructure changes, no environment variables needed. The feature is purely additive. LGTM from architecture and devops perspective.
Author
Owner

Review concerns addressed

All open reviewer concerns resolved across 8 commits on feat/issue-30-shopping-list.

Backend (commits 40ee4dae3afe1b)

Concern Fix Commit
Backend: merge key duplicated in 3 places Extracted mergeKey(UUID, String) private helper 40ee4da
Sable #1: ForbiddenException leaks role name in API response Changed message to "Insufficient permissions" 40a6a0e
Sable #2: No input validation on AddItemRequest Added @Valid + @NotBlank @Size(max=255) on customName, @Positive on quantity 9d210be
QA #2: No test for stale generated item removal Added generateFromPlanShouldRemoveStaleGeneratedItems eb5ee1a
QA #3: No test for sourceRecipes deduplication Added generateFromPlanShouldDeduplicateSourceRecipesWhenSameRecipeInTwoSlots eb5ee1a
QA #4: No household isolation test for checkItem Added checkItemShouldThrowWhenHouseholdMismatch eb5ee1a
QA #1: No HTTP-level role guard test Added controller test with HouseholdRoleInterceptor wired into standalone MockMvc e3afe1b

Frontend (commits be43fe9f6265ef)

Concern Fix Commit
Kai #2: use:enhance doesn't prevent scroll-to-top on check Added update({ reset: false }) callback be43fe9
Atlas #1: Button needs role="checkbox" + aria-checked Added both attributes to submit button in ChecklistItem be43fe9
Atlas #6: No focus ring on ChecklistItem button Added focus-visible:ring-2 focus-visible:ring-[var(--green)] be43fe9
Atlas #4: Day abbreviation text-[11px] below minimum Changed to text-[12px] in RecipeReferencePanel be43fe9
Kai #6: No loading state on generate button Added generating state; button shows and is disabled during submit be43fe9
Kai #5: AddCustomItem collapses even on action failure Callback now checks result.type === 'success' before collapsing be43fe9
Kai #1: Mobile/desktop layout duplication (~180 lines) Extracted ShoppingChecklist.svelte; used in both layouts 3cd9154
QA #5: No frontend component tests 25 tests across ShoppingHeader.test.ts, ChecklistItem.test.ts, AddCustomItem.test.ts f6265ef

Final state

  • Backend: 282 tests, 0 failures (was 277)
  • Frontend: 503 tests pass, 0 new failures (2 pre-existing recipe search failures unchanged)
  • npm run check: 0 errors
## Review concerns addressed ✅ All open reviewer concerns resolved across 8 commits on `feat/issue-30-shopping-list`. ### Backend (commits `40ee4da` – `e3afe1b`) | Concern | Fix | Commit | |---|---|---| | Backend: merge key duplicated in 3 places | Extracted `mergeKey(UUID, String)` private helper | `40ee4da` | | Sable #1: ForbiddenException leaks role name in API response | Changed message to `"Insufficient permissions"` | `40a6a0e` | | Sable #2: No input validation on `AddItemRequest` | Added `@Valid` + `@NotBlank @Size(max=255)` on `customName`, `@Positive` on `quantity` | `9d210be` | | QA #2: No test for stale generated item removal | Added `generateFromPlanShouldRemoveStaleGeneratedItems` | `eb5ee1a` | | QA #3: No test for `sourceRecipes` deduplication | Added `generateFromPlanShouldDeduplicateSourceRecipesWhenSameRecipeInTwoSlots` | `eb5ee1a` | | QA #4: No household isolation test for `checkItem` | Added `checkItemShouldThrowWhenHouseholdMismatch` | `eb5ee1a` | | QA #1: No HTTP-level role guard test | Added controller test with `HouseholdRoleInterceptor` wired into standalone MockMvc | `e3afe1b` | ### Frontend (commits `be43fe9` – `f6265ef`) | Concern | Fix | Commit | |---|---|---| | Kai #2: `use:enhance` doesn't prevent scroll-to-top on check | Added `update({ reset: false })` callback | `be43fe9` | | Atlas #1: Button needs `role="checkbox"` + `aria-checked` | Added both attributes to submit button in `ChecklistItem` | `be43fe9` | | Atlas #6: No focus ring on ChecklistItem button | Added `focus-visible:ring-2 focus-visible:ring-[var(--green)]` | `be43fe9` | | Atlas #4: Day abbreviation `text-[11px]` below minimum | Changed to `text-[12px]` in `RecipeReferencePanel` | `be43fe9` | | Kai #6: No loading state on generate button | Added `generating` state; button shows `…` and is disabled during submit | `be43fe9` | | Kai #5: `AddCustomItem` collapses even on action failure | Callback now checks `result.type === 'success'` before collapsing | `be43fe9` | | Kai #1: Mobile/desktop layout duplication (~180 lines) | Extracted `ShoppingChecklist.svelte`; used in both layouts | `3cd9154` | | QA #5: No frontend component tests | 25 tests across `ShoppingHeader.test.ts`, `ChecklistItem.test.ts`, `AddCustomItem.test.ts` | `f6265ef` | ### Final state - **Backend**: 282 tests, 0 failures (was 277) - **Frontend**: 503 tests pass, 0 new failures (2 pre-existing recipe search failures unchanged) - **`npm run check`**: 0 errors
marcel added 8 commits 2026-04-06 20:00:12 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- generateFromPlan removes stale generated items
- sourceRecipes deduplicates when same recipe appears in two slots
- checkItem throws ResourceNotFoundException on household mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ChecklistItem: use:enhance with reset:false, role=checkbox, aria-checked, focus ring
- RecipeReferencePanel: day abbreviation text-[12px] (was 11px)
- ShoppingHeader: generating pending state disables button during submit
- AddCustomItem: only collapse form on successful submission

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
25 tests covering counts, planner guard, aria-checked, strikethrough,
recipe labels, expand/collapse, and form submission.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit f6265efa92 into master 2026-04-08 22:22:02 +02:00
marcel deleted branch feat/issue-30-shopping-list 2026-04-08 22:22:03 +02:00
Sign in to join this conversation.