from __future__ import annotations import re from typing import List 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 router = APIRouter(tags=["households"]) class CreateHouseholdBody(ApiModel): name: str class HouseholdResponse(ApiModel): id: int name: str slug: str def slugify(name: str) -> str: s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") return s or "household" @router.get("/users/me/households", response_model=List[HouseholdResponse]) async def list_my_households( request: Request, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): results: list[HouseholdResponse] = [] async with conn.execute( """ SELECT h.id, h.name, h.slug FROM Household h JOIN HouseholdMember m ON m.household_id = h.id WHERE m.user_id = ? ORDER BY h.id """, (user.id,), ) as c: async for row in c: results.append(HouseholdResponse(id=int(row[0]), name=row[1], slug=row[2])) return results @router.post("/households", response_model=HouseholdResponse) async def create_household( request: Request, body: CreateHouseholdBody, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): slug = slugify(body.name) try: async with conn.execute( "INSERT INTO Household (name, slug) VALUES (?, ?)", (body.name, slug) ) as cur: lrid = cur.lastrowid if lrid is None: return error_response(request, 400, "Unable to create household") hid = int(lrid) await conn.execute( "INSERT INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)", (user.id, hid, "admin"), ) return HouseholdResponse(id=hid, name=body.name, slug=slug) except Exception: return error_response(request, 400, "Unable to create household") # Household-scoped router and endpoint to validate scoping mechanics scoped = APIRouter(prefix="/households/{householdSlug}") class WhoAmI(ApiModel): household_id: int household_slug: str @scoped.get("/whoami", response_model=WhoAmI) async def whoami(household=Depends(get_household_from_slug)): return WhoAmI(household_id=household["id"], household_slug=household["slug"]) # Members listing to unblock frontend class HouseholdMember(ApiModel): id: int display_name: str role: str @scoped.get("/members", response_model=list[HouseholdMember]) async def list_members( household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ): members: list[HouseholdMember] = [] async with conn.execute( """ SELECT u.id, u.display_name, m.role FROM HouseholdMember m JOIN User u ON u.id = m.user_id WHERE m.household_id = ? ORDER BY lower(u.display_name), u.id """, (household["id"],), ) as c: async for row in c: members.append(HouseholdMember(id=int(row[0]), display_name=row[1], role=row[2])) return members # Invitations class CreateInvitationBody(ApiModel): email: str class InvitationResponse(ApiModel): token: str status: str = "pending" 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, user: User = Depends(get_current_user), household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ): 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" try: await conn.execute( """ INSERT INTO HouseholdInvitation (household_id, email, invited_by_user_id, token, expires_at, status) VALUES (?, ?, ?, ?, ?, ?) """, (household["id"], body.email, user.id, token, expires_at, "pending"), ) base = settings.frontend_dev_url # 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") class AcceptInvitationBody(ApiModel): token: str class AcceptInvitationResponse(ApiModel): status: str = "accepted" household: HouseholdResponse # Accept invitation (mounted on root router via main.py) @router.post("/invitations/accept", response_model=AcceptInvitationResponse) async def accept_invitation( request: Request, body: AcceptInvitationBody, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): token = body.token # Lookup invitation async with conn.execute( "SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?", (token,), ) as c: row = await c.fetchone() if not row: return error_response(request, 404, "Invitation not found") inv_id = int(row[0]) hid = int(row[1]) status = row[2] if status != "pending": return error_response(request, 400, "Invitation not pending") # Add membership if not exists await conn.execute( "INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)", (user.id, hid, "member"), ) # Mark invitation accepted await conn.execute( "UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?", (inv_id,), ) # Load household details for response async with conn.execute( "SELECT id, name, slug FROM Household WHERE id = ?", (hid,), ) as c: hrow = await c.fetchone() if not hrow: return error_response(request, 404, "Household not found") return AcceptInvitationResponse( status="accepted", household=HouseholdResponse(id=int(hrow[0]), name=hrow[1], slug=hrow[2]), )