- POST `/api/v1/households/{householdSlug}/meals/{mealId}/consumed` marks a meal consumed within the household; validates timezone on provided `consumedDate`; clears outstanding meal requests only within that household.
- POST `/api/v1/households/{householdSlug}/shopping/current/meals/me` requests a meal under the household scope; visible only within that household in GET current.
- DELETE `/api/v1/households/{householdSlug}/shopping/current/meals/{mealId}` unrequests the meal (scoped) and returns `{ ok: true }`.
This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria.
- GET `/api/v1/meals/upcoming?from&to` → upcoming unconsumed meals in range; bulk loads participants, recipes, extra ingredients.
- GET `/api/v1/meals/{id}` → meal with participants/recipes/extra ingredients.
- POST `/api/v1/meals` → validate meal, insert participants, recipes, extra ingredients; sets `Location` header.
- PUT `/api/v1/meals/{id}` → validate/update and return current state.
- POST `/api/v1/meals/{id}/consumed` (auth required) → marks consumed; requires timezone in `consumed_date` if provided; removes any shopping requests for the meal.
- DELETE `/api/v1/meals/{id}` (auth required) → soft-delete meal via shopping cleanup then update.
- Shopping (`api/shopping.py`)
- GET `/api/v1/shopping/current` → aggregate of outstanding ingredient requests (no auth), requested meals, purchased items, plus lookup maps (`meals`, `recipes`, `ingredients`, and any referenced purchased lists). Outward storeName enum uses `"home"` for internal empty string.
- GET `/api/v1/shopping/{listId}` → purchased shopping list + lookups; 404 if not found.
- POST `/api/v1/shopping` (auth required) → purchase list with items; validates invariants in repository; returns full list + lookups. Missing/invalid cookie maps to 401 (special-case).
- GET `/api/v1/shopping/current/me/ingredients` (auth required) → outstanding ingredient requests for me (personId from cookie).
- POST `/api/v1/shopping/current/me/ingredients` (auth required) → sync my ingredient requests (add missing, remove extra). Matches existing entries by `id` or `line`.
- POST `/api/v1/shopping/current/meals/me` (auth required) → request a meal; 404 if meal not found; prevents duplicates.
- DELETE `/api/v1/shopping/current/meals/{mealId}` (auth required) → unrequest a meal; 404 if meal not found.
### 0.3 Data model (SQLite, created by `db.create()`)
- ProblemDetails content-type returned for 400/404/422.
- Pagination: `cursor` is treated leniently (invalid → start). `prevCursor` is computed by a DB helper; `total` is non-null integer.
- Recipe creation requires ≥1 ingredient; soft-deletes older version when updating.
- Meals validation (`api.meals.validate_meal`):
- Must have ≥1 chef, ≥1 cleanup, ≥1 consumer.
- Must have either at least one recipe or at least one extra ingredient.
- No duplicate participants by role.
- Each MealRecipe.servings must be > 0.
- POST `{id}` and body.id must match for update.
- Shopping invariants (`shopping.repository`):
- Purchase requires `purchased_by_id` and ≥1 item.
- Requests: exactly one of (ingredient|meal); person required; ingredient requests must have valid ingredient id (or be inserted when syncing); meal request must not already exist; purchased meal auto-updates `purchase_date` and clears requests when all meal ingredients are covered by list items.
v2 household-scoped API is complete and v1 has been removed from the app. The `persons` package and routes are deleted. Tests have been migrated to v2 equivalents or disabled when purely legacy. OpenAPI reflects JWT bearer and household scoping.
Refactor the backend from a single-tenant architecture to a robust, multi-tenant system based on "Households". This requires evolving the data model to enforce data isolation, overhauling the authentication system to support standard credential types, and introducing an invitation mechanism for household management. This plan is adapted to the existing codebase.
## 2. Core Concepts & Data Model
- **Data Isolation**: All primary resources (`recipes`, `meals`, `shopping_lists`, etc.) MUST be strictly scoped to a `household_id`. No API endpoint should ever return data from a household the authenticated user does not belong to.
- **User vs. Person**: The existing `persons` table and `Person` model will be replaced by a `users` table and a `HouseholdMember` link table.
- **Authentication**: Replace the prototype `user_id` cookie with a standard JWT-based system.
### New & Modified Data Models
- **`households`** (New Table):
-`id`: Primary Key
-`name`: `TEXT NOT NULL`
-`slug`: `TEXT NOT NULL UNIQUE`
- **`users`** (New Table, replaces `persons`):
-`id`: Primary Key
-`email`: `TEXT NOT NULL UNIQUE`
-`display_name`: `TEXT NOT NULL`
-`profile_photo_url`: `TEXT`
- **`household_members`** (New Table):
-`user_id`: FK to `users.id`
-`household_id`: FK to `households.id`
-`role`: `TEXT NOT NULL` (e.g., 'admin', 'member')
-`PRIMARY KEY (user_id, household_id)`
- **`local_credentials`** (New Table):
-`user_id`: PK, FK to `users.id`
-`hashed_password`: `TEXT NOT NULL`
- **`oauth_credentials`** (New Table):
-`user_id`: FK to `users.id`
-`provider`: `TEXT NOT NULL` (e.g., 'google')
-`provider_user_id`: `TEXT NOT NULL`
-`PRIMARY KEY (provider, provider_user_id)`
- **`household_invitations`** (New Table):
-`id`: Primary Key
-`household_id`: FK to `households.id`
-`email`: `TEXT NOT NULL`
-`invited_by_user_id`: FK to `users.id`
-`token`: `TEXT NOT NULL UNIQUE`
-`expires_at`: `DATETIME NOT NULL`
-`status`: `TEXT NOT NULL` ('pending', 'accepted', 'expired')
-`get_current_user` (JWT) is used by protected endpoints (households, shopping purchase, etc.). Legacy cookie-based helpers have been removed from the app surface.
-`api/meals.py`: `/api/v1/households/{householdSlug}/meals` upcoming/get/create/update/consumed/delete (inlined from v2).
-`api/shopping.py`: `/api/v1/households/{householdSlug}/shopping` current/list-by-id/purchase/request/unrequest (inlined from v2, with shared DTOs in `api/shopping_models.py`).
- Request individual ingredient: `POST /api/v1/households/{householdSlug}/shopping/current/ingredients` (scoped). Response is `ListIngredientItem`; item appears in `GET /current` under `outstandingItems`. Household isolation enforced. Duplicate requests for the same ingredient by the same user within the same household return the existing request (no duplicate rows). Covered by `tests/test_shopping_request_ingredient_dedupe_v2.py`.
- Legacy unscoped endpoints (e.g., `/api/v1/meals/*`, `/api/v1/shopping/*`) are not exposed; all data routes are under `/api/v1/households/{householdSlug}/...`.
- Protected household routes return 401 without Authorization; with Authorization, non-existent households yield 404, and non-membership yields 403.
- Tests: `tests/test_route_surface_lockdown.py` asserts these behaviors.
- **Bootstrap Update**: Modify `db.py` so that a fresh database bootstrap (`db.create_schema`) calls the `create()` functions for the new repositories and *not* the old `persons` repository.
- ✅ **Indices/Constraints**: Enforced unique `Household.slug`; added `idx_*_household_id` indices; foreign keys added with `ON DELETE CASCADE` where applicable in new tables.
- ✅ **Acceptance (initial)**: Added `tests/test_migration_households.py` covering: new tables exist, `household_id` columns exist, default household created, and data porting from `Person` to `User` and `HouseholdMember`. Full test suite passes.
-`POST /api/v1/households/{householdSlug}/meals/{id}/consumed` marks consumed with optional `consumedDate` (requires timezone if provided); removes outstanding meal requests; all operations scoped to household.
-`POST /api/v1/households/{householdSlug}/meals` creates a meal (scoped) with Location header; reuses v1 validation (≥1 chef, ≥1 cleanup, ≥1 consumer; ≥1 recipe or ≥1 extra ingredient).
-`PUT /api/v1/households/{householdSlug}/meals/{id}` updates a meal (scoped), enforcing URL/body ID match and validation; returns updated state.
-`DELETE /api/v1/households/{householdSlug}/meals/{id}` soft-deletes a meal (scoped) after removing outstanding requests in that household.
- GET `/api/v1/households/{householdSlug}/shopping/current` (scoped aggregate).
- GET `/api/v1/households/{householdSlug}/shopping/{listId}` returning purchased list + lookups scoped to household; cross-household returns 404.
- POST `/api/v1/households/{householdSlug}/shopping` to purchase list items scoped to household; validates invariants and updates outstanding requests.
- Scoped helpers in `shopping/repository.py` and `shopping/__init__.py` filter by `household_id` (find items, load list, purchased ingredients, and purchase_scoped).
- Notes: Normalized Ingredient.preparation to allow NULLs from DB (treated as empty string) to avoid 422 in v2 responses; set purchased_by_id in scoped purchase from JWT user.
- Tests: `tests/test_meals_write_v2.py` verifies create/update/delete flows under household scope; all v2 scoping tests PASS.
- **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403. Achieved.
- Data access policy: API layers must delegate persistence to repository modules; no direct SQL in routers. Current status: recipes, meals, and shopping routers call into their repositories for reads/writes. Legacy `*_v2.py` files have been removed; canonical routers are `api/recipes.py`, `api/meals.py`, and `api/shopping.py`.
- ⏳ Email Delivery: Stub only. Pending adding an email sender utility/service and persistence of delivery state.
- **Acceptance**: Admins can invite by email; invite accept adds user to household; listing households shows membership with roles. (Met for API behavior; email sending pending.)
- Tests updated to v2 household-scoped endpoints with JWT setup. Legacy v1 API tests are skipped (`tests/test_v1.py`), and the legacy `tests/test_main.py` has been removed.
- Implemented: JWT auth v2 with refresh cookie; household domains and membership; invitations; full household scoping across recipes/meals/shopping; OpenAPI augmentation with bearerAuth and 403; RFC7807 preserved; tests green.
- DTO alignment: Meals use MemberRef { id, displayName } (no Person in outward schema). MemberRef consolidated in `api/dtos.py`. Shopping DTOs/mappers consolidated in `api/shopping_models.py`.
- DTO alignment (v2): Meals use MemberRef; Recipes include `createdById` and `createdBy` (MemberRef); Shopping `purchasedBy` is now a MemberRef on outward lists.
- Current v2 recipes shape: `createdById` and `createdBy` (MemberRef) are included. Delete now returns `hiddenById` and `hiddenBy` (MemberRef) for the acting user.
1. Remove the `persons/` package and all code references across domains (recipes, meals, shopping). Replace `Person` with `users`/HouseholdMember everywhere:
- Code hotspots today: `recipes/models.py` (imports Person), `recipes/repository.py` (FKs, hide_recipe signature), `meals/models.py` (participants as List[Person]), `api/shopping_models.py` and `shopping/models.py` (ShoppingList.purchased_by typed as Person), and `shopping/repository.py` (FKs to Person).
- Replace internal usages with `user_id`/`MemberRef` where outward, and update repository DDL to stop referencing `Person`.
2. Recipes outward schema: DONE for `createdById`/`createdBy` and `hiddenById`/`hiddenBy` (MemberRef on delete). If/when we expose hide actor on other flows, keep MemberRef.
4. Delete or port legacy v1 test modules that are currently skipped (`tests/test_main.py`, `tests/test_v1.py`). Either migrate assertions to v2 routes or remove them so the full test run has no skips. Then remove the last vestiges of v1-only helpers.
5. Database polish:
- Add composite indices like `(household_id, id)` where pagination benefits (e.g., Recipe, Meal, ShoppingListItem).
- Add explicit FK constraints from tenant tables to `Household(id)` where safe.
- Ensure fresh bootstraps include `household_id` in table DDL (e.g., `recipes.repository.create`), not just via migration.
- Phase A: Introduced `users` alongside `persons`; added JWT while cookie auth remained briefly for transition.
- Phase B: Added households and backfilled default household; migrated routes under `/households/{householdSlug}`.
- Phase C (now): Removed cookie auth from app surface and inlined v2 routers; final step is to remove `persons` entirely and ensure no tests are skipped.
- Routers must use repository helpers for all persistence; direct `conn.execute(...)` calls in routers are prohibited, except for controlled PRAGMA/transaction management in `api/deps.py`.
- Recipes create now accepts a lean body (`RecipeCreate`) without internal IDs and sets `createdById` from the JWT user; delete uses a repository helper to set `date_hidden` and `hidden_by_id` atomically.
- Canonical API files are:
-`api/recipes.py` (household-scoped recipes)
-`api/meals.py` (household-scoped meals)
-`api/shopping.py` (household-scoped shopping)
-`api/households.py` (memberships and scoped helpers)
- Remove remaining `Person` fallbacks in domain repositories and delete the `persons/` package once tests are migrated. Ensure no code paths depend on `cookie_person`.
- Consider adding versioning endpoints for recipes explicitly rather than overloading POST.
- Add a temporary test helper to seed `User`/`HouseholdMember` rows from legacy `Person` when needed (now available as `tests/user_fixtures.py`). Use this to migrate tests off `persons` before deleting the package.