diff --git a/api/__init__.py b/api/__init__.py index f07c359..e18ed25 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,2 +1,2 @@ # API package for FastAPI routers. -# Routers will be split by feature: recipes, meals, persons, shopping, auth. +# Routers are split by feature: recipes, meals, shopping, auth, households. diff --git a/api/households.py b/api/households.py index 4c64b40..14b74cf 100644 --- a/api/households.py +++ b/api/households.py @@ -7,6 +7,7 @@ import aiosqlite from fastapi import APIRouter, Depends, Request from api.deps import get_current_user, get_db, error_response, get_household_from_slug +from pydantic import Field from common import ApiModel from users.models import User @@ -126,7 +127,12 @@ class InvitationResponse(ApiModel): status: str = "pending" -@scoped.post("/invitations", response_model=InvitationResponse) +class InviteLinkResponse(ApiModel): + # Force snake_case in JSON output to match spec and tests + invite_link: str = Field(serialization_alias="invite_link") + + +@scoped.post("/invitations", response_model=InviteLinkResponse) async def create_invitation( request: Request, body: CreateInvitationBody, @@ -136,6 +142,8 @@ async def create_invitation( ): import secrets from datetime import datetime, timedelta + from urllib.parse import urljoin, urlencode + from settings import settings token = secrets.token_urlsafe(24) expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z" @@ -147,7 +155,13 @@ async def create_invitation( """, (household["id"], body.email, user.id, token, expires_at, "pending"), ) - return InvitationResponse(token=token) + base = settings.frontend_dev_url or "http://localhost:8080/" + # Ensure base ends with a slash for urljoin + if not base.endswith("/"): + base = base + "/" + path_with_query = f"invitations/accept?{urlencode({'token': token})}" + invite_link = urljoin(base, path_with_query) + return InviteLinkResponse(invite_link=invite_link) except Exception: return error_response(request, 400, "Unable to create invitation") diff --git a/api/meals.py b/api/meals.py index dee7ea5..2c7434d 100644 --- a/api/meals.py +++ b/api/meals.py @@ -440,13 +440,9 @@ async def delete_meal_scoped( if not meal: return error_response(request, 404, "Meal not found") # Remove outstanding requests for this meal in current household - try: - from shopping.repository import remove_meal_request_scoped + from shopping.repository import remove_meal_request_scoped - await remove_meal_request_scoped(conn, meal_id, hid) - except Exception: - # Fallback: remove regardless of household (legacy cleanup) - await shopping.remove_request(conn, person=None, meal=meal) + await remove_meal_request_scoped(conn, meal_id, hid) await meals.delete_meal(conn, meal.id) return MealOut( diff --git a/backend-spec.md b/backend-spec.md index a0f3008..3114ee4 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -10,7 +10,7 @@ The objective is to complete the final remaining tasks to officially close out t This checklist represents all remaining work. -- [ ] **1. Implement "Copy Invite Link" API**: +- [x] **1. Implement "Copy Invite Link" API**: - **Objective**: Modify the invitation creation logic to support a "copy link" UX on the frontend, instead of sending an email. - **File**: `api/households.py` - **Action**: The existing `POST /api/v1/households/{householdSlug}/invitations` endpoint should be modified. Instead of returning a simple `201 Created`, it must create the invitation token and return a JSON object containing the full, shareable URL. @@ -21,11 +21,11 @@ This checklist represents all remaining work. } ``` -- [ ] **2. Final Codebase Sweep**: +- [x] **2. Final Codebase Sweep**: - **Objective**: Perform a final search for and remove any dead code, comments, or variables related to the old system. - **Action**: Search the entire codebase for the following keywords: `legacy`, `old`, `previous`, `workaround`, `fallback`, `person`. - **Outcome**: Any remaining artifacts from the migration are pruned, leaving the codebase in a clean, maintainable state for future development. -- [ ] **3. Mark Project as Complete**: +- [x] **3. Mark Project as Complete**: - **Objective**: Once the above tasks are done, this document is complete. - **Action**: Check this box and archive this specification. diff --git a/main.py b/main.py index 942e39c..583812b 100644 --- a/main.py +++ b/main.py @@ -89,7 +89,7 @@ async def request_validation_exc_handler(request: Request, exc: Exception): for e in exc.errors(): loc = ".".join([str(p) for p in e.get("loc", [])]) errors.setdefault(loc, []).append(e.get("msg")) - # Legacy cookie-based auth behavior removed; standard 422 for validation errors + # Standard 422 for request validation errors body = ProblemDetails( title="Validation Error", diff --git a/meals/repository.py b/meals/repository.py index 2126ec5..10fd653 100644 --- a/meals/repository.py +++ b/meals/repository.py @@ -284,7 +284,7 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None: """Populate participants for many meals in one query to avoid N+1. For each meal, fills meal.chefs, meal.cleanup, meal.consumers using a bulk - lookup of MealParticipant rows and a single persons.get_by_ids fetch. + lookup of MealParticipant rows and a single user lookup. """ if not meals: return diff --git a/openapi.json b/openapi.json index 6dd3442..b07fd6a 100644 --- a/openapi.json +++ b/openapi.json @@ -397,7 +397,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationResponse" + "$ref": "#/components/schemas/InviteLinkResponse" } } } @@ -2078,23 +2078,18 @@ ], "title": "IngredientPurchaseItemIn" }, - "InvitationResponse": { + "InviteLinkResponse": { "properties": { - "token": { + "invite_link": { "type": "string", - "title": "Token" - }, - "status": { - "type": "string", - "title": "Status", - "default": "pending" + "title": "Invite Link" } }, "type": "object", "required": [ - "token" + "invite_link" ], - "title": "InvitationResponse" + "title": "InviteLinkResponse" }, "ListIngredientItem": { "properties": { diff --git a/tests/test_invitations_v2.py b/tests/test_invitations_v2.py index d0d963a..5a81ed5 100644 --- a/tests/test_invitations_v2.py +++ b/tests/test_invitations_v2.py @@ -46,7 +46,17 @@ class TestInvitationsV2(unittest.IsolatedAsyncioTestCase): json={"email": "invitee@test.com"}, ) assert r.status_code == 200, r.text - token = r.json()["token"] + body = r.json() + invite_link = body.get("invite_link") + assert isinstance(invite_link, str) and "/invitations/accept?token=" in invite_link + + # Extract token from invite_link + from urllib.parse import urlparse, parse_qs + + qs = parse_qs(urlparse(invite_link).query) + token_list = qs.get("token", []) + assert token_list and isinstance(token_list[0], str) + token = token_list[0] assert isinstance(token, str) and len(token) >= 16 # Register invitee