From 76507476514fa1073abcdf7aad54169749438640 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:45:12 -0700 Subject: [PATCH] feat: household API endpoints (create, status, invite, accept, leave, remove-member) --- app/api/endpoints/household.py | 199 +++++++++++++++++++++++++++++++++ app/api/routes.py | 5 +- tests/test_household.py | 67 +++++++++++ 3 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 app/api/endpoints/household.py diff --git a/app/api/endpoints/household.py b/app/api/endpoints/household.py new file mode 100644 index 0000000..5a68810 --- /dev/null +++ b/app/api/endpoints/household.py @@ -0,0 +1,199 @@ +"""Household management endpoints — shared pantry for Premium users.""" +from __future__ import annotations + +import logging +import os +import secrets +from datetime import datetime, timedelta, timezone + +import requests +from fastapi import APIRouter, Depends, HTTPException + +from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN, get_session +from app.db.store import Store +from app.models.schemas.household import ( + HouseholdAcceptRequest, + HouseholdAcceptResponse, + HouseholdCreateResponse, + HouseholdInviteResponse, + HouseholdMember, + HouseholdRemoveMemberRequest, + HouseholdStatusResponse, +) + +log = logging.getLogger(__name__) +router = APIRouter() + +_INVITE_TTL_DAYS = 7 +_KIWI_BASE_URL = os.environ.get("KIWI_BASE_URL", "https://menagerie.circuitforge.tech/kiwi") + + +def _require_premium(session: CloudUser = Depends(get_session)) -> CloudUser: + if session.tier not in ("premium", "ultra", "local"): + raise HTTPException(status_code=403, detail="Household features require Premium tier.") + return session + + +def _require_household_owner(session: CloudUser = Depends(_require_premium)) -> CloudUser: + if not session.is_household_owner or not session.household_id: + raise HTTPException(status_code=403, detail="Only the household owner can perform this action.") + return session + + +def _household_store(household_id: str) -> Store: + """Open the household DB directly (used during invite acceptance).""" + from pathlib import Path + db_path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + return Store(db_path) + + +def _heimdall_post(path: str, body: dict) -> dict: + """Call Heimdall admin API. Returns response dict or raises HTTPException.""" + if not HEIMDALL_ADMIN_TOKEN: + log.warning("HEIMDALL_ADMIN_TOKEN not set — household Heimdall call skipped") + return {} + try: + resp = requests.post( + f"{HEIMDALL_URL}{path}", + json=body, + headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"}, + timeout=10, + ) + if not resp.ok: + raise HTTPException(status_code=502, detail=f"Heimdall error: {resp.text}") + return resp.json() + except requests.RequestException as exc: + raise HTTPException(status_code=502, detail=f"Heimdall unreachable: {exc}") + + +@router.post("/create", response_model=HouseholdCreateResponse) +async def create_household(session: CloudUser = Depends(_require_premium)): + """Create a new household. The calling user becomes owner.""" + if session.household_id: + raise HTTPException(status_code=409, detail="You are already in a household.") + data = _heimdall_post("/admin/household/create", {"owner_user_id": session.user_id}) + household_id = data.get("household_id", "local-household") + return HouseholdCreateResponse( + household_id=household_id, + message="Household created. Share an invite link to add members.", + ) + + +@router.get("/status", response_model=HouseholdStatusResponse) +async def household_status(session: CloudUser = Depends(_require_premium)): + """Return current user's household membership status.""" + if not session.household_id: + return HouseholdStatusResponse(in_household=False) + + members: list[HouseholdMember] = [] + if HEIMDALL_ADMIN_TOKEN: + try: + resp = requests.get( + f"{HEIMDALL_URL}/admin/household/{session.household_id}", + headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"}, + timeout=5, + ) + if resp.ok: + raw = resp.json() + for m in raw.get("members", []): + members.append(HouseholdMember( + user_id=m["user_id"], + joined_at=m.get("joined_at", ""), + is_owner=m["user_id"] == raw.get("owner_user_id"), + )) + except Exception as exc: + log.warning("Could not fetch household members: %s", exc) + + return HouseholdStatusResponse( + in_household=True, + household_id=session.household_id, + is_owner=session.is_household_owner, + members=members, + ) + + +@router.post("/invite", response_model=HouseholdInviteResponse) +async def create_invite(session: CloudUser = Depends(_require_household_owner)): + """Generate a one-time invite token valid for 7 days.""" + store = Store(session.db) + token = secrets.token_hex(32) + expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat() + store.conn.execute( + """INSERT INTO household_invites (token, household_id, created_by, expires_at) + VALUES (?, ?, ?, ?)""", + (token, session.household_id, session.user_id, expires_at), + ) + store.conn.commit() + invite_url = f"{_KIWI_BASE_URL}/#/join?household_id={session.household_id}&token={token}" + return HouseholdInviteResponse(token=token, invite_url=invite_url, expires_at=expires_at) + + +@router.post("/accept", response_model=HouseholdAcceptResponse) +async def accept_invite( + body: HouseholdAcceptRequest, + session: CloudUser = Depends(get_session), +): + """Accept a household invite. Opens the household DB directly to validate the token.""" + if session.household_id: + raise HTTPException(status_code=409, detail="You are already in a household.") + + hh_store = _household_store(body.household_id) + now = datetime.now(timezone.utc).isoformat() + row = hh_store.conn.execute( + """SELECT token, expires_at, used_at FROM household_invites + WHERE token = ? AND household_id = ?""", + (body.token, body.household_id), + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Invite not found.") + if row["used_at"] is not None: + raise HTTPException(status_code=410, detail="Invite already used.") + if row["expires_at"] < now: + raise HTTPException(status_code=410, detail="Invite has expired.") + + hh_store.conn.execute( + "UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?", + (now, session.user_id, body.token), + ) + hh_store.conn.commit() + + _heimdall_post("/admin/household/add-member", { + "household_id": body.household_id, + "user_id": session.user_id, + }) + + return HouseholdAcceptResponse( + message="You have joined the household. Reload the app to switch to the shared pantry.", + household_id=body.household_id, + ) + + +@router.post("/leave") +async def leave_household(session: CloudUser = Depends(_require_premium)): + """Leave the current household (non-owners only).""" + if not session.household_id: + raise HTTPException(status_code=400, detail="You are not in a household.") + if session.is_household_owner: + raise HTTPException(status_code=400, detail="The household owner cannot leave. Delete the household instead.") + _heimdall_post("/admin/household/remove-member", { + "household_id": session.household_id, + "user_id": session.user_id, + }) + return {"message": "You have left the household. Reload the app to return to your personal pantry."} + + +@router.post("/remove-member") +async def remove_member( + body: HouseholdRemoveMemberRequest, + session: CloudUser = Depends(_require_household_owner), +): + """Remove a member from the household (owner only).""" + if body.user_id == session.user_id: + raise HTTPException(status_code=400, detail="Use /leave to remove yourself.") + _heimdall_post("/admin/household/remove-member", { + "household_id": session.household_id, + "user_id": body.user_id, + }) + return {"message": f"Member {body.user_id} removed from household."} diff --git a/app/api/routes.py b/app/api/routes.py index 79395a2..ab4df1b 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household api_router = APIRouter() @@ -11,4 +11,5 @@ api_router.include_router(inventory.router, prefix="/inventory", tags=["invento api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) -api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) \ No newline at end of file +api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) +api_router.include_router(household.router, prefix="/household", tags=["household"]) \ No newline at end of file diff --git a/tests/test_household.py b/tests/test_household.py index a6b76ba..91252a0 100644 --- a/tests/test_household.py +++ b/tests/test_household.py @@ -3,6 +3,7 @@ import os from pathlib import Path from unittest.mock import patch, MagicMock import pytest +from fastapi.testclient import TestClient os.environ.setdefault("CLOUD_MODE", "false") @@ -38,3 +39,69 @@ def test_user_db_path_household(tmp_path, monkeypatch): monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path) result = cs._user_db_path("abc123", household_id="hh-xyz") assert result == tmp_path / "household_hh-xyz" / "kiwi.db" + + +# ── Integration tests (require router) ───────────────────────────────── + +def test_create_household_requires_premium(): + """Non-premium users cannot create a household.""" + from app.main import app + from app.cloud_session import get_session + import tempfile, pathlib + + db = pathlib.Path(tempfile.mktemp(suffix=".db")) + from app.db.store import Store + Store(str(db)) + + free_user = CloudUser(user_id="u1", tier="free", db=db, has_byok=False) + app.dependency_overrides[get_session] = lambda: free_user + client = TestClient(app) + resp = client.post("/api/v1/household/create") + assert resp.status_code == 403 + app.dependency_overrides.clear() + + +def test_invite_generates_token(): + """Invite endpoint returns a token and URL for owner in a household.""" + from app.main import app + from app.cloud_session import get_session + import tempfile, pathlib + + db = pathlib.Path(tempfile.mktemp(suffix=".db")) + from app.db.store import Store + Store(str(db)) + + session = CloudUser( + user_id="owner-1", tier="premium", db=db, + has_byok=False, household_id="hh-test", is_household_owner=True + ) + app.dependency_overrides[get_session] = lambda: session + client = TestClient(app) + resp = client.post("/api/v1/household/invite") + assert resp.status_code == 200 + data = resp.json() + assert "token" in data + assert "invite_url" in data + assert len(data["token"]) == 64 # 32 bytes hex + app.dependency_overrides.clear() + + +def test_accept_invalid_token_returns_404(): + """Accepting a non-existent token returns 404.""" + from app.main import app + from app.cloud_session import get_session + import tempfile, pathlib + + db = pathlib.Path(tempfile.mktemp(suffix=".db")) + from app.db.store import Store + Store(str(db)) + + session = CloudUser(user_id="new-user", tier="free", db=db, has_byok=False) + app.dependency_overrides[get_session] = lambda: session + client = TestClient(app) + resp = client.post("/api/v1/household/accept", json={ + "household_id": "hh-test", + "token": "deadbeef" * 8, + }) + assert resp.status_code == 404 + app.dependency_overrides.clear()