# MealQuest architecture This mirrors the architecture section of `CONTEXT.MD` in the CTXD project `mealquest`, which is the source of record; if the two disagree, CTXD wins and this file should be updated. It describes the **planned** application. The repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`, `internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI. Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD. ## Topology ``` PWA (phone / tablet / desktop) │ HTTPS ▼ Traefik v3.6 — 10.60.1.33 file route: mealquest-ccl.yml │ ▼ docker compose project "mealquest" — 10.60.1.14 ├── mealquest Go service + embedded PWA, host 8090 → container 8080 ├── searxng sidecar for recipe search; internal compose network │ only, no published port └── Litestream SQLite replication → Synology NAS 10.60.1.50 ``` One Go service serves the API, the auth flow, media and the PWA. Its SQLite database is replicated by Litestream to the NAS. Traefik terminates TLS. The service honours `X-Forwarded-For` and `X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY` (comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other peer they are ignored. The client is the rightmost `X-Forwarded-For` address that is not a trusted proxy. The resolved client IP is added as `client_ip` to every log line written while handling a request, and the auth routes refuse requests whose resolved scheme differs from `MEALQUEST_PUBLIC_URL`'s. The OIDC redirect URI is always built from `MEALQUEST_PUBLIC_URL`, never from request headers. ## Repository layout | Path | Purpose | | --- | --- | | `cmd/server` | Entry point; wiring only. | | `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. | | `internal/server` | HTTP server, router, middleware. | | `internal/frontend` | Embedded PWA, SPA fallback. | | `internal/auth` | Authentik OIDC, sessions, CSRF. | | `internal/db`, `internal/store` | SQLite connections, sqlc queries. | | `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. | | `internal/household`, `internal/dinners`, `internal/recipes` | Domain. | | `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. | | `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. | | `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. | | `internal/jobs`, `internal/scheduler` | Background jobs and schedules. | | `migrations/` | SQL migrations (no goose or golang-migrate). | | `web/` | React PWA. | | `deploy/` | Deployment files. | | `docs/` | Documentation. | | `design/` | Requirements and UI handoff (read-only reference). | ## Backend stack and conventions - Go 1.25, `CGO_ENABLED=0`, a single static binary. - Router: `go-chi/chi/v5`. Logging: `log/slog` JSON. - SQLite via `modernc.org/sqlite`, with `journal_mode=WAL`, `foreign_keys=ON` and `busy_timeout`. **One writer connection plus a read pool.** Queries generated by `sqlc`. - IDs are ULIDs (`oklog/ulid/v2`). - Timestamps are stored in UTC; local calendar dates are `YYYY-MM-DD` strings in the household timezone. - Configuration is environment-only (`MEALQUEST_*`). Secrets come only from files named by `*_FILE` variables. Missing or invalid config stops startup. - Error responses always have the body `{"error":{"code":"…","message":"…","retryable":false}}`. ## Authentication - Authentik OIDC; the backend is a **confidential client** (`coreos/go-oidc/v3`, `golang.org/x/oauth2`). - Session cookie `__Host-mealquest_session` (HttpOnly, Secure, SameSite=Lax). Sessions expire after 30 days idle or 180 days absolute. - Non-GET requests require the CSRF header `X-MealQuest-CSRF`. - Access requires membership of the Authentik group `mealquest-admin`. There is a **single role**: one shared household, no per-person profiles. ## Data model Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | Table | Holds | | --- | --- | | `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. | | `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes, yield, raw_jsonld, fetched_at. | | `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. | | `recipe_steps` | recipe_id, position, text. | | `photos` | source_url, local_path, width, height, sha256, fetched_at. | | `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. | | `suggestion_batches` | trigger, status, ai_run_id. | | `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. | | `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url, api_key_ct. | | `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. | | `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. | | `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. | | `push_subscriptions` | endpoint (unique), p256dh, auth, user_agent, failure_count, last_success_at. | | `notification_outbox` | event_key + channel (unique), payload, status, attempts, next_attempt_at, last_error. | | `jobs` | kind, status, payload, result (progressive), error, timestamps. | | `schedule_runs` | schedule, period_key (unique). | | `ai_runs` | skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in/out, error, redacted request/response (30-day retention). | | `sessions` | id_hash, subject, email, name, groups, csrf_token, created/last_seen/expires. | `recipes` holds a fetched recipe page; `dinners` holds the household's meal record and its lifecycle, optionally linked to a recipe and a photo. ## Dinner lifecycle ``` suggested (deck) ──Try it──▶ quest_log ──planned date passes──▶ tried │ ▲ │ │ └──────── explicit decision ─────┤ │ ├──▶ regular │ └──▶ archived └──Pass──▶ archived (name kept for dedupe) ``` From `tried`, an explicit decision moves the dinner to `regular`, to `archived`, or back to `quest_log`. Promotion to `regular` is **never automatic** (D-021). ## API surface - JSON API under `/api/v1`, using the error body above for every failure. It requires the session cookie and, for non-GET requests, the `X-MealQuest-CSRF` header. - `/auth/*` — the OIDC flow. - `/media/*` — cached photos. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. ## AI layer - **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible, Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs. - **Skill harness** (`ai/harness`): prompt layering, validation of the structured output, and **one repair retry**; if the repair also fails the run fails — no silent fallback. Every call is recorded in `ai_runs`. ## Recipe grounding - Dinners are grounded in real recipe pages found through recipe search (`searxng` or `brave`, per `recipe_search_settings`). - Fetching is **SSRF-safe**. - Recipe data is parsed from schema.org `Recipe` JSON-LD. - Photos are cached locally (`photos`) and processed with `golang.org/x/image`. ## Scheduler Times are in the household timezone. The times of `day_of_reminder` (`reminder_time`) and `rotation_check` (`review_time`), and the day and time of `suggestions_weekly`, `plan_draft` and `shopping_chat_autosend`, are household settings in `household_settings` (`dinner_time` is also stored there); the table shows their defaults. `week_rollover` and the `ai_runs` purge are fixed and not configurable. | Schedule | When | Does | | --- | --- | --- | | `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. | | `plan_draft` | Friday 09:00 | AI drafts next week's plan. | | `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | | `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. | | `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. | | `ai_runs` purge | Daily (fixed) | Deletes `ai_runs` rows past 30-day retention. | Runs are recorded in `schedule_runs` and executed through `jobs`. ## Notifications - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, `rotation_review`; each can go to Web Push and/or Google Chat per `notification_prefs`. - Delivery goes through `notification_outbox`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. ## Google Calendar - One-way push: the app is the source of truth; edits in Google Calendar do not come back. - Connected once with Google OAuth in Settings; one event per planned dinner. - **Deterministic event ids**; each plan day records its `calendar_event_id`. ## Frontend - React 19, TypeScript, Vite, React Router 7, TanStack Query 5, `vite-plugin-pwa`, `lucide-react` (stroke 2.75). - CSS Modules over `tokens.css`; no Tailwind, no Framer Motion, no hard-coded hex. Self-hosted Bagel Fat One and Figtree. - Tests: Vitest, Testing Library, jsdom. - Offline cache for the current plan, shopping list and recipes. | Route | Screen | | --- | --- | | `/` | Home | | `/onboarding` | Add regular dinners | | `/plan` | Weekly plan (Draft board) | | `/plan/shopping` | Shopping list (Supply run) | | `/plan/shopping/archive` | Supply archive | | `/discover` | Swipe suggestions (Side quests) | | `/quick` | Quick dinners | | `/cook-from-pantry` | Cook from what I have | | `/dinner/:id` | Recipe / dinner detail (Kitchen reading mode on tablet) | | `/settings` | Settings home | | `/settings/household` | Household settings | | `/settings/regulars` | Regular dinners | | `/settings/calendar` | Google Calendar connection | | `/settings/notifications` | Notifications and Google Chat | | `/settings/ai` | Admin · AI assistant | `/settings/household`, `/settings/regulars` and `/settings/calendar` come from CTXD; the other routes are defined in the UI handoff. ## Boundaries and gotchas - **Secrets never reach the client** — API keys, OAuth tokens and the Chat webhook stay server-side, encrypted at rest. - **The app is the source of truth for the plan**; Google Calendar is a one-way mirror. - **Never auto-promote to Regular** (D-021). - **Quick** means cooked in a crock pot **or** ≤ 30 minutes prep to table. - **Weeks run Monday–Sunday** in the household timezone. - **No silent fallbacks** — fail fast and report. - **iOS Web Push requires the PWA to be installed to the Home Screen**; show an install hint.