2025-11-01 02:43:26 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from typing import List
|
|
|
|
|
|
|
|
|
|
import aiosqlite
|
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
|
|
|
|
2025-11-01 02:51:08 +00:00
|
|
|
from api.deps import get_current_user, get_db, error_response, get_household_from_slug
|
2025-11-02 10:33:23 +00:00
|
|
|
from pydantic import Field
|
2025-11-01 02:43:26 +00:00
|
|
|
from common import ApiModel
|
2025-11-05 09:24:47 +00:00
|
|
|
from households import repository as households_repo
|
2025-11-01 02:43:26 +00:00
|
|
|
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(
|
2025-11-01 04:34:01 +00:00
|
|
|
request: Request,
|
|
|
|
|
user: User = Depends(get_current_user),
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
2025-11-01 02:43:26 +00:00
|
|
|
):
|
2025-11-05 09:24:47 +00:00
|
|
|
results = await households_repo.list_for_user(conn, user.id)
|
|
|
|
|
return [HouseholdResponse.model_validate(h) for h in results]
|
2025-11-01 02:43:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2025-11-05 09:24:47 +00:00
|
|
|
household = await households_repo.create_for_user(conn, body.name, slug, user.id)
|
|
|
|
|
if household is None:
|
2025-11-01 02:43:26 +00:00
|
|
|
return error_response(request, 400, "Unable to create household")
|
2025-11-05 09:24:47 +00:00
|
|
|
return HouseholdResponse.model_validate(household)
|
2025-11-01 02:51:08 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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"])
|
2025-11-01 03:17:42 +00:00
|
|
|
|
|
|
|
|
|
2025-11-01 06:14:18 +00:00
|
|
|
# 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),
|
|
|
|
|
):
|
2025-11-05 09:24:47 +00:00
|
|
|
member_data = await households_repo.list_members(conn, household["id"])
|
|
|
|
|
return [HouseholdMember.model_validate(m) for m in member_data]
|
2025-11-01 06:14:18 +00:00
|
|
|
|
|
|
|
|
|
2025-11-01 03:17:42 +00:00
|
|
|
# Invitations
|
|
|
|
|
class InvitationResponse(ApiModel):
|
|
|
|
|
token: str
|
|
|
|
|
status: str = "pending"
|
|
|
|
|
|
|
|
|
|
|
2025-11-02 10:33:23 +00:00
|
|
|
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)
|
2025-11-01 03:17:42 +00:00
|
|
|
async def create_invitation(
|
|
|
|
|
request: Request,
|
|
|
|
|
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
|
2025-11-02 10:33:23 +00:00
|
|
|
from urllib.parse import urljoin, urlencode
|
|
|
|
|
from settings import settings
|
2025-11-01 03:17:42 +00:00
|
|
|
|
|
|
|
|
token = secrets.token_urlsafe(24)
|
|
|
|
|
expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
|
2025-11-05 09:24:47 +00:00
|
|
|
|
|
|
|
|
success = await households_repo.create_invitation(
|
2025-11-05 09:27:59 +00:00
|
|
|
conn, household["id"], user.id, token, expires_at
|
2025-11-05 09:24:47 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not success:
|
2025-11-01 03:17:42 +00:00
|
|
|
return error_response(request, 400, "Unable to create invitation")
|
|
|
|
|
|
2025-11-05 09:24:47 +00:00
|
|
|
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)
|
|
|
|
|
|
2025-11-01 03:17:42 +00:00
|
|
|
|
2025-11-01 08:20:08 +00:00
|
|
|
class AcceptInvitationBody(ApiModel):
|
|
|
|
|
token: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AcceptInvitationResponse(ApiModel):
|
|
|
|
|
status: str = "accepted"
|
|
|
|
|
household: HouseholdResponse
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 03:17:42 +00:00
|
|
|
# Accept invitation (mounted on root router via main.py)
|
2025-11-01 08:20:08 +00:00
|
|
|
@router.post("/invitations/accept", response_model=AcceptInvitationResponse)
|
2025-11-01 03:17:42 +00:00
|
|
|
async def accept_invitation(
|
|
|
|
|
request: Request,
|
2025-11-01 08:20:08 +00:00
|
|
|
body: AcceptInvitationBody,
|
2025-11-01 03:17:42 +00:00
|
|
|
user: User = Depends(get_current_user),
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
):
|
|
|
|
|
# Lookup invitation
|
2025-11-05 09:24:47 +00:00
|
|
|
invitation = await households_repo.get_invitation_by_token(conn, body.token)
|
|
|
|
|
if not invitation:
|
2025-11-01 03:17:42 +00:00
|
|
|
return error_response(request, 404, "Invitation not found")
|
2025-11-05 09:24:47 +00:00
|
|
|
|
|
|
|
|
if invitation.status != "pending":
|
2025-11-01 03:17:42 +00:00
|
|
|
return error_response(request, 400, "Invitation not pending")
|
2025-11-05 09:24:47 +00:00
|
|
|
|
|
|
|
|
# Add membership and mark invitation accepted
|
|
|
|
|
await households_repo.accept_invitation(
|
|
|
|
|
conn, invitation.id, user.id, invitation.household_id
|
2025-11-01 03:17:42 +00:00
|
|
|
)
|
2025-11-05 09:24:47 +00:00
|
|
|
|
2025-11-01 08:20:08 +00:00
|
|
|
# Load household details for response
|
2025-11-05 09:24:47 +00:00
|
|
|
household = await households_repo.get_household_by_id(conn, invitation.household_id)
|
|
|
|
|
if not household:
|
2025-11-01 08:20:08 +00:00
|
|
|
return error_response(request, 404, "Household not found")
|
2025-11-05 09:24:47 +00:00
|
|
|
|
2025-11-01 08:20:08 +00:00
|
|
|
return AcceptInvitationResponse(
|
|
|
|
|
status="accepted",
|
2025-11-05 09:24:47 +00:00
|
|
|
household=HouseholdResponse.model_validate(household),
|
2025-11-01 08:20:08 +00:00
|
|
|
)
|