feat: household API endpoints (create, status, invite, accept, leave, remove-member)
This commit is contained in:
parent
dce8d05a09
commit
7650747651
3 changed files with 269 additions and 2 deletions
199
app/api/endpoints/household.py
Normal file
199
app/api/endpoints/household.py
Normal file
|
|
@ -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."}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
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()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -12,3 +12,4 @@ api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
|
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
||||||
|
|
@ -3,6 +3,7 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
os.environ.setdefault("CLOUD_MODE", "false")
|
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)
|
monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path)
|
||||||
result = cs._user_db_path("abc123", household_id="hh-xyz")
|
result = cs._user_db_path("abc123", household_id="hh-xyz")
|
||||||
assert result == tmp_path / "household_hh-xyz" / "kiwi.db"
|
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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue