CI / ci (push) Successful in 8m0s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
1405 lines
91 KiB
Markdown
1405 lines
91 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 application as built; nothing
|
||
it names is still only planned.
|
||
|
||
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. The OIDC redirect URI
|
||
and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request
|
||
headers, so sign-in works whichever peer the request arrives from.
|
||
|
||
## Repository layout
|
||
|
||
| Path | Purpose |
|
||
| --- | --- |
|
||
| `cmd/server` | Entry point; wiring only. |
|
||
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
|
||
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||
| `internal/server` | HTTP server, router, middleware. |
|
||
| `internal/forwarded` | Client IP and scheme from `X-Forwarded-*`, trusted only from `MEALQUEST_TRUSTED_PROXY`. |
|
||
| `internal/secureurl` | The transport rule for URLs that carry credentials (`secureurl.Check`). |
|
||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
||
| `internal/auth` | Authentik OIDC, sessions, CSRF. |
|
||
| `internal/db`, `internal/store` | sqlc-generated queries; SQLite connections, migrations at boot, transactions. |
|
||
| `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. |
|
||
| `internal/household`, `internal/dinners`, `internal/recipes` | Domain: household settings and local time, dinners (grounding, photo backfill), recipe search, fetch and grounding. |
|
||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills`, `internal/ai/aisettings` | AI provider adapters, skill harness, skills, AI settings. |
|
||
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas`, `internal/home` | Feature services. |
|
||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||
| `internal/vapid` | VAPID key generation and validation. |
|
||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||
| `migrations/` | SQL migrations, embedded and applied at boot by `internal/store` (no goose or golang-migrate); sqlc queries in `migrations/queries/`. |
|
||
| `web/` | React PWA. |
|
||
| `deploy/` | Production compose stack, systemd units, the [deploy runbook](../deploy/RUNBOOK.md) and the automated deploy (see Deployment). |
|
||
| `docs/` | Documentation (this file). |
|
||
| `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 (the page's JSON-LD), yield, raw_jsonld, fetched_at; and its grounding: photo_id, method, difficulty, minutes_total (normalized prep to table). |
|
||
| `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, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
|
||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), 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, revision (raised by every swap and stored draft). |
|
||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
|
||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
||
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
|
||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
||
| `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 (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
|
||
| `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 (suggested 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, behind the session cookie.
|
||
`GET /media/photos/{id}-{400|1200}.jpg` serves a stored size of a photo
|
||
with a `photos` row, with
|
||
`Cache-Control: private, max-age=31536000, immutable`; anything else is
|
||
404.
|
||
- `GET`/`PATCH /api/v1/settings/recipe-search` — provider, base_url and
|
||
the Brave key (`api_key`, write-only: responses carry only
|
||
`api_key_set` and `api_key_last4`; an empty string removes it). The
|
||
result must be usable: SearXNG needs a base_url, Brave a key.
|
||
Responses also carry the read-only `default_base_url`, the deployment's
|
||
`MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no
|
||
base_url is saved; only a PATCH of base_url stores a URL.
|
||
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
|
||
masala" and returns `{query, count}`, 409 `not_configured`, or 502
|
||
`search_failed` with the provider's error.
|
||
- `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider,
|
||
base_url, model, temperature, system_prompt, plus the read-only
|
||
`api_key_last4` and `key_verified_at`. PATCH requires a preset name as
|
||
provider, temperature 0–1 in 0.1 steps, a non-empty model and a
|
||
system_prompt of at most 8000 characters; a fixed-URL preset's base_url
|
||
must be empty or its own. Provider and base_url cannot change while a
|
||
key is stored (remove the key first), so a stored key is never sent to
|
||
an endpoint it was not stored for.
|
||
`PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least
|
||
8 characters, sealed with the secret box; an empty string removes it) and
|
||
clears `key_verified_at`; a preset without a fixed base_url takes a key
|
||
only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls
|
||
`Provider.Verify` and records `key_verified_at`;
|
||
`GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}`
|
||
live from the provider, with the key redacted from every id and
|
||
display_name. Both work before a model is chosen
|
||
(`presets.ForAccount`), answer 409 `not_configured` when the settings
|
||
cannot build a provider, and 502 with the provider's typed error
|
||
(`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504
|
||
`timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt`
|
||
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
|
||
response holds the key. `Service.HarnessConfig` reads the settings for
|
||
each skill run, so a saved change applies to the next run.
|
||
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
|
||
`{name}` or `{name, recipe_url}` (an https:// link, kept in
|
||
`recipe_url` for grounding and never returned in a dinner response, as
|
||
it may carry a personal token) with status and origin `regular` and
|
||
grounding_status `pending`, answering 201 with the dinner. The name is
|
||
always the household's: no part of a link is ever used as a dinner's
|
||
name, and a link without a name is 400. A name
|
||
is trimmed, at most 120 characters and must keep a word after
|
||
`harness.Normalize`, which sets `normalized_name`. A name
|
||
`harness.Similar` to an unarchived dinner's, or a link one already has
|
||
(the source_url of the recipe it is grounded in, else its pasted
|
||
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
|
||
again.
|
||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
||
any other status is 400. The Quest log (`quest_log`) is the dinners
|
||
accepted and not yet scheduled: one on the plan for the household's
|
||
current week (by its timezone) or a later week is left out. `PATCH /api/v1/dinners/{id}` edits `name`
|
||
(deduplicated against the other unarchived dinners), `method`
|
||
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
|
||
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
|
||
are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}`
|
||
sets status `archived` and keeps the row; `POST
|
||
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
||
the session's subject as `updated_by`; an unknown id is 404.
|
||
`GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in
|
||
one transaction: `{id, name, status, origin, method, minutes_total,
|
||
difficulty, cuisine, is_favorite, photo_urls, ingredients, steps,
|
||
source, grounding_status, grounding_error, why_picked}`. `photo_urls` is
|
||
`{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or
|
||
null with no photo; `ingredients` is `[{name, qty, unit, note,
|
||
raw_text}]` and `steps` the step texts, both in recipe position order and
|
||
empty (`[]`) with no recipe, as for a dinner whose grounding is
|
||
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
|
||
Two items at one position leave recipe order unknown, so such a recipe
|
||
is 500 rather than shown out of order. `source` is `{site_name}` of the
|
||
page the recipe was fetched from, null for a manual recipe or none; it
|
||
has no `url`, as the page's link may carry a personal token.
|
||
`site_name` is the name the page gives its site (publisher or
|
||
`og:site_name`) and null otherwise: a page naming no site is stored
|
||
under its host, which may be the pasted link's — a token in a
|
||
subdomain — or a redirect's, so any stored name that could be a host
|
||
(the link's host, an IP address or a dotted host name, even a real
|
||
name like "Food.com") is withheld. The detail screen shows "Recipe
|
||
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
|
||
suggestion that created the dinner — its pending or accepted one, as
|
||
declining a suggestion archives its dinner — null for one no suggestion
|
||
created; a dinner with more than one such suggestion is 500, as nothing
|
||
records which created it. An unknown or archived dinner is 404.
|
||
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
|
||
job and answers with its `grounding_job_id` without waiting for it
|
||
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
|
||
still answers 201, with grounding_status `failed` (`grounding_error`)
|
||
and `grounding_job_id` null. The job grounds the dinner's
|
||
`recipe_url` directly with `GroundURL`, else its name with `Ground`
|
||
(see Recipe grounding), and sets grounding_status `grounded` with the
|
||
recipe's recipe_id, photo_id, method, minutes_total and difficulty,
|
||
clearing the pasted `recipe_url` (the recipe replaces it), or
|
||
`failed` with `grounding_error` — also the job's failure code — one of
|
||
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
||
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
||
`grounding_error`. Each job carries the dinner's `grounding_revision`
|
||
from when it was enqueued; renaming a dinner raises the revision, and
|
||
renaming a `pending` one enqueues a job for the new name, clearing its
|
||
`recipe_url` when the normalized name changes.
|
||
A job for a dinner archived, no longer `pending` or at another revision
|
||
does no work, and a job only writes a dinner still unarchived, `pending`
|
||
and at its revision, so a fix-up made meanwhile wins and an earlier job
|
||
for an old name — queued or running — never decides the outcome.
|
||
Archiving or renaming a dinner also stops its running job where it is,
|
||
so no further search, page fetch, photo download or model call is made
|
||
for it. A
|
||
method, minutes_total or difficulty a member saved while the dinner was
|
||
`pending` is kept; grounding fills only those left empty. At boot, after
|
||
the job runner fails the jobs a previous process left running, every
|
||
unarchived dinner still `pending` with no `ground_dinner` job for its revision
|
||
queued or running is set `failed` with `interrupted`, so no dinner stays
|
||
pending with nothing left to ground it.
|
||
Photo backfill. `POST /api/v1/settings/photo-backfill` (CSRF-checked)
|
||
enqueues a `backfill_dinner_photos` job and answers 202 `{id}` without
|
||
waiting for it; while one is queued or running it enqueues nothing and
|
||
answers with that job's id, so two passes never overlap, across restarts
|
||
too. The kind is refused by `POST /api/v1/jobs`. The job
|
||
grounds, one at a time with `Ground` on its name, every unarchived dinner
|
||
that is `failed` with no photo and no pasted `recipe_url` — grounded,
|
||
`manual` and `pending` dinners are never listed, and a pasted link is
|
||
never replaced by a search. Success sets grounding_status `grounded` as
|
||
the `ground_dinner` job does (the recipe keeps its source URL and site);
|
||
failure leaves the dinner `failed` with the new `grounding_error`, for
|
||
the fix-recipe flow. One dinner's failure never stops the others; a
|
||
dinner renamed, archived or grounded by hand meanwhile is left as it is.
|
||
Its result is `{dinners: [{dinner_id, outcome, reason}]}`, outcome
|
||
`grounded`, `failed` (with its reason) or `skipped`. A dinner whose write
|
||
fails is `failed` with `grounding_error` and left as it was; the others
|
||
still run, then the job fails with `internal_error`, keeping that result.
|
||
The responses returning a `dinners.Dinner` representation (the list; the
|
||
create, update, archive, favorite and grounding responses; the idea
|
||
responses) carry `photo_backfill_eligible`, and
|
||
`GET /api/v1/dinners/{id}` does not; it says whether the backfill grounds
|
||
that dinner, computed by the same condition it lists dinners by, so a
|
||
client offers a search only where one runs.
|
||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||
ranks them; it fetches pages but downloads no photo and runs no model,
|
||
so `photo_url` is set only for a page already stored. `candidate_id` is
|
||
an opaque id the server resolves to the page it last offered that dinner
|
||
(kept in memory, so a restart means fetching the candidates again) at
|
||
the `grounding_revision` it was searched at, so a candidate offered
|
||
before any rename is refused even if the name is changed back; the
|
||
page's URL, which may carry a token, is never returned or cached. An
|
||
unknown dinner is 404, recipe search not set up 409 `not_configured` and
|
||
a search failure 502 `search_failed`; any other failure, such as a
|
||
stored recipe that cannot be read, is 500, logged with a fixed reason
|
||
and the dinner id only — never a candidate's URL or dependency error
|
||
text.
|
||
`POST /api/v1/dinners/{id}/ground` takes exactly one of `{candidate_id}`,
|
||
`{recipe_url}` (both grounded with `GroundURL`, setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
|
||
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
|
||
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
|
||
— no model runs — as a `recipes` row whose source_url is
|
||
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
||
Recipe built from it, and sets `manual` with its method and minutes, no
|
||
photo and the dinner's difficulty unchanged. Either clears the dinner's
|
||
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
|
||
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
||
set up 409 `not_configured`, a search or model failure 502
|
||
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
||
provider's text), and grounding a candidate or link that runs past 90
|
||
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
||
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
|
||
`dinner_archived`. A candidate or link is written only if the dinner is
|
||
still as it was before grounding began: one renamed or grounded some
|
||
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
|
||
409 `dinner_archived`, either leaving the dinner unchanged. A method,
|
||
minutes or difficulty a member edits meanwhile is kept over the recipe's.
|
||
Any other failure grounding a candidate or link, or recording it on the
|
||
dinner, is 500, logged with a fixed reason and the dinner id only —
|
||
never the link, which can carry a personal token, or dependency error
|
||
text.
|
||
- Side quest suggestions (`suggest.Service`). `POST
|
||
/api/v1/suggestions/batches` creates a `manual` batch and enqueues its
|
||
`suggestion_batch` job, answering 202 `{batch_id, job_id, trigger,
|
||
status}` without waiting; while a batch is `pending` or `running` it
|
||
answers 202 with that batch instead, so at most one runs at a time (the
|
||
check, the batch and its job share one write transaction). The job reads the
|
||
household context, the deck's names and every declined name, runs
|
||
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
||
grounds the survivors in the model's order with `Grounder.GroundMany`,
|
||
as many as are still wanted at a time, until 5 are kept or none are
|
||
left. A Side quest no recipe grounds is dropped. A `replenish` batch
|
||
stores each round's grounded ones before the next; a `scheduled` or
|
||
`manual` batch, which notifies, holds them and stores them all in one
|
||
transaction once its last round is done. Each is stored as a dinner
|
||
with status `suggested`, origin `side_quest`, grounding_status
|
||
`grounded` with the recipe's recipe_id, photo_id, method,
|
||
minutes_total and difficulty and the model's cuisine, and a `pending`
|
||
suggestion with the name it is suggested under, the reason and
|
||
`cited_regular_ids` — unless, since the job read its input, a dinner
|
||
it repeats was added, a suggestion it repeats passed, or a Regular it
|
||
cites renamed or archived (checked in the storing transaction). The job
|
||
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
|
||
failed `replenish` batch's Side quests stored before the failure stay
|
||
in the deck. If
|
||
recording the batch's end fails, its job fails; the job runner retries
|
||
a job's final status write with backoff until it lands, each attempt
|
||
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||
not retried, and the next start fails the job as interrupted. Recording
|
||
the batch's end is bounded the same way (one attempt, 5 seconds, made
|
||
even during shutdown), so a busy writer cannot hold a stopping worker;
|
||
a batch whose end timed out is left `running` with its job. Each time a
|
||
batch is started, before the active batch is selected, every batch
|
||
still `pending` or `running` with no job queued or running (the process
|
||
stopped, or recording its end failed) is set `failed`, so it does not
|
||
block the next.
|
||
- Cook from what I have (`ideas.Service`, Feature 18). `POST
|
||
/api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both
|
||
required) checks the list with `skills.CleanIngredients` — 1 to 20
|
||
entries, each non-blank, at most 80 characters, with a non-empty
|
||
`harness.Normalize` form that no earlier entry shares — answering 400
|
||
`invalid_request` otherwise, then enqueues an `ideas_from_ingredients`
|
||
job (service-only: `POST /jobs` refuses the kind) and answers 202
|
||
`{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope:
|
||
`skills.IdeasScope`, the flag and the normalized list in order), then
|
||
grounds its ideas in the model's order with `Grounder.GroundEach`, as
|
||
many as are still wanted at a time, until 5 are kept or none are left.
|
||
An idea no recipe grounds is dropped; with `quick_only`, so is one whose
|
||
grounded recipe is not a Quick dinner (`ideas.IsQuick`: method
|
||
`crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's
|
||
`est_minutes`). Ideas are not stored as dinners. The job publishes
|
||
`{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary,
|
||
method, minutes_total, difficulty, quick, recipe_id, photo_url,
|
||
source_url, site_name}], dropped}` at each stage (`suggesting`,
|
||
`grounding`, `done`) and again as each idea is kept, the moment its
|
||
grounding finishes, so the result fills idea by idea; the ideas shown
|
||
are always in the model's order, an earlier idea that grounds later
|
||
taking its place before those shown. It fails with `no_ideas_found`
|
||
(every idea was dropped), `ai_failed` (the skill or `normalize_recipe`
|
||
failed; no idea is made up instead, D-017), `search_not_configured` or
|
||
`search_failed`. A failed job shows no ideas, even those kept before the
|
||
failure: the kind registers a `FailedResult` view, so `GET
|
||
/api/v1/jobs/{id}` empties the `ideas` of any failed job's result on
|
||
read, however it failed (including `interrupted by restart` and an
|
||
`internal_error` whose clearing write failed). Before it fails it also
|
||
publishes its result again with no ideas, to tidy what is stored.
|
||
- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick
|
||
{kind, exclude[]}` (both required; `exclude` is `[]` on a first page)
|
||
checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`,
|
||
no default — and `exclude` with `skills.CleanExclude` — at most 60
|
||
names, each non-blank, at most 120 characters, with a non-empty
|
||
`harness.Normalize` form no earlier name shares — answering 400
|
||
`invalid_request` otherwise, then enqueues a `quick_ideas` job
|
||
(service-only) and answers 202 `{jobId}` without waiting. The job reads
|
||
the household context (`harness.BuildContext`, budget 16000), runs
|
||
`quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the
|
||
normalized excluded names in order), then grounds its ideas exactly as
|
||
the ingredients job does (the same `Grounder.GroundEach` rounds, model
|
||
order and failure codes) until 6 are kept or none are left. The kind is
|
||
enforced on the grounded recipe, never the model's estimate
|
||
(`ideas.OfQuickKind`): `crockpot` keeps method `crockpot`
|
||
(`normalize_recipe` writes a slow cooker as `crockpot`), `under30`
|
||
keeps a known `minutes_total` ≤ 30; every other idea is dropped. The
|
||
model is asked for 12, more than a page, so dropped ideas can be
|
||
replaced; when fewer than 6 survive the page shows only those, never
|
||
padded with an idea not of the kind. The job publishes `{stage, kind,
|
||
exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id,
|
||
photo_url, source_url, site_name}], dropped}` at each stage and as each
|
||
idea is kept, fails with the same reasons, and a failed job shows no
|
||
ideas through the same `FailedResult` view. "↻ Show me more" posts
|
||
again with every name the tab has shown so far in `exclude`.
|
||
- Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything
|
||
else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients`
|
||
or `quick_ideas` job, read from its stored result
|
||
(`ideas.Service.Idea`): its name and `recipe_id`, never grounded again.
|
||
A job still queued or running is 409 `ideas_not_ready` (its ideas may
|
||
still move); an unknown job, another kind, a failed job or an index past
|
||
its ideas is 404 `not_found`. `POST
|
||
/api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs
|
||
`dinners.Service.AddToQuestLog`: in one transaction, an unarchived
|
||
dinner the idea repeats — the same rule that refuses a duplicate Regular
|
||
(`harness.Similar` name, or the recipe's link) — is answered 200 as it
|
||
is (a Regular stays a Regular); one awaiting the household's choice
|
||
(`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing;
|
||
otherwise it creates a dinner with status `quest_log`, origin
|
||
`ingredients` or `quick`, grounded in the idea's recipe (its method,
|
||
minutes, difficulty and photo), answered 201. Repeating the call
|
||
answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight`
|
||
(no body) takes today as the household-local date in the household
|
||
timezone and its Monday (`plan.Service.Tonight`); when that week has no
|
||
plan it answers 409 `no_plan` and changes nothing — no dinner, no plan
|
||
(a plan holds all seven days; the household plans the week first).
|
||
Otherwise it adds or finds the dinner as `quest-log` does and puts it
|
||
on today through `plan.Service.Swap`, exactly like a Draft board swap
|
||
(plan marked edited, revision raised, a synced plan becomes `changed`,
|
||
the day's calendar event id cleared, `is_new` for a Quest log dinner),
|
||
answering 200 `{dinner, plan}`. The week is always today's own, so no
|
||
other day is written; a dinner already on another day of the week is
|
||
409 `dinner_already_planned`.
|
||
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
|
||
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
|
||
cited_regular_ids, status, dinner_status, decided_at, created_at}],
|
||
active_batch, failed_batch}`: the `pending` suggestions whose dinner is
|
||
still `suggested`, oldest first; the batch `pending` or `running`
|
||
(`{batch_id, job_id, trigger, status}`, or null); and the newest batch
|
||
when it `failed` (same shape, or null; a later batch, done or under
|
||
way, supersedes the failure), read in one snapshot, so a client that
|
||
did not start the batch can follow its job and learns of a failure it
|
||
was not following. At most one of the two is set. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
|
||
suggestion `accepted` and moves its dinner to `quest_log`; `POST
|
||
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
|
||
the dinner; every later batch, and the household context's passed list,
|
||
reads the suggestion's own name as declined, so renaming the archived
|
||
dinner changes nothing. Both record
|
||
the session's subject as the dinner's `updated_by` and answer 200
|
||
`{suggestion, replenish_job_id}`. Repeating a decision changes nothing
|
||
and answers 200; the opposite decision is 409 `suggestion_decided`, a
|
||
suggestion whose dinner left the deck some other way 409
|
||
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
|
||
fewer than 5 in the deck starts a `replenish` batch, or joins the batch
|
||
already pending or running, and returns that batch's job id either way
|
||
(`replenish_job_id` is null when the deck keeps 5 or more). The batch and its job are written in
|
||
the decision's own transaction, so a decision is never recorded without
|
||
the replenish it calls for; when they cannot be written, neither is the
|
||
decision, and a retry decides again.
|
||
- Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday
|
||
(`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET
|
||
/api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status,
|
||
ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has
|
||
no plan; `days` are the seven days Monday first, each `{date, dinner: {id,
|
||
name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means
|
||
the dinner was planned from the Quest log; `start_by` is the household's
|
||
dinner time on that date minus `minutes_total`, computed on read, for a
|
||
crock-pot dinner with known minutes, else null. `POST
|
||
/api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202
|
||
`{jobId}` for a `draft_week` job, or for the one already queued or
|
||
running for the week, so a week has at most one draft in flight. A
|
||
confirmed draft while the week's job was asked for without confirm is
|
||
409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular
|
||
and every Quest log dinner no other plan for the current or a later week
|
||
has, with the household context and last week's dinners, and stores the
|
||
week: a new plan is `draft`; a redraft keeps the `calendar_event_id` of
|
||
each day whose dinner stays the same and clears it for the others; a
|
||
`synced` or `changed` plan becomes `changed`.
|
||
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
|
||
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
|
||
left as it is and the job fails `confirm_required`. The job reads the
|
||
plan's revision before draft_week runs; if the plan was edited (a swap,
|
||
or another draft stored) by the time the draft is stored, the plan is
|
||
left as it is and the job fails `plan_changed`. The pool is read again
|
||
in the same transaction that stores the draft; if a dinner the draft
|
||
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
|
||
stored on another current or later week's plan, as by a draft of
|
||
another week), nothing is stored and the job fails `pool_changed`,
|
||
which drafting again resolves. A pool under seven
|
||
dinners fails the job `not_enough_dinners`, draft_week failing
|
||
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
|
||
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
|
||
`is_new`, clears the day's `calendar_event_id`, moves a `synced` plan to
|
||
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
||
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
|
||
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
|
||
calls `MarkTried` for the household-local day just past (see Scheduler).
|
||
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
|
||
emits its `rotation_review`, in one transaction;
|
||
a dinner already checked for that date is left as it is and not
|
||
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||
the dinner a Regular, `drop` ("Not for us") archives it and `not_made`
|
||
("We didn't make it") returns it to the Quest log; its origin is kept.
|
||
The check is closed with `decided_at` and the session's `updated_by`,
|
||
and the answer is `{dinner, plan_date, decision, dinner_status,
|
||
decided_at, updated_by}`. Any other decision is 400 `invalid_decision`,
|
||
an unknown dinner or a dinner never checked 404 `not_found`, a dinner
|
||
whose checks are all decided (including a second decision) 409
|
||
`no_pending_review`, and one no longer tried 409 `dinner_not_tried`.
|
||
Keep is the only path from tried to Regular. A drop records the
|
||
dinner's name on the check (`rotation_reviews.name`), and that name
|
||
joins the passed names (`ListPassedNames`) the Side quest dedupe and
|
||
household context use, so it is never suggested again, even after the
|
||
dinner is renamed.
|
||
- `/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 run writes one `ai_runs` row when it
|
||
ends, covering the repair turn too, with status `ok`, `repaired`,
|
||
`bad_output` or `provider_error`; a prompt that cannot be rendered is
|
||
`bad_output` and a provider that cannot be built is `provider_error`, both
|
||
with no turns. The harness builds the provider itself from the settings
|
||
and the decrypted API key (`presets.FromSettings`), so the key it redacts
|
||
is always the key the provider sends. Request and response
|
||
bodies are redacted before they are stored: the decrypted API key,
|
||
Authorization and API-key header values, Google Chat webhook URLs and URLs
|
||
with key-like query parameters are removed, inside JSON strings too; the
|
||
skill, provider and model columns are redacted the same way. Writing the
|
||
row is bounded: a run whose work ended in time must write its row before
|
||
the skill's deadline or it fails with `llm.ErrTimeout` and its row records
|
||
the timeout; a failed run's row has a further fixed bound, and if even
|
||
that write fails the run returns the write error with its own and no row
|
||
is stored. A stored row's status therefore always matches what the caller
|
||
received.
|
||
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
||
clearing the `suggestion_batches` and `week_plans` references first. The
|
||
daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
|
||
its fire time less `RunRetention`.
|
||
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
||
takes a scope key, and concurrent runs of one skill with one scope share a
|
||
single in-flight run and its result, error included (single-flight); runs
|
||
with different scopes are independent. A semaphore lets at most
|
||
`harness.DefaultMaxCalls` (2, set by `NewGuard`) LLM calls run at once;
|
||
waiting for a slot honours the context. Each skill declares a timeout
|
||
(`harness.DefaultTimeout`, 60 s, unless it needs another) bounding the
|
||
whole run, slot waits and repair turn included; a run past it fails with
|
||
`llm.ErrTimeout`. A caller whose context ends gets the context error; the
|
||
shared run is cancelled, freeing its slot, once no caller waits for it.
|
||
- **Prompt layering** (D-007): each skill version's prompt is a
|
||
`text/template` file embedded from `ai/skills/prompts/`
|
||
(`<skill>.v<version>.tmpl`). The system prompt is always, in order:
|
||
[1] the admin persona (`ai_settings.system_prompt`), [2] the skill's
|
||
instructions, [3] the household context block, [4] the output contract
|
||
with the JSON Schema — the contract is always last. The user message comes
|
||
from the skill's input. `checksums.txt` pins every template; changing one
|
||
without a version bump fails the tests.
|
||
- **Household context** (`harness.BuildContext`): layer 3 holds today's date
|
||
and weekday in the household timezone, the Regulars, the Quest log, the
|
||
last 60 passed Side quests and the plans of the last four ISO weeks. A
|
||
character budget trims the oldest plans first, then the oldest passes;
|
||
Regulars and the Quest log are never trimmed.
|
||
- **Name dedupe** (`harness.Normalize`, `harness.Similar`, `harness.Deduper`):
|
||
a dinner name is normalized by lowercasing, dropping combining marks and
|
||
folding precomposed letters to their base letters through a table generated
|
||
from Unicode data (`gen_folds.py`), so composed and decomposed spellings
|
||
match, stripping punctuation, dropping filler words (easy, best, homemade, quick, simple,
|
||
the, a, recipe, classic) and collapsing whitespace. Two names are the same
|
||
dinner when the token-set (Jaccard) similarity of their words is at least
|
||
0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and
|
||
passed names rejects a suggestion that repeats any of them.
|
||
- **Side quest suggestions** (`skills.SuggestSideQuests`, skill
|
||
`suggest_side_quests` v1): asks for twice the Side quests needed, at the
|
||
household temperature, with the household context plus the pending Side
|
||
quests. Malformed replies (bad JSON, a missing field, a bad enum, an
|
||
empty name) get the one repair turn. Well-formed Side quests that break a
|
||
rule are dropped, not repaired, and listed in `Dropped` with the rule:
|
||
one that repeats a Regular, Quest log dinner, pending Side quest, any
|
||
passed name (the whole history, not only the 60 in the context) or an
|
||
earlier Side quest of the batch; one citing no Regular or a name that is
|
||
not a Regular; and one whose reason is over 180 characters or names none
|
||
of its cited Regulars. Cited names are rewritten as the household writes
|
||
them. Grounding each survivor in a recipe is the caller's next step.
|
||
- **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill
|
||
`ideas_from_ingredients` v1): from the household's ingredient list (the
|
||
"Ingredients on hand" context section) and the Quick only flag, asks for
|
||
8 ideas `{name, uses[], missing[], est_minutes, method, difficulty,
|
||
search_query}` at the household temperature. Malformed replies (a
|
||
missing field, a bad enum, an empty name, more than 8 ideas) get the one
|
||
repair turn. The invariant is enforced in Go: a `uses` entry is kept
|
||
only when its `harness.Normalize` form is a listed ingredient's, and is
|
||
then written as the household wrote it, once, in the household's order;
|
||
`missing` loses blanks, repeats and anything listed. An idea left using
|
||
none of the listed ingredients, or repeating an earlier idea
|
||
(`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary
|
||
line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses
|
||
all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 ·
|
||
missing: …" otherwise, with "nothing missing" in place of the list when
|
||
nothing is.
|
||
- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the
|
||
household context, a kind (`crockpot` or `under30`, which picks the
|
||
prompt's rule) and the names already shown (the "Already shown" context
|
||
section), asks for 12 ideas `{name, est_minutes, method, difficulty,
|
||
search_query}` at the household temperature. Malformed replies (a
|
||
missing field, a bad enum, an empty name, more than 12 ideas) get the
|
||
one repair turn. An idea repeating an excluded name or an earlier idea
|
||
(`harness.Similar`, on the normalized names) is dropped into `Dropped`,
|
||
so an excluded name is never returned. Whether an idea is of the kind
|
||
is left to the grounded recipe (see Quick dinners above).
|
||
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
|
||
v1): from the names of the week's planned dinners in day order, writes
|
||
`{title}`, the Supply run's post title in the Supply archive, at the
|
||
household temperature with no household context. A title must be 1–48
|
||
characters on one line, with no emoji and no surrounding quotation
|
||
marks; a reply breaking a rule gets the one repair turn, never a
|
||
truncation. `shopping.Generate` (and `GeneratePushed`), on storing a
|
||
list that is still untitled — a new list, or one a failed title left
|
||
untitled — enqueues a `title_supply_run` job in the generation's
|
||
transaction and returns without waiting for it, so neither Push to
|
||
Calendar nor `POST /shopping/{weekStart}/generate` waits for a model
|
||
call; the list reads untitled until the job stores its title. The job's
|
||
scope is `<weekStart>@<plan revision>/<sha256 of the dinner names in
|
||
order>`, so runs share a title only for the same names (a renamed dinner
|
||
changes the names, not the revision). It writes `shopping_lists.title`
|
||
only while the list is untitled and not frozen. A title is written once;
|
||
a remake keeps it and enqueues no job. A failed title never fails the
|
||
generation: the list stays untitled (the UI shows the week label), the
|
||
failure is logged as a warning and recorded in `ai_runs`, the job fails
|
||
`title_failed`, and the week's next generation tries again.
|
||
`shopping.Freeze` titles a list still untitled once more before
|
||
freezing it (see Supply archive), through the same skill, scope and
|
||
conditional write.
|
||
|
||
## Supply archive (Feature 17)
|
||
|
||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||
generating the list or changing an item is 409 `archived`. It freezes only
|
||
a week that has ended in the household's time zone (its Monday is before
|
||
the Monday of the household's current week), so a week not yet ended is
|
||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||
frozen list does nothing. A list still untitled is titled first with
|
||
`archive_title` from the week's plan, as `Generate` titles it; a title
|
||
that cannot be written is logged and the list is frozen untitled, when its
|
||
week label stands in for good. Titling and freezing are ordered by a
|
||
per-service `RWMutex`: a `title_supply_run` job holds it shared across
|
||
reading the list, the `archive_title` run and the title write, and `Freeze`
|
||
holds it exclusively from reading the list to freezing it. So a freeze
|
||
waits for a job in flight to store its title or fail for good; when the
|
||
job failed, the freeze finds the list still untitled and makes its own
|
||
`archive_title` attempt before freezing. A job that starts during a freeze
|
||
waits for it, then finds the list titled (that title stands) or frozen
|
||
untitled (the job fails `title_failed`), and stores nothing. The
|
||
`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
|
||
`Freeze` for last week, judging the week ended by its fire time.
|
||
|
||
## Recipe grounding
|
||
|
||
- Dinners are grounded in real recipe pages found through recipe search
|
||
(`searxng` or `brave`, per `recipe_search_settings`).
|
||
- Fetching is **SSRF-safe** (`recipes.Fetcher`): https:// only, at every
|
||
redirect hop. Its dialer resolves the host once and refuses unless every
|
||
answer is public — every non-globally-reachable IPv4 special-purpose
|
||
range (loopback, RFC 1918, link-local, carrier-grade NAT, documentation
|
||
TEST-NETs, benchmarking 198.18/15, multicast, reserved and more) is
|
||
refused, IPv4-mapped forms included, and IPv6 must be global unicast
|
||
(2000::/3) outside documentation, 6to4, Teredo and other special ranges —
|
||
then dials the address it checked, so a
|
||
rebinding DNS answer cannot swap in a private one. Keep-alives and
|
||
environment proxies are off, so every request dials and checks afresh. At
|
||
most 3 redirects; a body over 2 MB is an error, never truncated; 10 s for
|
||
the whole fetch; a descriptive `User-Agent`; requests to one host at least
|
||
1 s apart, counted from requests actually sent (an abandoned wait holds no
|
||
slot); 2xx pages cached by URL for an hour (64 pages, oldest evicted), and
|
||
callers get their own copy of a cached body.
|
||
- Recipe data is parsed from schema.org `Recipe` JSON-LD
|
||
(`recipes.Extract`): a small targeted scan of the page's
|
||
`application/ld+json` scripts and `og:site_name` (no HTML parser
|
||
dependency), finding the Recipe node at a script's top level, in a
|
||
top-level array or in an `@graph`, with `@type` a string or an array of
|
||
terms or absolute schema.org IRIs (`https://schema.org/Recipe`). An image
|
||
or publisher given as an `{"@id": ...}` reference resolves to the node
|
||
with that `@id` anywhere in the page's JSON-LD. The largest image (by ImageObject width × height; the first of equals) is
|
||
resolved to an absolute URL; instructions — one string of lines, or a
|
||
list of strings, HowToSteps and HowToSections — are flattened in order;
|
||
ISO-8601 prep/cook/total times become minutes, total derived as prep +
|
||
cook when only those are given; the site name is the publisher's, else
|
||
`og:site_name`, else the host. A page with no Recipe is `ErrNoRecipe`,
|
||
with two is `ErrManyRecipes`; a shape schema.org does not allow — such as
|
||
a non-ISO-8601 duration like "20 minutes" or an empty one, or an image
|
||
`@id` no node has — is `ErrMalformedRecipe`, never guessed at.
|
||
- Recipe search (`recipes.SearchProvider`: `Search(ctx, query, n)` →
|
||
`[]Candidate{URL, Title, Snippet}`, n 1–20) appends " recipe" to a query
|
||
without that word. SearXNG is called at
|
||
`{recipe_search_settings.base_url}/search?q=…&format=json&categories=general` —
|
||
directly, not through the Fetcher, since this configured endpoint may be
|
||
on the internal network; Brave calls the Brave Web Search API with the
|
||
key decrypted from `recipe_search_settings.api_key_ct`
|
||
(`recipes.NewSearchProvider`). Redirects are not followed; 401/403 is
|
||
`ErrSearchRefused`, 429 `ErrSearchRateLimited`, other non-2xx a
|
||
`SearchStatusError`, and a body that is not the provider's JSON (SearXNG
|
||
without a `results` array; Brave without `"type": "search"` or with a
|
||
`web` object lacking `results` — Brave omits `web` when nothing matched),
|
||
over 1 MB or with a result lacking an absolute http(s) URL
|
||
`ErrSearchResponse`.
|
||
- Ranking (`recipes.Rank`) fetches and extracts the top 5 candidates
|
||
concurrently, rejects any whose recipe lacks ingredients or an image, and
|
||
orders the rest by the token similarity of the recipe's title to the dish
|
||
name (`harness.Similarity`, 0–1) plus a quarter for each of ingredients,
|
||
image, steps and total minutes; ties keep search order. No unranked
|
||
candidate is dropped silently: when some are ranked, they are returned
|
||
with `ErrCandidatesUnranked`; when none are, the error is
|
||
`ErrNoUsableRecipe`. Either is joined with each unranked candidate's URL
|
||
and reason (fetch failure, no valid recipe, missing ingredients or
|
||
image), so the caller decides per item what a failure means.
|
||
- Ingredient lines are normalized by the `normalize_recipe` v1 skill
|
||
(`skills.NormalizeRecipe`), always at temperature 0 whatever the
|
||
household's setting. From the title, raw ingredient lines, steps and
|
||
JSON-LD times it returns `{ingredients: [{name, qty, unit, note}], method,
|
||
difficulty, minutes_total}`. Validation requires exactly one ingredient
|
||
per input line, in order (a name matching another line but not its own is
|
||
out of order), each with a name; a mismatch gets the harness's repair
|
||
turn. Code-derived values win: a positive JSON-LD total is
|
||
`minutes_total`; "slow cooker" or "crock pot" in the title or a step makes
|
||
the method `crockpot`; a difficulty the model leaves null is derived —
|
||
hard over 10 steps or 90 minutes, easy at 5 steps or fewer and 45 minutes
|
||
or less, else medium, a crockpot's minutes not counting.
|
||
- Photos are cached locally (`photos`) and processed with
|
||
`golang.org/x/image`. `recipes.PhotoCache.DownloadPhoto` downloads through
|
||
the Fetcher's `FetchImage` (JPEG, PNG, WebP or GIF content types, 8 MB
|
||
cap, not cached), decodes the image (a GIF's first frame) and refuses a
|
||
decoded image under 300 px on its short side, recording nothing.
|
||
`CachePhoto` then records it in the caller's transaction: it scales the
|
||
image so its long side is 1200 px and 400 px, and stores each as JPEG
|
||
quality 82 at `$MEALQUEST_DATA_DIR/media/photos/{id}-{size}.jpg`. Images
|
||
are deduplicated by the SHA-256 of the downloaded bytes; the writer's
|
||
single connection serialises transactions, so one image is never
|
||
recorded twice. Files written for a transaction that rolls back are
|
||
removed by the caller (`RemovePhotoFiles`).
|
||
- Grounding (`recipes.Grounder`) ties these together.
|
||
`Ground(ctx, dishName, query)` searches for the top 5 candidates and ranks
|
||
them all, stored and new alike: a candidate whose `source_url` is already
|
||
stored is scored on its stored recipe (title, ingredients, steps, times,
|
||
photo source) and its page is never refetched; every other candidate is
|
||
fetched and extracted. Ranking alone decides between them. It then tries
|
||
each ranked candidate best first. A stored one is returned as it is, so a
|
||
stored recipe is reused only when it ranks above every new candidate that
|
||
grounds. A new one is grounded in this order: (1) `DownloadPhoto` —
|
||
the download and validation step of photo caching — (2)
|
||
`normalize_recipe`, then (3) one **transaction** that runs `CachePhoto`
|
||
(scales and writes the photo files and records the `photos` row) and
|
||
stores the recipe, ingredients and steps (an existing `source_url` is
|
||
updated in place); files written for a rolled-back transaction are
|
||
removed. It returns `Grounded{recipe_id,
|
||
photo_id, method, minutes_total, difficulty, source_url, site_name}`. A
|
||
candidate whose photo fails or whose model output is unusable
|
||
(`llm.ErrBadOutput`) is passed over; when none grounds, the error is a
|
||
typed `*recipes.ErrUngroundable` listing every reason (no candidates,
|
||
Rank's per-candidate reasons, photo or normalize failures). Failures
|
||
that are not the candidates' — search, model provider, database — are
|
||
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
|
||
URL. `Candidates(ctx, dishName, query, n)` returns the first n of the
|
||
candidates `Ground` would try, without grounding any. Search failures
|
||
wrap `recipes.ErrSearchFailed` and model provider failures
|
||
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
|
||
item, so one failure never affects its siblings; `GroundEach` grounds
|
||
the same way but reports each item, one at a time, as it finishes. A recipe is usable only
|
||
with ingredients, an image and, if given, a positive total time.
|
||
|
||
## 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 (`suggest.Service.WeeklySchedule`). |
|
||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
|
||
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
||
| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). |
|
||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
|
||
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
||
|
||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||
|
||
Job kinds (`internal/jobs`; each registered when `cmd/server` builds the
|
||
service named, before the runner starts). `POST /api/v1/jobs` refuses a
|
||
service-only kind; `GET /api/v1/jobs/{id}` reports any job.
|
||
|
||
| Kind | Registered by | Enqueued by | Service-only |
|
||
| --- | --- | --- | --- |
|
||
| `noop` | `jobs.RegisterBuiltins` | `POST /api/v1/jobs` (does nothing; proves the runner works) | no |
|
||
| `ground_dinner` | `dinners.Service` | Adding or renaming a `pending` dinner | no |
|
||
| `backfill_dinner_photos` | `dinners.Service` | `POST /api/v1/settings/photo-backfill` | yes |
|
||
| `suggestion_batch` | `suggest.Service` | `POST /api/v1/suggestions/batches`, a deck decision (replenish), `suggestions_weekly` | no |
|
||
| `draft_week` | `plan.Service` | `POST /api/v1/plans/{weekStart}/draft`, `plan_draft` | yes |
|
||
| `title_supply_run` | `shopping.Service` | Generating an untitled Supply run | yes |
|
||
| `ideas_from_ingredients`, `quick_ideas` | `ideas.Service` | `POST /api/v1/ideas/from-ingredients`, `POST /api/v1/ideas/quick` | yes |
|
||
|
||
`internal/scheduler` runs them. A schedule registers a name, a rule read
|
||
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
|
||
run function; settings changes reach it through `SettingsService.Subscribe`
|
||
and move the next fire time without a restart. Each fire time has a period
|
||
key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the
|
||
fire date for a weekly one, or of the week after for `WeeklyForNextWeek`
|
||
(`plan_draft`). Inserting `schedule_runs(schedule, period_key)` is the claim;
|
||
a period already claimed is skipped, so a period runs at most once and a
|
||
failed run is not retried. A claim that fails is not skipped: the pass is
|
||
repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings
|
||
change is recorded with the instant it was saved, and fire times up to that
|
||
instant follow the old rule and later ones the new, however late the
|
||
scheduler applies it. At boot, and whenever the scheduler wakes late,
|
||
every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is
|
||
processed oldest first, each running once. A most recent fire time that
|
||
passed longer ago is claimed without running (logged as missed) so it
|
||
never runs later; older ones are not looked at. DST
|
||
follows `household.LocalTime`: a skipped time fires later by the gap, a
|
||
repeated time fires once, at its first occurrence.
|
||
|
||
Each schedule is built by the service that does its work, as a
|
||
`scheduler.Schedule` value, and registered in `cmd/server` before the
|
||
scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`.
|
||
Its run calls `StartScheduledBatch(period.Week)`, which starts the same
|
||
batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`),
|
||
with the `scheduled` trigger, and returns at once; the batch runs as a job. When a
|
||
batch is already pending or running the period starts none and logs it.
|
||
`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week
|
||
after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls
|
||
`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week`
|
||
job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored
|
||
draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g.
|
||
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
|
||
has a plan, in any status, it enqueues nothing; when a draft of the week
|
||
is already queued or running it joins it. Either way it logs it.
|
||
`plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules
|
||
of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`,
|
||
and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`,
|
||
so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule:
|
||
for each date it reads the plan and fires 15 minutes
|
||
(`plan.StartReminderLead`) before the day's `start_by` (the same
|
||
computation the plan API shows) when the dinner is a crock-pot dinner
|
||
whose `start_by` is earlier than that day's `reminder_time`; its run
|
||
calls `EmitStartReminder(period.Date)`, which checks the same again. A
|
||
date whose week has no plan emits neither and logs it. A `DailyAt` fire
|
||
time is looked up from the data on every pass. A committed change that
|
||
can move a `start_by` calls `Scheduler.Wake` (passed to `plan.NewService`
|
||
and `dinners.NewService` as `wake`): a plan swap or stored draft, and a
|
||
dinner's method or minutes changed by edit or grounding. The next pass
|
||
comes at once, so a crock-pot dinner swapped in for tonight is still
|
||
reminded 15 minutes before its `start_by`, not at the next hourly timer
|
||
(`maxWait`); a lookup that fails is logged and repeated every
|
||
`ClaimRetry`.
|
||
`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`,
|
||
`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period
|
||
names the coming week. Its run calls `Autosend(period.Week)`, which posts
|
||
with `SendToChat(week, false)`, the household's own send, only when a
|
||
webhook is stored, `list_ready`'s Chat channel is on
|
||
(`notify.Service.ChannelOn`) and the week has a Supply run neither sent
|
||
nor with a send unconfirmed; otherwise it posts nothing and logs why,
|
||
with the key `list_ready:chat:{weekStart}`. It never resends; a send that
|
||
fails is the period's error. `shopping.Service.RolloverSchedule()` is
|
||
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
|
||
the fire date's, and a week with no Supply run freezes nothing and logs
|
||
it. `plan.Service.RotationCheckSchedule()` is `rotation_check`,
|
||
`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire
|
||
date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before
|
||
the fire date, which emits one `rotation_review` per Rotation check it
|
||
opens; it never makes a dinner a Regular, and a day with no Quest log
|
||
dinner planned opens none and logs the count.
|
||
`harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
|
||
`Daily(03:00)`, deleting rows created more than 30 days before the fire
|
||
time.
|
||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||
`SetClock` is visible to tests.
|
||
|
||
## 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`.
|
||
- Where each event is emitted, with its idempotency key. Each is stored by
|
||
`notify.EmitTx` in the transaction of the change it is about, so the
|
||
change and its notification commit together or not at all:
|
||
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for
|
||
by the API or the `plan_draft` schedule, stores the AI's draft of a
|
||
week. Only the first draft of a week notifies: a
|
||
redraft (Rebuild week) has the same key, and a manual swap never
|
||
drafts. Its title is "Next week's plan is ready" (or "This week's",
|
||
or "The plan for the week of Oct 26") and its body the drafted
|
||
dinners Monday to Sunday.
|
||
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
|
||
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||
after Push to Calendar; remaking it does not notify.
|
||
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
|
||
was scheduled for (carried in its job, so a batch caught up late is
|
||
still keyed by its own week), or `new_suggestions:{batchId}` for a
|
||
`manual` one (URL `/discover`), once, when the batch's last round is
|
||
done, in the one transaction that stores all its Side quests, so they
|
||
and their notification commit together or not at all. Its title
|
||
counts every Side quest the batch stored ("5 new Side quests are in")
|
||
and its body names them. A batch that fails at any round, as when
|
||
`suggest_side_quests` or a later round's `normalize_recipe` fails (the
|
||
failure is in `ai_runs`), stores no Side quest and sends nothing, so
|
||
no stored Side quest goes unannounced. A `replenish` batch never
|
||
notifies. This is the only place `new_suggestions` is emitted; the
|
||
`suggestions_weekly` schedule does not emit it itself.
|
||
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
||
`plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →",
|
||
naming the dinner planned on the date; it emits nothing for a week
|
||
with no plan. The `day_of_reminder` schedule calls it at
|
||
`reminder_time`.
|
||
- `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through
|
||
`plan.Service.EmitStartReminder(date)`, "Start the crock pot by
|
||
{time}" with the dinner's name, only for a crock-pot dinner whose
|
||
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
||
schedule calls it 15 minutes before `start_by`.
|
||
- `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card
|
||
asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}?
|
||
Keep it in rotation?", once per Rotation check it opens. The
|
||
`rotation_check` schedule calls it at `review_time` for yesterday.
|
||
- Delivery goes through `notification_outbox`.
|
||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||
webhook.
|
||
- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`,
|
||
`MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required
|
||
at boot, all three together. Both keys are unpadded base64url P-256 keys
|
||
(`internal/vapid`): the public key the 65-byte uncompressed point, the
|
||
private key the 32-byte scalar, read only from its file. Boot derives the
|
||
public point from the private key and refuses a pair that does not
|
||
match. The subject is `mailto:<address>` or an `https://` URL.
|
||
`go run ./cmd/vapidgen` prints a new pair.
|
||
- `internal/push` keeps the subscriptions, one row per endpoint, under the
|
||
signed-in `/api/v1`:
|
||
- `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the
|
||
browser's `applicationServerKey`. The private key is never served.
|
||
- `POST /api/v1/push/subscriptions` (CSRF-checked) takes
|
||
`{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the
|
||
endpoint an `https://` URL, the keys unpadded base64url as
|
||
`PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16
|
||
bytes); unknown fields such as `expirationTime`, and nulls, are
|
||
refused. It upserts by endpoint: an endpoint already stored keeps its
|
||
id and `created_at`, has its keys and user agent replaced and its
|
||
failure count cleared. It answers 200 with
|
||
`{id, endpoint, user_agent, created_at, updated_at}`, never the keys.
|
||
- `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes
|
||
`{"endpoint"}` and answers 204 whether or not it was stored.
|
||
- An invalid body is 400 `invalid_request` (not a JSON object of the
|
||
known fields) or `invalid_subscription` (a field fails validation).
|
||
- `POST /api/v1/settings/notifications/test-push` (CSRF-checked) checks
|
||
delivery to the household's devices. It queues "MealQuest test
|
||
notification" through `notification_outbox`, so it takes the real
|
||
delivery path with its retries. It is a `plan_ready` event whose tap
|
||
opens `/plan`. Its key is `plan_ready:test-<ULID>`, new on every call,
|
||
so `Emit` never deduplicates it. It goes to the `push` channel only
|
||
(`notify.Service.EmitPush`), never to the family Google Chat space.
|
||
It answers 202 `{event_key, subscriptions}`. Two cases send nothing
|
||
and answer 409 rather than report success: no device has subscribed
|
||
(`no_subscriptions`), or push is switched off for `plan_ready` in
|
||
the preferences (`push_off`).
|
||
- `internal/chat` keeps the Google Chat incoming webhook in `chat_settings`
|
||
and is the outbox's sender for the `chat` channel. The webhook URL holds
|
||
its own key and token, so it is sealed with `secretbox` in
|
||
`webhook_url_ct` and never appears in a response, log line or error.
|
||
Under the signed-in `/api/v1`:
|
||
- `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes
|
||
`{"url", "space_label"}`, both required. The URL must be exactly
|
||
`https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`:
|
||
that scheme and host spelled so, no port or user info, the space id
|
||
letters, digits, `-` or `_`, and the raw query exactly
|
||
`key=…&token=…` (in that order, no empty segment, no second `?`, no
|
||
other parameter), each decoding to at least 16 characters of letters,
|
||
digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100
|
||
characters. A refused body is 400 `invalid_request` (not a JSON object
|
||
of the two fields) or `invalid_webhook`. Saving replaces any stored
|
||
webhook and clears its test outcome.
|
||
- `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked,
|
||
last_test_at, last_test_status}`, so `url_masked` is `null` exactly when
|
||
no webhook is stored; `url_masked`
|
||
shows the space id and only the last four characters of the decoded
|
||
token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are
|
||
`null`.
|
||
- `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts
|
||
"MealQuest · test message", unthreaded, and records `last_test_at` and
|
||
`last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google
|
||
Chat answers, it answers 200 with that status as `http_status` and the
|
||
settings as GET gives them. No webhook stored is 409 `not_configured`;
|
||
a webhook replaced during the test is 409 `webhook_changed`; no answer
|
||
from Google Chat is recorded as failed and answered 502
|
||
`chat_unreachable`.
|
||
- A notification is posted as `{"text"}`, plus `cardsV2` with an "Open"
|
||
button to the payload's path under `MEALQUEST_PUBLIC_URL` for
|
||
`plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www`
|
||
(the ISO week of the payload's week) and
|
||
`messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload
|
||
with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer
|
||
is retried by the outbox; any other status, or no webhook stored, fails
|
||
the row at once (`notify.ErrUndeliverable`). Redirects are not
|
||
followed.
|
||
- The builders in `internal/chat/messages.go` make each event's
|
||
`notify.Event` (title and body shared with Web Push) and `chat.Text`
|
||
its Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||
Tikka masala + 4 more. Open the plan →".
|
||
|
||
## 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.
|
||
- `internal/gcal` owns the connection, as a confidential OAuth web client
|
||
(`MEALQUEST_GOOGLE_CLIENT_ID`, `MEALQUEST_GOOGLE_CLIENT_SECRET_FILE`, both
|
||
required at boot) over plain REST — no Google SDK. Every route is under
|
||
the signed-in `/api/v1`:
|
||
- `GET /api/v1/integrations/google/connect` redirects to Google's consent
|
||
screen with scopes `calendar.events` and `calendar.calendarlist.readonly`,
|
||
`access_type=offline`, `prompt=consent` and PKCE. Its `state` travels in
|
||
the signed `__Host-mealquest_google` flow cookie (the same
|
||
`auth.FlowCookie` mechanism as the login cookie), bound to the session by
|
||
a hash of the session's CSRF token. The service also keeps each started
|
||
connect's `state` in memory until the cookie expires; the callback
|
||
consumes it, so a replayed cookie is refused (`invalid_state`), and a
|
||
restart mid-connect means connecting again.
|
||
- `GET /api/v1/integrations/google/callback` (the redirect URI:
|
||
`MEALQUEST_PUBLIC_URL`'s origin plus this path) checks the state and
|
||
session, exchanges the code, refuses a response with no refresh token
|
||
(`reconnect_required`) or without both scopes, seals the refresh token
|
||
with `secretbox` into `integrations_google.refresh_token_ct`, sets
|
||
`connected_at` and redirects to `/settings/calendar`. A reconnect keeps
|
||
the chosen calendar and clears `last_sync_error`.
|
||
- `GET /api/v1/integrations/google` answers `{status, calendar_id,
|
||
calendar_name, connected_at, last_sync_error}`, where `status` is
|
||
`not_connected`, `connected` or `reconnect_required` (connected, but
|
||
Google refused the grant and its refresh token was cleared); absent
|
||
values are `null`. It never holds a token.
|
||
- `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at
|
||
Google, then deletes the row and, in the same transaction, forgets every
|
||
pushed event (see Push to Calendar); a token Google reports as
|
||
`invalid_token` counts as revoked, and any other revoke failure keeps
|
||
the connection and its pushed events. A connection that requires a
|
||
reconnect has no token to revoke and is deleted the same way.
|
||
- `GET /api/v1/integrations/google/calendars` trades the stored refresh
|
||
token for an access token and reads every page of the Calendar API's
|
||
`users/me/calendarList`, answering `[{id, summary, primary,
|
||
accessRole}]` for the calendars whose `accessRole` is `writer` or
|
||
`owner`, in Google's order. Nothing else from Google's response is
|
||
passed on.
|
||
- `PUT /api/v1/integrations/google/calendar` `{calendar_id}`
|
||
(CSRF-checked) lists the calendars the same way and stores
|
||
`calendar_id` and `calendar_name` (the calendar's `summary`) only when
|
||
the id is among them, answering `{calendar_id, calendar_name}`; any
|
||
other id is 422 `unknown_calendar` and leaves the stored choice as it
|
||
was. A calendar chosen earlier that has since been deleted or become
|
||
read-only drops out of the listing and cannot be chosen again; the
|
||
stored choice stays until the household chooses another.
|
||
- Listing and choosing answer 409 `not_connected` when nothing is
|
||
connected, and fail on Google as every Calendar call does (below).
|
||
- **Token failures**, the same for listing, choosing and pushing:
|
||
- When Google refuses the grant — `invalid_grant` on the token
|
||
refresh, or HTTP 401 from the Calendar API to the access token just
|
||
refreshed — the connection becomes reconnect required, under the
|
||
account lock: `refresh_token_ct` is cleared and `last_sync_error`
|
||
set to "Google access was revoked or expired — reconnect", and the
|
||
call answers 409 `google_reconnect_required`. Later calls answer the
|
||
same without contacting Google, until a reconnect stores a new token
|
||
(and clears `last_sync_error`) or a disconnect deletes the row. The
|
||
chosen calendar and every stored `calendar_event_id` are kept: the
|
||
pushed events are still on the chosen calendar, so the invariant
|
||
below holds, and the first push after reconnecting updates them in
|
||
place.
|
||
- HTTP 429 and 5xx from the token endpoint or the Calendar API are
|
||
retried at most twice (three tries), waiting 0.5 s then 1 s, still
|
||
under the account lock. If the last try fails the same way the call
|
||
answers 503 `google_unavailable` with `retryable: true`; nothing is
|
||
cleared or disconnected. An event insert retried after Google took it
|
||
is answered 409 and updated, so a retry never adds a second event.
|
||
- Any other Google failure is 502 `google_failed`.
|
||
- Connect's exchange-and-store, disconnect's read-revoke-delete and
|
||
every use of the stored token (listing and choosing calendars) hold one
|
||
lock across the Google requests and the database write: Google revokes
|
||
the whole grant, so a revoke overlapping a reconnect would otherwise
|
||
leave a stored token Google has already revoked.
|
||
- No response, log line or error ever holds a token or Google's response
|
||
text.
|
||
- Push to Calendar: `POST /api/v1/plans/{weekStart}/push-calendar`
|
||
(CSRF-checked, `plan.Service.Push` writing through
|
||
`gcal.Service.PushEvents`) answers 200 with the plan as synced. Each day
|
||
is one event in the chosen calendar: summary `Dinner: {name}`, from the
|
||
household `dinner_time` on that date for 60 minutes, as `dateTime` with
|
||
the household `timeZone` (so its UTC instant follows a DST change within
|
||
the week), and a description of the dinner's name, `Start by {time}` for
|
||
a crock-pot dinner of known minutes, and
|
||
`{MEALQUEST_PUBLIC_URL}/dinner/{id}`.
|
||
- **Deterministic event ids**: lowercase unpadded base32hex of SHA-256 of
|
||
`mealquest|{weekStart}|{date}` — 52 characters of `a`–`v` and `0`–`9`,
|
||
within Google's id rule. Each event is inserted; a 409 (the id exists,
|
||
even as an event deleted in Calendar) updates it instead, with status
|
||
`confirmed`, so pushing again never adds a second event.
|
||
- A push writes the days whose `calendar_event_id` is empty (never
|
||
pushed, swapped, or redrafted to another dinner). When none is, it
|
||
writes every day again, which puts back anything changed in Calendar.
|
||
- A stored `calendar_event_id` is always an event on the chosen calendar.
|
||
Choosing a calendar other than the stored one, and disconnecting (which
|
||
leaves none chosen; connecting again chooses none either), clear every
|
||
day's `calendar_event_id` in the same transaction as the choice or the
|
||
delete, and edit each plan that had one (revision raised, `synced` →
|
||
`changed`), so the next push writes the whole week to the calendar then
|
||
chosen and a push that read such a plan before is refused (409
|
||
`plan_changed`). A reconnect while connected keeps the chosen calendar,
|
||
so it keeps the event ids. Nothing else clears the choice: a grant
|
||
Google refuses clears only the stored token (see Token failures), so
|
||
the event ids stay on the calendar still chosen.
|
||
- Only a push that wrote all its events is recorded, in one transaction:
|
||
the plan becomes `synced` with `calendar_synced_at`, and each day
|
||
written stores its event id. A push that failed part way records
|
||
nothing and leaves the status as it was; pushing again updates the
|
||
events already written. The record runs under the account lock, in the
|
||
same hold as the event writes, so the chosen calendar cannot change
|
||
between them. A plan swapped or redrafted while it was being
|
||
pushed, or whose events another calendar choice cleared (its revision
|
||
moved), is not recorded: 409 `plan_changed`.
|
||
- Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar
|
||
chosen), 409 `calendar_unavailable` (the chosen calendar was deleted —
|
||
Google 404 — or is no longer writable — 403 `requiredAccessLevel`;
|
||
choose another), 409 `google_reconnect_required`, 503
|
||
`google_unavailable` (retryable) and 502 `google_failed` (see Token
|
||
failures), 404 when the week
|
||
has no plan, 400 `invalid_week`. The push holds the account lock
|
||
across its Google requests and its record, like listing and choosing.
|
||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||
the week at the plan revision `Push` pushed, and the 200 answer
|
||
carries `supply_run: {status, error}`. `status` is `generated`;
|
||
`archived` when the week's Supply run is in the Supply archive and was
|
||
left as it was; or `failed` — error `plan_changed` (retryable, logged
|
||
as a warning) when the plan was edited between the push and
|
||
generation, so no Supply run is made from a plan Calendar does not
|
||
show and pushing again makes it; or error `supply_run_failed`
|
||
(retryable, logged as an error) when it could not be made, and
|
||
`POST /api/v1/shopping/{weekStart}/generate` makes it again.
|
||
Generation checks the revision inside its own transaction, and never
|
||
fails or undoes the push — Calendar already shows the plan.
|
||
|
||
## Supply run API (Feature 17)
|
||
|
||
- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title,
|
||
status, sent_to_chat_at, chat_send_unconfirmed_at, needed_count,
|
||
total_count, groups: [{dinner:
|
||
{id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is
|
||
the week label (shown while `title` is null), `day` the plan day's
|
||
weekday ("Monday"), `qty` null when the recipe gives none,
|
||
`needed_count` the items still needed and `total_count` all items. 404
|
||
when the week has no Supply run, 400 `invalid_week`.
|
||
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
||
through `shopping.Generate` and answers with the same shape plus
|
||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
||
has no plan, 409 `archived` when its list is in the Supply archive.
|
||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||
its title (or week label), each dinner with items still needed and those
|
||
items, and a card whose "Open" button links to
|
||
`/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It
|
||
records `chat_send_unconfirmed_at` before posting, then
|
||
`sent_to_chat_at` (clearing it) once Google Chat accepts, and answers
|
||
with the Supply run as `GET` gives it. A week already sent, or with a
|
||
send unconfirmed, is answered as it is, with no second post, unless
|
||
`resend` is true. No webhook stored is 409 `not_configured`; Google Chat
|
||
answering with a status other than 2xx is 502 `chat_refused` (retryable
|
||
only for 429 or 5xx), clearing the send begun; Google Chat not
|
||
answering, or accepting a post that cannot then be recorded, is 502
|
||
`chat_unconfirmed` (not retryable: the post may have been made), which
|
||
leaves `chat_send_unconfirmed_at` set, as does the process stopping
|
||
mid-send. The screen shows an unconfirmed send and sends again only on
|
||
request. 404 when the week has no Supply run, 409
|
||
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
||
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
||
`shopping_chat_autosend` calls, through `Autosend`.
|
||
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
||
needed, needed_count, total_count}` with the list's counts after the
|
||
change. 404 for an unknown item, 409 `archived` when the list is in the
|
||
Supply archive, 400 `invalid_request` for a bad body.
|
||
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
|
||
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
|
||
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
|
||
Supply run of every week up to the household's current week, newest
|
||
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
|
||
week label), month ("2026-10", "October 2026") or year ("2026"). The
|
||
Monday is a household-local date, so the week of Monday 28 December
|
||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||
list's dinners in day order and `dinnerCount` their number,
|
||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||
`current` true for the household's current week. 400 `invalid_request`
|
||
for any other `by`. A frozen list is read through `GET
|
||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||
|
||
## Home API (Feature 21)
|
||
|
||
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
|
||
services that own each value, at "now" in the household's zone (never
|
||
UTC), and answers:
|
||
|
||
```json
|
||
{
|
||
"dateLabel": "Friday, Oct 2",
|
||
"timezone": "America/Chicago",
|
||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||
"sideQuestCount": 10,
|
||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||
"recentUpdates": {"unread": true}
|
||
}
|
||
```
|
||
|
||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
||
when the week has no plan; it changes at the household's local
|
||
midnight, so the PWA reads Home again at the next local midnight of
|
||
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
|
||
null when unknown. `sideQuestCount` is the pending cards of the deck,
|
||
read without writing (`suggest.PendingCount`): a Home read never fails
|
||
orphaned batches the way a `suggest.Deck` read does.
|
||
`shoppingList` is the upcoming week's Supply run when it has one, else
|
||
the current week's (`shopping.Get`); with neither, it is the current
|
||
week with `ready` false. `recentUpdates.unread` is true when a
|
||
notification was stored in `notification_outbox` after
|
||
`household_settings.updates_seen_at`, or at all while that is null
|
||
(`notify.StoredSince`); an event whose channels are all switched off is
|
||
never stored and never counts.
|
||
- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`)
|
||
answers the Recent updates sheet: the last 10 distinct events in
|
||
`notification_outbox`, newest first, one per event key whatever its
|
||
channels or delivery status (an event stored for push and Google Chat is
|
||
one update):
|
||
|
||
```json
|
||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||
```
|
||
|
||
Every field comes from the stored payload, which never holds a webhook
|
||
URL or secret; `url` is the same-origin path the event opens.
|
||
- `POST /api/v1/updates/seen` with `{"through": "<created_at>"}` (CSRF
|
||
header required) records that the household has seen its updates
|
||
through the newest one it was shown, and answers 204. The last-seen
|
||
instant is `household_settings.updates_seen_at` — the one place it is
|
||
stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older
|
||
than the stored one is accepted and changes nothing, and an event
|
||
stored after `through` keeps `unread` true. It is not a settings edit:
|
||
`updated_by` and `updated_at` are left as they are and settings
|
||
subscribers are not told. 400 `invalid_request` for a body that is not
|
||
exactly that object, a `through` that is not RFC 3339, or one after
|
||
now.
|
||
|
||
## 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.
|
||
- Logout (`POST /auth/logout`) answers with `Clear-Site-Data: "storage",
|
||
"cache"`, wiping the offline marker, the service worker and its caches; the
|
||
worker leaves that request to the browser, as it does every network-only one.
|
||
|
||
| 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.
|
||
|
||
First run: while `household_settings.onboarding_completed_at` (read from
|
||
`GET /api/v1/settings/household`) is null, every route redirects to
|
||
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
||
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
||
mock 1h, adding and archiving through the dinners API optimistically; Next
|
||
waits for three saved Regulars, and a common pick is not offered once a
|
||
Regular has a name `harness.Similar` to it — the PWA's copy of the name
|
||
rules in `web/src/dinners/names.ts` folds letters with
|
||
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
|
||
same table as the server's, and runs the same cases,
|
||
`web/src/dinners/testdata/dedupe_cases.json`, as the server's) and
|
||
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
||
the iOS install hint, "Turn on notifications" and "Maybe later"). "Maybe
|
||
later", and "Turn on notifications" once notifications are enabled, send
|
||
`PATCH /api/v1/settings/household` with `{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
||
to the time of that update (keeping the first time if it is already set;
|
||
the field itself is read-only and `false` is refused) and the app goes Home.
|
||
Completing needs at least three Regulars (`household.MinRegulars`); with
|
||
fewer the server answers 400 `invalid_settings` naming
|
||
`onboarding_completed` and changes nothing. Step 3 can be reached with
|
||
fewer — by its URL, or after another member removes a Regular — so it then
|
||
shows the server's reason with an "Add Regulars" button back to step 2.
|
||
Notification permission is never asked for on page load. "Turn on
|
||
notifications" subscribes this device to Web Push from the tap and says
|
||
how it ended — on, blocked by the browser, or unsupported (on iPhone and
|
||
iPad, until MealQuest is on the Home Screen) — then offers only "Finish
|
||
setting up" (which completes onboarding as above); a dismissed permission
|
||
prompt can be asked again. Offline on the
|
||
saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||
so the saved pages open as they are.
|
||
|
||
Regular dinners (`/settings/regulars`) lists the Regulars from
|
||
`GET /api/v1/dinners?status=regular`, each with its photo, "method ·
|
||
minutes", favorite heart and grounding badge ("Finding recipe…" while
|
||
`pending`, "Recipe found" when `grounded` or `manual`, "Needs a recipe" when
|
||
`failed`). The add field takes a name or a pasted `https://` recipe link.
|
||
Tapping a Regular opens an edit sheet (name, method, minutes, difficulty;
|
||
remove archives it after a confirmation); tapping one that needs a recipe
|
||
opens "Fix recipe" (pick one of the recipe candidates, paste a link or type
|
||
the ingredients and steps in). Adds, removals and the heart show at once and
|
||
are undone if the server refuses them. While any Regular is `pending`, the
|
||
list is read again every 3 seconds (not while a change is in flight), and
|
||
polling stops once none is.
|
||
|
||
## Deployment
|
||
|
||
Production is the compose project `mealquest` on 10.60.1.14
|
||
([`deploy/compose.yaml`](../deploy/compose.yaml)): the `mealquest` image built
|
||
from the root `Dockerfile`, `litestream` replicating the database to the
|
||
NAS over SFTP, and `searxng` on the internal network only. Configuration is
|
||
`deploy/.env` and file secrets in `deploy/secrets/`, neither committed. The
|
||
services never restart themselves; systemd units in `deploy/systemd/` own
|
||
the host:
|
||
|
||
| Unit | Does |
|
||
| --- | --- |
|
||
| `mealquest-firewall.service` | Restricts port 8090 to Traefik in `DOCKER-USER`; required before the stack starts. |
|
||
| `mealquest.service` | Starts and stops the compose stack. |
|
||
| `mealquest-media-backup.timer` / `.service` | Backs up cached photos to the NAS nightly at 03:30. |
|
||
| `mealquest-auto-deploy.timer` / `.service` | Automated deploy of `main` (below). |
|
||
|
||
Releases: only `overseer` merges `dev` into `main`. The image carries its
|
||
version (`git describe`: a release tag, else the short SHA), which
|
||
`/health` reports. A deploy follows
|
||
[`deploy/RUNBOOK.md`](../deploy/RUNBOOK.md) — build, pre-deploy restore
|
||
point, boot-config check, ship and restart, verify the deployed SHA and a
|
||
real screen — with rollback to the previous release. The automated deploy
|
||
([`deploy/auto-deploy.sh`](../deploy/auto-deploy.sh), installed as
|
||
`/usr/local/sbin/mealquest-auto-deploy`) runs those same steps unattended:
|
||
its timer checks every quarter hour (plus up to 5 minutes' random delay),
|
||
and deploys `refs/remotes/origin/main` only when it differs from the last
|
||
SHA in the deploy record `/home/overseer/mealquest-auto-deploy.log`, its
|
||
Gitea CI status is `success`, its last attempt did not fail, and
|
||
`mealquest.service` is active. It stops at the first failure and never
|
||
retries a failed SHA; `--ref <ref>` is the manual override. Installing,
|
||
seeding the record and enabling the timer are host steps in the runbook's
|
||
"Automated deploys". Database restore and the boot configuration are in
|
||
CTXD `RUNBOOKS.MD`.
|
||
|
||
## 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.
|