fix: wire recipe corpus to cloud per-user DBs via SQLite ATTACH (#102)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

Cloud mode: attach shared read-only corpus DB (RECIPE_DB_PATH env var)
as "corpus" schema so per-user SQLite DBs can access 3.19M recipes.
All corpus table references now use self._cp prefix ("corpus." in cloud,
"" in local). FTS5 pseudo-column kept unqualified per SQLite spec.
compose.cloud.yml: bind-mount /Library/Assets/kiwi/kiwi.db read-only.

Also fix batch of audit issues:
- #101: OCR approval used source="receipt_ocr" for inventory_items — use "receipt"
- #89/#100: Shopping confirm-purchase used source="shopping_list" — use "manual"
- #103: Frontend inventory filter sent ?status= but API expects ?item_status=
- #104: InventoryItemUpdate schema missing purchase_date field; store.py allowed set also missing it
- #105: Guest cookie Secure flag tied to CLOUD_MODE instead of X-Forwarded-Proto; broke HTTP direct-port access
This commit is contained in:
pyr0ball 2026-04-18 14:21:56 -07:00
parent 8483b9ae5f
commit 890216a1f0
12 changed files with 570 additions and 59 deletions

View file

@ -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}"

View file

@ -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(

View file

@ -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()
)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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:

View file

@ -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<InventoryItem[]> {
@ -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<ShoppingItem[]>('/shopping', { params: { include_checked: includeChecked } }).then(r => r.data),
add: (item: ShoppingItemCreate) =>
api.post<ShoppingItem>('/shopping', item).then(r => r.data),
addFromRecipe: (recipeId: number, includeCovered = false) =>
api.post<ShoppingItem[]>('/shopping/from-recipe', { recipe_id: recipeId, include_covered: includeCovered }).then(r => r.data),
update: (id: number, update: ShoppingItemUpdate) =>
api.patch<ShoppingItem>(`/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<OrchUsage | null> {
@ -920,4 +994,22 @@ export async function getOrchUsage(): Promise<OrchUsage | null> {
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<SessionInfo | null> {
try {
const resp = await api.get<SessionInfo>('/session/bootstrap')
return resp.data
} catch {
return null
}
}
export default api

View file

@ -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,
})