Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
11 KiB
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/slogJSON. - SQLite via
modernc.org/sqlite, withjournal_mode=WAL,foreign_keys=ONandbusy_timeout. One writer connection plus a read pool. Queries generated bysqlc. - IDs are ULIDs (
oklog/ulid/v2). - Timestamps are stored in UTC; local calendar dates are
YYYY-MM-DDstrings in the household timezone. - Configuration is environment-only (
MEALQUEST_*). Secrets come only from files named by*_FILEvariables. 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, theX-MealQuest-CSRFheader. /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 inai_runs.
Recipe grounding
- Dinners are grounded in real recipe pages found through recipe search
(
searxngorbrave, perrecipe_search_settings). - Fetching is SSRF-safe.
- Recipe data is parsed from schema.org
RecipeJSON-LD. - Photos are cached locally (
photos) and processed withgolang.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 pernotification_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.