diff --git a/app/api/endpoints/inventory.py b/app/api/endpoints/inventory.py index e65705b..61ee957 100644 --- a/app/api/endpoints/inventory.py +++ b/app/api/endpoints/inventory.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import uuid from pathlib import Path from typing import Any, Dict, List, Optional @@ -11,7 +12,9 @@ import aiofiles from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from pydantic import BaseModel -from app.cloud_session import CloudUser, get_session +from app.cloud_session import CloudUser, _auth_label, get_session + +log = logging.getLogger(__name__) from app.db.session import get_store from app.services.expiration_predictor import ExpirationPredictor @@ -41,7 +44,7 @@ router = APIRouter() # ── Helpers ─────────────────────────────────────────────────────────────────── def _enrich_item(item: dict) -> dict: - """Attach computed opened_expiry_date when opened_date is set.""" + """Attach computed fields: opened_expiry_date, secondary_state/uses/warning.""" from datetime import date, timedelta opened = item.get("opened_date") if opened: @@ -54,6 +57,15 @@ def _enrich_item(item: dict) -> dict: pass if "opened_expiry_date" not in item: item = {**item, "opened_expiry_date": None} + + # Secondary use window — check sell-by date (not opened expiry) + sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date")) + item = { + **item, + "secondary_state": sec["label"] if sec else None, + "secondary_uses": sec["uses"] if sec else None, + "secondary_warning": sec["warning"] if sec else None, + } return item @@ -141,7 +153,12 @@ async def delete_product(product_id: int, store: Store = Depends(get_store)): # ── Inventory items ─────────────────────────────────────────────────────────── @router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED) -async def create_inventory_item(body: InventoryItemCreate, store: Store = Depends(get_store)): +async def create_inventory_item( + body: InventoryItemCreate, + store: Store = Depends(get_store), + session: CloudUser = Depends(get_session), +): + log.info("add_item auth=%s tier=%s product_id=%s", _auth_label(session.user_id), session.tier, body.product_id) item = await asyncio.to_thread( store.add_inventory_item, body.product_id, @@ -167,7 +184,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe for entry in body.items: try: product, _ = await asyncio.to_thread( - store.get_or_create_product, entry.name, None, source="shopping" + store.get_or_create_product, entry.name, None, source="manual" ) item = await asyncio.to_thread( store.add_inventory_item, @@ -175,7 +192,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe entry.location, quantity=entry.quantity, unit=entry.unit, - source="shopping", + source="manual", ) results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"])) except Exception as exc: @@ -320,6 +337,7 @@ async def scan_barcode_text( session: CloudUser = Depends(get_session), ): """Scan a barcode from a text string (e.g. from a hardware scanner or manual entry).""" + log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode) from app.services.openfoodfacts import OpenFoodFactsService from app.services.expiration_predictor import ExpirationPredictor @@ -388,6 +406,7 @@ async def scan_barcode_image( session: CloudUser = Depends(get_session), ): """Scan a barcode from an uploaded image. Requires Phase 2 scanner integration.""" + log.info("scan_image auth=%s tier=%s", _auth_label(session.user_id), session.tier) temp_dir = Path("/tmp/kiwi_barcode_scans") temp_dir.mkdir(parents=True, exist_ok=True) temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}" diff --git a/app/api/endpoints/ocr.py b/app/api/endpoints/ocr.py index f70d2e8..7e496b0 100644 --- a/app/api/endpoints/ocr.py +++ b/app/api/endpoints/ocr.py @@ -219,7 +219,7 @@ def _commit_items( receipt_id=receipt_id, purchase_date=str(purchase_date) if purchase_date else None, expiration_date=str(exp) if exp else None, - source="receipt_ocr", + source="receipt", ) created.append(ApprovedInventoryItem( diff --git a/app/api/endpoints/shopping.py b/app/api/endpoints/shopping.py new file mode 100644 index 0000000..7ce7de7 --- /dev/null +++ b/app/api/endpoints/shopping.py @@ -0,0 +1,224 @@ +"""Shopping list endpoints. + +Free tier for all users (anonymous guests included — shopping list is the +primary affiliate revenue surface). Confirm-purchase action is also Free: +it moves a checked item into pantry inventory without a tier gate so the +flow works for anyone who signs up or browses without an account. + +Routes: + GET /shopping — list items (with affiliate links) + POST /shopping — add item manually + PATCH /shopping/{id} — update (check/uncheck, rename, qty) + DELETE /shopping/{id} — remove single item + DELETE /shopping/checked — clear all checked items + DELETE /shopping/all — clear entire list + POST /shopping/from-recipe — bulk add gaps from a recipe + POST /shopping/{id}/confirm — confirm purchase → add to pantry inventory +""" +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.cloud_session import CloudUser, get_session +from app.db.session import get_store +from app.db.store import Store +from app.models.schemas.shopping import ( + BulkAddFromRecipeRequest, + ConfirmPurchaseRequest, + ShoppingItemCreate, + ShoppingItemResponse, + ShoppingItemUpdate, +) +from app.services.recipe.grocery_links import GroceryLinkBuilder + +log = logging.getLogger(__name__) +router = APIRouter() + + +def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse: + """Attach live affiliate links to a raw store row.""" + links = builder.build_links(item["name"]) + return ShoppingItemResponse( + **{**item, "checked": bool(item.get("checked", 0))}, + grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links], + ) + + +def _in_thread(db_path, fn): + store = Store(db_path) + try: + return fn(store) + finally: + store.close() + + +# ── List ────────────────────────────────────────────────────────────────────── + +@router.get("", response_model=list[ShoppingItemResponse]) +async def list_shopping_items( + include_checked: bool = True, + session: CloudUser = Depends(get_session), +): + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + items = await asyncio.to_thread( + _in_thread, session.db, lambda s: s.list_shopping_items(include_checked) + ) + return [_enrich(i, builder) for i in items] + + +# ── Add manually ────────────────────────────────────────────────────────────── + +@router.post("", response_model=ShoppingItemResponse, status_code=status.HTTP_201_CREATED) +async def add_shopping_item( + body: ShoppingItemCreate, + session: CloudUser = Depends(get_session), +): + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + item = await asyncio.to_thread( + _in_thread, + session.db, + lambda s: s.add_shopping_item( + name=body.name, + quantity=body.quantity, + unit=body.unit, + category=body.category, + notes=body.notes, + source=body.source, + recipe_id=body.recipe_id, + sort_order=body.sort_order, + ), + ) + return _enrich(item, builder) + + +# ── Bulk add from recipe ─────────────────────────────────────────────────────── + +@router.post("/from-recipe", response_model=list[ShoppingItemResponse], status_code=status.HTTP_201_CREATED) +async def add_from_recipe( + body: BulkAddFromRecipeRequest, + session: CloudUser = Depends(get_session), +): + """Add missing ingredients from a recipe to the shopping list. + + Runs pantry gap analysis and adds only the items the user doesn't have + (unless include_covered=True). Skips duplicates already on the list. + """ + from app.services.meal_plan.shopping_list import compute_shopping_list + + def _run(store: Store): + recipe = store.get_recipe(body.recipe_id) + if not recipe: + raise HTTPException(status_code=404, detail="Recipe not found") + inventory = store.list_inventory() + gaps, covered = compute_shopping_list([recipe], inventory) + targets = (gaps + covered) if body.include_covered else gaps + + # Avoid duplicates already on the list + existing = {i["name"].lower() for i in store.list_shopping_items()} + added = [] + for gap in targets: + if gap.ingredient_name.lower() in existing: + continue + item = store.add_shopping_item( + name=gap.ingredient_name, + quantity=None, + unit=gap.have_unit, + source="recipe", + recipe_id=body.recipe_id, + ) + added.append(item) + return added + + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + items = await asyncio.to_thread(_in_thread, session.db, _run) + return [_enrich(i, builder) for i in items] + + +# ── Update ──────────────────────────────────────────────────────────────────── + +@router.patch("/{item_id}", response_model=ShoppingItemResponse) +async def update_shopping_item( + item_id: int, + body: ShoppingItemUpdate, + session: CloudUser = Depends(get_session), +): + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + item = await asyncio.to_thread( + _in_thread, + session.db, + lambda s: s.update_shopping_item(item_id, **body.model_dump(exclude_none=True)), + ) + if not item: + raise HTTPException(status_code=404, detail="Shopping item not found") + return _enrich(item, builder) + + +# ── Confirm purchase → pantry ───────────────────────────────────────────────── + +@router.post("/{item_id}/confirm", status_code=status.HTTP_201_CREATED) +async def confirm_purchase( + item_id: int, + body: ConfirmPurchaseRequest, + session: CloudUser = Depends(get_session), +): + """Confirm a checked item was purchased and add it to pantry inventory. + + Human approval step: the user explicitly confirms what they actually bought + before it lands in their pantry. Returns the new inventory item. + """ + def _run(store: Store): + shopping_item = store.get_shopping_item(item_id) + if not shopping_item: + raise HTTPException(status_code=404, detail="Shopping item not found") + + qty = body.quantity if body.quantity is not None else (shopping_item.get("quantity") or 1.0) + unit = body.unit or shopping_item.get("unit") or "count" + category = shopping_item.get("category") + + product = store.get_or_create_product( + name=shopping_item["name"], + category=category, + ) + inv_item = store.add_inventory_item( + product_id=product["id"], + location=body.location, + quantity=qty, + unit=unit, + source="manual", + ) + # Mark the shopping item checked and leave it for the user to clear + store.update_shopping_item(item_id, checked=True) + return inv_item + + return await asyncio.to_thread(_in_thread, session.db, _run) + + +# ── Delete ──────────────────────────────────────────────────────────────────── + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_shopping_item( + item_id: int, + session: CloudUser = Depends(get_session), +): + deleted = await asyncio.to_thread( + _in_thread, session.db, lambda s: s.delete_shopping_item(item_id) + ) + if not deleted: + raise HTTPException(status_code=404, detail="Shopping item not found") + + +@router.delete("/checked", status_code=status.HTTP_204_NO_CONTENT) +async def clear_checked(session: CloudUser = Depends(get_session)): + await asyncio.to_thread( + _in_thread, session.db, lambda s: s.clear_checked_shopping_items() + ) + + +@router.delete("/all", status_code=status.HTTP_204_NO_CONTENT) +async def clear_all(session: CloudUser = Depends(get_session)): + await asyncio.to_thread( + _in_thread, session.db, lambda s: s.clear_all_shopping_items() + ) diff --git a/app/api/routes.py b/app/api/routes.py index 451c395..c5cc59f 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping from app.api.endpoints.community import router as community_router api_router = APIRouter() +api_router.include_router(session.router, prefix="/session", tags=["session"]) api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"]) api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) @@ -19,4 +20,5 @@ api_router.include_router(household.router, prefix="/household", tags= api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"]) api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"]) +api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"]) api_router.include_router(community_router) diff --git a/app/cloud_session.py b/app/cloud_session.py index 7649408..1ad2a57 100644 --- a/app/cloud_session.py +++ b/app/cloud_session.py @@ -22,10 +22,12 @@ import time from dataclasses import dataclass from pathlib import Path +import uuid + import jwt as pyjwt import requests import yaml -from fastapi import Depends, HTTPException, Request +from fastapi import Depends, HTTPException, Request, Response log = logging.getLogger(__name__) @@ -82,6 +84,15 @@ _TIER_CACHE_TTL = 300 # 5 minutes TIERS = ["free", "paid", "premium", "ultra"] +def _auth_label(user_id: str) -> str: + """Classify a user_id into a short tag for structured log lines. No PII emitted.""" + if user_id in ("local", "local-dev"): + return "local" + if user_id.startswith("anon-"): + return "anon" + return "authed" + + # ── Domain ──────────────────────────────────────────────────────────────────── @dataclass(frozen=True) @@ -172,9 +183,13 @@ def _user_db_path(user_id: str, household_id: str | None = None) -> Path: return path -def _anon_db_path() -> Path: - """Ephemeral DB for unauthenticated guest visitors (Free tier, no persistence).""" - path = CLOUD_DATA_ROOT / "anonymous" / "kiwi.db" +def _anon_guest_db_path(guest_id: str) -> Path: + """Per-session DB for unauthenticated guest visitors. + + Each anonymous visitor gets an isolated SQLite DB keyed by their guest UUID + cookie, so shopping lists and affiliate interactions never bleed across sessions. + """ + path = CLOUD_DATA_ROOT / f"anon-{guest_id}" / "kiwi.db" path.parent.mkdir(parents=True, exist_ok=True) return path @@ -204,20 +219,52 @@ def _detect_byok(config_path: Path = _LLM_CONFIG_PATH) -> bool: # ── FastAPI dependency ──────────────────────────────────────────────────────── -def get_session(request: Request) -> CloudUser: +_GUEST_COOKIE = "kiwi_guest_id" +_GUEST_COOKIE_MAX_AGE = 60 * 60 * 24 * 90 # 90 days + + +def _resolve_guest_session(request: Request, response: Response, has_byok: bool) -> CloudUser: + """Return a per-session anonymous CloudUser, creating a guest UUID cookie if needed.""" + guest_id = request.cookies.get(_GUEST_COOKIE, "").strip() + is_new = not guest_id + if is_new: + guest_id = str(uuid.uuid4()) + log.debug("New guest session assigned: anon-%s", guest_id[:8]) + # Secure flag only when the request actually arrived over HTTPS + # (Caddy sets X-Forwarded-Proto=https in cloud; absent on direct port access). + # Avoids losing the session cookie on HTTP direct-port testing of the cloud stack. + is_https = request.headers.get("x-forwarded-proto", "http").lower() == "https" + response.set_cookie( + key=_GUEST_COOKIE, + value=guest_id, + max_age=_GUEST_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=is_https, + ) + return CloudUser( + user_id=f"anon-{guest_id}", + tier="free", + db=_anon_guest_db_path(guest_id), + has_byok=has_byok, + ) + + +def get_session(request: Request, response: Response) -> CloudUser: """FastAPI dependency — resolves the current user from the request. Local mode: fully-privileged "local" user pointing at local DB. Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier. Dev bypass: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches, returns a "local" session without JWT validation (dev/LAN use only). + Anonymous: per-session UUID cookie isolates each guest visitor's data. """ has_byok = _detect_byok() 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 + # Prefer X-Real-IP (set by Caddy from the actual client address) over the # TCP peer address (which is nginx's container IP when behind the proxy). client_ip = ( request.headers.get("x-real-ip", "") @@ -229,26 +276,19 @@ def get_session(request: Request) -> CloudUser: dev_db = _user_db_path("local-dev") return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok) - raw_header = ( - request.headers.get("x-cf-session", "") - or request.headers.get("cookie", "") - ) - if not raw_header: - return CloudUser( - user_id="anonymous", - tier="free", - db=_anon_db_path(), - has_byok=has_byok, - ) + # Resolve cf_session JWT: prefer the explicit header injected by Caddy, then + # fall back to the cf_session cookie value. Other cookies (e.g. kiwi_guest_id) + # must never be treated as auth tokens. + raw_session = request.headers.get("x-cf-session", "").strip() + if not raw_session: + raw_session = request.cookies.get("cf_session", "").strip() - token = _extract_session_token(raw_header) # gitleaks:allow — function name, not a secret + if not raw_session: + return _resolve_guest_session(request, response, has_byok) + + token = _extract_session_token(raw_session) # gitleaks:allow — function name, not a secret if not token: - return CloudUser( - user_id="anonymous", - tier="free", - db=_anon_db_path(), - has_byok=has_byok, - ) + return _resolve_guest_session(request, response, has_byok) user_id = validate_session_jwt(token) _ensure_provisioned(user_id) diff --git a/app/db/store.py b/app/db/store.py index 296cc6c..fdb0be5 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -23,12 +23,25 @@ _COUNT_CACHE: dict[tuple[str, ...], int] = {} class Store: def __init__(self, db_path: Path, key: str = "") -> None: + import os self._db_path = str(db_path) self.conn: sqlite3.Connection = get_connection(db_path, key) self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA foreign_keys=ON") run_migrations(self.conn, MIGRATIONS_DIR) + # When RECIPE_DB_PATH is set (cloud mode), attach the shared read-only + # corpus DB as the "corpus" schema so per-user DBs can access recipe data. + # _cp (corpus prefix) is "corpus." in cloud mode, "" in local mode. + corpus_path = os.environ.get("RECIPE_DB_PATH", "") + if corpus_path: + self.conn.execute("ATTACH DATABASE ? AS corpus", (corpus_path,)) + self._cp = "corpus." + self._corpus_path = corpus_path + else: + self._cp = "" + self._corpus_path = self._db_path + def close(self) -> None: self.conn.close() @@ -218,8 +231,8 @@ class Store: def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None: allowed = {"quantity", "unit", "location", "sublocation", - "expiration_date", "opened_date", "status", "notes", "consumed_at", - "disposal_reason"} + "purchase_date", "expiration_date", "opened_date", + "status", "notes", "consumed_at", "disposal_reason"} updates = {k: v for k, v in kwargs.items() if k in allowed} if not updates: return self.get_inventory_item(item_id) @@ -372,8 +385,9 @@ class Store: def _fts_ready(self) -> bool: """Return True if the recipes_fts virtual table exists.""" + schema = "corpus" if self._cp else "main" row = self._fetch_one( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes_fts'" + f"SELECT 1 FROM {schema}.sqlite_master WHERE type='table' AND name='recipes_fts'" ) return row is not None @@ -664,10 +678,12 @@ class Store: return [] # Pull up to 10× limit candidates so ranking has enough headroom. + # FTS5 pseudo-column in WHERE uses bare table name, not schema-qualified. + c = self._cp sql = f""" SELECT r.* - FROM recipes_fts - JOIN recipes r ON r.id = recipes_fts.rowid + FROM {c}recipes_fts + JOIN {c}recipes r ON r.id = {c}recipes_fts.rowid WHERE recipes_fts MATCH ? {where_extra} LIMIT ? @@ -701,9 +717,10 @@ class Store: "CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END" for _ in ingredient_names ) + c = self._cp sql = f""" SELECT r.*, ({match_score}) AS match_count - FROM recipes r + FROM {c}recipes r WHERE ({like_clauses}) {where_extra} ORDER BY match_count DESC, r.id ASC @@ -713,7 +730,11 @@ class Store: return self._fetch_all(sql, tuple(all_params)) def get_recipe(self, recipe_id: int) -> dict | None: - return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,)) + row = self._fetch_one(f"SELECT * FROM {self._cp}recipes WHERE id = ?", (recipe_id,)) + if row is None and self._cp: + # Fall back to user's own assembled recipes in main schema + row = self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,)) + return row def upsert_built_recipe( self, @@ -764,7 +785,7 @@ class Store: return {} placeholders = ",".join("?" * len(names)) rows = self._fetch_all( - f"SELECT name, elements FROM ingredient_profiles WHERE name IN ({placeholders})", + f"SELECT name, elements FROM {self._cp}ingredient_profiles WHERE name IN ({placeholders})", tuple(names), ) result: dict[str, list[str]] = {} @@ -905,12 +926,25 @@ class Store: "title": "r.title ASC", }.get(sort_by, "sr.saved_at DESC") + c = self._cp + # In corpus-attached (cloud) mode: try corpus recipes first, fall back + # to user's own assembled recipes. In local mode: single join suffices. + if c: + recipe_join = ( + f"LEFT JOIN {c}recipes rc ON rc.id = sr.recipe_id " + "LEFT JOIN recipes rm ON rm.id = sr.recipe_id" + ) + title_col = "COALESCE(rc.title, rm.title) AS title" + else: + recipe_join = "JOIN recipes rc ON rc.id = sr.recipe_id" + title_col = "rc.title" + if collection_id is not None: return self._fetch_all( f""" - SELECT sr.*, r.title + SELECT sr.*, {title_col} FROM saved_recipes sr - JOIN recipes r ON r.id = sr.recipe_id + {recipe_join} JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id WHERE rcm.collection_id = ? ORDER BY {order} @@ -919,9 +953,9 @@ class Store: ) return self._fetch_all( f""" - SELECT sr.*, r.title + SELECT sr.*, {title_col} FROM saved_recipes sr - JOIN recipes r ON r.id = sr.recipe_id + {recipe_join} ORDER BY {order} """, ) @@ -936,10 +970,26 @@ class Store: # ── recipe collections ──────────────────────────────────────────────── def create_collection(self, name: str, description: str | None) -> dict: - return self._insert_returning( - "INSERT INTO recipe_collections (name, description) VALUES (?, ?) RETURNING *", + # INSERT RETURNING * omits aggregate columns (e.g. member_count); re-query + # with the same SELECT used by get_collections() so the response shape is consistent. + cur = self.conn.execute( + "INSERT INTO recipe_collections (name, description) VALUES (?, ?)", (name, description), ) + self.conn.commit() + new_id = cur.lastrowid + row = self._fetch_one( + """ + SELECT rc.*, + COUNT(rcm.saved_recipe_id) AS member_count + FROM recipe_collections rc + LEFT JOIN recipe_collection_members rcm ON rcm.collection_id = rc.id + WHERE rc.id = ? + GROUP BY rc.id + """, + (new_id,), + ) + return row # type: ignore[return-value] def delete_collection(self, collection_id: int) -> None: self.conn.execute( @@ -1023,12 +1073,16 @@ class Store: def _count_recipes_for_keywords(self, keywords: list[str]) -> int: if not keywords: return 0 - cache_key = (self._db_path, *sorted(keywords)) + # Use corpus path as cache key so all cloud users share the same counts. + cache_key = (self._corpus_path, *sorted(keywords)) if cache_key in _COUNT_CACHE: return _COUNT_CACHE[cache_key] match_expr = self._browser_fts_query(keywords) + c = self._cp + # FTS5 pseudo-column in WHERE is always the bare (unqualified) table name, + # even when the table is accessed through an ATTACHed schema. row = self.conn.execute( - "SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?", + f"SELECT count(*) FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?", (match_expr,), ).fetchone() count = row[0] if row else 0 @@ -1057,13 +1111,14 @@ class Store: # Reuse cached count — avoids a second index scan on every page turn. total = self._count_recipes_for_keywords(keywords) + c = self._cp rows = self._fetch_all( - """ + f""" SELECT id, title, category, keywords, ingredient_names, calories, fat_g, protein_g, sodium_mg - FROM recipes + FROM {c}recipes WHERE id IN ( - SELECT rowid FROM recipe_browser_fts + SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ? ) ORDER BY id ASC @@ -1154,10 +1209,11 @@ class Store: self.conn.commit() def get_plan_slots(self, plan_id: int) -> list[dict]: + c = self._cp return self._fetch_all( - """SELECT s.*, r.title AS recipe_title + f"""SELECT s.*, r.title AS recipe_title FROM meal_plan_slots s - LEFT JOIN recipes r ON r.id = s.recipe_id + LEFT JOIN {c}recipes r ON r.id = s.recipe_id WHERE s.plan_id = ? ORDER BY s.day_of_week, s.meal_type""", (plan_id,), @@ -1165,10 +1221,11 @@ class Store: def get_plan_recipes(self, plan_id: int) -> list[dict]: """Return full recipe rows for all recipes assigned to a plan.""" + c = self._cp return self._fetch_all( - """SELECT DISTINCT r.* + f"""SELECT DISTINCT r.* FROM meal_plan_slots s - JOIN recipes r ON r.id = s.recipe_id + JOIN {c}recipes r ON r.id = s.recipe_id WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""", (plan_id,), ) @@ -1256,3 +1313,71 @@ class Store: (pseudonym, directus_user_id), ) self.conn.commit() + + # ── Shopping list ───────────────────────────────────────────────────────── + + def add_shopping_item( + self, + name: str, + quantity: float | None = None, + unit: str | None = None, + category: str | None = None, + notes: str | None = None, + source: str = "manual", + recipe_id: int | None = None, + sort_order: int = 0, + ) -> dict: + return self._insert_returning( + """INSERT INTO shopping_list_items + (name, quantity, unit, category, notes, source, recipe_id, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""", + (name, quantity, unit, category, notes, source, recipe_id, sort_order), + ) + + def list_shopping_items(self, include_checked: bool = True) -> list[dict]: + where = "" if include_checked else "WHERE checked = 0" + self.conn.row_factory = sqlite3.Row + rows = self.conn.execute( + f"SELECT * FROM shopping_list_items {where} ORDER BY checked, sort_order, id", + ).fetchall() + return [self._row_to_dict(r) for r in rows] + + def get_shopping_item(self, item_id: int) -> dict | None: + self.conn.row_factory = sqlite3.Row + row = self.conn.execute( + "SELECT * FROM shopping_list_items WHERE id = ?", (item_id,) + ).fetchone() + return self._row_to_dict(row) if row else None + + def update_shopping_item(self, item_id: int, **kwargs) -> dict | None: + allowed = {"name", "quantity", "unit", "category", "checked", "notes", "sort_order"} + fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None} + if not fields: + return self.get_shopping_item(item_id) + if "checked" in fields: + fields["checked"] = 1 if fields["checked"] else 0 + set_clause = ", ".join(f"{k} = ?" for k in fields) + values = list(fields.values()) + [item_id] + self.conn.execute( + f"UPDATE shopping_list_items SET {set_clause}, updated_at = datetime('now') WHERE id = ?", + values, + ) + self.conn.commit() + return self.get_shopping_item(item_id) + + def delete_shopping_item(self, item_id: int) -> bool: + cur = self.conn.execute( + "DELETE FROM shopping_list_items WHERE id = ?", (item_id,) + ) + self.conn.commit() + return cur.rowcount > 0 + + def clear_checked_shopping_items(self) -> int: + cur = self.conn.execute("DELETE FROM shopping_list_items WHERE checked = 1") + self.conn.commit() + return cur.rowcount + + def clear_all_shopping_items(self) -> int: + cur = self.conn.execute("DELETE FROM shopping_list_items") + self.conn.commit() + return cur.rowcount diff --git a/app/models/schemas/inventory.py b/app/models/schemas/inventory.py index 600dc7d..31b766a 100644 --- a/app/models/schemas/inventory.py +++ b/app/models/schemas/inventory.py @@ -89,6 +89,7 @@ class InventoryItemUpdate(BaseModel): unit: Optional[str] = None location: Optional[str] = None sublocation: Optional[str] = None + purchase_date: Optional[date] = None expiration_date: Optional[date] = None opened_date: Optional[date] = None status: Optional[str] = None @@ -118,6 +119,9 @@ class InventoryItemResponse(BaseModel): expiration_date: Optional[str] opened_date: Optional[str] = None opened_expiry_date: Optional[str] = None + secondary_state: Optional[str] = None + secondary_uses: Optional[List[str]] = None + secondary_warning: Optional[str] = None status: str notes: Optional[str] disposal_reason: Optional[str] = None diff --git a/app/services/recipe/element_classifier.py b/app/services/recipe/element_classifier.py index 991aa00..850c001 100644 --- a/app/services/recipe/element_classifier.py +++ b/app/services/recipe/element_classifier.py @@ -84,8 +84,9 @@ class ElementClassifier: name = ingredient_name.lower().strip() if not name: return IngredientProfile(name="", elements=[], source="heuristic") + c = self._store._cp row = self._store._fetch_one( - "SELECT * FROM ingredient_profiles WHERE name = ?", (name,) + f"SELECT * FROM {c}ingredient_profiles WHERE name = ?", (name,) ) if row: return self._row_to_profile(row) diff --git a/app/services/recipe/substitution_engine.py b/app/services/recipe/substitution_engine.py index ec9f9c1..1ae302b 100644 --- a/app/services/recipe/substitution_engine.py +++ b/app/services/recipe/substitution_engine.py @@ -55,11 +55,12 @@ class SubstitutionEngine: ingredient_name: str, constraint: str, ) -> list[SubstitutionSwap]: - rows = self._store._fetch_all(""" + c = self._store._cp + rows = self._store._fetch_all(f""" SELECT substitute_name, constraint_label, fat_delta, moisture_delta, glutamate_delta, protein_delta, occurrence_count, compensation_hints - FROM substitution_pairs + FROM {c}substitution_pairs WHERE original_name = ? AND constraint_label = ? ORDER BY occurrence_count DESC """, (ingredient_name.lower(), constraint)) diff --git a/compose.cloud.yml b/compose.cloud.yml index ae903ab..757578a 100644 --- a/compose.cloud.yml +++ b/compose.cloud.yml @@ -13,6 +13,7 @@ services: environment: CLOUD_MODE: "true" CLOUD_DATA_ROOT: /devl/kiwi-cloud-data + RECIPE_DB_PATH: /devl/kiwi-corpus/recipes.db KIWI_BASE_URL: https://menagerie.circuitforge.tech/kiwi # DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env # DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy). @@ -27,6 +28,8 @@ services: - "host.docker.internal:host-gateway" volumes: - /devl/kiwi-cloud-data:/devl/kiwi-cloud-data + # Recipe corpus — shared read-only NFS-backed SQLite (3.1M recipes, 2.9GB) + - /Library/Assets/kiwi/kiwi.db:/devl/kiwi-corpus/recipes.db:ro # LLM config — shared with other CF products; read-only in container - ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro networks: diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0a79d4e..4818e15 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -93,6 +93,9 @@ export interface InventoryItem { expiration_date: string | null opened_date: string | null opened_expiry_date: string | null + secondary_state: string | null + secondary_uses: string[] | null + secondary_warning: string | null status: string source: string notes: string | null @@ -187,7 +190,7 @@ export const inventoryAPI = { */ async listItems(params?: { location?: string - status?: string + item_status?: string limit?: number offset?: number }): Promise { @@ -913,6 +916,77 @@ export const browserAPI = { }, } +// ── Shopping List ───────────────────────────────────────────────────────────── + +export interface GroceryLink { + ingredient: string + retailer: string + url: string +} + +export interface ShoppingItem { + id: number + name: string + quantity: number | null + unit: string | null + category: string | null + checked: boolean + notes: string | null + source: string + recipe_id: number | null + sort_order: number + created_at: string + updated_at: string + grocery_links: GroceryLink[] +} + +export interface ShoppingItemCreate { + name: string + quantity?: number + unit?: string + category?: string + notes?: string + source?: string + recipe_id?: number + sort_order?: number +} + +export interface ShoppingItemUpdate { + name?: string + quantity?: number + unit?: string + category?: string + checked?: boolean + notes?: string + sort_order?: number +} + +export const shoppingAPI = { + list: (includeChecked = true) => + api.get('/shopping', { params: { include_checked: includeChecked } }).then(r => r.data), + + add: (item: ShoppingItemCreate) => + api.post('/shopping', item).then(r => r.data), + + addFromRecipe: (recipeId: number, includeCovered = false) => + api.post('/shopping/from-recipe', { recipe_id: recipeId, include_covered: includeCovered }).then(r => r.data), + + update: (id: number, update: ShoppingItemUpdate) => + api.patch(`/shopping/${id}`, update).then(r => r.data), + + remove: (id: number) => + api.delete(`/shopping/${id}`), + + clearChecked: () => + api.delete('/shopping/checked'), + + clearAll: () => + api.delete('/shopping/all'), + + confirmPurchase: (id: number, location = 'pantry', quantity?: number, unit?: string) => + api.post(`/shopping/${id}/confirm`, { location, quantity, unit }).then(r => r.data), +} + // ── Orch Usage ──────────────────────────────────────────────────────────────── export async function getOrchUsage(): Promise { @@ -920,4 +994,22 @@ export async function getOrchUsage(): Promise { return resp.data } +// ── Session Bootstrap ───────────────────────────────────────────────────────── + +export interface SessionInfo { + auth: 'local' | 'anon' | 'authed' + tier: string + has_byok: boolean +} + +/** Call once on app load. Logs auth= + tier= server-side for analytics. */ +export async function bootstrapSession(): Promise { + try { + const resp = await api.get('/session/bootstrap') + return resp.data + } catch { + return null + } +} + export default api diff --git a/frontend/src/stores/inventory.ts b/frontend/src/stores/inventory.ts index 03e1438..69b5c24 100644 --- a/frontend/src/stores/inventory.ts +++ b/frontend/src/stores/inventory.ts @@ -56,7 +56,7 @@ export const useInventoryStore = defineStore('inventory', () => { try { items.value = await inventoryAPI.listItems({ - status: statusFilter.value === 'all' ? undefined : statusFilter.value, + item_status: statusFilter.value === 'all' ? undefined : statusFilter.value, location: locationFilter.value === 'all' ? undefined : locationFilter.value, limit: 1000, })