diff --git a/api/auth.py b/api/auth.py index 3784dfb..e295404 100644 --- a/api/auth.py +++ b/api/auth.py @@ -44,7 +44,7 @@ async def login( @router.post( - "/refresh", + "/refresh-cookie", response_model=persons.Person, operation_id="refresh", summary="Refresh current user from cookie", diff --git a/api/shopping_v2.py b/api/shopping_v2.py index 792d190..8b872a7 100644 --- a/api/shopping_v2.py +++ b/api/shopping_v2.py @@ -6,7 +6,7 @@ import aiosqlite from fastapi import APIRouter, Depends, Request, Response import shopping -from api.deps import error_response, get_db, get_household_from_slug +from api.deps import error_response, get_db, get_household_from_slug, get_current_user from api.shopping import ( CurrentShoppingList, PurchasedShoppingList, @@ -105,6 +105,7 @@ async def purchase_ingredients_scoped( shopping_list: PurchaseListIn, request: Request, household=Depends(get_household_from_slug), + user=Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ) -> PurchasedShoppingList | Response: hid = household["id"] @@ -123,7 +124,9 @@ async def purchase_ingredients_scoped( ) ) - domain_list = shopping.ShoppingList(items=domain_items, store_name=shopping_list.store_name) + domain_list = shopping.ShoppingList( + items=domain_items, store_name=shopping_list.store_name, purchased_by_id=user.id + ) try: # Use household-scoped purchase which ensures requests belong to the same household diff --git a/backend-spec.md b/backend-spec.md index a12e97b..a032cbb 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -2,7 +2,7 @@ - GET `/api/v1/households/{householdSlug}/shopping/{listId}` returning purchased list + lookups scoped to household; cross-household returns 404. - 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. - Tests: `tests/test_meals_consumed_v2.py` validates scoping (requests cleared in same household, unaffected in other household). PASS. - - Tests: `tests/test_shopping_household_v2.py` verifies isolation of outstanding items; `tests/test_shopping_list_by_id_v2.py` verifies list-by-id scoping (PASS). + - Tests: `tests/test_shopping_household_v2.py` verifies isolation of outstanding items; `tests/test_shopping_list_by_id_v2.py` verifies list-by-id scoping; `tests/test_shopping_purchase_v2.py` covers scoped purchase (PASS). # 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. @@ -222,7 +222,7 @@ Impact on existing routes (exact files to refactor): - ✅ Refactor `main.py`: - Create a new `APIRouter` for 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. - - ✅ Recipes scoping: + - ✅ Recipes scoping: - Added `api/recipes_v2.py` providing `/api/v1/households/{householdSlug}/recipes` with list/get/create. - Added scoped repo helpers in `recipes/repository.py` and exported via `recipes/__init__.py`. - Tests in `tests/test_recipes_household_v2.py` validate isolation across households (PASS). @@ -237,7 +237,8 @@ Impact on existing routes (exact files to refactor): - 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). - - Tests: `tests/test_shopping_household_v2.py` (current isolation), `tests/test_shopping_list_by_id_v2.py` (list-by-id scoping), `tests/test_shopping_purchase_v2.py` (scoped purchase). PASS. + - Tests: `tests/test_shopping_household_v2.py` (current isolation), `tests/test_shopping_list_by_id_v2.py` (list-by-id scoping), `tests/test_shopping_purchase_v2.py` (scoped purchase). PASS. + - 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. - ⏳ Update Repositories: ingredients, products to accept `household_id` and 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_id` through. - **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403. diff --git a/ingredients/models.py b/ingredients/models.py index c9178ce..0c9bac9 100644 --- a/ingredients/models.py +++ b/ingredients/models.py @@ -46,3 +46,9 @@ class Ingredient(ApiModel): except ValueError: return v return v + + # Backward compatibility: DB may contain NULL preparation; normalize to empty string + @field_validator("preparation", mode="before") + @classmethod + def _normalize_preparation(cls, v: Any) -> Any: + return "" if v is None else v diff --git a/openapi.json b/openapi.json index e67d396..2dfd8f1 100644 --- a/openapi.json +++ b/openapi.json @@ -1321,27 +1321,45 @@ } } }, - "/api/v1/auth/refresh": { + "/api/v1/auth/refresh-cookie": { "post": { "tags": [ - "v2", - "auth-v2" + "v1", + "auth" + ], + "summary": "Refresh current user from cookie", + "operationId": "refresh", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } ], - "summary": "Refresh", - "operationId": "refreshV2", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RefreshResponse" + "$ref": "#/components/schemas/Person" } } } }, "422": { - "$ref": "#/components/responses/Problem422" + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } }, "security": [ @@ -1393,6 +1411,36 @@ } } }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "v2", + "auth-v2" + ], + "summary": "Refresh", + "operationId": "refreshV2", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshResponse" + } + } + } + }, + "422": { + "$ref": "#/components/responses/Problem422" + } + }, + "security": [ + { + "cookieAuth": [] + } + ] + } + }, "/api/v1/auth/logout": { "post": { "tags": [