phase complete

This commit is contained in:
jableader 2025-11-02 21:33:23 +11:00
parent fb90a4ad32
commit 126ed0572c
8 changed files with 41 additions and 26 deletions

View file

@ -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.

View file

@ -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")

View file

@ -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(

View file

@ -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.

View file

@ -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",

View file

@ -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

View file

@ -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": {

View file

@ -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