21 KiB
Backend Specification: Household Multi-Tenancy (v2)
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.
Date reviewed: 2025-11-01
Repo modules checked: main.py, api/*, persons/*, meals/*, ingredients/*, recipes/*, products/*, shopping/*, common.py, db.py, settings.py, tests in tests/*.
Key conventions in v1:
- Response shape uses camelCase aliases (via
ApiModelincommon.py). - RFC7807 Problem Details are returned for 400/404/422 with
application/problem+json(handlers inmain.py). - Pagination uses a
Page<T>envelope:{ items: T[], nextCursor?: string, prevCursor?: string, total: number }. - Some endpoints require a
user_idcookie; missing cookie generally yields 422 (validation), except one special-case mapping to 401 (see below).
0. Current v1 Baseline (validated)
0.1 Auth (prototype)
- Mechanism:
user_idcookie containing a Person ID. - Endpoints (
api/auth.py):- POST
/api/v1/auth/loginbody{ username: string }→ setsuser_idcookie if person exists; 404 ProblemDetails if not. - POST
/api/v1/auth/refresh→ returns the current Person (requires cookie).
- POST
- Dependencies (
api/deps.py):cookie_person: requires cookie, loads Person by id; 401 if unknown id, 422 if cookie missing (FastAPI validation).error_response: builds RFC7807 responses.
Special-case 401: In main.py, missing user_id cookie on POST /api/v1/shopping is mapped from 422 → 401 for “Unauthorized”. All other protected endpoints surface 422 when the cookie is missing.
0.2 API surface
-
Persons (
api/persons.py)- GET
/api/v1/persons→Page<Person>with optional name filterq, cursor pagination. - POST
/api/v1/persons→ create Person; setsLocationheader.
- GET
-
Recipes (
api/recipes.py)- GET
/api/v1/recipes→Page<Recipe>; loads ingredients per page. - GET
/api/v1/recipes/{id}→ full recipe (ingredients + createdBy). - GET
/api/v1/recipes/parse?url=...(auth required) → scrape/parse a recipe; 400 if not found. - GET
/api/v1/recipes/ingredients/parse?ingredients=...&ingredients=...→ parse raw ingredient lines (no auth); matches existing products. - POST
/api/v1/recipes(auth required) → validate (≥1 ingredient), perform versioning (hide base if id≥0), set createdById, insert ingredients; setsLocationheader. - DELETE
/api/v1/recipes/{id}(auth required) → soft-delete (hide) recipe.
- GET
-
Meals (
api/meals.py)- 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; setsLocationheader. - PUT
/api/v1/meals/{id}→ validate/update and return current state. - POST
/api/v1/meals/{id}/consumed(auth required) → marks consumed; requires timezone inconsumed_dateif provided; removes any shopping requests for the meal. - DELETE
/api/v1/meals/{id}(auth required) → soft-delete meal via shopping cleanup then update.
- GET
-
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 byidorline. - 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.
- GET
0.3 Data model (SQLite, created by db.create())
- Person(id PK, name UNIQUE)
- Recipe(id PK, name, link, serves, image_urls TEXT JSON, based_on_recipe FK, date_created, created_by_id FK NOT NULL, date_hidden, hidden_by_id FK)
- Ingredient(id PK, name, line, preparation, unit, quantity REAL, product_id FK, recipe_id FK, meal_id FK)
- Product(id PK, product_id UNIQUE, shop_code, link, name, quantity, unit, img_small, img_large, raw_data TEXT) + ProductTag(food_item_id, tag)
- Meal(id PK, suggested_date, consumed_date NULL, deleted_date NULL, purchase_date NULL)
- MealParticipant(meal_id, person_id, role)
- MealRecipe(meal_id, recipe_id, servings)
- ShoppingList(id PK, created_date, store_name, purchased_by_id FK)
- ShoppingListItem(id PK, ingredient_id FK, list_id FK NULL for requests, person_id FK, meal_id FK, recipe_id FK, created_date)
0.4 Validated behaviors and invariants
- ProblemDetails content-type returned for 400/404/422.
- Pagination:
cursoris treated leniently (invalid → start).prevCursoris computed by a DB helper;totalis 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_idand ≥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_dateand clears requests when all meal ingredients are covered by list items. - Outward
storeNameuseshome|woolworths|coles(maps internal empty string tohome).
- Purchase requires
- Auth cookie missing:
- Most protected endpoints → 422 from FastAPI validation.
- Special case: POST
/api/v1/shopping→ 401 via custom handler.
1. Objective
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 ahousehold_id. No API endpoint should ever return data from a household the authenticated user does not belong to. - User vs. Person: The existing
personstable andPersonmodel will be replaced by auserstable and aHouseholdMemberlink table. - Authentication: Replace the prototype
user_idcookie with a standard JWT-based system.
New & Modified Data Models
households(New Table):id: Primary Keyname:TEXT NOT NULLslug:TEXT NOT NULL UNIQUE
users(New Table, replacespersons):id: Primary Keyemail:TEXT NOT NULL UNIQUEdisplay_name:TEXT NOT NULLprofile_photo_url:TEXT
household_members(New Table):user_id: FK tousers.idhousehold_id: FK tohouseholds.idrole:TEXT NOT NULL(e.g., 'admin', 'member')PRIMARY KEY (user_id, household_id)
local_credentials(New Table):user_id: PK, FK tousers.idhashed_password:TEXT NOT NULL
oauth_credentials(New Table):user_id: FK tousers.idprovider:TEXT NOT NULL(e.g., 'google')provider_user_id:TEXT NOT NULLPRIMARY KEY (provider, provider_user_id)
household_invitations(New Table):id: Primary Keyhousehold_id: FK tohouseholds.idemail:TEXT NOT NULLinvited_by_user_id: FK tousers.idtoken:TEXT NOT NULL UNIQUEexpires_at:DATETIME NOT NULLstatus:TEXT NOT NULL('pending', 'accepted', 'expired')
3. API & Logic Changes
3.1. Authentication API
- Refactor
api/auth.py:- Replace existing login/refresh logic with JWT-based authentication.
POST /api/v1/auth/register: Body{ email, password, displayName }. CreatesUserandLocalCredentials. Returns JWT.POST /api/v1/auth/login: Body{ email, password }. Validates credentials. Returns JWT.POST /api/v1/auth/google: Body{ token }. Validates Google ID token, finds/createsUserandOAuthCredentials. Returns JWT.
- JWT Structure: Payload must contain
user_id. Use short-lived access tokens with a long-lived refresh token stored in anHttpOnlycookie.
Notes from v1 baseline to carry forward:
- Replace the
user_idcookie dependency (cookie_person) withget_current_user(JWT) and update all protected endpoints in:api/recipes.py,api/meals.py,api/shopping.py. - Preserve RFC7807 error semantics and the existing OpenAPI augmentation in
api/openapi.py. The special-case 401 mapping for POST/api/v1/shoppingwill become unnecessary—JWT middleware should standardize 401/403.
3.2. Household & Tenancy API
- Create
api/households.py:GET /api/v1/users/me/households: (Auth: JWT) Returns a list of the user's households.POST /api/v1/households: (Auth: JWT) Body{ name }. Creates aHouseholdand adds the user as the first 'admin' member.
- Household-Scoped Routes:
- All data-related routes must be prefixed:
/api/v1/households/{householdSlug}/.... - Create a new dependency in
api/deps.py:get_household_from_slugwill:- Take
householdSlugfrom the path. - Verify the authenticated user (from JWT) is a member of that household.
- Raise
HTTPException(403, "Forbidden")if not a member. - Return the
householdobject (or justhousehold_id) for use in endpoints.
- Take
- All data-related routes must be prefixed:
Impact on existing routes (exact files to refactor):
api/recipes.py,api/meals.py,api/shopping.py,api/products.py→ move underAPIRouter(prefix="/api/v1/households/{householdSlug}")or mount via ahousehold_routerinmain.py.- Repository functions must accept
household_idand filter by it in all SELECT/INSERT/UPDATE. Seemeals.repository,recipes.repository,ingredients.repository,shopping.repository,products.repository. personsis replaced byusers + household_members. Endpoints that currently returnPersonshould instead resolve household-scopedUserreferences.
3.3. Invitation API
- Add to
api/households.py:POST /api/v1/households/{householdSlug}/invitations: (Auth: JWT, household membership). Body{ email }. CreatesHouseholdInvitation, sends email.
- Add to
api/auth.py:POST /api/v1/invitations/accept: (Auth: JWT). Body{ token }. Validates token, adds user to household.
4. Actionable Implementation Steps
- [~] Database Schema and Migration:
- ✅ Schema Definition: Added new packages and tables:
userswith tablesUser,LocalCredentials,OAuthCredentials(seeusers/repository.py).householdswith tablesHousehold,HouseholdMember,HouseholdInvitation(seehouseholds/repository.py).db.create()now initializes these tables alongside existing v1 tables.
- ✅ Migration Logic: Implemented
scripts/migration_to_households.pywhich:- Connect to the database (reusing logic from
db.py). - Calls
create()for new repos to ensure tables exist. - Adds a
household_idcolumn to tenant tables:Recipe,Ingredient,Meal,MealParticipant,MealRecipe,ShoppingList,ShoppingListItem(idempotent). - Creates indices
idx_<table>_household_idfor all above tables. - Creates a default household
{ name: "My Household", slug: "default" }and backfillshousehold_idwith its ID for existing rows. - Ports
Personrows toUser(email derived as<name>@example.com) and createsHouseholdMemberlinks (roleadmin).
- Connect to the database (reusing logic from
- Bootstrap Update: Modify
db.pyso that a fresh database bootstrap (db.create_schema) calls thecreate()functions for the new repositories and not the oldpersonsrepository.
- ✅ Schema Definition: Added new packages and tables:
-
✅ Indices/Constraints: Enforced unique
Household.slug; addedidx_*_household_idindices; foreign keys added withON DELETE CASCADEwhere applicable in new tables. -
✅ Acceptance (initial): Added
tests/test_migration_households.pycovering: new tables exist,household_idcolumns exist, default household created, and data porting fromPersontoUserandHouseholdMember. Full test suite passes. -
Pending follow-ups for this step:
- Add composite indices
(household_id, id)where high-cardinality pagination will benefit. - Extend migration to add FK constraints from tenant tables to
Household(id)where safe. - Plan and implement data backfill for cross-table references once
usersreplacepersonsin code.
- Add composite indices
-
[✅] Implement New Authentication System:
- Implemented v2 JWT auth while keeping v1 cookie auth intact during transition:
api/auth_v2.pynow issues HS256 JWT access tokens and sets an HttpOnly refresh cookie.- Endpoints:
POST /api/v1/auth/register,POST /api/v1/auth/login,POST /api/v1/auth/refresh,POST /api/v1/auth/logout.
- Endpoints:
api/deps.get_current_userverifies JWT access tokens and loads theUserfrom DB.security.pyprovides a minimal JWT utility with configurable issuer/audience, secrets, and TTLs.settings.pyextended with JWT config and secrets via env.- Tests updated:
tests/test_auth_and_households_v2.pynow expects JWT-shaped tokens and verifies refresh flow. - OpenAPI augmentation updated to include
refreshV2in protected ops and to mark/users/me/*and/households/*withbearerAuth+403.
- Notes:
- Password hashing remains SHA-256 placeholder; to be upgraded to bcrypt/argon2 in a follow-up.
- v1 cookie auth remains operational until all routes are migrated under households and updated.
- Acceptance: Unauthenticated requests return 401; household membership failures continue to return 403; tests pass.
- Implemented v2 JWT auth while keeping v1 cookie auth intact during transition:
-
[~] Implement Household Scoping:
- ✅ Created
households/package withmodels.pyandrepository.py. - ✅ Added initial
api/households.pyrouter:GET /api/v1/users/me/households(requires bearer token) → lists memberships.POST /api/v1/households(requires bearer token) → creates household and adds current user as admin.
- ✅ Implemented
get_household_from_sluginapi/deps.py. - ✅ Refactor
main.py:- Create a new
APIRouterfor household-scoped routes, e.g.,household_router = APIRouter(prefix="/api/v1/households/{householdSlug}"). - Mounted a scoped helper endpoint and a new recipes v2 router under this prefix.
- Create a new
- ✅ Recipes scoping:
- Added
api/recipes_v2.pyproviding/api/v1/households/{householdSlug}/recipeswith list/get/create. - Added scoped repo helpers in
recipes/repository.pyand exported viarecipes/__init__.py. - Tests in
tests/test_recipes_household_v2.pyvalidate isolation across households (PASS).
- Added
- ✅ Meals (partial): Added
api/meals_v2.pywith:/api/v1/households/{householdSlug}/meals/upcomingfiltering byhousehold_id./api/v1/households/{householdSlug}/meals/{id}returns 404 across households.- Repository functions
find_upcoming_meals_by_date_range_scopedandfind_meal_by_id_scopedadded. Tests verify isolation.
- ✅ Created
-
✅ Shopping (partial): Added
api/shopping_v2.pywith/api/v1/households/{householdSlug}/shopping/current; added scoped helpers inshopping/repository.pyandshopping/__init__.pyto filter byhousehold_id. Testtests/test_shopping_household_v2.pyverifies isolation of outstanding items. -
⏳ Update Repositories: ingredients, products to accept
household_idand filter accordingly; extend meals create/update/consumed/delete and shopping write flows (purchase, requests) with scoping. -
⏳ Update Routers: move/duplicate remaining routers under the household router and wire
household_idthrough. -
Acceptance: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.
-
Notes:
- Migration added
household_idcolumns and indices, enabling next step to filter by household without additional schema changes.
- Migration added
- [~] Implement Household & Invitation Logic:
- ✅ Households router implemented for listing and creating households.
- ✅ Invitations API:
POST /api/v1/households/{householdSlug}/invitations(JWT + membership): creates a pending invitation and returns a token.POST /api/v1/invitations/accept(JWT): validates token, adds user as member, marks invitation as accepted.- Tests:
tests/test_invitations_v2.pycover create + accept flow and membership visibility.
- ⏳ 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.)
-
[ ] Update OpenAPI Specification:
- ✅ Augmentation updated in
api/openapi.py:- Adds
bearerAuthsecurity scheme while keepingcookieAuthfor v1. - Marks household routes and
/users/me/*with bearer security and adds403Problem response. - Preserves RFC7807 Problem responses and shopping storeName outward enum normalization.
- Adds
- ✅ New tests
tests/test_openapi_security.pyverify presence of bearerAuth and 403s. - ⏳ Export script already writes
openapi.json; once JWT is fully in place and routes are moved under household prefixes, re-run and hand off to frontend.
- ✅ Augmentation updated in
-
[ ] Refactor and Test:
- Update
tests/to reflect the new API structure and authentication. Tests will need to be updated to handle the{householdSlug}path parameter and provide a valid JWT. - Manually test all API flows to ensure data is strictly isolated between households.
- Cleanup: Once all tests pass, remove the
persons/package and any lingering references to it.
- Update
- Keep existing v1 behavior parity (ProblemDetails, Location headers on create, pagination envelopes, ingredient parsing behavior). Update/extend tests in:
tests/test_main.py,tests/test_v1.py,tests/test_shopping*.py.
5. Gaps vs Current Codebase (summary)
What’s missing today (must be implemented in v2):
- JWT-based authentication (register/login/google) and
usersdomain. Replacepersonsentirely. - Household domain:
households,household_members,household_invitationstables, repos, and APIs. - Household scoping: path prefixes, membership checks, repository filtering by
household_idacross all data tables. - OpenAPI security scheme update to JWT bearer;
403responses for membership violations.
What to preserve from v1:
- CamelCase response keys, non-null collection properties,
Page<T>envelope and cursor semantics, RFC7807 responses,Locationheader on create. - Shopping outward storeName normalization ("home" instead of empty string).
Nice-to-have carryovers (already partially implemented as per tighten-api-spec.md):
- Outward union models for shopping list items to reduce nullability (implemented in API without changing DB schema).
6. Edge Cases and Error Semantics
- Unauthenticated → 401 (JWT), not 422 (replace the v1 cookie-missing 422/401 hybrid behavior).
- Not a member of household → 403 with ProblemDetails.
- Validation stays 400 with ProblemDetails (e.g., meal validation failures; recipe without ingredients; shopping invariants).
422reserved for request model validation errors.
7. Rollout & Migration Plan
- Phase A: Introduce
usersalongsidepersons; dual-write in migration; add JWT while keeping cookie for limited time (behind feature flag) if needed. - Phase B: Add households and backfill default household; gate routes to household prefix with a migration shim for old clients if necessary.
- Phase C: Remove cookie auth and
personspackage; require JWT everywhere; finalize OpenAPI to v2.
Data migration acceptance:
- All existing data appears under a default household and is accessible to the migrated user accounts.
8. Acceptance Criteria (v2)
- Authentication: JWT bearer with register/login/google flows; refresh via HttpOnly cookie;
get_current_userdependency replaced everywhere. - Tenancy: All read/write queries filter by
household_id; cross-household access returns 403; invitation flow works. - API parity: v1 behavior preserved aside from auth/paths; ProblemDetails and pagination semantics intact; Location headers set on creates.
- OpenAPI: Security scheme is JWT; household path param present; arrays required and enums normalized; 400/404/422 standardized; 403 added on household routes.