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-01 02:43:26 +00:00
|
|
|
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(
|
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
|
|
|
):
|
|
|
|
|
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")
|
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),
|
|
|
|
|
):
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 03:17:42 +00:00
|
|
|
# Invitations
|
|
|
|
|
class CreateInvitationBody(ApiModel):
|
|
|
|
|
email: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InvitationResponse(ApiModel):
|
|
|
|
|
token: str
|
|
|
|
|
status: str = "pending"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@scoped.post("/invitations", response_model=InvitationResponse)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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"),
|
|
|
|
|
)
|
|
|
|
|
return InvitationResponse(token=token)
|
|
|
|
|
except Exception:
|
|
|
|
|
return error_response(request, 400, "Unable to create invitation")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Accept invitation (mounted on root router via main.py)
|
|
|
|
|
@router.post("/invitations/accept")
|
|
|
|
|
async def accept_invitation(
|
|
|
|
|
request: Request,
|
|
|
|
|
body: dict,
|
|
|
|
|
user: User = Depends(get_current_user),
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
):
|
|
|
|
|
token = body.get("token")
|
|
|
|
|
if not token:
|
|
|
|
|
return error_response(request, 400, "Token required")
|
|
|
|
|
# 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,),
|
|
|
|
|
)
|
|
|
|
|
return {"status": "accepted"}
|