From 9602f84e62e08f5e8be65cb8365464e3faaf4750 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:27:56 -0700 Subject: [PATCH 01/17] feat: add household_invites migration (017) --- app/db/migrations/017_household_invites.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/db/migrations/017_household_invites.sql diff --git a/app/db/migrations/017_household_invites.sql b/app/db/migrations/017_household_invites.sql new file mode 100644 index 0000000..9a4fabd --- /dev/null +++ b/app/db/migrations/017_household_invites.sql @@ -0,0 +1,10 @@ +-- 017_household_invites.sql +CREATE TABLE IF NOT EXISTS household_invites ( + token TEXT PRIMARY KEY, + household_id TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + used_at TEXT, + used_by TEXT +); From 9985d12156bd617daf6842cf105fc2a1dfb6860d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:30:07 -0700 Subject: [PATCH 02/17] feat: extend CloudUser with household_id + update session resolution Add household_id and is_household_owner fields to CloudUser dataclass. Update _user_db_path to route household members to a shared DB path. Update _fetch_cloud_tier to return a 3-tuple and cache a dict. Update get_session to unpack and propagate household fields. --- app/cloud_session.py | 41 +++++++++++++++++++++++++++++------------ tests/test_household.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 tests/test_household.py diff --git a/app/cloud_session.py b/app/cloud_session.py index ba35bbb..8aa642b 100644 --- a/app/cloud_session.py +++ b/app/cloud_session.py @@ -76,7 +76,7 @@ def _is_bypass_ip(ip: str) -> bool: _LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db")) -_TIER_CACHE: dict[str, tuple[str, float]] = {} +_TIER_CACHE: dict[str, tuple[dict, float]] = {} _TIER_CACHE_TTL = 300 # 5 minutes TIERS = ["free", "paid", "premium", "ultra"] @@ -90,6 +90,8 @@ class CloudUser: tier: str # free | paid | premium | ultra | local db: Path # per-user SQLite DB path has_byok: bool # True if a configured LLM backend is present in llm.yaml + household_id: str | None = None + is_household_owner: bool = False # ── JWT validation ───────────────────────────────────────────────────────────── @@ -130,14 +132,16 @@ def _ensure_provisioned(user_id: str) -> None: log.warning("Heimdall provision failed for user %s: %s", user_id, exc) -def _fetch_cloud_tier(user_id: str) -> str: +def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]: + """Returns (tier, household_id | None, is_household_owner).""" now = time.monotonic() cached = _TIER_CACHE.get(user_id) if cached and (now - cached[1]) < _TIER_CACHE_TTL: - return cached[0] + entry = cached[0] + return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False) if not HEIMDALL_ADMIN_TOKEN: - return "free" + return "free", None, False try: resp = requests.post( f"{HEIMDALL_URL}/admin/cloud/resolve", @@ -145,17 +149,23 @@ def _fetch_cloud_tier(user_id: str) -> str: headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"}, timeout=5, ) - tier = resp.json().get("tier", "free") if resp.ok else "free" + data = resp.json() if resp.ok else {} + tier = data.get("tier", "free") + household_id = data.get("household_id") + is_owner = data.get("is_household_owner", False) except Exception as exc: log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc) - tier = "free" + tier, household_id, is_owner = "free", None, False - _TIER_CACHE[user_id] = (tier, now) - return tier + _TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner}, now) + return tier, household_id, is_owner -def _user_db_path(user_id: str) -> Path: - path = CLOUD_DATA_ROOT / user_id / "kiwi.db" +def _user_db_path(user_id: str, household_id: str | None = None) -> Path: + if household_id: + path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db" + else: + path = CLOUD_DATA_ROOT / user_id / "kiwi.db" path.parent.mkdir(parents=True, exist_ok=True) return path @@ -225,8 +235,15 @@ def get_session(request: Request) -> CloudUser: user_id = validate_session_jwt(token) _ensure_provisioned(user_id) - tier = _fetch_cloud_tier(user_id) - return CloudUser(user_id=user_id, tier=tier, db=_user_db_path(user_id), has_byok=has_byok) + tier, household_id, is_household_owner = _fetch_cloud_tier(user_id) + return CloudUser( + user_id=user_id, + tier=tier, + db=_user_db_path(user_id, household_id=household_id), + has_byok=has_byok, + household_id=household_id, + is_household_owner=is_household_owner, + ) def require_tier(min_tier: str): diff --git a/tests/test_household.py b/tests/test_household.py new file mode 100644 index 0000000..40a3f9a --- /dev/null +++ b/tests/test_household.py @@ -0,0 +1,38 @@ +"""Tests for household session resolution in cloud_session.py.""" +import os +from pathlib import Path +from unittest.mock import patch, MagicMock +import pytest + +os.environ.setdefault("CLOUD_MODE", "false") + +from app.cloud_session import ( + CloudUser, + _user_db_path, + CLOUD_DATA_ROOT, +) + + +def test_clouduser_has_household_fields(): + u = CloudUser( + user_id="u1", tier="premium", db=Path("/tmp/u1.db"), + has_byok=False, household_id="hh-1", is_household_owner=True + ) + assert u.household_id == "hh-1" + assert u.is_household_owner is True + + +def test_clouduser_household_defaults_none(): + u = CloudUser(user_id="u1", tier="free", db=Path("/tmp/u1.db"), has_byok=False) + assert u.household_id is None + assert u.is_household_owner is False + + +def test_user_db_path_personal(): + path = _user_db_path("abc123", household_id=None) + assert path == CLOUD_DATA_ROOT / "abc123" / "kiwi.db" + + +def test_user_db_path_household(): + path = _user_db_path("abc123", household_id="hh-xyz") + assert path == CLOUD_DATA_ROOT / "household_hh-xyz" / "kiwi.db" From ed6813713ef0c5177b265164eab7acc2d01e6d31 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:38:41 -0700 Subject: [PATCH 03/17] test: use tmp_path for _user_db_path tests; remove duplicate comment Patch _user_db_path tests to monkeypatch CLOUD_DATA_ROOT onto a tmp_path so they never touch /devl or any real filesystem path. Remove duplicate X-Real-IP comment block in cloud_session.get_session. --- app/cloud_session.py | 2 -- tests/test_household.py | 16 +++++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/cloud_session.py b/app/cloud_session.py index 8aa642b..ee6c583 100644 --- a/app/cloud_session.py +++ b/app/cloud_session.py @@ -208,8 +208,6 @@ def get_session(request: Request) -> CloudUser: if not CLOUD_MODE: return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok) - # Prefer X-Real-IP (set by nginx from the actual client address) over the - # TCP peer address (which is nginx's container IP when behind the proxy). # Prefer X-Real-IP (set by nginx from the actual client address) over the # TCP peer address (which is nginx's container IP when behind the proxy). client_ip = ( diff --git a/tests/test_household.py b/tests/test_household.py index 40a3f9a..a6b76ba 100644 --- a/tests/test_household.py +++ b/tests/test_household.py @@ -6,10 +6,10 @@ import pytest os.environ.setdefault("CLOUD_MODE", "false") +import app.cloud_session as cs from app.cloud_session import ( CloudUser, _user_db_path, - CLOUD_DATA_ROOT, ) @@ -28,11 +28,13 @@ def test_clouduser_household_defaults_none(): assert u.is_household_owner is False -def test_user_db_path_personal(): - path = _user_db_path("abc123", household_id=None) - assert path == CLOUD_DATA_ROOT / "abc123" / "kiwi.db" +def test_user_db_path_personal(tmp_path, monkeypatch): + monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path) + result = cs._user_db_path("abc123") + assert result == tmp_path / "abc123" / "kiwi.db" -def test_user_db_path_household(): - path = _user_db_path("abc123", household_id="hh-xyz") - assert path == CLOUD_DATA_ROOT / "household_hh-xyz" / "kiwi.db" +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" From e605954254efa778c84d248f91fbc2a7ce4aa9b1 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:39:04 -0700 Subject: [PATCH 04/17] chore: bump circuitforge-core dep to >=0.8.0; fix stale resources imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject.toml: circuitforge-core>=0.6.0 → >=0.8.0 (orch split) - vl_model.py: circuitforge_core.resources → circuitforge_orch.client - llm_recipe.py: circuitforge_core.resources → circuitforge_orch.client --- app/services/ocr/vl_model.py | 2 +- app/services/recipe/llm_recipe.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/ocr/vl_model.py b/app/services/ocr/vl_model.py index f7580ca..b7c459c 100644 --- a/app/services/ocr/vl_model.py +++ b/app/services/ocr/vl_model.py @@ -33,7 +33,7 @@ def _try_docuvision(image_path: str | Path) -> str | None: if not cf_orch_url: return None try: - from circuitforge_core.resources import CFOrchClient + from circuitforge_orch.client import CFOrchClient from app.services.ocr.docuvision_client import DocuvisionClient client = CFOrchClient(cf_orch_url) diff --git a/app/services/recipe/llm_recipe.py b/app/services/recipe/llm_recipe.py index 11ccd02..4d03441 100644 --- a/app/services/recipe/llm_recipe.py +++ b/app/services/recipe/llm_recipe.py @@ -143,7 +143,7 @@ class LLMRecipeGenerator: cf_orch_url = os.environ.get("CF_ORCH_URL") if cf_orch_url: try: - from circuitforge_core.resources import CFOrchClient + from circuitforge_orch.client import CFOrchClient client = CFOrchClient(cf_orch_url) return client.allocate( service="vllm", diff --git a/pyproject.toml b/pyproject.toml index 3909e88..7729635 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "httpx>=0.27", "requests>=2.31", # CircuitForge shared scaffold - "circuitforge-core>=0.6.0", + "circuitforge-core>=0.8.0", ] [tool.setuptools.packages.find] From 2db4de6d8f66d80bce4389729a7038243d22dc69 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:40:30 -0700 Subject: [PATCH 05/17] feat: add household Pydantic schemas --- app/models/schemas/household.py | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 app/models/schemas/household.py diff --git a/app/models/schemas/household.py b/app/models/schemas/household.py new file mode 100644 index 0000000..11c0675 --- /dev/null +++ b/app/models/schemas/household.py @@ -0,0 +1,44 @@ +"""Pydantic schemas for household management endpoints.""" +from __future__ import annotations + +from typing import Optional +from pydantic import BaseModel + + +class HouseholdCreateResponse(BaseModel): + household_id: str + message: str + + +class HouseholdMember(BaseModel): + user_id: str + joined_at: str + is_owner: bool + + +class HouseholdStatusResponse(BaseModel): + in_household: bool + household_id: Optional[str] = None + is_owner: bool = False + members: list[HouseholdMember] = [] + max_seats: int = 4 + + +class HouseholdInviteResponse(BaseModel): + invite_url: str + token: str + expires_at: str + + +class HouseholdAcceptRequest(BaseModel): + household_id: str + token: str + + +class HouseholdAcceptResponse(BaseModel): + message: str + household_id: str + + +class HouseholdRemoveMemberRequest(BaseModel): + user_id: str From dce8d05a09c783487dbc9564fe2f244e541f7cba Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:41:53 -0700 Subject: [PATCH 06/17] refactor: use str | None + Field(default_factory=list) in household schemas --- app/models/schemas/household.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/models/schemas/household.py b/app/models/schemas/household.py index 11c0675..945d64e 100644 --- a/app/models/schemas/household.py +++ b/app/models/schemas/household.py @@ -1,8 +1,7 @@ """Pydantic schemas for household management endpoints.""" from __future__ import annotations -from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field class HouseholdCreateResponse(BaseModel): @@ -18,9 +17,9 @@ class HouseholdMember(BaseModel): class HouseholdStatusResponse(BaseModel): in_household: bool - household_id: Optional[str] = None + household_id: str | None = None is_owner: bool = False - members: list[HouseholdMember] = [] + members: list[HouseholdMember] = Field(default_factory=list) max_seats: int = 4 From 76507476514fa1073abcdf7aad54169749438640 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:45:12 -0700 Subject: [PATCH 07/17] 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() From c7861344b79304214fc3d9a76aefd326af9a200a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:47:39 -0700 Subject: [PATCH 08/17] feat: add MessageResponse schema; wire response_model on leave + remove-member endpoints --- app/api/endpoints/household.py | 13 +++++++------ app/models/schemas/household.py | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/api/endpoints/household.py b/app/api/endpoints/household.py index 5a68810..f923761 100644 --- a/app/api/endpoints/household.py +++ b/app/api/endpoints/household.py @@ -19,6 +19,7 @@ from app.models.schemas.household import ( HouseholdMember, HouseholdRemoveMemberRequest, HouseholdStatusResponse, + MessageResponse, ) log = logging.getLogger(__name__) @@ -170,8 +171,8 @@ async def accept_invite( ) -@router.post("/leave") -async def leave_household(session: CloudUser = Depends(_require_premium)): +@router.post("/leave", response_model=MessageResponse) +async def leave_household(session: CloudUser = Depends(_require_premium)) -> MessageResponse: """Leave the current household (non-owners only).""" if not session.household_id: raise HTTPException(status_code=400, detail="You are not in a household.") @@ -181,14 +182,14 @@ async def leave_household(session: CloudUser = Depends(_require_premium)): "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."} + return MessageResponse(message="You have left the household. Reload the app to return to your personal pantry.") -@router.post("/remove-member") +@router.post("/remove-member", response_model=MessageResponse) async def remove_member( body: HouseholdRemoveMemberRequest, session: CloudUser = Depends(_require_household_owner), -): +) -> MessageResponse: """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.") @@ -196,4 +197,4 @@ async def remove_member( "household_id": session.household_id, "user_id": body.user_id, }) - return {"message": f"Member {body.user_id} removed from household."} + return MessageResponse(message=f"Member {body.user_id} removed from household.") diff --git a/app/models/schemas/household.py b/app/models/schemas/household.py index 945d64e..6b50b52 100644 --- a/app/models/schemas/household.py +++ b/app/models/schemas/household.py @@ -41,3 +41,7 @@ class HouseholdAcceptResponse(BaseModel): class HouseholdRemoveMemberRequest(BaseModel): user_id: str + + +class MessageResponse(BaseModel): + message: str From 70b1319b60e1261861e18f0dd65585ef376f48ad Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:48:25 -0700 Subject: [PATCH 09/17] feat: add householdAPI typed wrappers to api.ts --- frontend/src/services/api.ts | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3834695..aed86c6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -266,6 +266,20 @@ export const inventoryAPI = { return response.data }, + /** + * Bulk-add items by ingredient name (no barcode required). + * Idempotent: re-adding an existing product just creates a new inventory entry. + */ + async bulkAddByName(items: Array<{ + name: string + quantity?: number + unit?: string + location?: string + }>): Promise<{ added: number; failed: number; results: Array<{ name: string; ok: boolean; item_id?: number; error?: string }> }> { + const response = await api.post('/inventory/items/bulk-add-by-name', { items }) + return response.data + }, + /** * Scan barcode from image */ @@ -433,6 +447,7 @@ export interface RecipeSuggestion { match_count: number element_coverage: Record swap_candidates: SwapCandidate[] + matched_ingredients: string[] missing_ingredients: string[] directions: string[] prep_notes: string[] @@ -440,6 +455,7 @@ export interface RecipeSuggestion { level: number is_wildcard: boolean nutrition: NutritionPanel | null + source_url: string | null } export interface NutritionFilters { @@ -477,6 +493,7 @@ export interface RecipeRequest { wildcard_confirmed: boolean nutrition_filters: NutritionFilters excluded_ids: number[] + shopping_mode: boolean } export interface Staple { @@ -519,4 +536,55 @@ export const settingsAPI = { }, } +// ========== Household Types ========== + +export interface HouseholdMember { + user_id: string + joined_at: string + is_owner: boolean +} + +export interface HouseholdStatus { + in_household: boolean + household_id: string | null + is_owner: boolean + members: HouseholdMember[] + max_seats: number +} + +export interface HouseholdInvite { + invite_url: string + token: string + expires_at: string +} + +// ========== Household API ========== + +export const householdAPI = { + async create(): Promise<{ household_id: string; message: string }> { + const response = await api.post('/household/create') + return response.data + }, + async status(): Promise { + const response = await api.get('/household/status') + return response.data + }, + async invite(): Promise { + const response = await api.post('/household/invite') + return response.data + }, + async accept(householdId: string, token: string): Promise<{ message: string; household_id: string }> { + const response = await api.post('/household/accept', { household_id: householdId, token }) + return response.data + }, + async leave(): Promise<{ message: string }> { + const response = await api.post('/household/leave') + return response.data + }, + async removeMember(userId: string): Promise<{ message: string }> { + const response = await api.post('/household/remove-member', { user_id: userId }) + return response.data + }, +} + export default api From 7cce05b95ae8552f35fce25bcfacbb179e95a46a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:51:03 -0700 Subject: [PATCH 10/17] feat: household management UI in Settings (Premium-gated) --- frontend/src/components/SettingsView.vue | 293 ++++++++++++++++++++++- 1 file changed, 292 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/SettingsView.vue b/frontend/src/components/SettingsView.vue index 4e71ba5..f6085b9 100644 --- a/frontend/src/components/SettingsView.vue +++ b/frontend/src/components/SettingsView.vue @@ -64,14 +64,114 @@ +
+

