Files
MealQuest/docs/ARCHITECTURE.md
T
dev_agent 9f4537d0cb
CI / ci (push) Successful in 23s
CI / ci (pull_request) Successful in 24s
Feature 01 task 6: AGENTS.md, CLAUDE.md, README and architecture docs (#7)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:26:15 -04:00

11 KiB
Raw Blame History

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.

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.