docs(ios-handoff): A-Foundation — README skeleton + philosophy + repo-tour + architecture + API contracts

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:33:20 +02:00
1 parent 9bfbbdf345
commit a756c51be8
5 files changed
+2141

No files matched your search

+187
View File
@@ -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 | `<MaintainershipBanner>` + 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: <locale>` 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`.
+283
View File
@@ -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`.
@@ -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>/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: <opaque-string>` 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`.
File diff suppressed because it is too large. Load diff
+103
View File
@@ -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.