From a756c51be856ed052d352a0288b9e2d97feb0107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 23:33:20 +0200 Subject: [PATCH] =?UTF-8?q?docs(ios-handoff):=20A-Foundation=20=E2=80=94?= =?UTF-8?q?=20README=20skeleton=20+=20philosophy=20+=20repo-tour=20+=20arc?= =?UTF-8?q?hitecture=20+=20API=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/v15-ios-handoff/00-philosophy.md | 187 +++ .planning/v15-ios-handoff/01-repo-tour.md | 283 +++++ .../v15-ios-handoff/02-server-architecture.md | 511 ++++++++ .planning/v15-ios-handoff/03-api-contracts.md | 1057 +++++++++++++++++ .planning/v15-ios-handoff/README.md | 103 ++ 5 files changed, 2141 insertions(+) create mode 100644 .planning/v15-ios-handoff/00-philosophy.md create mode 100644 .planning/v15-ios-handoff/01-repo-tour.md create mode 100644 .planning/v15-ios-handoff/02-server-architecture.md create mode 100644 .planning/v15-ios-handoff/03-api-contracts.md create mode 100644 .planning/v15-ios-handoff/README.md diff --git a/.planning/v15-ios-handoff/00-philosophy.md b/.planning/v15-ios-handoff/00-philosophy.md new file mode 100644 index 000000000..12beef93e --- /dev/null +++ b/.planning/v15-ios-handoff/00-philosophy.md @@ -0,0 +1,187 @@ +--- +file: 00-philosophy.md +purpose: Why-questions — every load-bearing project decision and the reasoning behind it, so iOS code reads in the same dialect as the server +when_to_read: First read after README. Skip only if you have shipped two features here already. +prerequisites: none +estimated_tokens: ~3000 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +## TL;DR + +HealthLog is a self-hosted personal-health PWA with AI insights, Withings + Apple Health ingest, and a doctor-report PDF. The decisions below are not negotiable inside the codebase — they are how this project survives a fourteenth release without a rewrite. iOS code that contradicts any of them will fail review even when the diff is technically correct. + +## The ten load-bearing rules + +| # | Rule | Why | +| --- | --- | --- | +| 1 | Conservative semver — patch beats minor | Releases are bugfix-heavy with additive features; the v1.5.0 → v1.4.14 reroute confirmed users prefer many small patches over occasional minor jumps | +| 2 | Marc-Voice everywhere user-facing | Every artifact (commits, CHANGELOG, in-app copy, release notes) reads as Marc's authorship; never expose "Claude/AI/agent/marathon/phase" | +| 3 | AI insights are the differentiator | Visual, evidence-grounded, dynamic, multi-provider; not generic chat | +| 4 | Six locales from day 1 | EN + DE are Marc-maintained; FR / ES / IT / PL ship native first-party translation with a maintainership banner — no machine-translated fallbacks | +| 5 | `develop` → `main` via PR; tags on `main` only | Long-lived develop branch; main is release-only; GHCR + Coolify build only from main tags | +| 6 | No AI tells in user-facing copy | The user never sees the model name, the chain-of-thought, the "as an AI" disclaimer, the marathon phrasing, or the wave number | +| 7 | Multi-provider AI routing with failover | Anthropic → OpenAI → Codex → local; one failing provider doesn't kill the Coach | +| 8 | Recharts stays | No Apple Charts swap, no Lightweight-Charts, no D3 rewrite — visual identity is locked, replacement requires explicit approval | +| 9 | Postgres + OAuth, not SQLite + biometric | The server is a multi-user database, not a phone-shaped key-value store; iOS is a client of it | +| 10 | MDR 2017/745 boundary on medical features | Coach refuses GLP-1 dose recommendations (GROUND RULE 9) and drug-level estimates (GROUND RULE 15) with MDR + MDCG 2021-24 cites — Research Mode is opt-in observational only | + +## Rule 1 — Conservative semver + +| Symptom | Action | +| --- | --- | +| A release adds a new endpoint | Patch bump (`1.4.24 → 1.4.25`) — endpoint is additive | +| A release breaks an existing endpoint | Minor bump (`1.4.x → 1.5.0`) — only with deliberate migration | +| A release drops support for a measurement type | Major bump (`1.x.y → 2.0.0`) — never executed | + +The v1.5 milestone exists for the iOS app + Apple Health rebase, not as a number-bump. v1.4.25 fits 90 commits past v1.4.24 baseline (insights sub-pages, GLP-1 end-to-end, six locales, source-priority two-axis) into a patch because everything is additive — old clients keep working unchanged. + +## Rule 2 — Marc-Voice + +| Channel | Voice rule | +| --- | --- | +| Git commit messages | English, terse, professional, no agent-voice | +| CHANGELOG.md | Marc-as-author, "Adds X" not "Claude added X" | +| GitHub release notes | Identical to CHANGELOG entry, no agent mentions | +| In-app copy (UI strings) | English-default with the rest of `messages/en.json`; same voice | +| Doc-site (`docs/`) | Marc-as-author | +| Audit reports under `docs/audit/` | Marc-as-author | +| `.planning/` working notes | Internal — can be candid; never published | + +A code review that finds `// generated by agent` or `// marathon phase X added Y` will reject the diff. v1.4.20 retroactively scrubbed the prior CHANGELOG to remove inherited voice tells; the rule is permanent. + +## Rule 3 — AI insights are the differentiator + +The Coach drawer + per-status insight cards + Daily Briefing + correlation cards + weekly report are the selling point. They are: + +- **Visual** — every claim renders with a chart context, not a paragraph +- **Evidence-grounded** — the model receives a real SNAPSHOT with the user's data, never invents numbers (the `---KEYVALUES---` sentinel exists to surface the load-bearing numbers verbatim back to the user) +- **Dynamic** — fresh insights evict stale ones; mutations invalidate every read query touching the resource +- **Multi-provider** — fallback chain is structural (see Rule 7), not "if Claude is down we panic" + +iOS code that bypasses the snapshot to do "smart" local computation defeats the entire pipeline. + +## Rule 4 — Six locales from day 1 + +| Locale | Maintainer | Coach prompt | Banner | +| --- | --- | --- | --- | +| EN | Marc | Native | None | +| DE | Marc | Native | None | +| FR | Community | Native | `` + translation-feedback issue template | +| ES | Community | Native | Banner + template | +| IT | Community | Native | Banner + template | +| PL | Community | Native | Banner + template | + +Pre-v1.4.25 the Coach used an English body with a `REPLY LANGUAGE: ` footer. v1.4.25 flipped this to **native per-locale system prompts** with a YAML-encoded safety-contract matrix per locale (single source of truth across drafting + tests). The refusal-probe matrix in CI catches cross-locale regressions. + +iOS strings live in the same `messages/{locale}.json` bundles. The `i18n-locale-integrity.test.ts` blocks any PR that drops a key in one locale without dropping it everywhere. + +## Rule 5 — Branch model + +``` + feature/* ──┐ + ├──► develop ──► (release-merge) ──► main ──► tag vX.Y.Z ──► GHCR build ──► Coolify deploy + fix/* ──┘ │ + └──► CHANGELOG entry pinned to the tag +``` + +Daily work goes on develop. `main` only receives release merges and tags. The GHCR docker workflow builds **from main only** so a dev push to develop never poisons the production image. Coolify auto-deploy listens on the GHCR tag. + +Pre-v1.4.20 there was no main/develop split; the v1.4.20 marathon introduced it and every release since (v1.4.20 through v1.4.25) followed the pattern. + +## Rule 6 — No AI tells + +Forbidden in user-facing surfaces: + +| Phrase | Replacement | +| --- | --- | +| "As an AI…" | (delete the entire disclaimer line) | +| "I'm Claude / I'm GPT-4 / I'm an LLM" | (delete) | +| "marathon / phase / wave / session / agent" | Rephrase or delete | +| "I was trained on…" | (delete) | +| "I cannot help with that, however…" | A real refusal: "This is medical advice that needs a physician — open the doctor report and bring it to your appointment." | + +The Coach refusal copy is hand-written per locale in `messages/{locale}.json` under the `coach.refusal.*` keys; the prompt-pattern guard re-emits the localised refusal without ever hitting a provider, so the LLM cannot leak its own identity even under prompt injection. + +## Rule 7 — Multi-provider AI routing + +Order of fallback (see `src/lib/ai/provider-runner.ts` → `runRawCompletionWithFallback()`): + +1. **User-configured providers** in their saved priority order (Settings → AI Provider) +2. **Admin-configured fallback** (typically the operator's OpenAI key) +3. **No-key fallback** — rule-based copy, never claims to be the AI + +Each attempt is logged as a Wide-Event with `provider`, `attempts`, `firstStatus`. v1.4.25 W5 split provider-down from rate-limited: every-attempt-429 surfaces the `coach.provider.rate_limited` copy with a warning toast; all-attempts-failed surfaces `coach.provider.unavailable`. + +iOS calls `/api/insights/chat` and reads `error.code` from the SSE `error` frame to render the right copy — the iOS app never picks the provider directly. + +## Rule 8 — Recharts stays + +The visual identity of every chart in the app is locked. Pre-v1.4.20 there was a Lightweight-Charts evaluation that got reverted for accessibility (no aria-labels, no high-contrast tokens). The chart library swap is in the "explicit-approval-required" category — design + product sign-off before any PR. + +For iOS, this means **Swift Charts is the equivalent of Recharts** — pick the OS-native one, never roll a custom canvas renderer. The visual aesthetic (axis tokens, color rules, legend placement) is what the user remembers — the renderer is what makes it stable. + +## Rule 9 — Postgres + OAuth, not SQLite + biometric + +Server-side decisions: + +| Choice | Rejected | Why | +| --- | --- | --- | +| Postgres | SQLite | Multi-user from v1.0; concurrent writes from Withings webhook + iOS batch + manual web entry | +| OAuth + session cookies + Bearer tokens | Biometric-only | Cross-device, web-first, audit-friendly | +| Argon2 password hashing | bcrypt / PBKDF2 | OWASP 2024 recommendation; libargon2 is mature | +| Passkeys via SimpleWebAuthn | Apple-only WebAuthn | Cross-browser support | + +For iOS: + +- The phone keychain stores the **refresh token**, not the user's password +- Apple Health is a **client-side data source**, not a primary store — every HealthKit sample flows through `POST /api/measurements/batch` into the canonical Postgres +- Biometric unlock is a **UX shortcut** for accessing the locally-cached refresh token, not a security primitive + +## Rule 10 — MDR boundary + +Two GROUND RULES make this binding inside the Coach prompt: + +| Rule | Cite | Behaviour | +| --- | --- | --- | +| GROUND RULE 9 | EU MDR 2017/745 + MDCG 2021-24 | Coach refuses GLP-1 dose recommendations; offers EMA EPAR titration ladder as observational reference only | +| GROUND RULE 15 | EU MDR + EMA EPAR + Schneck/Urva 2024 | Coach refuses drug-level estimates; Research Mode chart is opt-in, behind a versioned acknowledgment dialog (Migration 0058), observational use only | + +Research Mode lifecycle: `GET / POST / DELETE /api/auth/me/research-mode`. The acknowledgment carries a version string (`RESEARCH_MODE_DISCLAIMER_VERSION`); a stale version forces re-prompt. The drug-level chart is a one-compartment PK approximation — qualitative trace only, no numeric "your current level is X ng/mL" claim. + +iOS replicates this verbatim: + +- The GLP-1 detail screen surfaces the Research Mode toggle in Settings → Advanced +- Tapping the chart on a non-acknowledged account presents the dialog with the **live** server-side disclaimer copy (fetch via `GET /api/auth/me/research-mode`, do not hardcode) +- Re-prompt is automatic when the server's `currentDisclaimerVersion` differs from the user's `acknowledgedVersion` + +## Cross-cutting consequences + +### Cache invalidation on new content + +A successful mutation invalidates every read query touching the resource. TanStack Query keys are namespaced per resource (see `src/lib/query-keys.ts`). The convention: same `queryKey` MUST map to the same unwrap shape — `(await res.json()).data`. Two endpoints accidentally sharing a key but returning different shapes is a cache-poisoning bug that hides until production. + +For iOS, the equivalent rule: every mutation that writes a measurement / workout / medication / mood MUST trigger a refetch of the matching list query. If the iOS state layer uses a different cache model (e.g. SwiftData), preserve the rule: never serve stale data after a write. + +### No personal data in user-facing artifacts + +Marc's name, BD-Zielbereich values, measurement counts, lab values — none of these appear in CHANGELOG / GH releases / `docs/audit/v*-summary.md` / docs site / landing. The retroactive scrub of v1.4.14–v1.4.19 (in v1.4.20) and the pre-release PII-grep gate enforce this. For iOS: never put user data into commit messages, release notes, or screenshots checked into the repo. + +### Umlauts everywhere + +UTF-8 end-to-end. `"Nrnberg"` instead of `"Nürnberg"` is a bug. iOS string-handling must preserve umlauts through every encoding step; this includes URL parameters (use `URLComponents`, not string concatenation) and POST body bytes (always send `application/json; charset=utf-8`). + +### Research before complex features + +AI / insights / chart work runs through a research agent first. The agent benchmarks Apple Health / Withings / Oura, proposes an ecosystem-fit plan, then the implementation phase starts. The research artifacts live under `.planning/research/`. For iOS, mirror this: before building the Coach drawer or the dashboard, read the equivalent research note under `.planning/research/`. + +## STOP HERE if… + +| If your task is… | …skip the rest and read… | +| --- | --- | +| "Just call one endpoint" | `03-api-contracts.md` | +| "Understand the data shape" | `04-data-model.md` | +| "Wire the Coach drawer" | `10-coach-pipeline.md` | +| "Build a chart" | `06-ui-conventions.md` | + +Otherwise: continue to `01-repo-tour.md`. diff --git a/.planning/v15-ios-handoff/01-repo-tour.md b/.planning/v15-ios-handoff/01-repo-tour.md new file mode 100644 index 000000000..33b51c969 --- /dev/null +++ b/.planning/v15-ios-handoff/01-repo-tour.md @@ -0,0 +1,283 @@ +--- +file: 01-repo-tour.md +purpose: Where everything lives — src/ layout, naming conventions, migrations, locales, tests, CI, deploy config +when_to_read: After 00-philosophy.md. Re-read whenever you need to find a file and the search-fu fails. +prerequisites: 00-philosophy.md +estimated_tokens: ~3000 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +## TL;DR + +Top-level is a stock Next.js 15 + Prisma + pnpm monorepo. Source lives in `src/`. Migrations live in `prisma/migrations/` and are SQL-only. Translations live in `messages/{locale}.json`. Tests split three ways: Vitest unit (`src/**/__tests__/`), Vitest integration (`tests/integration/`), Playwright e2e (`e2e/`). Planning artifacts live in `.planning/`. CI workflows live in `.github/workflows/`. + +## Top-level + +``` +/ +├── src/ # All application code (see § src/ layout) +├── prisma/ +│ ├── schema.prisma # Single Prisma schema +│ └── migrations/ # SQL migrations 0001 → 0060 (additive, forward-only) +├── messages/ # i18n bundles +│ ├── en.json # English (Marc-maintained, default) +│ ├── de.json # German (Marc-maintained) +│ ├── fr.json es.json it.json pl.json # Community-maintained +│ └── (see 08-locales-i18n.md for hygiene rules) +├── scripts/ +│ ├── generate-openapi.ts # OpenAPI generation from Zod schemas +│ ├── check-openapi.ts # Drift gate — fails CI on schema-vs-spec drift +│ ├── i18n/ # i18n integrity helpers +│ └── restore-backup.ts +├── e2e/ # Playwright (24 spec files at v1.4.25) +├── tests/ # Vitest integration (testcontainers + real Postgres) +├── public/ # Static assets, manifest.webmanifest, icons +├── docs/ # User-facing docs site sources +├── .planning/ # Internal working notes — not published +│ ├── v15-ios-handoff/ # ← this doc-pack lives here +│ ├── research/ # Research notes for complex features +│ ├── phase-*-report.md # Per-feature delivery reports +│ └── codebase/ # Output of /gsd:map-codebase +├── .github/workflows/ # CI definitions +├── docker-compose.yml # Local dev (Postgres + app) +├── Dockerfile # Production image (multi-stage) +├── docker-entrypoint.sh +├── package.json # Single workspace; pnpm 10.31.0 +├── prisma.config.ts +├── next.config.ts # Next.js 16.2.6 config (turbopack) +├── tsconfig.json # strict mode +├── vitest.config.mts # Unit-test config (jsdom) +├── vitest.integration.config.mts # Integration-test config (node + testcontainers) +├── playwright.config.ts +├── eslint.config.mjs # ESLint 9 flat config +├── postcss.config.mjs # Tailwind CSS v4 +├── components.json # shadcn/ui registry config +├── CHANGELOG.md # Single canonical changelog +├── CONTRIBUTING.md # External-contributor guide +├── CONTRIBUTING-AI.md # Internal-agent guide +├── AGENTS.md # Per-area agent ownership map +├── README.md # Marketing + 30-second-read intro +└── LICENSE # AGPL-3.0-only +``` + +## src/ layout + +``` +src/ +├── app/ # Next.js App Router (RSC + route handlers) +│ ├── (page routes) # /, /dashboard, /insights/*, /measurements, /medications, /settings, /admin, … +│ ├── api/ # Route handlers — see § API layout +│ ├── layout.tsx # Root layout (theme, providers, locale) +│ ├── globals.css # Tailwind + Dracula tokens +│ ├── favicon.ico robots.ts +│ ├── error.tsx # Per-route error boundary +│ └── global-error.tsx # Root error boundary +│ +├── components/ +│ ├── ui/ # shadcn primitives (Button, Sheet, Dialog, …) +│ ├── charts/ # Recharts wrappers (LineChart, BarChart, StackedBar, sparklines) +│ ├── dashboard/ # Dashboard tiles + grid +│ ├── insights/ # Insights mother-page + sub-pages + Coach drawer +│ ├── measurements/ # Manual-entry forms + lists +│ ├── medications/ # Med list, intake, GLP-1 specialist surfaces +│ ├── mood/ # Mood logger + chart +│ ├── onboarding/ # Wizard primitives (OnboardingShell + per-step components) +│ ├── settings/ # 23 settings sections +│ ├── admin/ # Admin-only screens (user list, login overview, …) +│ ├── targets/ # /insights/zielwerte +│ ├── doctor-report/ # PDF preview + per-section toggles +│ ├── gamification/ # Achievements + streaks +│ ├── monitoring/ # Wide-event viewer (admin) +│ ├── comparison/ # Comparison-overlay primitives +│ ├── layout/ # AppShell + AuthShell + AdminShell +│ ├── i18n/ # LocaleProvider + Trans component +│ ├── app-settings-provider.tsx +│ ├── error-details.tsx +│ └── providers.tsx # TanStack Query + Theme + Toast providers +│ +├── lib/ # Server + isomorphic helpers +│ ├── ai/ # AI provider chain + Coach + insights prompts (see 02-server-architecture.md) +│ ├── analytics/ # summarize(), correlations, classifications, health-score +│ ├── auth/ # session, passkey, password, issue-token, refresh-token, audit +│ ├── coach/ # (Note: lives under lib/ai/coach/ — see 02) +│ ├── insights/ # status writers (general/bp/weight/pulse/bmi/mood/compliance) +│ ├── jobs/ # pg-boss workers + cron schedules (see 02) +│ ├── medications/ # GLP-1 PK helpers, drug-knowledge layer, titration +│ ├── notifications/ # dispatcher, senders (Telegram / NTFY / Web-Push / APNs) +│ ├── validations/ # Zod schemas — the single source of truth for every wire shape +│ ├── withings/ # OAuth client, sync (measure + activity + sleep v2), webhook +│ ├── i18n/ # server-locale resolver, server-translator +│ ├── tz/ # per-user timezone resolver + helpers +│ ├── monitoring/ # glitchtip sender +│ ├── logging/ # Wide-Event builder + context + transports +│ ├── openapi/ # Zod-to-OpenAPI generator +│ ├── api-handler.ts # apiHandler() + requireAuth() + requireAdmin() + HttpError +│ ├── api-response.ts # apiSuccess() + apiError() + safeJson() + getClientIp() +│ ├── db.ts # Prisma client singleton +│ ├── db-compat.ts # Schema-version guard (legacy callsite) +│ ├── crypto.ts # encrypt/decrypt (per-user secrets in the DB) +│ ├── rate-limit.ts # checkRateLimit() + rateLimitHeaders() +│ ├── idempotency.ts # withIdempotency() — HTTP-level retry replay +│ ├── query-keys.ts # TanStack Query keys — never duplicate, never collide +│ ├── format.ts format-locale.ts time-window-format.ts timezone.ts +│ ├── doctor-report-*.ts # PDF rendering +│ ├── export.ts # CSV export +│ ├── gravatar.ts geo.ts glucose.ts +│ └── (more — see src/lib/ listing) +│ +├── hooks/ # React hooks (TanStack-Query consumers, ResizeObserver helpers, etc.) +├── generated/ # Prisma client output (gitignored — regenerated on pnpm db:generate) +├── instrumentation.ts # Next.js instrumentation hook — boots pg-boss worker +├── proxy.ts # Next.js middleware (auth redirect, onboarding-pending cookie, locale) +└── __tests__/ # Unit tests live colocated under each module's __tests__/ +``` + +## API layout — `src/app/api/` + +| Path | Purpose | +| --- | --- | +| `admin/` | Admin-only routes | +| `ai/` | AI provider + Codex OAuth | +| `analytics/` | Trends + health-score aggregator | +| `audit-log/` | Audit-log read API | +| `auth/` | Login, logout, register, profile, refresh, passkey, codex, me/* (timezone, source-priority, doctor-report-prefs, coach-prefs, research-mode, devices) | +| `bugreport/` | User-submitted bug reports | +| `dashboard/` | summary, glp1, widgets, chart-overlay-prefs | +| `devices/` | Device list + revoke | +| `doctor-report/` | PDF generation | +| `export/` | CSV export | +| `feedback/` | AI-insight feedback (thumbs + free text) | +| `gamification/` | Achievements, streaks | +| `health/` | `/api/health` liveness probe (200 / 503) | +| `import/` | CSV + Apple Health XML import | +| `ingest/` | Generic ingest endpoints (legacy) | +| `insights/` | chat (SSE), generate, cards, comprehensive, correlations, targets, feedback, provider-chain, settings, glp1-timeline, per-status (blood-pressure / weight / pulse / mood / bmi / medication-compliance) | +| `integrations/` | Withings + Apple-Health status reconciliation | +| `internal/` | Server-internal probes | +| `measurements/` | route.ts, batch, by-external-ids, [id], series | +| `medications/` | route.ts, intake, intake-summary, [id]/* (intake, glp1, inventory, side-effects, cadence, titration, compliance, phase-config, api-endpoint) | +| `monitoring/` | Wide-event read API | +| `mood/ mood-entries/` | Mood logging | +| `notifications/` | preferences, status, vapid, web-push | +| `onboarding/` | step, complete, tour | +| `personal-records/` | PR list | +| `send/` | Generic notification dispatch (admin) | +| `settings/` | App-level settings (admin) | +| `telegram/` | Telegram bot test | +| `tokens/` | API token list + create + revoke | +| `user/` | User CRUD (admin) | +| `version/` | App version probe | +| `withings/` | connect, callback, disconnect, status, sync, credentials, webhook | +| `workouts/` | batch | + +Full HTTP-verb-by-verb contract: see `03-api-contracts.md`. + +## Naming conventions + +| Convention | Example | Notes | +| --- | --- | --- | +| Route handlers | `src/app/api/{path}/route.ts` | Next.js App Router; one file per HTTP path, export `GET`/`POST`/`PUT`/`DELETE`/`PATCH` | +| Dynamic segments | `[id]`, `[token]`, `[step]` | Bracket-folder names; accessed via `params` argument | +| Zod schemas | `src/lib/validations/{resource}.ts` | One file per resource; `createXSchema`, `updateXSchema`, `listXSchema` | +| Prisma model | `PascalCase` in `schema.prisma` | Generated client at `src/generated/prisma/client` | +| DB column case | snake_case in DB, camelCase in code | Via Prisma `@map` | +| Migration files | `prisma/migrations/{NNNN}_{snake_case_description}/migration.sql` | Sequential numbering 0001 → 0060 at v1.4.25 | +| Test files | colocated under `__tests__/` directories | `unit.test.ts` for vitest unit, `integration.test.ts` under `tests/` | +| Translation keys | `dot.notation.lowercase` | Same key in every locale; the integrity test blocks drift | +| Audit-log actions | `resource.verb` | `measurement.create`, `auth.login.password`, `medication.intake.update` | +| Wide-Event names | same as audit-log when applicable | Annotated via `annotate({ action: { name } })` | + +## Where to find… + +| Need | Location | +| --- | --- | +| The list of `pnpm` commands | `package.json` § scripts | +| The list of measurement types | `src/lib/validations/measurement.ts` (Zod enum) and `prisma/schema.prisma` (`MeasurementType`) | +| The Coach system prompt | `src/lib/ai/coach/system-prompt.ts` + `src/lib/ai/prompts/safety-contracts.{locale}.yaml` | +| The Coach refusal copy | `messages/{locale}.json` under `coach.refusal.*` | +| The current PROMPT_VERSION | `src/lib/ai/prompts/insight-generator.ts` → `PROMPT_VERSION` | +| Default rate-limit values | Per-route; see the route file for the `checkRateLimit()` call | +| The cron schedule for X | `src/lib/jobs/reminder-worker.ts` § schedules table | +| Withings OAuth client | `src/lib/withings/client.ts` | +| Apple Health identifier map | `src/lib/measurements/apple-health-mapping.ts` | +| GLP-1 drug-knowledge layer | `src/lib/medications/glp1-knowledge.ts` and the EMA / EPAR references in `docs/` | +| GLP-1 PK helper | `src/lib/medications/glp1-pk.ts` | +| PR detection logic | `src/lib/jobs/pr-detection.ts` | +| Per-user timezone resolver | `src/lib/tz/resolver.ts` → `userDayKey()`, `DEFAULT_TIMEZONE` | +| Source-priority resolver | `src/lib/sources/` + `src/lib/validations/source-priority.ts` | +| The `apiHandler` + `requireAuth` helpers | `src/lib/api-handler.ts` | +| Idempotency wrapper | `src/lib/idempotency.ts` → `withIdempotency()` | +| Wide-Event annotation | `src/lib/logging/context.ts` → `annotate({ action, meta })` | +| Audit-log writer | `src/lib/auth/audit.ts` → `auditLog(action, { userId, ipAddress, details })` | + +## package.json scripts + +| Command | Purpose | +| --- | --- | +| `pnpm dev` | Start Next.js dev server on http://localhost:3000 (Turbopack) | +| `pnpm build` | Production build | +| `pnpm start` | Run production build | +| `pnpm lint` | ESLint 9 flat-config | +| `pnpm typecheck` | TypeScript strict-mode no-emit | +| `pnpm format` / `pnpm format:check` | Prettier write / check | +| `pnpm test` / `pnpm test:watch` | Vitest unit suite | +| `pnpm test:integration` | Vitest integration suite (testcontainers + Postgres) | +| `pnpm e2e` / `pnpm e2e:ui` | Playwright | +| `pnpm db:generate` | Regenerate Prisma client | +| `pnpm db:push` | Push schema without a migration (dev only) | +| `pnpm db:migrate` | Create + apply a new migration | +| `pnpm db:migrate:deploy` | Apply migrations in CI / production | +| `pnpm db:studio` | Open Prisma Studio | +| `pnpm openapi:generate` | Generate `docs/openapi.yaml` from Zod schemas | +| `pnpm openapi:check` | Drift gate — fails when schema and spec diverge | + +The CI one-liner (mirrors `.github/workflows/`): + +```bash +pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build +``` + +## CI workflows — `.github/workflows/` + +| File | Trigger | What it does | +| --- | --- | --- | +| `dependabot-auto-merge.yml` | Dependabot PR | Auto-merges minor / patch bumps after CI green | +| `docker-publish.yml` | Push tag `v*` on `main` | Builds multi-arch GHCR image (linux/amd64 on ubuntu-latest + linux/arm64 on ubuntu-24.04-arm), merges manifest | +| `e2e.yml` | PR + push | Playwright suite against ephemeral Postgres | +| `integration.yml` | PR + push | Vitest integration suite | +| `post-publish-verify.yml` | After GHCR build | Pulls the published image, runs health-check probes | +| `security.yml` | Daily + PR | npm audit, secret scanning | + +## Coolify deploy config + +The runtime is **Coolify** on a self-hosted server. The deploy config lives: + +- **In Coolify's UI**, not in the repo — the project + environment + tag-based auto-deploy listener is configured there +- The GHCR image tag controls deploys — Coolify polls the tag and redeploys on a new digest +- Environment variables ship via Coolify's env panel (POSTGRES_PASSWORD, DATABASE_URL, SESSION_SECRET, ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, plus optional AI provider keys + Withings + Apple-Health bundles) + +For iOS purposes: the public URL is the production HealthLog instance; the API base URL is configurable in the app's Settings (the iOS app supports self-hosted operators, not just Marc's instance). + +Detail in `19-deploy-runtime.md`. + +## Planning artifacts — `.planning/` + +| Subdir | What it holds | +| --- | --- | +| `phase-*-report.md` | Per-feature delivery reports — read these for a feature's "why" | +| `research/` | Research notes that precede complex features | +| `codebase/` | Output of `/gsd:map-codebase` — mechanical structure reports | +| `v15-ios-handoff/` | This doc-pack | + +When investigating a feature: search `.planning/` first — a report explains "why" in a way the code never can. + +## STOP HERE if… + +| If your task is… | …skip the rest and read… | +| --- | --- | +| "What does endpoint X return?" | `03-api-contracts.md` | +| "What does the DB look like?" | `04-data-model.md` | +| "How do I run the dev server?" | `CONTRIBUTING.md` (root) | +| "What's the deploy story?" | `19-deploy-runtime.md` | + +Otherwise: continue to `02-server-architecture.md`. diff --git a/.planning/v15-ios-handoff/02-server-architecture.md b/.planning/v15-ios-handoff/02-server-architecture.md new file mode 100644 index 000000000..a28c5244e --- /dev/null +++ b/.planning/v15-ios-handoff/02-server-architecture.md @@ -0,0 +1,511 @@ +--- +file: 02-server-architecture.md +purpose: Backend services architecture — Next.js, Prisma, pg-boss queues, cron jobs, multi-provider AI routing, Coach / Insights module structure, Health Score, source-priority two-axis +when_to_read: After 01-repo-tour.md. Before building any backend-integrated feature. +prerequisites: 00-philosophy.md, 01-repo-tour.md +estimated_tokens: ~5000 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +## TL;DR + +Single Next.js 16 process serves the web UI (RSC + App Router) and the API (route handlers). A pg-boss worker boots in-process via `src/instrumentation.ts` and runs ~20 recurring crons against the same Postgres. The AI Coach and the per-status insight cards run on a multi-provider routing layer with structural fallback. Source-priority is a two-axis resolver: per-metric ladder + per-device-type override. Health Score is server-deterministic with a per-component provenance accordion. + +## Stack at a glance + +| Layer | Tech | Version | +| --- | --- | --- | +| Runtime | Node.js | 20+ | +| Framework | Next.js | 16.2.6 (App Router + RSC + Turbopack) | +| Language | TypeScript | strict mode | +| UI | React 19 + shadcn/ui + Tailwind v4 | — | +| Charts | Recharts | 3.8.1 | +| ORM | Prisma | 7.8.0 (pg adapter) | +| DB | PostgreSQL | 16+ | +| Queues | pg-boss | 12.18.2 (uses same Postgres) | +| Validation | Zod v4 | 4.4.3 | +| Auth — passwords | Argon2 (`@node-rs/argon2`) | 2.0.2 | +| Auth — passkeys | SimpleWebAuthn | 13.3.0 | +| Push | web-push (VAPID) + APNs (`@parse/node-apn`) | 3.6.7 / 8.1.0 | +| State | TanStack Query | 5.100.9 | +| Forms | react-hook-form + Zod resolver | 7.75.0 / 5.2.2 | +| Test — unit | Vitest 4 + jsdom | — | +| Test — integration | Vitest + testcontainers (real Postgres) | — | +| Test — e2e | Playwright | 1.59.1 | +| OpenAPI | zod-openapi | 5.4.6 — drift-checked in CI | + +## Request lifecycle + +``` +HTTP request + │ + ▼ +src/proxy.ts (Next.js middleware) ← auth redirect, locale cookie, hl_onboarding cookie short-circuit + │ + ▼ +src/app/api//route.ts ← exports GET / POST / PUT / DELETE / PATCH + │ + ▼ +apiHandler(handler) ← src/lib/api-handler.ts + │ ▶ start Wide-Event builder + │ ▶ propagate x-request-id + │ ▶ run handler inside AsyncLocalStorage + │ ▶ catch HttpError / SyntaxError → JSON error envelope + │ ▶ emit Wide Event on completion (sampled) + ▼ +handler body + │ 1. requireAuth() / requireAdmin() ← cookie session OR Bearer hlk_* + │ 2. checkRateLimit(key, max, ms) + │ 3. safeJson() + Zod schema.safeParse() + │ 4. business logic (Prisma queries, optional pg-boss enqueue, external calls) + │ 5. auditLog(action, { userId, ipAddress, details }) + │ 6. annotate({ action: { name }, meta }) + │ 7. return apiSuccess(data, status) or apiError(message, status, { errorCode, headers }) + ▼ +Response envelope: { data, error } (+ optional meta on error) +``` + +## Auth precedence (cookie-first) + +``` +1. Valid session cookie → cookie path (full user access; requiredPermission ignored) +2. No cookie + Bearer hlk_* → ApiToken path (scope-gated) +3. Neither → 401 +``` + +`requireAdmin()` is cookie-only — Bearer tokens never elevate. See `05-auth-flows.md` for the full token lifecycle. + +## Database — Prisma + Postgres + +- Single Prisma schema at `prisma/schema.prisma` +- One-way forward-only migrations under `prisma/migrations/` +- 60 migrations at v1.4.25 (migrations 0043–0060 are the v1.4.25 additions; all additive) +- Generated client emitted to `src/generated/prisma/client` (gitignored, regenerated via `pnpm db:generate`) +- `src/lib/db.ts` exports a singleton — never instantiate `PrismaClient` ad-hoc +- Cross-user safety is enforced by `userId` predicates in every query; the unique indexes (`(userId, type, source, externalId)` on `Measurement`, `(userId, source, externalId)` on `Workout`) carry NULL-distinct semantics + +Detail in `04-data-model.md`. + +## pg-boss — queue + scheduler + +pg-boss runs in-process alongside Next.js. It uses the same Postgres for queue storage (its own schema `pgboss`), so deployment is one container, one DB. + +Boot lifecycle: + +``` +src/instrumentation.ts + └─ src/lib/jobs/reminder-worker.ts → startWorker() + ├─ boss.start() + ├─ boss.schedule(name, cron, {}, { tz: "Europe/Berlin" }) ← for every recurring queue + └─ boss.work(name, { localConcurrency: 1 }, handler) ← for every consumer +``` + +### Recurring schedules + +All cron schedules use `Europe/Berlin` TZ. Source: `src/lib/jobs/reminder-worker.ts` schedules table. + +| Queue | Cron | Frequency | Handler | +| --- | --- | --- | --- | +| `medication-reminder-check` | `*/15 * * * *` | Every 15 min | Reminder dispatcher — phase-config aware | +| `withings-measure-sync` | `0 * * * *` | Hourly :00 | Fallback measure sync (webhook is primary since v1.4.25) | +| `withings-activity-sync` | `0 * * * *` | Hourly :00 | Activity (steps + distance + active-energy) sync — Since v1.4.25 | +| `withings-sleep-v2-sync` | `15 * * * *` | Hourly :15 | Sleep v2 stage-level segments — Since v1.4.25 | +| `mood-log-sync` | `30 * * * *` | Hourly :30 | Mood-log secret refresh | +| `pr-detection-fallback` | `*/30 * * * *` | Every 30 min | Personal-record sweep across all users (the per-user job is enqueued on every batch ingest) — Since v1.4.25 | +| `general-status` | `0 2 * * *` | Daily 02:00 | Generate `insights.general-status.{locale}` cards | +| `blood-pressure-status` | `5 2 * * *` | Daily 02:05 | Per-status BP card | +| `weight-status` | `10 2 * * *` | Daily 02:10 | Per-status weight card | +| `pulse-status` | `15 2 * * *` | Daily 02:15 | Per-status pulse card | +| `bmi-status` | `20 2 * * *` | Daily 02:20 | Per-status BMI card | +| `medication-compliance-status` | `25 2 * * *` | Daily 02:25 | Per-status compliance card | +| `host-metric-sample` | `* * * * *` | Every minute | Host CPU / memory sample | +| `rate-limit-cleanup` | `*/5 * * * *` | Every 5 min | Drop expired rate-limit rows | +| `idempotency-cleanup` | `0 3 * * *` | Daily 03:00 | Drop expired idempotency-key rows | +| `audit-log-cleanup` | `15 3 * * *` | Daily 03:15 | Retention sweep | +| `medication-inventory-expire` | `30 3 * * *` | Daily 03:30 | Flip IN_USE pens past their 30-day clock to EXPIRED — Since v1.4.25 | +| `data-backup` | `0 3 * * 0` | Weekly Sun 03:00 | Local backup | +| `offhost-backup` | `30 2 * * *` | Daily 02:30 | S3-compatible offhost backup | +| `feedback-aggregator` | `0 4 * * *` | Daily 04:00 | Roll up AI-insight feedback into daily aggregates | + +### Per-user enqueue paths (not on a cron) + +| Queue | Triggered by | Purpose | +| --- | --- | --- | +| `pr-detection` (per-user) | `POST /api/measurements/batch`, `POST /api/workouts/batch` | Run PR sweep with `silent` flag for historical backfills (>50 entries) | +| `withings-activity-sync` (per-user) | Withings webhook (subscription channel) | Webhook-primary; cron is the catch-net | +| `withings-sleep-v2-sync` (per-user) | Withings webhook | Same pattern | + +### Worker concurrency + +Every queue uses `localConcurrency: 1` by default. The PR detection worker bumps to 5 in the per-user enqueue path (concurrent users; each user serial). Worker status is exported via `src/lib/jobs/worker-status.ts` and surfaces on `/api/health` as `worker: running | stopped`. + +## Multi-provider AI routing + +``` +src/lib/ai/ +├── provider.ts # resolveProvider() (legacy single-provider) + resolveProviderChain() +├── provider-chain.ts # Per-user chain resolution (Settings → AI Provider) +├── provider-runner.ts # runRawCompletionWithFallback() + AllProvidersFailedError +├── anthropic-client.ts # Anthropic Messages API +├── openai-client.ts # OpenAI Chat Completions +├── codex-client.ts # ChatGPT (Codex) via OAuth +├── codex-oauth.ts # Codex OAuth dance + token refresh +├── codex-slug-cache.ts # Slug→model-name cache +├── local-client.ts # Local LLM (Ollama / LM Studio) +├── mock-client.ts # Test fixture +├── no-key-fallbacks.ts # Rule-based copy when zero providers configured +├── citation-coverage.ts # Citation-presence check +├── confidence.ts # Confidence scoring helper +├── feedback-attribution.ts # Per-provider feedback attribution +├── generate-insight.ts # Top-level insight generation (used by per-status writers) +├── legacy-payload.ts # Pre-strict-schema payload detector +├── medical-references.ts # MDR + EMA EPAR + EMA citation library +├── provider.ts +├── schema.ts # Zod schema for insight payloads +├── types.ts +├── coach/ +│ ├── budget.ts # enforceBudget() + recordSpend() — daily token cap per user +│ ├── glp1-snapshot.ts # GLP-1-aware snapshot enrichment +│ ├── keyvalues.ts # ---KEYVALUES--- sentinel parser +│ ├── persistence.ts # CoachConversation + CoachMessage CRUD (encrypted at rest) +│ ├── refusal.ts # Pattern-based prompt-injection + off-topic guard +│ ├── snapshot.ts # buildCoachSnapshot(userId, scope) → SNAPSHOT block +│ ├── system-prompt.ts # getCoachSystemPrompt(locale, prefs) — native per-locale +│ ├── target-prompts.ts # Per-target prompt overlays +│ ├── target-scope.ts # Scope-resolver helpers +│ └── types.ts # CoachStreamEvent, CoachProvenance, CoachKeyValue +├── prompts/ +│ ├── base-system.ts # Shared safety contract +│ ├── insight-generator.ts # PROMPT_VERSION + strict insights system prompt +│ ├── native-prompts.ts # Native per-locale prompt bodies +│ ├── safety-contracts.{en,de,fr,es,it,pl}.yaml ← YAML matrix per locale +│ ├── safety-contracts.ts # YAML loader +│ ├── general-status.ts blood-pressure.ts weight.ts pulse.ts bmi.ts mood.ts medication-compliance.ts +``` + +### Provider chain resolution + +``` +resolveProviderChain(userId) + ↓ +[providerType, instance] pairs in user-priority order + ↓ +runRawCompletionWithFallback({ providers, params }) + ↓ on each attempt: + - call provider with params + - on 429 → record attempt, continue to next + - on other 5xx → record attempt, continue to next + - on success → return { result, workingProvider } + ↓ on all fail: + - throw AllProvidersFailedError(attempts) +``` + +The route reads `attempts.every(a => a.httpStatus === 429)` to distinguish provider rate-limit from generic unavailability — surfaces `coach.provider.rate_limited` vs `coach.provider.unavailable` to iOS via the SSE `error` frame. + +### Daily token budget + +`enforceBudget(userId)` consults `CoachUsage` (per-day token ledger). Exceeded → 429 with `coach.budget.exceeded`. iOS surfaces the daily-limit copy from `messages/{locale}.json`. Budget resets at midnight in the user's timezone. + +### PROMPT_VERSION + +Live constant at `src/lib/ai/prompts/insight-generator.ts` → `PROMPT_VERSION`. Currently `4.25.0`. Bumps when ANY prompt body, safety-contract YAML, or refusal copy changes. Stored on every persisted Coach message (`CoachMessage.promptVersion`) so a future regression can be attributed. + +GROUND RULE 9 (no GLP-1 dose recommendations) and GROUND RULE 15 (no drug-level estimates) live in `safety-contracts.{locale}.yaml` and are mirrored verbatim in CI refusal probes. + +## Coach module + +Top-level flow on `POST /api/insights/chat`: + +``` +1. requireAuth() (cookie OR Bearer) +2. coachChatRequestSchema.safeParse() +3. enforceBudget() +4. resolveServerLocale({ request, override, userLocale }) +5. detectRefusal({ message, locale }) ← pattern-based; on refuse short-circuit (no provider call) +6. fetch/create conversation +7. persist user message (encrypted) +8. parseCoachPrefs(user.coachPrefsJson) ← defaultWindow, source toggles +9. buildCoachSnapshot(userId, effectiveScope) ← SNAPSHOT block (metric tables + provenance windows) +10. getCoachSystemPrompt(locale, coachPrefs) ← native per-locale; safety contract folded in +11. buildHistoryWindow(turns) ← 20-turn cap with synthetic summary placeholder +12. runRawCompletionWithFallback({...}) +13. parseKeyValuesSentinel(rawReply) ← extract ---KEYVALUES--- block +14. persist assistant message (encrypted) + recordSpend(tokens) +15. stream SSE: token frames → provenance frame → done frame +``` + +The `provenance` SSE frame carries the enriched envelope: + +``` +{ type: "provenance", metricSource: { windows, metrics, counts?, keyValues? } } +``` + +`keyValues` is the parsed `---KEYVALUES---` block — load-bearing numbers the Coach drew on. iOS surfaces these in the collapsible "What is this based on?" disclosure under the assistant bubble. + +Detail in `10-coach-pipeline.md`. + +## Insights module + +``` +src/lib/insights/ +├── features.ts # extractFeatures(userId) — every dashboard tile pulls from here +├── prompt.ts # buildUserPrompt({ features, comparison? }) — strict schema +├── memory.ts # Per-status cache lookups + writes (audit_logs row reuse) +├── correlations.ts # pairByTimestamp + pearsonCorrelation wrappers +├── chart-tokens.ts # Token map for chart citations +├── bucket-series.ts # Time bucketing +├── week-iso.ts # ISO week math +├── sanitize.ts # Strip PII before sending to provider +├── sub-page-metric.ts # Per-sub-page metric resolver +├── general-status.ts blood-pressure-status.ts weight-status.ts pulse-status.ts +├── mood-status.ts bmi-status.ts medication-compliance-status.ts +└── glp1-plateau.ts # 4-week plateau detection (GLP-1 specific) ← Since v1.4.25 +``` + +Each per-status writer: + +1. Reads the user's last 90 days from `Measurement` (+ mood + intake events as relevant) +2. Runs `summarize()` from `src/lib/analytics/trends.ts` +3. Builds a strict-schema prompt +4. Calls the provider chain via `generateInsight()` +5. Validates the response against `insightResultSchema` +6. Caches the result in `audit_logs` keyed on `action = "insights.{scope}-status.{locale}"` + +The per-status cards (`/api/insights/cards`) compose the cached blobs into the iOS-friendly `InsightCard` DTO with `severity ∈ { alert, caution, info, good }`. + +Detail in `11-insights-pipeline.md`. + +## Health Score + +Server-deterministic four-component score with provenance: + +| Component | Source | Range | +| --- | --- | --- | +| Blood pressure | BP-status classification × in-target proportion | 0–25 | +| Weight | Distance from target band × stability | 0–25 | +| Mood | 30-day average mood score | 0–25 | +| Compliance | 30-day medication intake % | 0–25 | + +Total 0–100. Each component carries an `asOf` timestamp and a canonical source (`WITHINGS` / `APPLE_HEALTH` / `MANUAL`) — the provenance accordion under the score number renders these inline with `aria-labelledby` panel pairing. + +Computation: `src/lib/analytics/health-score.ts` → `computeHealthScore(input)`. The input is built in `src/app/api/analytics/route.ts` (see lines ~698-720 for the canonical builder) by pulling the same last-30-day metric set the dashboard tiles consume — single source of truth for the displayed numbers vs the score. + +Detail in `16-health-score.md`. + +## Source priority — two-axis resolver (Since v1.4.25) + +Persisted as `User.sourcePriorityJson` (nullable JSONB). Null → use `DEFAULT_SOURCE_PRIORITY` verbatim. + +``` +{ + // Axis 1 — per-metric ladder + metricPriority: { + weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"], + bp_sys: ["WITHINGS", "APPLE_HEALTH", "MANUAL"], + steps: ["APPLE_HEALTH", "WITHINGS", "MANUAL"], + // … + }, + // Axis 2 — per-device-type override (within the chosen source) + deviceTypePriority: { + weight: ["scale", "watch", "phone"], // Withings Body+ wins over a watch-derived weight + bp_sys: ["band", "watch", "phone"], + default: ["watch", "phone", "band"] + } +} +``` + +The resolver `pickCanonicalSource(rows, type)`: + +1. Walk `metricPriority[type]` (falling back to `DEFAULT_SOURCE_PRIORITY[type]`). +2. For each source in order, find candidate rows. +3. If multiple rows from the same source, apply `deviceTypePriority[type]` (falling back to `deviceTypePriority.default`) to tiebreak. +4. Return the surviving row; treat `deviceType = null` as `unknown` (always lowest priority). + +Defaults at v1.4.25: + +| Class | Default ladder | +| --- | --- | +| Cumulative (steps, distance, active-energy) | APPLE_HEALTH → WITHINGS → MANUAL | +| Sleep | APPLE_HEALTH → WITHINGS → MANUAL | +| HRV | APPLE_HEALTH → WITHINGS → MANUAL | +| Resting HR | APPLE_HEALTH → WITHINGS → MANUAL | +| Point measurements (weight, BP, pulse, body fat, …) | WITHINGS → APPLE_HEALTH → MANUAL | + +iOS replicates the resolver behaviour client-side ONLY for dashboard fast-path display; the canonical resolution always happens server-side, and the iOS app pulls from `/api/dashboard/summary` for the displayed numbers. + +## Logging — Wide-Event + audit-log + +Two parallel logging surfaces: + +| Surface | What it records | Sink | +| --- | --- | --- | +| **Wide-Event** | Per-request structured event (route, status, latency, auth method, action.name, meta) | `audit_logs` table + optional GlitchTip + sampling to a generic transport | +| **Audit-log** | Security-relevant events (login, token use, mutations, settings changes) | `audit_logs` table | + +`apiHandler()` wraps every route handler so the Wide-Event builder lives inside an AsyncLocalStorage; `annotate({ action, meta })` adds fields anywhere in the handler call tree. `auditLog(action, { userId, ipAddress, details })` is fire-and-forget — never blocks the response. + +## Idempotency + +Header `Idempotency-Key: ` on POST/DELETE replays the cached response within a 24h window. Implementation: `src/lib/idempotency.ts` → `withIdempotency(handler)`. The cache key is `(userId, route, method, idempotencyKey)`. + +Routes that wrap with `withIdempotency`: + +| Route | Verb | +| --- | --- | +| `/api/measurements` (single + batch sub-mode) | POST | +| `/api/measurements/batch` | POST | +| `/api/workouts/batch` | POST | +| `/api/medications/[id]/intake` | POST | + +Routes that intentionally do NOT (SSE streams cannot replay through the JSON-based cache): + +- `/api/insights/chat` — the conversationId existence check + 20-turn cap serves the dedup contract instead. + +## Rate limits + +Defined per-route via `checkRateLimit(key, max, windowMs)`. The key is typically `resource:action:{userId}` for authed routes or `resource:action:{ip}` for pre-auth routes. The hardened batch endpoints (Since v1.4.25 W10): + +| Route | Key | Limit | +| --- | --- | --- | +| `/api/measurements/batch` | `measurements:batch:{userId}` | 60 / min | +| `/api/workouts/batch` | `workouts:batch:{userId}` | 60 / min | +| `/api/auth/login` | `auth:login:{ip}` | 5 / 15 min | +| `/api/auth/refresh` | `auth:refresh:{ip}` | 60 / 15 min | +| `/api/auth/passkey/login-verify` | `auth:passkey-verify:{ip}` | 10 / 15 min | +| `/api/onboarding/step` | `onboarding-step:{userId}` | 30 / 10 min | +| `/api/auth/me/research-mode` (POST) | `research-mode:post:{userId}` | 5 / min | +| `/api/insights/generate` | `insights:generate:{userId}` | env-tunable, default 10 / h | + +429 responses carry `X-RateLimit-*` headers via `rateLimitHeaders(rl)`. + +## Notifications + +``` +src/lib/notifications/ +├── dispatcher.ts # Per-event-type dispatcher with channel cascade +├── retry-policy.ts # Exponential backoff +├── channel-state.ts # NotificationChannel CRUD +├── vapid-config.ts # VAPID key generation + persistence +├── types.ts # EVENT_TYPES, CHANNEL_TYPE_LABELS +└── senders/ + ├── telegram.ts # Telegram bot + ├── ntfy.ts # ntfy.sh (or self-hosted ntfy) + ├── web-push.ts # Browser Web Push (VAPID) + └── apns.ts # Apple Push Notification Service (Since v1.4.23 scaffolding) +``` + +Channel cascade: each user has zero-to-many `NotificationChannel` rows. The dispatcher walks them in order and respects per-channel + per-event-type `NotificationPreference` toggles. Global admin toggles (`AppSettings.{telegramGlobal, ntfyGlobal, webPushGlobal}`) act as hard stops. + +For iOS — the APNs sender wires into the existing dispatcher, so the same `eventType` (`PERSONAL_RECORD_ACHIEVED`, `MEDICATION_REMINDER`, `INSIGHT_READY`) reaches the device through the registered APNs token. iOS registers via the `devices/` endpoint family — detail in `05-auth-flows.md`. + +## Withings integration + +``` +src/lib/withings/ +├── client.ts # OAuth code-exchange, refresh, getAuthorizationUrl, hasActivityScope +├── credentials.ts # Per-user Client ID/Secret (encrypted in DB) +├── sync.ts # Measure sync (BP, weight, body composition, temperature, …) +├── sync-activity.ts # Activity sync (steps + distance + active-energy) — Since v1.4.25 +├── sync-sleep.ts # Sleep v2 stage-level segments — Since v1.4.25 +├── webhook-handler.ts # POST /api/withings/webhook handler +└── mapping.md # Withings measure type → Measurement enum mapping (human-readable) +``` + +Webhook subscription channels (Since v1.4.25): + +| Channel | Event type | +| --- | --- | +| 1 | New measure (weight, BP, pulse, body composition) | +| 2 | New activity (steps + distance + calories) | +| 3 | Activity activity update | +| 4 | New sleep segment (sleep v2) | +| 16 | New temperature reading | +| 50 | Auth revoked | + +OAuth scope upgrade banner: when `WithingsConnection.scope` is null (legacy v1.4.24 connection) or doesn't include `user.activity`, the Settings → Integrations card surfaces a reconnect banner. iOS surfaces the same banner; on tap, opens the Withings OAuth URL in `SFSafariViewController`. + +Detail in `15-withings-bridge.md`. + +## Apple Health bridge + +``` +src/lib/measurements/apple-health-mapping.ts +``` + +Server-side identifier map (ported from `k0rventen/apple-health-grafana` + `dogsheep/healthkit-to-sqlite` with MIT + Apache-2.0 attribution recorded in source headers and `NOTICE`). Covers the v1.4.25 ingest surface; iOS-18 long-tail mappings (sleep apnea, GAD-7, paddle/row sports, FHIR clinical) carry inline release-window comments. + +``` +mapAppleHealthEntry({ hkIdentifier, value, unit, startDate, endDate, sleepStage? }) + → { type, value, unit, takenAt, sleepStage? } | null +``` + +Returns `null` when the identifier is unknown — the batch endpoint surfaces that as a per-entry `skipped` with `reason: "unmappable_identifier"`. iOS does not need to lock its identifier set to the server's — unknown identifiers degrade gracefully. + +## Onboarding wizard (Since v1.4.25) + +| Step | Persisted | Endpoint | +| --- | --- | --- | +| 0 | Welcome (server-rendered) | — | +| 1 | Goals chip-picker | `POST /api/onboarding/step { step: 1 }` | +| 2 | Source selection 4-card grid | `POST /api/onboarding/step { step: 2 }` | +| 3 | Baseline form (height / DOB / gender) | `POST /api/onboarding/step { step: 3 }` | +| 4 | Source-connect step + Done — flips `onboardingCompletedAt` | `POST /api/onboarding/step { step: 4 }` | + +State machine guards: + +- Step out-of-order → 409 +- Already-completed user → 409 +- Concurrent advance (read-then-write race) → 409 via `updateMany` with conditional WHERE + +Detail in `18-onboarding.md`. + +## Personal Records (Since v1.4.25) + +| Component | Location | +| --- | --- | +| Schema | `PersonalRecord` table (Migration 0054) | +| Detector | `src/lib/jobs/pr-detection.ts` — MAX / MIN per metric + workout slots | +| Trigger | Per-user enqueue on batch ingest; 30-min fallback cron | +| Warm-up gate | First datapoint per metric does NOT promote itself | +| Silent flag | Historical backfills (>50 entries) suppress push | +| Push opt-in | Per-user toggle (default off) | +| Trend tile badge | Renders when record landed in last 30 days; WCAG-AA contrast | + +Detail in `17-personal-records.md`. + +## Per-user timezone (Since v1.4.25) + +`User.timezone` column (Migration 0043, default `Europe/Berlin`). Threaded through ten surfaces: + +- CSV exporter — ISO-8601 with offset +- Formatters (`src/lib/format-locale.ts`, `src/lib/time-window-format.ts`) +- Profile picker — covers all IANA zones +- Admin sets the default for new accounts +- Signup detects browser tz +- Doctor-report PDF +- Chart x-axes — `timezone` prop on every chart wrapper +- Coach snapshot timestamps +- `MoodEntry.tz` records local-day grouping (Migration 0044) +- Weight-weekday correlator + +`src/lib/tz/resolver.ts`: + +``` +userDayKey(date, tz) // → "YYYY-MM-DD" in tz +DEFAULT_TIMEZONE = "Europe/Berlin" +isValidTimezone(tz) // → boolean (runtime Intl.DateTimeFormat probe) +invalidateUserTimezone(userId) // → cache evict +``` + +iOS reads `user.timezone` from `/api/auth/me` and uses it as the canonical display tz. Server is the source of truth; iOS never overrides without writing back via `PUT /api/auth/me/timezone`. + +## STOP HERE if… + +| If your task is… | …skip the rest and read… | +| --- | --- | +| "How do I call endpoint X?" | `03-api-contracts.md` | +| "How does the Coach really work?" | `10-coach-pipeline.md` | +| "How do I wire the AI providers?" | `12-ai-providers.md` | +| "How does Withings sync?" | `15-withings-bridge.md` | + +Otherwise: continue to `03-api-contracts.md`. diff --git a/.planning/v15-ios-handoff/03-api-contracts.md b/.planning/v15-ios-handoff/03-api-contracts.md new file mode 100644 index 000000000..e9ffca7b7 --- /dev/null +++ b/.planning/v15-ios-handoff/03-api-contracts.md @@ -0,0 +1,1057 @@ +--- +file: 03-api-contracts.md +purpose: Every HTTP endpoint iOS will call, with Zod schema excerpts, rate limits, error codes, and curl self-tests +when_to_read: Building any iOS feature that calls the server API +prerequisites: 05-auth-flows.md (for headers + token handling), 04-data-model.md (for enum value lists) +estimated_tokens: ~8000 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +## TL;DR + +All API routes are JSON-in / JSON-out with envelope `{ data, error, meta? }`. Auth is either cookie session OR `Authorization: Bearer hlk_`. Every mutation is wrapped in `apiHandler` (Wide-Event logging + error envelope) and most batched mutations honour `Idempotency-Key` headers for 24h replay. Rate-limit responses (429) carry `X-RateLimit-*` headers. + +## Envelope contract + +``` +// Success +{ "data": , "error": null } + +// Error +{ "data": null, "error": "", "meta": { "errorCode": "" } } +``` + +`apiSuccess(data, status?)` / `apiError(message, status, { errorCode, headers })` from `src/lib/api-response.ts`. + +## Required headers + +| Header | When | Example | +| --- | --- | --- | +| `Authorization: Bearer hlk_` | Every authenticated call from iOS | `Bearer hlk_a1b2c3…` | +| `Content-Type: application/json; charset=utf-8` | Every body-carrying request | — | +| `X-Client-Type: native` | iOS app on `/api/auth/login` and `/api/auth/passkey/login-verify` — triggers refresh-token issuance | — | +| `X-Device-Id: ` | iOS auth flows — binds the refresh token to a device row | — | +| `User-Agent: HealthLog-iOS/1.5.0` | Recommended on every iOS call — Wide-Event logs it for forensics | — | +| `Idempotency-Key: ` | Every batched mutation (measurements, workouts, single measurement POST, intake POST) | UUIDv4 | +| `x-request-id` | Optional — propagated through Wide-Event logging | UUIDv4 | +| `Accept-Language` | Optional fallback for `resolveServerLocale()` when user has no saved locale | `en-US,en;q=0.9,de;q=0.8` | + +## Auth scope contract + +Auth precedence (cookie-first, never both): + +1. Valid session cookie → cookie path (full user access) +2. No cookie + Bearer `hlk_*` → API-token path (scope-gated) +3. Neither → 401 + +The iOS app uses path 2 exclusively. Login issues a wildcard-scope `["*"]` access token + a refresh token. Routes that declare no `requiredPermission` accept ANY authenticated token (Since v1.4.25 W10 — the prior contract 403'd narrow-scope tokens on unscoped routes, which broke iOS-only endpoints like `/api/measurements/by-external-ids`, `/api/personal-records`, `/api/medications/[id]/glp1`, `/api/dashboard/glp1`). + +`requireAdmin()` is cookie-only — no Bearer ever elevates. iOS never reaches admin routes. + +--- + +# § Auth + +## POST /api/auth/login + +Password (or username) + password login. + +```ts +// from src/lib/validations/auth.ts:31-34 +export const loginPasswordSchema = z.object({ + email: z.string().trim().min(1, "Email or username required"), + password: z.string().min(1), +}); +``` + +| Field | Auth | Body | Rate limit | Idempotency | +| --- | --- | --- | --- | --- | +| Native (iOS) flow | None (creates token) | `{ email, password }` | 5 / 15 min / IP | — | + +Headers (iOS): + +- `X-Client-Type: native` → triggers refresh-token issuance +- `X-Device-Id: ` → binds refresh token to a device row + +Response (native, with refresh token): + +```json +{ + "data": { + "user": { "id": "usr_…", "username": "marc" }, + "token": "hlk_", + "tokenExpiresAt": "2026-05-15T12:00:00.000Z", + "refreshToken": "hlr_", + "refreshTokenExpiresAt": "2026-07-14T12:00:00.000Z" + }, + "error": null +} +``` + +Error codes: 401 `Invalid credentials`, 422 `Invalid credentials` (schema fail), 429 `Too many login attempts. Please try again later.` + +Self-test: + +```bash +curl -sS -X POST https://your-host/api/auth/login \ + -H 'Content-Type: application/json' \ + -H 'X-Client-Type: native' \ + -H 'X-Device-Id: 00000000-0000-0000-0000-000000000001' \ + -d '{"email":"marc@example.com","password":"…"}' +``` + +## POST /api/auth/refresh + +Exchange a refresh token for a new access + refresh pair (refresh tokens are one-time-use, rotated on every call). + +```ts +// Body — hand-rolled validation, two fields +{ refreshToken: string, revoke?: boolean } +``` + +| Auth | Rate limit | +| --- | --- | +| None (token-bearing) | 60 / 15 min / IP | + +Response: + +```json +{ + "data": { + "token": "hlk_", + "tokenExpiresAt": "2026-05-15T13:00:00.000Z", + "refreshToken": "hlr_", + "refreshTokenExpiresAt": "2026-07-14T13:00:00.000Z" + }, + "error": null +} +``` + +Error codes: 401 `Invalid refresh token`, 401 `Refresh token reuse detected — please log in again.` (reuse = security event; force re-login), 422 `refreshToken required`, 429 `Too many refresh attempts. Please retry later.` + +Body `{ refreshToken, revoke: true }` → logout-on-device. Returns `{ "revoked": true|false }`. + +## POST /api/auth/logout + +Cookie-only path. Clears the session cookie. For iOS use `POST /api/auth/refresh { refreshToken, revoke: true }` instead. + +## POST /api/auth/register + +```ts +// from src/lib/validations/auth.ts:6-29 +export const registerSchema = z.object({ + email: z.email("Invalid email address"), + username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/), + password: z.string().min(12), + timezone: z.string().max(64).optional(), // browser-detected +}); +``` + +Send `X-Client-Type: native` + `X-Device-Id` to receive the same access + refresh token bundle on success. + +## GET /api/auth/me + +Returns the current user. Cookie OR Bearer. + +```json +// Response data +{ + "id": "usr_…", + "username": "marc", + "email": "marc@example.com", + "role": "USER", // "USER" | "ADMIN" + "heightCm": 178, + "dateOfBirth": "1990-01-15T00:00:00.000Z", + "gender": "MALE", // "MALE" | "FEMALE" | null + "timezone": "Europe/Berlin", // IANA + "onboardingCompletedAt": "2026-04-12T18:22:00.000Z", + "onboardingTourCompleted": true, + "gravatarUrl": "https://www.gravatar.com/avatar/…", + "glucoseUnit": "mg/dL", // or "mmol/L" + "lastReportPracticeName": "Dr. Schmidt" +} +``` + +## PUT /api/auth/profile + +Body subset of: + +```ts +// from src/lib/validations/auth.ts:36-41 +export const profileSchema = z.object({ + email: z.email().nullable().optional(), + heightCm: z.number().min(50).max(300).nullable().optional(), + dateOfBirth: z.string().nullable().optional(), // ISO date + gender: z.enum(["MALE", "FEMALE"]).nullable().optional(), +}); +``` + +## PUT /api/auth/me/timezone + +```json +// Request +{ "timezone": "America/Los_Angeles" } +// Response +{ "data": { "timezone": "America/Los_Angeles" } } +``` + +422 when the string fails the runtime `Intl.DateTimeFormat` validity check. + +## GET/PUT /api/auth/me/coach-prefs + +Coach drawer preferences — `defaultWindow`, per-source toggles. Schema in `src/lib/validations/coach-prefs.ts`. + +## GET/PUT /api/auth/me/doctor-report-prefs + +Per-section toggles for the doctor report. Schema in `src/lib/validations/doctor-report-prefs.ts`. Mood defaults to off (clinical sensitivity). + +## GET/PUT /api/auth/me/source-priority + +Two-axis source-priority resolver state. Schema in `src/lib/validations/source-priority.ts`. PUT body example: + +```json +{ + "metricPriority": { + "weight": ["WITHINGS", "APPLE_HEALTH", "MANUAL"], + "bp_sys": ["WITHINGS", "APPLE_HEALTH", "MANUAL"] + }, + "deviceTypePriority": { + "weight": ["scale", "watch"], + "default": ["watch", "phone"] + } +} +``` + +## GET/POST/DELETE /api/auth/me/research-mode + +Research Mode acknowledgment lifecycle (GLP-1 drug-level chart gate). + +```json +// GET → current state +{ + "data": { + "enabled": false, + "acknowledgedAt": null, + "acknowledgedVersion": null, + "currentDisclaimerVersion": "1.0.0" + } +} +// POST body +{ "acknowledged": true, "version": "1.0.0" } +// On stale version → 400 research-mode.version.stale +// On rate-limit → 429 (5/min/user) +// DELETE → idempotent disable; writes audit-log either way +``` + +iOS rule: always fetch `currentDisclaimerVersion` from GET first; never hardcode. When `acknowledgedVersion !== currentDisclaimerVersion`, force re-prompt. + +## GET /api/auth/me/devices + +List the registered devices (one row per `X-Device-Id`). Includes APNs token state, last refresh, last access. + +## Passkey flow + +| Endpoint | Body | +| --- | --- | +| `POST /api/auth/passkey/register-options` | `{ username }` → WebAuthn `PublicKeyCredentialCreationOptions` | +| `POST /api/auth/passkey/register-verify` | WebAuthn attestation → `{ credentialId }` | +| `POST /api/auth/passkey/login-options` | `{ }` → `PublicKeyCredentialRequestOptions` | +| `POST /api/auth/passkey/login-verify` | WebAuthn assertion → access + refresh token bundle (with native headers) | + +Rate limits: 10 / 15 min on each — `auth:passkey-verify:{ip}`. + +iOS note: passkeys are a sync-credential boundary — Apple Keychain ↔ iCloud Keychain. The iOS app prefers passkey > password where both are configured. + +--- + +# § Tokens + +## GET /api/tokens + +List the caller's API tokens. + +## POST /api/tokens + +Create a token. Body `{ name: string, expiresInDays?: 1..365 }`. Response carries the raw token ONCE — never re-fetchable. + +```ts +// from src/app/api/tokens/route.ts:11-14 +const createTokenSchema = z.object({ + name: z.string().min(1, "Name required").max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); +``` + +403 when the admin has globally disabled the API. iOS uses the auto-issued token from login — operators may issue narrow-scope tokens through this endpoint for integrations. + +## DELETE /api/tokens/[id] + +Revoke a token (sets `revoked = true`; never deletes the row — audit trail intact). + +--- + +# § Measurements + +The single-row POST and the array-mode of `/api/measurements` share schemas with the iOS-oriented `/api/measurements/batch`. iOS uses **batch** for HealthKit and **single POST** only for the rare manual-entry path. + +## GET /api/measurements + +```ts +// from src/lib/validations/measurement.ts:244-261 +export const listMeasurementsSchema = z.object({ + type: measurementTypeEnum.optional(), + from: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), + to: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), + limit: z.coerce.number().int().min(1).max(500).optional().default(100), + offset: z.coerce.number().int().min(0).optional().default(0), + sortBy: z.enum(["type", "value", "measuredAt", "source"]).optional().default("measuredAt"), + sortDir: z.enum(["asc", "desc"]).optional().default("desc"), +}); +``` + +Response `{ data: { measurements: Measurement[], meta: { total, limit, offset } } }`. + +## POST /api/measurements + +Single or batch (array). Batch sub-mode caps at **5 entries** — use `/api/measurements/batch` for anything bigger. + +```ts +// from src/lib/validations/measurement.ts:198-229 +export const createMeasurementSchema = z.object({ + type: measurementTypeEnum, + value: z.number(), + measuredAt: z.iso.datetime({ offset: true }).transform(s => new Date(s)), + notes: z.string().max(25).optional(), + source: measurementSourceEnum.optional().default("MANUAL"), + glucoseContext: glucoseContextEnum.optional(), // required for BLOOD_GLUCOSE only + deviceType: z.string().min(1).max(32).nullable().optional(), +}).refine(...) +.refine(BLOOD_GLUCOSE ↔ glucoseContext invariant); + +// Batch sub-mode (line 263) +export const createBatchMeasurementSchema = z.object({ + measurements: z.array(createMeasurementSchema).min(1).max(5), +}); +``` + +`measurementTypeEnum` (29 values at v1.4.25): WEIGHT, BLOOD_PRESSURE_SYS, BLOOD_PRESSURE_DIA, PULSE, BODY_FAT, SLEEP_DURATION, ACTIVITY_STEPS, BLOOD_GLUCOSE, TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION, HEART_RATE_VARIABILITY, RESTING_HEART_RATE, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, VO2_MAX, BODY_TEMPERATURE, FAT_FREE_MASS, FAT_MASS, MUSCLE_MASS, SKIN_TEMPERATURE, PULSE_WAVE_VELOCITY, VASCULAR_AGE, VISCERAL_FAT, AUDIO_EXPOSURE_ENV, AUDIO_EXPOSURE_HEADPHONE, TIME_IN_DAYLIGHT. + +`measurementSourceEnum`: MANUAL, WITHINGS, IMPORT, APPLE_HEALTH. + +## POST /api/measurements/batch + +The primary HealthKit ingest path. + +```ts +// from src/app/api/measurements/batch/route.ts:61-82 +const batchEntrySchema = z.object({ + hkIdentifier: z.string().min(1).max(120), + value: z.number().finite(), + unit: z.string().min(1).max(60), + startDate: z.iso.datetime({ offset: true }), + endDate: z.iso.datetime({ offset: true }), + sleepStage: z.number().int().min(0).max(20).optional(), + externalId: z.string().min(1).max(120), + externalSourceVersion: z.string().min(1).max(120).optional(), + deviceType: deviceTypeEnum.nullable().optional(), // watch|band|ring|phone|scale|other|unknown +}); +const batchPayloadSchema = z.object({ entries: z.array(batchEntrySchema).min(1) }); + +// Caps +const MAX_BATCH_ENTRIES = 500; +const BATCH_RATE_LIMIT_MAX = 60; // per minute per user +``` + +The server maps each entry through `mapAppleHealthEntry()`. Per-entry status `inserted | duplicate | skipped` so the iOS sync cursor advances accurately. + +```json +// Response +{ + "data": { + "processed": 142, + "inserted": 138, + "duplicates": 3, + "skipped": [{ "index": 41, "reason": "unmappable_identifier" }], + "entries": [ + { "index": 0, "status": "inserted" }, + { "index": 1, "status": "duplicate" }, + { "index": 2, "status": "skipped", "reason": "value_out_of_range" } + // … + ] + } +} +``` + +iOS sync cursor: advance past both `inserted` and `duplicate`; surface `skipped` as a diagnostic but advance past it too (the entry will not become valid by retrying). + +Race reconciliation: under contention the server downgrades enough `inserted` per-entry statuses to `duplicate` so the aggregate counts match the per-entry envelope. The DB row is identical either way (single-copy via the composite unique index). + +Idempotency: HTTP-level via `Idempotency-Key` (24h replay) + per-entry via `(userId, type, source, externalId)`. + +Error codes: +- 422 with `errorCode: "measurement.batch.too_large"` — batch > 500 entries +- 429 `Too many batch submissions, try again later` — per-user 60/min budget +- 401 Bearer expired / revoked + +PR detection: enqueued after every successful batch. Batches > 50 entries pass `silent: true` so a historical backfill doesn't fire hundreds of pushes. + +Self-test: + +```bash +curl -sS -X POST https://your-host/api/measurements/batch \ + -H 'Authorization: Bearer hlk_…' \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: 00000000-0000-0000-0000-000000000001' \ + -d '{ + "entries": [{ + "hkIdentifier": "HKQuantityTypeIdentifierBodyMass", + "value": 78.2, + "unit": "kg", + "startDate": "2026-05-14T08:00:00+02:00", + "endDate": "2026-05-14T08:00:00+02:00", + "externalId": "1F2A3B4C-5D6E-7F8A-9B0C-1D2E3F4A5B6C", + "deviceType": "scale" + }] + }' +``` + +## DELETE /api/measurements/by-external-ids + +iOS deletion reconciliation. HealthKit gives no deleted-UUID stream, so the iOS app does a periodic 30-day window reconciliation: pulls the current HK sample UUIDs in the window and POSTs the `externalIds` the server has that are no longer present. + +```ts +// from src/app/api/measurements/by-external-ids/route.ts:37-42 +const payloadSchema = z.object({ + externalIds: z.array(z.string().min(1).max(120)).min(0).max(500), +}); +``` + +Cross-user safety: `userId` predicate in the `deleteMany` — rows owned by another user are silently skipped (no 401). Empty arrays return `{ deletedCount: 0 }` with 200. + +Response: `{ "data": { "deletedCount": 17 } }`. + +## GET /api/measurements/[id] + +Single-row read. + +## PATCH /api/measurements/[id] + +Update value / measuredAt / notes. + +```ts +// from src/lib/validations/measurement.ts:231-242 +export const updateMeasurementSchema = z.object({ + value: z.number().min(0).max(500000).optional(), + measuredAt: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), + notes: z.string().max(25).nullable().optional(), +}); +``` + +## DELETE /api/measurements/[id] + +Hard delete. + +## GET /api/measurements/series + +Time-bucketed series for a single type — used by chart components. Query `type=&from=&to=&bucket=day|week|month`. + +--- + +# § Workouts (Since v1.4.25) + +## POST /api/workouts/batch + +```ts +// from src/lib/validations/workout.ts:21-42 +export const workoutSportTypeEnum = z.enum([ + "walking","running","cycling","hiking","swimming","rowing","elliptical","stairClimber", + "yoga","mindAndBody","strength","hiit","dance","golf","tennis","basketball","soccer", + "crossTraining","mixedCardio","other" +]); +``` + +Each workout carries (see `createWorkoutSchema` in the same file): sportType, startedAt, endedAt, optional totalEnergyKcal / totalDistanceM / avgHR / maxHR / minHR / stepCount / elevationM / pauseDurationSec, source, optional externalId + externalSourceVersion, optional `metadata` JSONB (opaque — Coach never reads it), optional nested `route: { geometry: GeoJSON-LineString, sampleTimestamps?: [{ t, speedMs?, hr? }] }`. + +Caps: +- **100 workouts per batch** +- **20 000 points per LineString** +- **5 MB request body** — 413 with `errorCode: "workout.batch.payload_too_large"` above this +- 60 batches / min / user + +Response identical to measurements batch (per-entry status, race reconciliation). + +Idempotency: `(userId, source, externalId)` composite unique index, NULL-distinct. + +iOS sport-type mapping lives in the iOS-side companion to `apple-health-mapping.ts` — unknown HK workout types lower to `"other"`. + +--- + +# § Medications + +## GET /api/medications + +List active medications with last-intake timestamps + today's event counts. + +```json +// Per-row shape (excerpt) +{ + "id": "med_…", + "name": "Ozempic", + "dose": "0.5 mg", + "treatmentClass": "GLP1", // "GENERIC" | "GLP1" + "dosesPerUnit": 4, // doses per pen/vial (GLP-1) + "category": "HORMONE", // BLOOD_PRESSURE | VITAMIN | … (clinical taxonomy) + "active": true, + "notificationsEnabled": true, + "schedules": [{ "windowStart": "08:00", "windowEnd": "10:00", "label": "Morning", "dose": null, "daysOfWeek": [0,1,2,3,4,5,6] }], + "lastTakenAt": "2026-05-13T07:42:18.000Z", + "todayEventCount": 1 +} +``` + +## POST /api/medications + +```ts +// from src/lib/validations/medication.ts:39-48 +export const createMedicationSchema = z.object({ + name: z.string().min(1).max(100), + dose: z.string().min(1).max(50), + category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + treatmentClass: z.enum(["GENERIC","GLP1"]).optional(), + dosesPerUnit: z.number().int().min(1).max(100).optional(), + schedules: z.array(scheduleSchema).min(1), +}); +``` + +`scheduleSchema` carries `windowStart`/`windowEnd` (HH:mm), optional `label`, `dose`, `daysOfWeek` (0-6, 0=Sunday), `intervalWeeks` (1-4 for weekly GLP-1). + +## GET/PUT/DELETE /api/medications/[id] + +PUT body in `updateMedicationSchema` (same fields, all optional). + +## GET /api/medications/intake?scope=today + +Today's intake events in the user's timezone. + +```json +{ + "data": [ + { + "id": "intake_…", + "medicationId": "med_…", + "scheduledAt": "2026-05-14T07:00:00.000Z", + "takenAt": "2026-05-14T07:18:22.000Z", + "status": "taken", // "pending" | "taken" | "skipped" | "snoozed" + "snoozedUntil": null + } + ] +} +``` + +## GET /api/medications/intake?scope=compliance&days=30 + +Per-day scheduled-vs-taken bucket totals. `days` defaults to 30, range 1-365. + +```json +{ "data": [{ "date": "2026-04-15", "scheduled": 2, "taken": 2 }, …] } +``` + +## POST /api/medications/intake + +Update a single intake event. + +```ts +// from src/app/api/medications/intake/route.ts:33-44 +const updateSchema = z.object({ + intakeId: z.string().min(1), + status: z.enum(["taken","skipped","snoozed"]), + takenAt: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), + snoozedUntil: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), +}); +``` + +`snoozed` default-snoozes for 30 minutes when `snoozedUntil` is omitted. + +## POST /api/medications/[id]/intake + +Create a new intake event (mostly used by reminder UI on the web). + +```ts +// from src/lib/validations/medication.ts:61-73 +export const intakeSchema = z.object({ + medicationId: z.string().min(1), + scheduledFor: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), + takenAt: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), + skipped: z.boolean().optional().default(false), + idempotencyKey: z.string().max(128).optional(), +}); +``` + +Wraps `withIdempotency`. POST `/api/medications/[id]/intake/import` accepts an external-source intake (e.g. Telegram bot); `/api/medications/[id]/intake/purge` clears intake history. + +## GET /api/medications/intake-summary + +Aggregate compliance stats per medication for a window. + +## GET /api/medications/[id]/cadence + +GLP-1 cadence visualisation payload — schedule expansion + closest-intake pairing + compliance chips. + +## GET/PUT/DELETE /api/medications/[id]/phase-config + +Reminder phase tuning (early / on-time / late thresholds). + +## GET /api/medications/[id]/compliance + +Per-medication compliance bucket history. + +## GET /api/medications/[id]/titration + +GLP-1 titration ladder — current step + remaining ladder, framed as observational reference (GROUND RULE 15). + +## GET /api/medications/[id]/glp1 + +GLP-1 dose-change history + EMA drug-knowledge enrichment. + +## POST /api/medications/[id]/glp1 + +```ts +// from src/lib/validations/medication.ts:175-213 +export const glp1DoseChangePostSchema = z.object({ + effectiveFrom: z.iso.datetime({ offset: true }).transform(s => new Date(s)) + .refine(d => d.getTime() >= MIN_EFFECTIVE_FROM.getTime()) // 2020-01-01 + .refine(d => d.getTime() <= Date.now() + FIVE_YEARS_MS), // ±5y + doseValue: z.number().finite().min(0).max(100), // mg + doseUnit: z.string().min(1).max(10), + note: z.string().max(500).nullable().optional(), +}); +export const glp1InventoryPostSchema = z.object({ + delta: z.number().int().finite().min(-100).max(100).refine(n => n !== 0), + reason: z.string().min(1).max(200), +}); +// Body must carry exactly one of doseChange | inventory +``` + +## GET/POST /api/medications/[id]/inventory + +Pen-and-vial inventory (Since v1.4.25). Items move SEALED → IN_USE → EXPIRED. `markAsFirstUseAt` starts the 30-day clock; 03:30 cron flips expired rows in a single `updateMany`. Re-runs the state machine on every PATCH so a back-dated first-use immediately moves a stale pen to EXPIRED. + +```ts +// from src/lib/validations/medication.ts:116-143 +export const createInventoryItemSchema = z.object({ + dosesTotal: z.number().int().min(1).max(100), + printedExpiry: z.iso.datetime({ offset: true }).transform(s => new Date(s)).nullable().optional(), + purchasedAt: z.iso.datetime({ offset: true }).transform(s => new Date(s)).nullable().optional(), + notes: z.string().max(200).nullable().optional(), +}); +export const updateInventoryItemSchema = z.object({ + markAsFirstUseAt: z.iso.datetime({ offset: true }).transform(s => new Date(s)).optional(), + markAsUsedUp: z.boolean().optional(), + printedExpiry: z.iso.datetime({ offset: true }).transform(s => new Date(s)).nullable().optional(), + notes: z.string().max(200).nullable().optional(), +}); +``` + +## GET/POST /api/medications/[id]/side-effects + +21-entry × 5-category taxonomy. Category derived server-side (no client-supplied category) so the taxonomy cannot drift between client and server. DELETE `/api/medications/[id]/side-effects/[logId]` removes an entry. + +--- + +# § Insights + +## GET /api/insights/cards + +iOS-friendly composed insight cards. + +```json +{ + "data": [ + { + "id": "card_…", + "title": "Blood pressure trending down", + "summary": "Average systolic over the last 30 days fell by 4 mmHg.", + "body": "Long-form prose with provenance…", + "severity": "good", // "alert" | "caution" | "info" | "good" + "recommendations": [{ "id": "rec_…", "label": "Keep the morning routine", "actionURL": null }], + "generatedAt": "2026-05-14T02:05:00.000Z", + "provider": "anthropic" + } + ] +} +``` + +## POST /api/insights/generate + +Force re-generation. Rate-limited (default 10/hour, env-tunable). Evicts per-status cache so the next status fetch re-runs against fresh data. + +## GET /api/insights/comprehensive + +Full insights payload (mother-page). Wraps the per-status cards + correlations + alerts + trend summaries. + +## GET /api/insights/correlations + +Currently returns `[]` — full correlations engine is part of `/comprehensive`. iOS uses this only for empty-state rendering. + +## GET /api/insights/targets + +Targets page payload — per-metric target ranges + current status + Coach handoff visibility flag. + +## POST /api/insights/feedback + +Thumbs + free-text feedback on an insight. Schema in `src/lib/validations/recommendation-feedback.ts`. + +## GET/POST /api/insights/chat + +The Coach. **Streaming SSE on POST.** GET is the conversation-list paginator. + +### POST /api/insights/chat + +```ts +// from src/lib/ai/coach/types.ts:87-93 +export const coachChatRequestSchema = z.object({ + conversationId: z.string().min(1).max(64).optional(), + message: z.string().min(1).max(4000), + prefill: z.string().max(2000).optional(), + locale: z.enum(["en","de"]).optional(), // refusal copy locale; main locale resolved server-side + scope: coachScopeSchema.optional(), // { sources?: [...], window?: last7days|last30days|last90days|allTime } +}); +``` + +Response is **`text/event-stream; charset=utf-8`**, one frame per JSON event: + +```ts +// from src/lib/ai/coach/types.ts:103-110 +type CoachStreamEvent = + | { type: "token"; token: string } + | { type: "provenance"; metricSource: CoachProvenance } + | { type: "done"; conversationId: string; messageId: string } + | { type: "error"; code: string; message: string }; +``` + +SSE headers (server-set): `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`, `X-Accel-Buffering: no`. + +iOS uses an `URLSession` data task with delegate-based streaming, parses each `data: {...}\n\n` frame on `\n\n` boundaries. Status is ALWAYS 200 even on provider error — read the `error` frame inside the stream: + +| Error code in `error` frame | Surface | +| --- | --- | +| `coach.provider.unavailable` | "All AI providers are temporarily unavailable. Try again in a minute." | +| `coach.provider.rate_limited` | Warning toast: "AI provider rate-limited. Try again in a minute." | +| `coach.provider.empty` | "Provider returned an empty response. Try again." | +| `coach.provider.none` | "No AI provider configured. Open Settings → AI Provider." | +| `coach.budget.exceeded` (HTTP 429) | "Daily Coach limit reached. Try again tomorrow." | +| `coach.conversation.notFound` (HTTP 404) | "That conversation was deleted. Start a new one." | +| `coach.request.invalid` (HTTP 422) | "Request validation failed. Update the app." | + +Refusal short-circuit: when `detectRefusal()` matches (prompt-injection / off-topic / GROUND RULE 9 / GROUND RULE 15), the server emits ONE `token` frame with the localised refusal copy followed by `done`. No provider call, no spend recorded. + +### GET /api/insights/chat?cursor=&limit= + +Conversation-list paginator. Default limit 20, hard cap 50. Response `{ conversations: [{ id, title, createdAt, updatedAt, messageCount }], nextCursor }`. + +### GET /api/insights/chat/[id] + +Full conversation with messages decrypted. Each message carries `id, role, content, createdAt, metricSource (CoachProvenance), providerType, promptVersion`. + +### DELETE /api/insights/chat/[id] + +Delete a conversation (cascade-deletes messages). + +### DELETE /api/insights/chat/messages/[id] + +Delete a single message. + +## GET /api/insights/glp1-timeline + +Therapy timeline payload — titration history aligned with weight trace for the GLP-1 detail page. + +## GET /api/insights/provider-chain + +Read the user's saved provider chain configuration. + +## GET/PUT /api/insights/settings + +Coach + insights settings (locale override, default window, opt-in toggles). + +--- + +# § Coach (alias of /api/insights/chat) + +For convenience the iOS team often refers to "the Coach endpoint" — there is no `/api/coach/*` namespace. Every Coach call is `/api/insights/chat`. Coach prefs live at `/api/auth/me/coach-prefs`. + +--- + +# § Dashboard + +## GET /api/dashboard/summary + +iOS-shaped dashboard payload — greeting, intake streaks, today's compliance, highlighted insight, per-metric latest + sparkline + trend. + +```ts +// from src/app/api/dashboard/summary/route.ts:25-47 +type MetricKind = "weight" | "bloodPressure" | "pulse" | "bodyFat" | "glucose" + | "sleep" | "steps" | "totalBodyWater" | "boneMass" | "oxygenSaturation"; + +interface MetricCard { + id: string; + kind: MetricKind; + title: string; + latestValue: number | null; + secondaryValue: number | null; + unit: string; + trend: "up" | "down" | "flat" | "unknown"; + sparkline: number[]; // last 7 days + updatedAt: string | null; +} +``` + +`bloodPressure.latestValue` carries SYS; `secondaryValue` carries DIA. + +## GET /api/dashboard/glp1 + +GLP-1 dashboard tile payload — next-injection schedule + week-over-week weight delta + active pen state. Returns `null` data when the user has no `treatmentClass = GLP1` medication. + +## GET/PUT /api/dashboard/widgets + +Per-user dashboard layout JSON. + +## GET/PUT /api/dashboard/chart-overlay-prefs + +Per-chart comparison-overlay preference. + +--- + +# § Withings + +## GET /api/withings/connect + +302 redirect to Withings OAuth authorize URL. Sets `withings_state` httpOnly cookie for CSRF. **Browser-only** — iOS opens this URL in `SFSafariViewController` instead of a fetch. + +## GET /api/withings/callback?code=…&state=… + +OAuth callback — exchanges code, persists the connection (`WithingsConnection`), redirects to `/settings/integrations?withings=connected`. + +## GET /api/withings/status + +```json +{ + "data": { + "connected": true, + "configured": true, + "lastSyncedAt": "2026-05-14T08:00:00.000Z", + "connectedAt": "2026-04-01T12:00:00.000Z", + "tokenExpired": false, + "tokenRefreshFailed": false, + "tokenExpiresAt": "2026-05-14T09:00:00.000Z", + "scope": "user.metrics,user.activity,user.info", + "hasActivityScope": true + } +} +``` + +`hasActivityScope` is the pre-computed flag the Settings reconnect banner reads. Null `scope` → legacy v1.4.24 connection; the banner surfaces. + +## POST /api/withings/sync + +Force a sync. Body `{ kind: "measure" | "activity" | "sleep" }`. + +## DELETE /api/withings/disconnect + +Revoke the OAuth grant + drop the `WithingsConnection` row. + +## GET/PUT /api/withings/credentials + +Per-user Client ID + Client Secret (encrypted). 422 when shape invalid. + +## POST /api/withings/webhook/[token] + +Withings push delivery. Token-scoped (per-user). Not called by iOS. + +--- + +# § Health Score + +The health score is included in `/api/analytics`'s response. There is no standalone `/api/health-score` route. + +## GET /api/analytics + +Wraps trends + classifications + correlations + the four-component Personal Health Score. iOS reads `data.healthScore` for the score number and `data.healthScore.components[]` for the provenance accordion. + +```json +{ + "data": { + "healthScore": { + "total": 78, + "components": [ + { "key": "bp", "score": 22, "asOf": "2026-05-13T07:30:00.000Z", "source": "WITHINGS" }, + { "key": "weight", "score": 20, "asOf": "2026-05-14T06:18:00.000Z", "source": "WITHINGS" }, + { "key": "mood", "score": 18, "asOf": "2026-05-13T21:00:00.000Z", "source": "MANUAL" }, + { "key": "compliance", "score": 18, "asOf": "2026-05-14T08:00:00.000Z", "source": "MANUAL" } + ] + }, + "trends": { … }, + "correlations": [ … ], + "alerts": [ … ] + } +} +``` + +--- + +# § Notifications + +## GET /api/notifications/preferences + +```json +{ + "data": { + "channels": [ + { "id": "ch_…", "type": "APNS", "label": "Apple Push", "enabled": true, "globallyEnabled": true } + ], + "preferences": [ + { "channelId": "ch_…", "eventType": "MEDICATION_REMINDER", "enabled": true } + ], + "eventTypes": ["MEDICATION_REMINDER","INSIGHT_READY","PERSONAL_RECORD_ACHIEVED", …] + } +} +``` + +## PUT /api/notifications/preferences + +```ts +// from src/lib/validations/notifications.ts +{ channelId: string, eventType: string, enabled: boolean } +``` + +## GET /api/notifications/status + +Aggregate notification health (per-channel last-success + last-error + retry-state). + +## GET /api/notifications/vapid + +Returns the server's VAPID public key for browser push enrolment. iOS reads it only if it ever wires a web-push fallback. + +## POST/DELETE /api/notifications/web-push + +Browser-push subscription register / unregister. iOS uses the `devices/` endpoint family with APNs tokens instead. + +--- + +# § Onboarding (Since v1.4.25) + +## POST /api/onboarding/step + +```ts +// from src/app/api/onboarding/step/route.ts:43-45 +const stepBodySchema = z.object({ + step: z.number().int().min(1).max(4), +}); +``` + +State machine guards: +- Submitted step MUST equal `current + 1` (no skipping) +- Already-completed user (`onboardingCompletedAt != null`) → 409 +- Concurrent advance → 409 (conditional `updateMany` with the WHERE re-asserting the precondition) +- Rate limit 30 writes / 10 min / user → 429 + +Response: + +```json +{ "data": { "step": 3, "onboardingCompletedAt": null } } +``` + +Step 4 advance flips `onboardingCompletedAt` to NOW and clears the `hl_onboarding` proxy cookie. + +## POST /api/onboarding/complete + +Legacy v1.4.20 single-call completion. iOS uses `step: 4` on the new endpoint instead. + +## POST /api/onboarding/tour + +Mark the post-onboarding product tour as completed. + +--- + +# § Personal Records (Since v1.4.25) + +## GET /api/personal-records + +Paginated. Default 25, max 200. + +```json +{ + "data": { + "records": [ + { + "id": "pr_…", + "metricType": "RESTING_HEART_RATE", + "direction": "MIN", // "MIN" | "MAX" + "value": 52, + "unit": "bpm", + "achievedAt": "2026-05-13T07:00:00.000Z", + "previousBest": 54 + } + ], + "meta": { "total": 12, "limit": 25, "offset": 0 } + } +} +``` + +## DELETE /api/personal-records/[id] + +Currently web-only (admin can scrub bad records). iOS shouldn't depend on this. + +--- + +# § Health probe + +## GET /api/health + +```json +// Public (unauth) +{ "status": "ok" } +// Admin (cookie session) +{ + "status": "ok", + "timestamp": "2026-05-14T08:00:00.000Z", + "database": "connected", + "worker": "running", + "workerLastHeartbeat": "2026-05-14T07:59:55.000Z" +} +``` + +200 when DB + worker both healthy; 503 otherwise. iOS uses this only for the operator-facing "is the server up?" diagnostic in Settings. + +## GET /api/version + +Reports the deployed semver + git sha. + +--- + +## Error envelope quick reference + +| HTTP | Meaning | iOS handling | +| --- | --- | --- | +| 200 | Success | Read `data` | +| 201 | Created | Read `data` | +| 400 | Bad request (malformed body, stale-version) | Surface `error` text; do not retry | +| 401 | Not authenticated / invalid token / expired token | Refresh via `/api/auth/refresh`; on `reuse detected` force re-login | +| 403 | Insufficient permissions / API globally disabled / admin-only | Surface `error` text | +| 404 | Not found | Surface `error` text | +| 409 | Conflict (duplicate, out-of-order onboarding, concurrent write) | Refresh state and retry once | +| 413 | Payload too large | Halve batch size and retry | +| 415 | Wrong Content-Type | Code bug — always send `application/json` | +| 422 | Validation failed | Surface the first issue from `error` (Zod messages are user-readable) | +| 429 | Rate-limited / budget exceeded | Read `X-RateLimit-*` and back off | +| 500 | Server error | Retry with exponential backoff | + +--- + +## STOP HERE if… + +| If your task is… | …skip the rest and read… | +| --- | --- | +| "How do I refresh tokens?" | `05-auth-flows.md` | +| "What's in the DB tables I touch?" | `04-data-model.md` | +| "Why does the Coach refuse this prompt?" | `13-medical-content.md` | +| "How does PR detection actually work?" | `17-personal-records.md` | diff --git a/.planning/v15-ios-handoff/README.md b/.planning/v15-ios-handoff/README.md new file mode 100644 index 000000000..103031332 --- /dev/null +++ b/.planning/v15-ios-handoff/README.md @@ -0,0 +1,103 @@ +--- +file: README.md +purpose: Navigation map for the v1.5 iOS handoff doc-pack — what each file is for, which order to read in, and which questions map to which file +when_to_read: First. Always. Before opening any other file in this directory. +prerequisites: none +estimated_tokens: ~1800 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +## TL;DR + +This doc-pack is the v1.4.25 contract handoff for the v1.5 iOS native app. Start with `00-philosophy.md` if you have never touched the codebase before. Skip to `03-api-contracts.md` if you only need an endpoint shape. + +The pack is twenty-one files split across four authors (A through D) plus a final cross-link pass (E). Files prefixed with the same digit form a thematic cluster (00–03 foundation, 04–05 data and auth, 06–08 UI and state, 09–11 AI). Each file is self-contained: read one in isolation and the YAML frontmatter tells you what context to pre-load. + +## Inventory + +| File | Owner | Purpose | ~Tokens | +| --- | --- | --- | --- | +| `README.md` | A | This file. Navigation map. | 1800 | +| `00-philosophy.md` | A | Why-questions — every load-bearing project decision and the reasoning behind it | 3000 | +| `01-repo-tour.md` | A | `src/` layout, where to find migrations / messages / e2e / planning artifacts | 3000 | +| `02-server-architecture.md` | A | Backend services — Next.js, Prisma, pg-boss queues, cron jobs, Coach + Insights module structure | 5000 | +| `03-api-contracts.md` | A | Every HTTP endpoint iOS will call, with Zod schema excerpts, rate limits, error codes, and curl self-tests | 8000 | +| `04-data-model.md` | B | Prisma schema reference — every table iOS touches, every enum, every composite index | 5000 | +| `05-auth-flows.md` | B | Session cookie vs Bearer vs refresh token, passkey + password + native-client paths, scope rules | 4000 | +| `06-ui-conventions.md` | C | Component primitives, shadcn/ui style, Dracula tokens, Recharts decisions, full-width grid rule | 3000 | +| `07-state-management.md` | C | TanStack Query keys, mutation invalidation, idempotency-key wiring, optimistic-update playbook | 3000 | +| `08-locales-i18n.md` | C | Six-locale matrix, message-key hygiene, Coach native-prompt locale routing, maintainership banner | 2500 | +| `09-recommended-flow.md` | C | How to build a feature end-to-end — the proven order across this codebase | 2500 | +| `10-coach-pipeline.md` | D | Coach snapshot + prompt + provider chain + refusal + budget + KEYVALUES sentinel | 5000 | +| `11-insights-pipeline.md` | D | Insights generation, strict schema, per-status caching, comparison snapshot | 4000 | +| `12-ai-providers.md` | D | Provider routing — Anthropic, OpenAI, Codex (ChatGPT), local — and failover rules | 3000 | +| `13-medical-content.md` | D | MDR boundary, GROUND RULES, refusal probes, citation surfacing | 3000 | +| `14-glp1-surfaces.md` | D | GLP-1 specialist endpoints, treatment-class flag, pen inventory, side-effect taxonomy, drug-level Research Mode | 4000 | +| `15-withings-bridge.md` | B | Withings OAuth, webhook subscription, activity + sleep v2 sync, scope upgrade banner | 3500 | +| `16-health-score.md` | D | Health Score component derivation, provenance accordion, four-component scoring | 2000 | +| `17-personal-records.md` | D | PR detection worker, push opt-in, warmup gate, trend-badge contract | 1500 | +| `18-onboarding.md` | C | Nested-route wizard, step state machine, race-safe step advance | 1500 | +| `19-deploy-runtime.md` | A | Coolify config location, GHCR multi-arch image, environment variables, secret rotation | 2500 | +| `20-cross-link-index.md` | E | Master question-to-file map, stop-here markers, and Wave-4-5 diff summary against v1.4.24 | 3500 | + +## Question → File (seed; Agent E finalises) + +| Question | File | +| --- | --- | +| "Which auth method should iOS use?" | `05-auth-flows.md` | +| "How do I post a HealthKit batch?" | `03-api-contracts.md` § Measurements | +| "What does a workout DTO look like?" | `03-api-contracts.md` § Workouts | +| "Why is the Coach chat SSE instead of one-shot JSON?" | `10-coach-pipeline.md` | +| "Why six locales?" | `08-locales-i18n.md` and `00-philosophy.md` | +| "What is the MDR boundary?" | `13-medical-content.md` | +| "What is `treatmentClass = GLP1`?" | `14-glp1-surfaces.md` | +| "Why Postgres and not SQLite?" | `00-philosophy.md` | +| "Where do feature flags live?" | `02-server-architecture.md` | +| "What is the develop → main branch model?" | `00-philosophy.md` | + +(Agent E expands this to a 50+ row decision tree.) + +## Reading orders by goal (seed; Agent E finalises) + +| Goal | Order | +| --- | --- | +| First-time read, build mental model | 00 → 01 → 02 → 04 → 05 → 03 | +| Wire HealthKit batch ingest | 05 → 04 → 03 § Measurements → 03 § Workouts | +| Build the Coach drawer | 10 → 12 → 13 → 03 § Coach | +| Build the dashboard | 02 → 03 § Dashboard → 06 → 07 → 16 | +| Build GLP-1 surfaces | 14 → 04 → 13 → 03 § Medications | +| Set up Withings reconnect | 15 → 03 § Withings → 04 | +| Onboarding flow | 18 → 03 § Onboarding | +| Set up CI / Coolify | 19 → 01 | + +## Stop-here markers index (Agent E finalises) + +Every file carries `STOP HERE if …` markers so a narrowly-scoped reader can exit early. Agent E will publish the master index here once A–D files land. + +## Marathon-pattern handoff section + +Detail in `09-recommended-flow.md`. The pattern this server-side codebase uses for feature delivery (parallel sub-agent waves, decision-relay communication, quality gates) maps cleanly onto a Swift app — Agent E cross-links there. + +## Conventions in this pack + +- YAML frontmatter on every file — read it. +- TL;DR at the top of every body — 2-3 sentences, before any H2. +- Tables and decision trees outrank prose. +- Code excerpts use `// from path:line` headers — copy-paste-ready. +- `Since v1.4.24` diff markers flag Wave-4-5 additions iOS-Claude familiar with the previous baseline can fast-skim. +- English, terse, professional. No emojis. + +## Version anchor + +Every file in this pack is locked to **v1.4.25 / sha 49f71c92**. If you read a file and your local code differs from the cited excerpt, you are on a different version — check `git log` for the sha first, do not assume the doc is wrong. + +## Cross-link tables (Agent E finalises) + +After Agents B / C / D land their files, Agent E will populate: + +- Master question → file decision tree (50+ rows) +- Stop-here-marker master index +- Wave-4-5 diff table against v1.4.24 +- Reading orders by goal (expanded to 15+ scenarios) + +Until E lands, treat the seed tables above as the working draft.