215 lines
9.3 KiB
Markdown
215 lines
9.3 KiB
Markdown
# 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, port 8090
|
|||
|
|
├── searxng sidecar for recipe search
|
|||
|
|
└── Litestream SQLite replication → Synology NAS 10.60.1.50
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
One container serves the API, the auth flow, media and the PWA. SQLite lives
|
|||
|
|
on the host volume and is continuously 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 (embedded; no goose/golang-migrate). |
|
|||
|
|
| `web/` | React PWA. |
|
|||
|
|
| `deploy/` | Compose, Traefik route, Litestream config. |
|
|||
|
|
| `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` (structured).
|
|||
|
|
- 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** (authorization
|
|||
|
|
code flow handled server-side with `coreos/go-oidc/v3` and `x/oauth2`).
|
|||
|
|
- Session cookie `__Host-mealquest_session` (Secure, HttpOnly, path `/`).
|
|||
|
|
- Unsafe 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` | Household name, timezone, preferences. |
|
|||
|
|
| `recipes` | Dinners: name, method, minutes, difficulty, source, lifecycle status. |
|
|||
|
|
| `recipe_ingredients` | Ingredients with quantities per recipe. |
|
|||
|
|
| `recipe_steps` | Ordered steps per recipe. |
|
|||
|
|
| `photos` | Cached recipe photos. |
|
|||
|
|
| `dinners` | Dinner records and lifecycle state. |
|
|||
|
|
| `suggestion_batches` | One AI suggestion run. |
|
|||
|
|
| `suggestions` | Side quests with reason ("Why this?") and Try it / Pass status. |
|
|||
|
|
| `week_plans` | One plan per Monday-start week; draft or synced. |
|
|||
|
|
| `plan_days` | The dinner for each day of a week plan. |
|
|||
|
|
| `rotation_reviews` | Rotation check answers ("Keep it in rotation?"). |
|
|||
|
|
| `shopping_lists` | One Supply run per week; frozen into the Supply archive after the week. |
|
|||
|
|
| `shopping_groups` | Items grouped by dinner. |
|
|||
|
|
| `shopping_items` | Items, quantity, needed / have-it. |
|
|||
|
|
| `ai_settings` | Provider, model, temperature, system prompt, API key (`_ct`). |
|
|||
|
|
| `recipe_search_settings` | Recipe search configuration (searxng). |
|
|||
|
|
| `integrations_google` | Google Calendar OAuth tokens (`_ct`) and calendar id. |
|
|||
|
|
| `chat_settings` | Google Chat webhook URL (`_ct`). |
|
|||
|
|
| `notification_prefs` | Per-event push / Chat toggles, reminder time. |
|
|||
|
|
| `push_subscriptions` | Web Push subscriptions. |
|
|||
|
|
| `notification_outbox` | Pending notifications for delivery. |
|
|||
|
|
| `jobs` | Durable background job queue. |
|
|||
|
|
| `schedule_runs` | Record of scheduled runs (idempotency). |
|
|||
|
|
| `ai_runs` | Every AI skill call: inputs, outputs, validation, timing. |
|
|||
|
|
| `sessions` | Server-side sessions. |
|
|||
|
|
|
|||
|
|
## Dinner lifecycle
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
suggested ──Try it──▶ quest_log ──cooked──▶ tried ──Rotation check──▶ regular
|
|||
|
|
│ └────────────────────▶ archived
|
|||
|
|
└──Pass──▶ declined (not suggested again)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Promotion to Regular is **never automatic** (D-021): it happens only when the
|
|||
|
|
household answers "Keep it in rotation?".
|
|||
|
|
|
|||
|
|
## API surface
|
|||
|
|
|
|||
|
|
- JSON API under `/api/v1`, using the error body above for every failure.
|
|||
|
|
- `/auth/*` — OIDC login, callback, logout.
|
|||
|
|
- `/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`): each skill (suggestions, week draft,
|
|||
|
|
ingredient ideas, quick ideas, archive post titles, …) layers its prompt —
|
|||
|
|
system prompt from `ai_settings`, skill instructions, household context —
|
|||
|
|
then validates the structured output against the skill's schema. An invalid
|
|||
|
|
response gets **one repair retry**; a second failure is an error, never a
|
|||
|
|
silent fallback. Every call is recorded in `ai_runs`.
|
|||
|
|
|
|||
|
|
## Recipe grounding
|
|||
|
|
|
|||
|
|
- Recipes are grounded in real pages found via the `searxng` sidecar.
|
|||
|
|
- Fetching is **SSRF-safe** (public addresses only, size and time limits).
|
|||
|
|
- Recipe data is parsed from schema.org `Recipe` JSON-LD.
|
|||
|
|
- Photos are downloaded, processed (`golang.org/x/image`) and cached locally;
|
|||
|
|
the client never hot-links external images.
|
|||
|
|
|
|||
|
|
## Scheduler
|
|||
|
|
|
|||
|
|
Times are in the household timezone.
|
|||
|
|
|
|||
|
|
| 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 | Freezes last week's list into the Supply archive, rolls the plan. |
|
|||
|
|
| `ai_runs` purge | Periodic | Deletes old `ai_runs` rows. |
|
|||
|
|
|
|||
|
|
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||
|
|
|
|||
|
|
## Notifications
|
|||
|
|
|
|||
|
|
- Events: plan ready, list ready, day-of reminder, new suggestions; 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.
|
|||
|
|
- OAuth once in Settings; one event per planned dinner.
|
|||
|
|
- **Deterministic event ids** derived from the plan day, so re-pushing
|
|||
|
|
updates the same events instead of creating duplicates.
|
|||
|
|
|
|||
|
|
## 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 Regulars |
|
|||
|
|
| `/plan` | Weekly plan (Draft board) |
|
|||
|
|
| `/plan/shopping` | Supply run |
|
|||
|
|
| `/plan/shopping/archive` | Supply archive |
|
|||
|
|
| `/discover` | Swipe Side quests |
|
|||
|
|
| `/quick` | Quick dinners |
|
|||
|
|
| `/cook-from-pantry` | Cook from what I have |
|
|||
|
|
| `/dinner/:id` | Recipe detail / Kitchen reading mode |
|
|||
|
|
| `/settings` | Settings home |
|
|||
|
|
| `/settings/notifications` | Notifications and Google Chat |
|
|||
|
|
| `/settings/ai` | Admin · AI assistant |
|
|||
|
|
|
|||
|
|
## 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.
|