Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .specswarm/features/009-meal-prep/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Specification Quality Checklist: Meal Prep — Batch Sessions & Make-Ahead Integrations

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-06-13
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs) — table/column names appear only in
Key Entities as domain entities, consistent with the in-repo spec house style (cf. 004)
- [x] Focused on user value and business needs (make-ahead loop, HAVE_IT)
- [x] Written for non-technical stakeholders (scenarios in plain language)
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain (two scope forks resolved in Clarifications)
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable (SC-001..006)
- [x] Success criteria are technology-agnostic
- [x] All acceptance scenarios are defined (4 scenarios)
- [x] Edge cases identified (cancel, re-completion idempotency, exhausted portions → MUST_ACQUIRE)
- [x] Scope clearly bounded (Included vs Excluded; distribution explicitly deferred)
- [x] Dependencies and assumptions identified (Assumptions section)

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows (batch cook, HAVE_IT, regression, cancel)
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- Scope was materially reshaped during specification: investigation found the direct-entry prepped
stack already production-ready (feature 001) and HAVE_IT derivation already shipped (feature 004).
The two scope forks (batch-sessions+integrations; yield-to-inventory-only) were confirmed with the
user before writing.
- REQ-PP-005..009 (auto-distribution, planning horizon, replan) explicitly deferred — recorded in
Excluded with a note that the schema leaves room to add them later.
- One assumption to confirm in `/ss:clarify` if needed: recipe base-yield field availability for
ingredient scaling (FR-PP-020 / Assumptions).
91 changes: 91 additions & 0 deletions .specswarm/features/009-meal-prep/contracts/meal-prep-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Contracts: Meal Prep Service

SPA data access is client-side `supabase-js` against PostgREST (no REST endpoints of our own). The
"contracts" are therefore the client service signatures and the PostgREST operations they perform,
plus their invariants. All operations run under the caller's RLS context (owner-scoped).

## `mealPrepService.ts`

```ts
// CREATE — session + its recipes (>=1). INV-PL-006 enforced here (reject empty).
createSession(draft: NewMealPrepSession): Promise<MealPrepSession>
// inserts meal_prep_sessions (status PLANNED) + meal_prep_session_recipes rows.
// throws if draft.recipes.length < 1 or any servingsToPrep <= 0 (INV-PL-007).

// READ
getSessions(householdId?: string | null): Promise<MealPrepSession[]> // PLANNED first, then by prep_day
getSession(id: string): Promise<MealPrepSession | null>

// UPDATE (PLANNED only)
addRecipe(sessionId: string, r: { recipeId; recipeName; servingsToPrep }): Promise<MealPrepSession>
removeRecipe(sessionId: string, sessionRecipeId: string): Promise<MealPrepSession>
// rejects removal of the last recipe (would violate INV-PL-006).
updateServings(sessionRecipeId: string, servingsToPrep: number): Promise<void> // > 0
setPrepDay(sessionId: string, prepDay: string): Promise<void> // not in past

// COMPLETE — yield to inventory (idempotent). FR-PP-014/015/016.
completeSession(sessionId: string, yields: YieldChoice[]): Promise<MealPrepSession>
// guard: SELECT prepped_meals WHERE meal_prep_session_id = sessionId.
// if rows exist -> return session unchanged (already yielded).
// for each session recipe: addPreppedMeal({
// origin: 'PREP_SESSION', recipe_id, recipe_name, meal_prep_session_id: sessionId,
// name: recipeName, original_portions: servingsToPrep, portions_remaining: servingsToPrep,
// storage_location, prepared_date: today, expiration_date: today + shelfLife(storage),
// defrost_state: storage === 'FREEZER' ? 'FROZEN' : 'NOT_APPLICABLE' })
// then UPDATE meal_prep_sessions SET status='COMPLETED', completed_at=now().
// NOTE: addPreppedMeal is being FIXED in this feature to NOT fire a positive INITIALIZED event
// (Design A) — the row insert is authoritative for the starting count (INV-INV-011/INV-CC-006).

// CANCEL — PLANNED -> CANCELLED, no portions. FR-PP-017.
cancelSession(sessionId: string): Promise<MealPrepSession>

// PREP -> SHOPPING. FR-PP-020..023.
generatePrepShoppingList(sessionId: string): Promise<{ shoppingListId: string; itemCount: number }>
// loads each recipe's ingredients, scales by servingsToPrep/recipe.servings,
// computes gap vs pantry index, then writes shopping_lists(source_type='FROM_PREP',
// meal_prep_session_id=sessionId) + shopping_list_items (each item notes forRecipes).
// available while session is PLANNED (does not require completion).
```

### Errors
- `MealPrepServiceError(message, cause?)` — wraps supabase errors, mirrors `PreppedMealServiceError`.
- Yield reuses `PreppedMealServiceError` / `PortionLedgerError` from the prepped stack unchanged.

## `prepShoppingList.ts` (pure — no I/O, node-testable)

