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 households import repository as households_repo 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 = await households_repo.list_for_user(conn, user.id) return [HouseholdResponse.model_validate(h) for h in 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) household = await households_repo.create_for_user(conn, body.name, slug, user.id) if household is None: return error_response(request, 400, "Unable to create household") return HouseholdResponse.model_validate(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), ): member_data = await households_repo.list_members(conn, household["id"]) return [HouseholdMember.model_validate(m) for m in member_data] # 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" success = await households_repo.create_invitation( conn, household["id"], body.email, user.id, token, expires_at ) if not success: return error_response(request, 400, "Unable to create invitation") 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) 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), ): # Lookup invitation invitation = await households_repo.get_invitation_by_token(conn, body.token) if not invitation: return error_response(request, 404, "Invitation not found") if invitation.status != "pending": return error_response(request, 400, "Invitation not pending") # Add membership and mark invitation accepted await households_repo.accept_invitation( conn, invitation.id, user.id, invitation.household_id ) # Load household details for response household = await households_repo.get_household_by_id(conn, invitation.household_id) if not household: return error_response(request, 404, "Household not found") return AcceptInvitationResponse( status="accepted", household=HouseholdResponse.model_validate(household), )