Cook History

+

+ No recipes cooked yet. Tap "I cooked this" on any recipe to log it. +

+ +
+ + +
+

Household

+ + +

Loading…

+ + +

{{ householdError }}

+ + + + + + +
@@ -159,4 +344,110 @@ onMounted(async () => { opacity: 1; transform: none; } + +.mt-md { + margin-top: var(--spacing-md); +} + +.mt-sm { + margin-top: var(--spacing-sm); +} + +.log-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.log-entry { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--spacing-sm); + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--color-border); +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log-date { + flex-shrink: 0; +} + +.btn-ghost { + background: transparent; + border: none; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + cursor: pointer; + padding: var(--spacing-xs) var(--spacing-sm); +} + +.btn-ghost:hover { + color: var(--color-error, #dc2626); + background: transparent; + transform: none; +} + +.btn-sm { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-size-sm); +} + +.household-id { + font-size: var(--font-size-xs); + background: var(--color-bg-secondary); + padding: 2px 6px; + border-radius: 4px; +} + +.member-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.member-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--color-border); + gap: var(--spacing-sm); +} + +.member-row:last-child { + border-bottom: none; +} + +.member-id { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-secondary); +} + +.invite-row { + display: flex; + gap: var(--spacing-xs); + align-items: center; +} + +.invite-input { + flex: 1; + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} From 11a0d1f3a6dda510173881271b15126d44a39ef8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 22:53:55 -0700 Subject: [PATCH 11/17] feat: handle household invite accept on app load via URL hash --- frontend/src/App.vue | 68 +++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index fe179f8..327b6e3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -16,6 +16,18 @@