```ts
interface RecipeForPrep {
recipeId: string;
recipeName: string;
baseServings: number; // recipes.servings (>0)
servingsToPrep: number; // >0
ingredients: { name: string; unit: string; quantity: number; isOptional: boolean }[];
}

// required = quantity * (servingsToPrep / baseServings); aggregate by (normName, unit);
// drop items whose normName is in pantryIndex; optional ingredients excluded from the gap.
computePrepShoppingGap(
recipes: RecipeForPrep[],
pantryIndex: Set<string> // normalized names with quantity > 0
): PrepShoppingGapItem[]

// normName = name.trim().toLowerCase().replace(/\s+/g, ' ') (matches fulfillment.ts)
```

## `mealPrepStore.svelte.ts` (runes)
Exposes: `sessions()`, `sessionsLoading()`, `sessionsError()`, `loadSessions()`,
`optimisticCreateSession()`, `optimisticCompleteSession()`, `optimisticCancelSession()`,
`optimisticAddRecipe()`, `optimisticRemoveRecipe()`. Optimistic-update-then-resync, mirroring
`preppedMealStore.svelte.ts`. After `completeSession`, also triggers `loadPreppedMeals()` so the
Prepped tab reflects the new yield.

## Invariant checklist (verified by tests)
- INV-PL-006: cannot create/leave a session with 0 recipes.
- INV-PL-007: `servings_to_prep > 0` (DB CHECK + service guard).
- INV-PL-008: `completed_at` set ⟺ `status='COMPLETED'` (DB CHECK).
- INV-INV-006: yielded portions have valid `recipe_id` + `recipe_name`.
- INV-INV-007/009: FREEZER yields are FROZEN; `expiration_date > prepared_date`.
- INV-CC-006/P14: portions written only via `portion_events` (no blind column write).
- INV-XD-004: `FROM_PREP` shopping list always references a valid session (DB CHECK).
- FR-PP-014: re-completion yields no duplicate portions (idempotency guard).
- P7: pgTAP asserts owner-only access + anon sees zero rows on both new tables.
162 changes: 162 additions & 0 deletions .specswarm/features/009-meal-prep/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Data Model: Meal Prep — Batch Sessions

## New tables (migration 0009)

### `meal_prep_sessions`

| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` PK `DEFAULT gen_random_uuid()` | P8 |
| `owner_id` | `uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE` | RLS scope |
| `household_id` | `uuid` (nullable, no FK) | mirrors `prepped_meals`; Household feature later |
| `status` | `meal_prep_session_status NOT NULL DEFAULT 'PLANNED'` | enum below |
| `prep_day` | `date NOT NULL` | suggested/overridden (REQ-PP-003/004) |
| `completed_at` | `timestamptz` (nullable) | set iff `status='COMPLETED'` (INV-PL-008) |
| `created_at` | `timestamptz NOT NULL DEFAULT now()` | P9 |
| `updated_at` | `timestamptz NOT NULL DEFAULT now()` | P9 |

Enum: `CREATE TYPE meal_prep_session_status AS ENUM ('PLANNED','COMPLETED','CANCELLED');`

Constraint (INV-PL-008):
```sql
CONSTRAINT meal_prep_sessions_completed_has_timestamp
CHECK ((status = 'COMPLETED') = (completed_at IS NOT NULL))
```
Indexes: `(owner_id)`, `(household_id)`, `(prep_day)`.

> INV-PL-006 (≥1 recipe per session) is a cross-row invariant — enforced in the service layer
> (a session is created together with its first recipe; a recipe-removal that would empty a session
> is rejected). Documented as not statically enforceable by a single-table CHECK, same pattern the
> recipes module uses for "≥1 ingredient" (0001_recipes.sql:8).

### `meal_prep_session_recipes`

| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` PK `DEFAULT gen_random_uuid()` | P8 |
| `meal_prep_session_id` | `uuid NOT NULL REFERENCES meal_prep_sessions(id) ON DELETE CASCADE` | |
| `recipe_id` | `uuid NOT NULL REFERENCES recipes(id) ON DELETE CASCADE` | |
| `recipe_name` | `text NOT NULL CHECK (char_length(recipe_name) BETWEEN 1 AND 500)` | denormalized snapshot |
| `servings_to_prep` | `integer NOT NULL CHECK (servings_to_prep > 0)` | INV-PL-007 |
| `created_at` | `timestamptz NOT NULL DEFAULT now()` | |

Constraint: `UNIQUE (meal_prep_session_id, recipe_id)` — a recipe appears at most once per session.
Index: `(meal_prep_session_id)`.

### RLS (P7 — default-deny, owner-scoped)
```sql
ALTER TABLE meal_prep_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "meal_prep_sessions_owner_all" ON meal_prep_sessions
FOR ALL USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());

ALTER TABLE meal_prep_session_recipes ENABLE ROW LEVEL SECURITY;
-- access gated through the parent session's ownership
CREATE POLICY "meal_prep_session_recipes_owner_all" ON meal_prep_session_recipes
FOR ALL USING (EXISTS (SELECT 1 FROM meal_prep_sessions s
WHERE s.id = meal_prep_session_id AND s.owner_id = auth.uid()))
WITH CHECK (EXISTS (SELECT 1 FROM meal_prep_sessions s
WHERE s.id = meal_prep_session_id AND s.owner_id = auth.uid()));
```

### Data API grants (per project memory — post-2026-05-30 grant flip)
```sql
GRANT SELECT, INSERT, UPDATE, DELETE ON meal_prep_sessions, meal_prep_session_recipes TO authenticated;
GRANT ALL ON meal_prep_sessions, meal_prep_session_recipes TO service_role;
GRANT SELECT ON meal_prep_sessions, meal_prep_session_recipes TO anon; -- RLS still yields 0 rows pre-login
```
`updated_at` touch trigger on `meal_prep_sessions` matching the repo's existing convention.

### Ownership guard (security review)
`check_session_recipe_ownership()` (SECURITY DEFINER, `BEFORE INSERT OR UPDATE OF recipe_id`)
rejects a session recipe whose `recipe_id` is not owned by `auth.uid()` — the bare FK would
otherwise be a cross-user existence oracle and could propagate a foreign `recipe_id` into yielded
`prepped_meals`. Mirrors 0007's `check_source_planned_meal_ownership`.

## Altered table (migration 0010) — shopping FROM_PREP (INV-XD-004)

```sql
-- Step 1 (own statement): extend the enum
ALTER TYPE shopping_list_source ADD VALUE IF NOT EXISTS 'FROM_PREP';

-- Step 2: link column + integrity
ALTER TABLE shopping_lists ADD COLUMN meal_prep_session_id uuid
REFERENCES meal_prep_sessions(id) ON DELETE SET NULL;

ALTER TABLE shopping_lists ADD CONSTRAINT shopping_lists_from_prep_has_session
CHECK (
(source_type = 'FROM_PREP' AND meal_prep_session_id IS NOT NULL) OR
(source_type <> 'FROM_PREP' AND meal_prep_session_id IS NULL)
);
```
Existing `FROM_PLAN` range CHECK (0007) is untouched. `FROM_PREP` lists carry no generated_range
(those remain NULL — the existing range CHECK only constrains FROM_PLAN).

### Detach-on-delete (security review)
`detach_prep_shopping_lists()` (`BEFORE DELETE ON meal_prep_sessions`) flips any linked
`FROM_PREP` list to `MANUAL` and nulls its `meal_prep_session_id` before the FK's `SET NULL`
fires. Without it, deleting a session that generated a shopping list would null the FK on a row
still tagged `FROM_PREP`, violating the INV-XD-004 CHECK and aborting the delete (and the
`auth.users` delete cascade). The list is the user's work product and survives as a plain list.

## Reused tables (no schema change)
- `prepped_meals` (0003): yield target — `origin='PREP_SESSION'`, `meal_prep_session_id` set,
`recipe_id`/`recipe_name` set (INV-INV-006). `meal_prep_session_id` column already exists.
- `portion_events` (0004): `INITIALIZED` event per yielded portion (via `addPreppedMeal()`).
- `recipes` / `recipe_ingredients` (0001): read for scaling & shopping aggregation.
- `pantry_items` (0002): read for gap detection.
- `planned_meals` (0006): PREPPED source already supports prepped portions (feature 004).

## Client types (new — `src/lib/planning/mealPrep/types.ts`)

```ts
export type MealPrepSessionStatus = 'PLANNED' | 'COMPLETED' | 'CANCELLED';

export interface MealPrepSessionRecipe {
id: string;
recipeId: string;
recipeName: string;
servingsToPrep: number;
}

export interface MealPrepSession {
id: string;
status: MealPrepSessionStatus;
prepDay: string; // ISO date
completedAt: string | null;
recipes: MealPrepSessionRecipe[];
}

export interface NewMealPrepSession {
prepDay: string;
recipes: { recipeId: string; recipeName: string; servingsToPrep: number }[]; // length >= 1
}

/** Per-recipe storage choice captured at completion. */
export interface YieldChoice {
sessionRecipeId: string;
storageLocation: 'FRIDGE' | 'FREEZER';
}

/** Pure prep→shopping output (prepShoppingList.ts). */
export interface PrepShoppingGapItem {
name: string;
unit: string;
quantity: number;
forRecipes: string[]; // recipe names that contribute this item
}
```

## State transitions

```
create (>=1 recipe)
∅ ───────────────────────────▶ PLANNED
│ complete() [guard: no existing yielded portions]
│ ├─ yield N prepped_meals (origin=PREP_SESSION, INITIALIZED events)
│ └─ set status=COMPLETED, completed_at=now()
▼
COMPLETED (terminal)
PLANNED ──cancel()──▶ CANCELLED (terminal; no portions created)
```
Completion is idempotent: re-running `complete()` on a session that already has linked prepped
portions performs no further yield (FR-PP-014).
Loading
Loading