From 3594b549d99f92bde5d11ec0e9aa078eec621b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:02:37 +0200 Subject: [PATCH 01/79] feat(sleep): place sleep-debt and rhythm cards side by side when both present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both the sleep-debt and chronotype cards carry a settled readout, render them two-up on large screens instead of stacked; they are compact and read better paired. A lone data-bearing card — or either card in its learning / partial state — keeps full width so it never leaves a half-width orphan. The grid collapses to one column below the lg breakpoint and uses items-start so a toggled-open chronotype card does not stretch its neighbour. --- .../__tests__/sleep-rhythm-section.test.tsx | 35 +++++++++++++++++++ .../insights/sleep/sleep-rhythm-section.tsx | 17 ++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx index 696d6b98..63999282 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx @@ -102,6 +102,41 @@ describe("", () => { expect(html).toContain("md:py-6"); }); + it("places the two cards side by side when both carry settled data", () => { + rhythmMock.mockReturnValue({ + data: READY_DTO, + isLoading: false, + isError: false, + }); + const html = render(); + // Two-up on large screens, single column below. + expect(html).toContain("lg:grid-cols-2"); + }); + + it("keeps a lone data-bearing card full width when the other is still learning", () => { + rhythmMock.mockReturnValue({ + data: { + ...READY_DTO, + // Sleep-debt settled, chronotype still learning → no two-up grid. + chronotype: { + state: "learning", + msfMinutes: null, + msfScMinutes: null, + band: null, + socialJetlagMinutes: null, + freeNightsCounted: 2, + workNightsCounted: 5, + freeNightsUntilReady: 4, + }, + } satisfies SleepRhythmDto, + isLoading: false, + isError: false, + }); + const html = render(); + expect(html).toContain("grid-cols-1"); + expect(html).not.toContain("lg:grid-cols-2"); + }); + it("renders nothing when disabled", () => { rhythmMock.mockReturnValue({ data: undefined, diff --git a/src/components/insights/sleep/sleep-rhythm-section.tsx b/src/components/insights/sleep/sleep-rhythm-section.tsx index 68c57837..a8846696 100644 --- a/src/components/insights/sleep/sleep-rhythm-section.tsx +++ b/src/components/insights/sleep/sleep-rhythm-section.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@/hooks/use-auth"; import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useSleepRhythm } from "./use-sleep-rhythm"; import { SleepDebtCard } from "./sleep-debt-card"; @@ -52,8 +53,22 @@ export function SleepRhythmSection({ enabled }: { enabled: boolean }) { ); } + // Both cards are compact; when each carries a settled (non-learning) readout + // they sit two-up. A lone card with data spans full width so it never leaves + // a half-width orphan with dead space beside it (mirrors the mood Einordnung + // tiles). The cards' own learning/partial states stay stacked full-width. + const debtHasData = data.sleepDebt.state === "ready"; + const chronotypeHasData = + data.chronotype.state === "ready" && data.chronotype.band != null; + const bothHaveData = debtHasData && chronotypeHasData; + return ( -
+
From 8b5b35bbdd4fcdfc2eec146c9ad21307cd1ab29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:03:28 +0200 Subject: [PATCH 02/79] docs(self-hosting): document the notification channels and Web Push setup --- docs/self-hosting/getting-started.md | 1 + docs/self-hosting/notifications.md | 193 +++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 docs/self-hosting/notifications.md diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index e2bcdfb4..ca8abe9a 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -147,6 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build | Next step | File | | --------- | ---- | | TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` | +| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` | | Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` | | Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` | | Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md new file mode 100644 index 00000000..affe53ab --- /dev/null +++ b/docs/self-hosting/notifications.md @@ -0,0 +1,193 @@ +# Notifications — push channels for a self-host + +HealthLog delivers reminders (medication doses, mood check-ins, personal +records) and a few operational alerts through a small cascade of push +channels. The single message to take away: **you do not need an Apple +Developer account or APNs to get push.** Three of the four channels are +free, work in the browser or in a messaging app you already have, and +cover every platform a self-hoster runs. APNs is only for the optional +native iOS app. + +When an event fires, the dispatcher fans it out to every channel a user +has enabled, in a fixed order — APNs first, then Telegram, then ntfy, +then Web Push as the universal fallback +(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and +independent: a user can enable one, two, or all four, and a failure on +one never blocks the others. + +## TL;DR — which channel needs what + +| Channel | What it needs | Cost | Where it works | +| --- | --- | --- | --- | +| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) | +| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs | +| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser | +| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) | + +Web Push is the recommended default — it needs no third-party account, +just a keypair you generate yourself once. + +## Web Push (recommended default) + +Web Push delivers notifications straight to a browser — desktop or +mobile — even when the HealthLog tab is closed, as long as the browser +or installed PWA is alive. It is the broadest-reach channel and the one +to set up first. + +### 1. Generate a VAPID keypair + +VAPID is the signing scheme that lets a push service trust your server. +Generate a keypair once with the bundled `web-push` CLI: + +```bash +npx web-push generate-vapid-keys +``` + +That prints a **public key** and a **private key** (both Base64URL). You +also choose a **subject** — a `mailto:` address the push service can +contact about your sender, e.g. `mailto:you@example.com`. + +### 2. Store the keys + +The server loads VAPID config from the database first and falls back to +environment variables (`src/lib/notifications/vapid-config.ts`). Either +path works; the admin panel is the easier one because it survives without +touching `.env`. + +**Admin panel (preferred).** Sign in as the admin user, open +`/admin`, and find the **Web Push VAPID** card +(`src/components/admin/web-push-vapid-section.tsx`). Paste the public key, +the private key, and the subject, then save. The private key is encrypted +at rest with your `ENCRYPTION_KEY`, exactly like the other secrets in the +admin panel. The card shows a "configured" badge once all three fields +are set. + +**Environment variables (alternative).** If you would rather keep +secrets out of the database, set them in `.env` / the compose +`environment:` block instead: + +```env +VAPID_PUBLIC_KEY="BElx...(public key)" +VAPID_PRIVATE_KEY="...(private key)" +VAPID_SUBJECT="mailto:you@example.com" +``` + +The database values win when both are present. The loader also accepts a +few aliases (`WEB_PUSH_VAPID_PUBLIC_KEY`, `WEB_PUSH_PUBLIC_KEY`, and the +private/subject equivalents) so a config copied from another deployment +keeps working. + +### 3. Subscribe a device + +Each user enables Web Push for their own browser from +`/settings/notifications` — the **Browser Push** card has a Subscribe +button, and a Test button to fire a one-off push once subscribed. The +browser prompts for notification permission on first subscribe; once +granted, that browser receives every enabled event. + +### iPhone caveat — Add to Home Screen first + +Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and +only for a site that has been **installed to the Home Screen as a PWA**. +A regular Safari tab cannot subscribe. So on iPhone: + +1. Open your HealthLog instance in Safari. +2. Tap the Share button, then **Add to Home Screen**. +3. Launch HealthLog from the new Home Screen icon (not from the Safari + tab). +4. Open `/settings/notifications` inside that installed app and tap + Subscribe; accept the permission prompt. + +Android and desktop browsers have no such step — Subscribe and grant the +permission, and push works. + +## Telegram + +Telegram delivers reminders as bot messages, with inline action buttons +on medication reminders (Taken / snooze / skip). It is a good fit when +you already live in Telegram or want push without running a browser. + +1. In Telegram, open **@BotFather**, create a bot, and copy the **bot + token** (it looks like `123456:ABC-DEF...`). +2. Send `/start` to your new bot so it can message you. +3. Find your **chat ID** — send a message to **@userinfobot**, or read it + from the Bot API. +4. In HealthLog open `/settings/notifications`, fill the Telegram card's + **Bot Token** and **Chat ID**, toggle it on, and save. + +One bot can serve every user on the instance; each user pairs their own +chat ID. The token is encrypted at rest. + +## ntfy + +[ntfy](https://ntfy.sh) is the most self-hoster-native option: a tiny +pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You +subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and +HealthLog publishes to that topic. + +1. Pick a hard-to-guess topic name (the topic *is* the access control on + public `ntfy.sh` — anyone who knows the string can read it, so treat + it like a secret). +2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/`. +3. In HealthLog open `/settings/notifications`, set the ntfy card's + **Server URL** (defaults to `https://ntfy.sh`; point it at your own + server if you self-host ntfy) and **Topic**, then save. + +If your ntfy server requires auth, an access token can be attached; the +server URL is validated as a public host and dialled through the egress +guard (`src/lib/notifications/senders/ntfy.ts`). + +## APNs — native iOS app only + +Apple Push Notification service is used by **only** the native SwiftUI +iOS app (TestFlight / App Store build). PWA and browser users never touch +it. Setting it up requires a paid **Apple Developer Program** membership. + +You do **not** need APNs to give iPhone users push: the installed PWA +gets every reminder over Web Push (see the iPhone caveat above). APNs adds +one Apple-platform exclusive the PWA cannot offer — the lock-screen Live +Activity — plus the tighter native delivery path. Reminders themselves +arrive on both. + +If you do run the native app and want server-driven push, set the APNs +manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers +plus exactly one key source: + +| Variable | Purpose | +| --- | --- | +| `APNS_KEY_ID` | The `.p8` key's Key ID | +| `APNS_TEAM_ID` | Your Apple Developer Team ID | +| `APNS_BUNDLE_ID` | The app's bundle identifier | +| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) | +| `APNS_KEY` | OR the raw PEM body inline | +| `APNS_KEY_FILE` | OR a path to the `.p8` file | + +APNs is **all-or-none**: either set the three IDs plus one key source, or +leave the whole block unset. A partial config is rejected so the channel +never silently half-works (`scripts/check-env.ts`). When more than one key +source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` > +`APNS_KEY_FILE`. + +Two gotchas worth knowing before you blame a stale token: + +- **Base64 the key.** Pasting a raw `.p8` PEM through some env-file + pipelines mangles its line breaks. `APNS_KEY_B64` (the PEM body, + base64-encoded) sidesteps that entirely and is the recommended source. +- **Scope the key "Sandbox & Production".** In the Apple Developer + Portal, a push key defaults to Sandbox-only. A Sandbox-only key fails + on production (TestFlight / App Store) device tokens with + `BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox & + Production**; a wrongly scoped key cannot be reconfigured — re-issue it. + +## Which channel should I pick? + +| Your setup | Recommended channel | +| --- | --- | +| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | +| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. | +| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. | +| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | + +Mixing channels is fine and common — e.g. Web Push on the laptop and +ntfy on the phone. Each user configures their own set from +`/settings/notifications`. From 355fa9a67089cd54d5618243c2844cb85b9adf61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:04:48 +0200 Subject: [PATCH 03/79] feat(schema): add per-user Polar/Oura credentials, measurement reminders, lab results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0162 lands four additive surfaces for v1.17.1, all forward-only and non-destructive. - users: Polar and Oura BYO-app client id/secret, encrypted at app level and stored as text, mirroring the existing WHOOP/Fitbit credential columns 1:1. Each self-hoster registers their own Polar AccessLink / Oura Cloud app and pastes the client id/secret into Settings before the OAuth connect flow. - devices: live_activity_push_token for the iOS Live Activity update/end APNs channel. The client owns the ActivityKit lifecycle; the server only stores the most recent push token so the worker can address the update. - measurement_reminders: preventive-care ("Vorsorge") reminders. Cadence is a rolling interval_days or an RFC-5545 rrule, so the recurrence engine drives the server-authoritative next_due_at. The completion target is an optional measurement_type (auto-resolved when a matching reading lands) or a free-text label that resolves on a manual check-off. Soft-deleted. - lab_results: minimal structured lab store — one analyte per row, optional panel grouping and reference range, optional encrypted note — so a returned blood panel has somewhere to land alongside the Vorsorge reminder. No enum change: the Polar/Oura/Nightscout measurement sources already landed in migration 0160. --- .../migration.sql | 118 ++++++++++++++++ prisma/schema.prisma | 129 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql diff --git a/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql new file mode 100644 index 00000000..8a7615be --- /dev/null +++ b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql @@ -0,0 +1,118 @@ +-- v1.17.1 — schema foundation for per-user Polar/Oura BYO-app credentials, +-- preventive-care (Vorsorge) measurement reminders, a minimal structured lab +-- store, and the iOS Live Activity push-token channel (#22). +-- +-- Purely-additive: four new nullable columns on `users` (encrypted client +-- credentials), one new nullable column on `devices`, and two new tables. No +-- backfill, no existing row touched, no enum change (Polar/Oura/Nightscout +-- measurement sources already landed in migration 0160). +-- +-- 1. `users` += Polar + Oura BYO-app client id/secret (encrypted at app +-- level, stored as TEXT), mirroring the WHOOP/Fitbit credential columns. +-- 2. `devices` += `live_activity_push_token` for the iOS Live Activity +-- update/end APNs channel (#22). NULL when no active Live Activity. +-- 3. `measurement_reminders` — preventive-care reminders. Cadence is a +-- rolling `interval_days` OR an RFC-5545 `rrule`; the recurrence engine +-- drives `next_due_at` (server-authoritative). Completion target is an +-- optional `measurement_type` (auto-resolve) or a free-text `label`. +-- Soft-deleted. +-- 4. `lab_results` — one analyte per row, optional panel grouping + optional +-- reference range, optional encrypted note. Pairs with the Vorsorge +-- "annual blood panel" reminder so a result has somewhere to land. +-- Soft-deleted. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: every column drops with `DROP COLUMN IF EXISTS`; both tables +-- drop with `DROP TABLE IF EXISTS`. No data is rewritten. + +-- ── 1. users — Polar + Oura BYO-app client credentials (encrypted) ───── +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "polar_client_id_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "polar_client_secret_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "oura_client_id_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "oura_client_secret_encrypted" TEXT; + +-- ── 2. devices — iOS Live Activity push token (#22) ──────────────────── +ALTER TABLE "devices" + ADD COLUMN IF NOT EXISTS "live_activity_push_token" TEXT; + +-- ── 3. measurement_reminders — preventive-care (Vorsorge) reminders ──── +CREATE TABLE IF NOT EXISTS "measurement_reminders" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "measurement_type" "MeasurementType", + "interval_days" INTEGER, + "rrule" TEXT, + "anchor_date" TIMESTAMP(3), + "notify_hour" INTEGER NOT NULL DEFAULT 9, + "location" TEXT, + "next_due_at" TIMESTAMP(3), + "last_satisfied_at" TIMESTAMP(3), + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "measurement_reminders_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_deleted_at_idx" + ON "measurement_reminders" ("user_id", "deleted_at"); +CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_next_due_at_idx" + ON "measurement_reminders" ("user_id", "next_due_at"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'measurement_reminders_user_id_fkey' + ) THEN + ALTER TABLE "measurement_reminders" + ADD CONSTRAINT "measurement_reminders_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- ── 4. lab_results — minimal structured lab store ────────────────────── +CREATE TABLE IF NOT EXISTS "lab_results" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "panel" TEXT, + "analyte" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "unit" TEXT NOT NULL, + "reference_low" DOUBLE PRECISION, + "reference_high" DOUBLE PRECISION, + "taken_at" TIMESTAMP(3) NOT NULL, + "source" TEXT NOT NULL DEFAULT 'MANUAL', + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "lab_results_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "lab_results_user_id_analyte_taken_at_idx" + ON "lab_results" ("user_id", "analyte", "taken_at"); +CREATE INDEX IF NOT EXISTS "lab_results_user_id_deleted_at_idx" + ON "lab_results" ("user_id", "deleted_at"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'lab_results_user_id_fkey' + ) THEN + ALTER TABLE "lab_results" + ADD CONSTRAINT "lab_results_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba56aa38..715611d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,12 +127,26 @@ model User { // tokens are stored encrypted. polarAccessTokenEncrypted String? @map("polar_access_token_encrypted") polarUserIdEncrypted String? @map("polar_user_id_encrypted") + // v1.17.1 — Polar BYO-app client credentials (W1c, per-user, encrypted at + // app level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 + // so a self-hoster pastes their own Polar AccessLink app's client id/secret + // into Settings before the OAuth connect flow runs. Stored encrypted as a + // string via `src/lib/crypto.ts`, matching every other `*Encrypted` column. + polarClientIdEncrypted String? @map("polar_client_id_encrypted") + polarClientSecretEncrypted String? @map("polar_client_secret_encrypted") // v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level). // Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored // encrypted. ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted") ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted") + // v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app + // level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a + // self-hoster pastes their own Oura Cloud app's client id/secret into Settings + // before the OAuth connect flow runs. Stored encrypted as a string via + // `src/lib/crypto.ts`, matching every other `*Encrypted` column. + ouraClientIdEncrypted String? @map("oura_client_id_encrypted") + ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted") // moodLog integration (per-user, encrypted) moodLogUrlEncrypted String? @map("mood_log_url_encrypted") @@ -527,6 +541,15 @@ model User { // v1.11.0 W3 — one durable period-narrative row per (period, locale). // Delete/regenerate-clean; the generated prose is AES-256-GCM at rest. insightNarratives InsightNarrative[] + // v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row + // per user-created reminder ("BP every 7 days", "annual blood panel"). + // Server computes the canonical `nextDueAt`; the cron fires through the + // existing notification dispatcher. Soft-deleted. + measurementReminders MeasurementReminder[] + // v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …) + // that pairs with the Vorsorge "annual blood panel" reminder so a result + // has somewhere to land. Soft-deleted. + labResults LabResult[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -3090,6 +3113,14 @@ model Device { /// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to /// all the user's devices and iOS dedupes locally). medicationDelivery String? @map("medication_delivery") + /// v1.17.1 (#22) — APNs push token for the iOS Live Activity update/end + /// channel. ActivityKit issues a per-Activity push token distinct from the + /// device APNs token; the server stores the most recent one so the worker + /// can push a Live Activity content-state update or an end signal. NULL when + /// the device has no active Live Activity. The iOS client owns the + /// ActivityKit lifecycle and registers/clears this token; no server-side + /// behaviour hangs off it beyond addressing the update push. + liveActivityPushToken String? @map("live_activity_push_token") lastSeen DateTime @default(now()) @map("last_seen") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -3105,6 +3136,104 @@ model Device { @@map("devices") } +// ─── Measurement / Vorsorge reminders ──────────────────────── + +/// v1.17.1 — preventive-care ("Vorsorge") reminders: "measure BP every 7 +/// days", "annual blood panel", "log weight weekly". Reuses the existing +/// medication-reminder cadence vocabulary (`intervalDays` rolling cadence OR +/// an RFC-5545 `rrule`) so the recurrence engine drives the due-date math, and +/// rides the existing notification dispatcher + `push_attempts` ledger. The +/// completion target is either a `measurementType` (auto-resolved when a +/// matching measurement lands) or a free-text `label` for non-measurement +/// Vorsorge that only resolves on a manual check-off. `nextDueAt` is +/// server-computed + stored (server-authoritative — iOS consumes the resolved +/// value, never recomputes). Soft-deleted. +model MeasurementReminder { + id String @id @default(cuid()) + userId String @map("user_id") + /// User-facing copy: "Blutdruck messen", "Großes Blutbild". + label String + /// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood + /// panel, a dermatology check) with no measurement to match against; those + /// resolve only on a manual satisfy. Non-NULL = the cron advances + /// `lastSatisfiedAt` when a matching Measurement of this type lands. + measurementType MeasurementType? @map("measurement_type") + /// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence + /// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in + /// practice; both NULL is a one-shot reminder anchored on `anchorDate`. + intervalDays Int? @map("interval_days") + /// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through + /// the same recurrence engine the medication schedules use. + rrule String? + /// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt` + /// when NULL. + anchorDate DateTime? @map("anchor_date") + /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour. + /// DST-safe windowing is applied at the cron via the existing TZ helpers. + notifyHour Int @default(9) @map("notify_hour") + /// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional. + location String? + /// Server-computed canonical next-due instant (server-authoritative). + nextDueAt DateTime? @map("next_due_at") + /// When the reminder was last fulfilled (a matching measurement or a manual + /// satisfy). Re-anchors the next-due computation. + lastSatisfiedAt DateTime? @map("last_satisfied_at") + /// Master on/off for this reminder. Distinct from soft-delete: a disabled + /// reminder is retained + editable but never fires. + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, deletedAt]) + @@index([userId, nextDueAt]) + @@map("measurement_reminders") +} + +// ─── Lab results ───────────────────────────────────────────── + +/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual +/// blood panel" reminder. HealthLog has no first-class blood-biomarker type +/// beyond glucose, so a self-hoster who gets a panel back had nowhere to record +/// LDL / HbA1c / ferritin / TSH. This is the lean storage row: one analyte per +/// row, optional panel grouping, optional reference range. The doctor-report / +/// charting wiring lands in sibling work — this model only owns the shape. +/// Soft-deleted. +model LabResult { + id String @id @default(cuid()) + userId String @map("user_id") + /// Optional grouping label, e.g. "Großes Blutbild". NULL = standalone value. + panel String? + /// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin". + analyte String + value Float + unit String + /// Optional reference-range bounds as reported by the lab. + referenceLow Float? @map("reference_low") + referenceHigh Float? @map("reference_high") + /// When the sample was taken (UTC instant; wall-clock anchoring is the + /// reader's concern per the schema's UTC-only convention). + takenAt DateTime @map("taken_at") + /// Provenance: "MANUAL" today; import paths set their own source string. + source String @default("MANUAL") + /// Optional free-text note, AES-256-GCM encrypted at rest via + /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per convention). + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, analyte, takenAt]) + @@index([userId, deletedAt]) + @@map("lab_results") +} + // ─── User Achievements ─────────────────────────────────────── model UserAchievement { From bc12633569f322f7bfd9830955f7bc42750bef90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:16:09 +0200 Subject: [PATCH 04/79] fix(sleep): reconstruct an ordered WHOOP hypnogram timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHOOP's v2 API returns only per-stage sleep duration totals — no onset timestamps, no hypnogram endpoint. The mapper stamped all five stage totals on the one sleep-END instant, so the hypnogram reconstructed every stage as a span touching the night's right edge: bars stacked right-aligned with no clock times whenever WHOOP was the sole source. Lay the asleep stages back-to-back from sleep onset in a fixed physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one timed row per segment with measuredAt at that segment's end and a distinct indexed externalId. IN_BED stays a single envelope row over the whole window. The existing reader and renderer are untouched — they now see distinct per-segment instants and place each block at its own time. The order is synthesised, not measured, so every reconstructed row is flagged and the night DTO carries a per-session `reconstructed` boolean (GET /api/sleep/night, regenerated OpenAPI). The client renders the hypnogram but labels it an approximate layout and never recomputes; real-series sources (Apple Health, Withings, Fitbit) stay false. --- docs/api/openapi.yaml | 12 +++ src/app/api/sleep/night/route.ts | 9 ++ .../analytics/__tests__/sleep-night.test.ts | 42 +++++++++ src/lib/analytics/sleep-night.ts | 29 ++++++ src/lib/openapi/routes/measurements.ts | 5 + src/lib/whoop/__tests__/client.test.ts | 67 +++++++++++-- src/lib/whoop/client.ts | 93 +++++++++++++++---- src/lib/whoop/sync-sleep.ts | 19 +++- 8 files changed, 246 insertions(+), 30 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bf628854..e1fd8c8c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8982,6 +8982,11 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + reconstructed: + type: boolean + description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in + a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate + layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit). stages: type: object propertyNames: @@ -9047,6 +9052,7 @@ components: - inBedMinutes - awakeMinutes - awakenings + - reconstructed - stages - segments additionalProperties: false @@ -9102,6 +9108,11 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + reconstructed: + type: boolean + description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in + a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate + layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit). stages: type: object propertyNames: @@ -9167,6 +9178,7 @@ components: - inBedMinutes - awakeMinutes - awakenings + - reconstructed - stages - segments additionalProperties: false diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index a930d3a3..29d8874f 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -12,6 +12,11 @@ * priority ladder — so two sources' timelines never overlay. A view over the * existing per-stage `SLEEP_DURATION` rows; no schema, no new type. * + * `reconstructed` (per session) is true when the winning source has no + * per-stage onset timestamps and the server synthesised a contiguous timeline + * (WHOOP) — the client renders the hypnogram but labels it an approximate + * layout. A real-series source (Apple Health / Withings / Fitbit) is false. + * * `date` omitted → the most recent night in the trailing-year window. * * Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered. @@ -64,6 +69,10 @@ function serializeSession(s: SleepSession) { inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes), awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes), awakenings: s.awakenings, + // iOS #18 — `reconstructed` is true when the source has no per-stage onset + // timestamps and the server synthesised a contiguous timeline (WHOOP). iOS + // labels the hypnogram as an approximate layout; it never recomputes. + reconstructed: s.reconstructed, // iOS #18 — round the per-stage map too; the underlying totals sum // second-precision segments and otherwise serialise as e.g. 88.4999. stages: Object.fromEntries( diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 17b32973..00221cea 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -380,6 +380,48 @@ describe("reconstructSleepSessions", () => { expect(sessions[0].asleepMinutes).toBe(420); }); + it("flags a WHOOP-won night as reconstructed (synthetic stage order) with clock-time-distinct segments", () => { + // The ingest mapper reconstructs an ordered, contiguous WHOOP timeline. + // The reconstructed segments carry distinct measuredAt instants, so the + // hypnogram lays them at distinct clock times (no shared right edge). + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), // 21:00→01:00 + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "WHOOP"), // 01:00→03:00 + srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "WHOOP"), // 03:00→05:00 + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + const s = sessions[0]; + expect(s.source).toBe("WHOOP"); + expect(s.reconstructed).toBe(true); + // No two segments end on the same instant — not stacked on the right edge. + const ends = s.segments.map((seg) => seg.end.getTime()); + expect(new Set(ends).size).toBe(ends.length); + }); + + it("does NOT flag an Apple-Health night as reconstructed (measured timeline)", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("APPLE_HEALTH"); + expect(sessions[0].reconstructed).toBe(false); + }); + + it("does NOT flag a Withings night as reconstructed (real per-segment series)", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WITHINGS"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WITHINGS"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("WITHINGS"); + expect(sessions[0].reconstructed).toBe(false); + }); + it("drops the bare ASLEEP aggregate from segments + stages when granular exists (HIGH H1, v1.11.5)", () => { // Apple-Health shape: granular CORE/DEEP/REM PLUS the unspecified ASLEEP // aggregate covering the same period, plus IN_BED. The hypnogram must not diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 590361df..88024e6c 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -281,6 +281,19 @@ export interface SleepNight { /** Sentinel for rows that carry no source — collapses to one bucket. */ const NO_SOURCE = "__none__"; +/** + * Sources whose per-segment stage ORDER is synthesised, not measured. WHOOP's + * v2 API exposes only per-stage duration totals (no onset timestamps), so the + * ingest mapper reconstructs a contiguous timeline in a fixed physiological + * order (`whoop/client.ts` `mapSleep`). A session won by such a source carries + * `reconstructed: true` so the UI labels it as an approximate layout. Apple + * Health / Withings / Fitbit all store a real per-segment series and stay off + * this set. + */ +const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet = new Set([ + "WHOOP", +]); + /** * Separator between the source and device-type parts of a writer key. * `` cannot appear in a `MeasurementSource` enum literal or a @@ -615,6 +628,17 @@ export interface SleepSession { awakenings: number; /** The canonical source's segments, sorted by start, for the hypnogram. */ segments: SleepSegment[]; + /** + * `true` when the winning writer's stage ORDER is synthesised rather than + * measured. WHOOP's v2 API returns only per-stage duration totals — no onset + * timestamps — so the server reconstructs a contiguous timeline in a fixed + * physiological order (`whoop/client.ts` `mapSleep`). The segment positions + * are then approximate, not measured, and the UI / iOS must label the night + * as an approximate layout (never present it as measured stage timing). + * Sources that store a real per-segment series (Apple Health, Withings, + * Fitbit) stay `false`. + */ + reconstructed: boolean; } /** Resolve a stage row to its absolute span (start = end − duration). */ @@ -760,6 +784,11 @@ export function reconstructSleepSessions( stages, awakenings: countAwakenings(segments), segments, + // The winning writer's stage order is synthetic when its source + // reconstructs the timeline (WHOOP). The UI labels such a night as an + // approximate layout; a real-series source (Apple/Withings/Fitbit) is + // measured. + reconstructed: RECONSTRUCTED_TIMELINE_SOURCES.has(canonicalSource), }); } return sessions.sort((a, b) => a.start.getTime() - b.start.getTime()); diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 15de359a..9dbb9266 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -200,6 +200,11 @@ const sleepSessionResource = z.object({ inBedMinutes: z.number().int().nonnegative().nullable(), awakeMinutes: z.number().int().nonnegative().nullable(), awakenings: z.number().int().nonnegative(), + reconstructed: z + .boolean() + .describe( + "True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).", + ), stages: z.record(sleepStageEnum, z.number().int().nonnegative()), segments: z.array(sleepSegmentResource), }); diff --git a/src/lib/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts index 74d3f12b..385ce900 100644 --- a/src/lib/whoop/__tests__/client.test.ts +++ b/src/lib/whoop/__tests__/client.test.ts @@ -320,15 +320,64 @@ describe("mapSleep", () => { it("maps per-stage SLEEP_DURATION rows (ms→min) with sleepStage", () => { const rows = mapSleep(base); const dur = rows.filter((r) => r.type === "SLEEP_DURATION"); - const byStage = Object.fromEntries(dur.map((r) => [r.sleepStage, r.value])); - expect(byStage.CORE).toBe(240); // light → CORE - expect(byStage.DEEP).toBe(90); // slow-wave → DEEP - expect(byStage.REM).toBe(120); - expect(byStage.AWAKE).toBe(30); - expect(byStage.IN_BED).toBe(480); + // One asleep/awake row per reconstructed segment + one IN_BED envelope. The + // per-stage TOTALS are unchanged (the reconstruction only reorders timing). + const minutesByStage: Record = {}; + for (const r of dur) { + minutesByStage[r.sleepStage!] = + (minutesByStage[r.sleepStage!] ?? 0) + r.value; + } + expect(minutesByStage.CORE).toBe(240); // light → CORE + expect(minutesByStage.DEEP).toBe(90); // slow-wave → DEEP + expect(minutesByStage.REM).toBe(120); + expect(minutesByStage.AWAKE).toBe(30); + expect(minutesByStage.IN_BED).toBe(480); expect(dur.every((r) => r.unit === "minutes")).toBe(true); }); + it("reconstructs an ordered, contiguous, non-overlapping per-segment timeline from sleep ONSET", () => { + const segs = mapSleep(base) + .filter((r) => r.type === "SLEEP_DURATION" && r.sleepStage !== "IN_BED") + .map((r) => ({ + stage: r.sleepStage, + end: r.measuredAt.getTime(), + start: r.measuredAt.getTime() - r.value * 60_000, + reconstructed: r.reconstructed, + })) + .sort((a, b) => a.start - b.start); + + // AWAKE (lead) → CORE → DEEP → REM, laid back-to-back. + expect(segs.map((s) => s.stage)).toEqual(["AWAKE", "CORE", "DEEP", "REM"]); + // First segment starts at sleep ONSET (s.start), not the END edge. + expect(segs[0].start).toBe(new Date(base.start).getTime()); + // Contiguous + non-overlapping: each segment's start == previous end. + for (let i = 1; i < segs.length; i++) { + expect(segs[i].start).toBe(segs[i - 1].end); + } + // Distinct END instants — no stacking on the night's right edge. + expect(new Set(segs.map((s) => s.end)).size).toBe(segs.length); + // Every reconstructed row is flagged so the UI labels the night approximate. + expect(segs.every((s) => s.reconstructed === true)).toBe(true); + }); + + it("emits IN_BED as a single un-reconstructed envelope row over [start, end]", () => { + const inBed = mapSleep(base).filter((r) => r.sleepStage === "IN_BED"); + expect(inBed).toHaveLength(1); + const r = inBed[0]!; + expect(r.value).toBe(480); + // measuredAt = sleep END → segmentOf resolves the span back to the window. + expect(r.measuredAt.toISOString()).toBe(base.end); + expect(r.reconstructed).toBeUndefined(); + }); + + it("gives each reconstructed segment a distinct indexed externalId", () => { + const ids = mapSleep(base) + .filter((r) => r.reconstructed) + .map((r) => r.externalId); + expect(ids.every((id) => id?.startsWith("sleep-uuid:seg:"))).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + it("sums SLEEP_NEED components ms→min", () => { const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED"); expect(need?.value).toBe(490); // 450 + 30 + 10 + 0 @@ -344,9 +393,9 @@ describe("mapSleep", () => { expect(byType.RESPIRATORY_RATE?.unit).toBe("breaths/min"); }); - it("uses sleep.end as measuredAt", () => { - const row = mapSleep(base)[0]!; - expect(row.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z"); + it("stamps the non-segment scores on sleep.end", () => { + const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED")!; + expect(need.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z"); }); it("emits nothing for an unscored sleep", () => { diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index a75ad1e0..19c30ddc 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -505,20 +505,24 @@ export interface MappedMeasurement { fieldTag: string; /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; + /** + * Full externalId for rows the mapper must key itself rather than letting + * the sync layer build `:`. The reconstructed sleep + * segments (one row per synthetic span) need an index in the key so the + * several rows of one stage stay distinct under + * `userId_type_source_externalId`. When set the sync layer uses it verbatim. + */ + externalId?: string; + /** + * `true` on the synthesised per-segment sleep rows whose ORDER is invented: + * WHOOP's v2 API returns only per-stage duration totals (no onset + * timestamps), so the timeline is reconstructed in a fixed physiological + * order. The night is flagged so the UI labels it as an approximate layout + * and never presents it as measured stage timing. + */ + reconstructed?: boolean; } -/** WHOOP sleep stage → HealthLog SleepStage (light→CORE, slow-wave→DEEP). */ -const SLEEP_STAGE_MAP: Record< - string, - { stage: MappedMeasurement["sleepStage"]; fieldTag: string } -> = { - total_light_sleep_time_milli: { stage: "CORE", fieldTag: "sleep_core" }, - total_slow_wave_sleep_time_milli: { stage: "DEEP", fieldTag: "sleep_deep" }, - total_rem_sleep_time_milli: { stage: "REM", fieldTag: "sleep_rem" }, - total_awake_time_milli: { stage: "AWAKE", fieldTag: "sleep_awake" }, - total_in_bed_time_milli: { stage: "IN_BED", fieldTag: "sleep_in_bed" }, -}; - function round2(n: number): number { return parseFloat(n.toFixed(2)); } @@ -589,16 +593,71 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] { const out: MappedMeasurement[] = []; const stages = s.score.stage_summary; - for (const [key, mapping] of Object.entries(SLEEP_STAGE_MAP)) { - const ms = stages[key as keyof typeof stages]; - if (typeof ms !== "number") continue; + + // WHOOP v2 returns only per-stage DURATION totals (no per-stage onset + // timestamps, no hypnogram endpoint). Stamping every stage total on the one + // sleep-END instant makes the hypnogram reconstruct five spans that all touch + // the night's right edge (the stacked-bar artefact). Since the API never + // carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the + // asleep stages back-to-back from sleep ONSET in a fixed physiological order, + // emitting one timed row per segment with `measuredAt = that segment's END`. + // The ORDER is synthetic, so every reconstructed row is flagged + // `reconstructed: true` and the night DTO advertises it — the UI labels it as + // an approximate layout and never presents it as measured stage timing. + // + // IN_BED stays a single envelope row spanning the whole [start, end] window: + // the in-bed reader wants the union envelope, not a placed segment. AWAKE is + // laid as a leading segment from onset so the asleep stages still partition + // the remaining window. + const onset = new Date(s.start).getTime(); + // Order the laid-out stages: a brief AWAKE settling-in block, then the asleep + // stages CORE → DEEP → REM. Each carries its own duration; the cursor walks + // forward so the spans are contiguous and non-overlapping. + const TIMELINE_ORDER: ReadonlyArray<{ + key: keyof typeof stages; + stage: NonNullable; + tag: string; + }> = [ + { key: "total_awake_time_milli", stage: "AWAKE", tag: "sleep_awake" }, + { key: "total_light_sleep_time_milli", stage: "CORE", tag: "sleep_core" }, + { key: "total_slow_wave_sleep_time_milli", stage: "DEEP", tag: "sleep_deep" }, + { key: "total_rem_sleep_time_milli", stage: "REM", tag: "sleep_rem" }, + ]; + let cursor = onset; + let segIndex = 0; + for (const { key, stage, tag } of TIMELINE_ORDER) { + const ms = stages[key]; + if (typeof ms !== "number" || ms <= 0) continue; + const segEnd = cursor + ms; out.push({ type: "SLEEP_DURATION", value: round2(ms * MS_TO_MIN), unit: "minutes", + measuredAt: new Date(segEnd), + // The sync layer keys reconstructed rows by this externalId verbatim; the + // index keeps the several rows of one night distinct under + // userId_type_source_externalId. + fieldTag: tag, + externalId: `${s.id}:seg:${tag}:${segIndex}`, + sleepStage: stage, + reconstructed: true, + }); + cursor = segEnd; + segIndex += 1; + } + + // IN_BED — single envelope row over the whole sleep window. `measuredAt` is + // the sleep END so `segmentOf` resolves the span back to [start, end]; the + // in-bed union-envelope reader consumes the full window. + const inBedMs = stages.total_in_bed_time_milli; + if (typeof inBedMs === "number" && inBedMs > 0) { + out.push({ + type: "SLEEP_DURATION", + value: round2(inBedMs * MS_TO_MIN), + unit: "minutes", measuredAt, - fieldTag: mapping.fieldTag, - sleepStage: mapping.stage, + fieldTag: "sleep_in_bed", + sleepStage: "IN_BED", }); } diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 234052ad..a9717317 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -4,8 +4,14 @@ * `mapSleep` (per-stage SLEEP_DURATION rows, SLEEP_NEED, the SLEEP_* * percentages, RESPIRATORY_RATE), and upserts as `source = WHOOP`. * - * Per-stage rows carry the `sleepStage` axis so the five stage rows for one - * night stay distinct under the dedup key. externalId = `:`. + * Sleep rows carry the `sleepStage` axis so the night's rows stay distinct + * under the dedup key. WHOOP exposes only per-stage DURATION totals (no onset + * timestamps), so `mapSleep` RECONSTRUCTS an ordered, contiguous per-segment + * timeline (CORE/DEEP/REM/AWAKE laid back-to-back from sleep onset) and flags + * those rows `reconstructed`; each reconstructed segment carries its own + * indexed externalId `:seg::`. IN_BED stays a single + * envelope row keyed `:sleep_in_bed`. The non-segment scores keep + * the `:` shape. */ import { fetchSleeps, fetchSleepById, mapSleep } from "./client"; import { @@ -53,7 +59,10 @@ export async function syncUserSleep( value: m.value, unit: m.unit, measuredAt: m.measuredAt, - externalId: `${s.id}:${m.fieldTag}`, + // Reconstructed sleep segments carry their own indexed externalId so + // the several rows of one stage stay distinct; everything else keeps + // the `:` shape. + externalId: m.externalId ?? `${s.id}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, }); } @@ -92,7 +101,9 @@ export async function syncWhoopSleepById( value: m.value, unit: m.unit, measuredAt: m.measuredAt, - externalId: `${record.id}:${m.fieldTag}`, + // Reconstructed sleep segments carry their own indexed externalId + // (see syncUserSleep). + externalId: m.externalId ?? `${record.id}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, }); } From bd467d0c4daf4daf724207d308d08986ce25548b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:16:16 +0200 Subject: [PATCH 05/79] fix(sleep): emit Fitbit sleep as per-segment rows, not a stage collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Google sleep payload carries a real per-stage segment series, but the mapper summed each stage's segments into one row stamped on the stage's last end. The hypnogram then reconstructed all of a stage's time as a single block ending at the night's right edge — the same stacked-bar artefact as WHOOP, despite the source carrying true timing. Emit one row per segment with measuredAt at that segment's end and an indexed externalId so the several segments of one stage stay distinct under the dedup key. The timeline is measured (real onsets), so these rows are not flagged reconstructed. --- src/lib/fitbit/__tests__/client.test.ts | 42 +++++++++++++--------- src/lib/fitbit/client.ts | 46 +++++++++++-------------- src/lib/fitbit/sync-sleep.ts | 19 +++++----- 3 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 38b54aee..9edb6b4e 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -526,7 +526,7 @@ describe("sleep-stage mapping", () => { expect(mapFitbitSleepStage(42)).toBeNull(); }); - it("maps a session into per-stage SLEEP_DURATION rows with measuredAt = stage END", () => { + it("maps a session into per-SEGMENT SLEEP_DURATION rows with measuredAt = segment END (real timeline)", () => { const session = { sleep: { startTime: "2026-05-10T22:00:00.000Z", @@ -556,23 +556,31 @@ describe("sleep-stage mapping", () => { }, }; const out = mapSleepSession(session); - const byStage = Object.fromEntries(out.map((m) => [m.sleepStage, m])); - - // CORE = the two light segments summed (30 + 45 = 75 min). - expect(byStage.CORE!.value).toBe(75); - expect(byStage.CORE!.type).toBe("SLEEP_DURATION"); - expect(byStage.CORE!.unit).toBe("minutes"); - // measuredAt is the LATEST end for that stage (the second light segment). - expect(byStage.CORE!.measuredAt.toISOString()).toBe( + + // One row PER segment (no collapse) — 4 segments → 4 rows. + expect(out).toHaveLength(4); + expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true); + expect(out.every((m) => m.unit === "minutes")).toBe(true); + + // Each row carries its OWN end instant + duration — a real clock-time + // timeline, not a stage collapse. + const core = out.filter((m) => m.sleepStage === "CORE"); + expect(core.map((m) => m.value)).toEqual([30, 45]); + expect(core.map((m) => m.measuredAt.toISOString())).toEqual([ + "2026-05-10T22:30:00.000Z", "2026-05-11T00:15:00.000Z", - ); - expect(byStage.DEEP!.value).toBe(60); - expect(byStage.REM!.value).toBe(45); + ]); + const deep = out.find((m) => m.sleepStage === "DEEP")!; + expect(deep.value).toBe(60); + expect(deep.measuredAt.toISOString()).toBe("2026-05-10T23:30:00.000Z"); + const rem = out.find((m) => m.sleepStage === "REM")!; + expect(rem.value).toBe(45); - // externalId field-tag is session-anchored so a re-score overwrites in place. - expect(byStage.DEEP!.fieldTag).toBe( - "2026-05-11T06:00:00.000Z:sleep_deep", - ); + // Every fieldTag is distinct (session anchor + stage + segment index) so + // the several segments of one stage never collide under the dedup key. + const tags = out.map((m) => m.fieldTag); + expect(new Set(tags).size).toBe(tags.length); + expect(tags).toContain("2026-05-11T06:00:00.000Z:sleep_deep:1"); }); it("anchors the session externalId on sleep.interval.end_time for an INTERVAL-shaped point", () => { @@ -592,7 +600,7 @@ describe("sleep-stage mapping", () => { }, }); const deep = out.find((m) => m.sleepStage === "DEEP"); - expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep"); + expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep:0"); }); it("yields nothing for a session with no parseable segments", () => { diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 25c21714..e31ee245 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -1244,10 +1244,18 @@ function sleepSessionAnchor(point: FitbitDataPoint): string { } /** - * Map one Google sleep session into per-stage `SLEEP_DURATION` rows. Each - * stage's segments are summed into one row carrying the stage's `SleepStage`, - * `measuredAt = the latest segment END for that stage`, and a stage-scoped - * fieldTag so the (up to six) stage rows for one night stay distinct under the + * Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows. The + * Google sleep payload carries a real per-stage segment series (each with its + * own start/end), so one row is emitted PER SEGMENT — `measuredAt = that + * segment's END` — rather than collapsing a stage's segments onto a single + * lastEnd instant. The collapsed shape stamped every stage total on its last + * end, so the hypnogram reconstructed all of a stage's time as one block ending + * at the night's right edge (the stacked-bar artefact); per-segment rows lay + * each block at its true clock time. The timeline is MEASURED (real onsets), so + * these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets. + * + * Each segment carries a stage-scoped, INDEXED fieldTag so the several segments + * of one stage stay distinct under the `(userId, type, source, externalId)` * dedup key. Unknown stage labels are skipped; a session with no parseable * segment yields nothing. */ @@ -1257,38 +1265,24 @@ export function mapSleepSession( const segments = readSleepSegments(point); if (segments.length === 0) return []; - // Accumulate per-stage minutes + track the latest end instant for each stage. - const perStage = new Map< - FitbitSleepStage, - { minutes: number; lastEnd: Date } - >(); + const anchor = sleepSessionAnchor(point); + const out: FitbitMappedMeasurement[] = []; + let segIndex = 0; for (const seg of segments) { const stage = mapFitbitSleepStage(seg.stage); if (!stage) continue; const mins = minutesBetween(seg.startTime, seg.endTime); - if (mins === null) continue; + if (mins === null || !(mins > 0)) continue; const end = new Date(seg.endTime as string); - const prev = perStage.get(stage); - if (prev) { - prev.minutes += mins; - if (end > prev.lastEnd) prev.lastEnd = end; - } else { - perStage.set(stage, { minutes: mins, lastEnd: end }); - } - } - - const anchor = sleepSessionAnchor(point); - const out: FitbitMappedMeasurement[] = []; - for (const [stage, agg] of perStage) { - if (!(agg.minutes > 0)) continue; out.push({ type: "SLEEP_DURATION", - value: round2(agg.minutes), + value: round2(mins), unit: "minutes", - measuredAt: agg.lastEnd, - fieldTag: `${anchor}:sleep_${stage.toLowerCase()}`, + measuredAt: end, + fieldTag: `${anchor}:sleep_${stage.toLowerCase()}:${segIndex}`, sleepStage: stage, }); + segIndex += 1; } return out; } diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts index 953cd40f..d7ac50f1 100644 --- a/src/lib/fitbit/sync-sleep.ts +++ b/src/lib/fitbit/sync-sleep.ts @@ -2,16 +2,19 @@ * Fitbit / Google Health sleep-bundle sync (v1.12.0, W5). * * Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps - * each into per-stage `SLEEP_DURATION` rows via `mapSleepSession` (minutes per - * stage; `measuredAt = the stage's END instant; harmonised onto the shared - * `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the night-total + - * hypnogram readers already consume for WHOOP / Apple). Upserts as + * each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row + * per stage segment; `measuredAt = that segment's END instant; harmonised onto + * the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the + * night-total + hypnogram readers already consume for WHOOP / Apple). Google + * carries a real per-segment series, so the rows lay each block at its true + * clock time (a MEASURED timeline, not reconstructed). Upserts as * `source = FITBIT`. * - * Per-stage rows carry the `sleepStage` axis so the (up to six) stage rows for - * one night stay distinct under the `(userId, type, source, externalId)` dedup - * key. externalId = `:sleep_` — a re-scored night - * overwrites in place. A 24 h overlap covers Google's after-the-fact re-score. + * Each segment carries the `sleepStage` axis and an indexed fieldTag so the + * several segments of one stage stay distinct under the + * `(userId, type, source, externalId)` dedup key. externalId = + * `:sleep_:` — a re-scored night overwrites in place. + * A 24 h overlap covers Google's after-the-fact re-score. * * A per-data-class 403 soft-skips the resource — the sleep bundle is granted * independently of activity / metrics in the Google consent flow. From 7ad0b9c4f434d296a79f0dd3f50bd73062175e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:16:23 +0200 Subject: [PATCH 06/79] fix(sleep): stamp Withings sleep segments at the END, not the START MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withings sleep sync stamped each segment's measuredAt with its startdate, but every reader treats measuredAt as the segment END and resolves the span as start = end − duration. Each night was therefore rendered one segment-length earlier than reality. Stamp the enddate so the span lands at its true clock time. --- src/lib/withings/__tests__/sync-sleep.test.ts | 7 +++++-- src/lib/withings/sync-sleep.ts | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index 28757ebd..6ae04f3a 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -194,7 +194,7 @@ describe("syncUserSleep — segment writes + idempotency", () => { expect(firstDuration.data.unit).toBe("minutes"); }); - it("converts startdate (unix seconds) to a Date in measuredAt", async () => { + it("stamps measuredAt at the segment END (enddate, unix seconds → Date)", async () => { installFetchMock([ { startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 }, ]); @@ -205,7 +205,10 @@ describe("syncUserSleep — segment writes + idempotency", () => { const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as { data: { measuredAt: Date }; }; - expect(arg.data.measuredAt.getTime()).toBe(1715000000 * 1000); + // measuredAt is the segment END — every reader treats it as the END and + // resolves the span as start = end − duration. Stamping the START shifted + // the night one segment-length earlier. + expect(arg.data.measuredAt.getTime()).toBe(1715003600 * 1000); }); it("skips state 4 (synthetic marker) without throwing", async () => { diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 231d94f6..4b92080d 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -26,10 +26,13 @@ * when Withings re-emits the night with adjusted segment boundaries. * * Date semantics: each segment's `measuredAt` is the segment's - * `startdate` (unix seconds → `Date`). The duration in minutes lands - * in `value`. The HealthLog analytics aggregator already groups - * stage rows under their parent night via the per-night `dayKey` - * helper, so the segment-level timestamp is the canonical sort key. + * `enddate` (unix seconds → `Date`). Every reader treats `measuredAt` + * as the segment END and resolves the span as `start = end − duration` + * (`sleep-night.ts` `segmentOf`); stamping the START shifted the whole + * night one segment-length earlier. The duration in minutes lands in + * `value`. The HealthLog analytics aggregator groups stage rows under + * their parent night via the per-night `dayKey` helper, so the + * segment-level END timestamp is the canonical sort key. */ import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; @@ -258,7 +261,11 @@ export async function syncUserSleep( segmentIndex++; continue; } - const measuredAt = new Date(segment.startdate * 1000); + // `measuredAt` is the segment END (`enddate`). Every reader treats + // `measuredAt` as the END instant — `segmentOf` resolves the span as + // `start = measuredAt − value·60_000` (sleep-night.ts). Stamping the START + // here shifted the whole night one segment-length earlier; END is correct. + const measuredAt = new Date(segment.enddate * 1000); const durationSec = Math.max(0, segment.enddate - segment.startdate); // Canonical SLEEP_DURATION unit is minutes (Apple Health alignment, // v1.4.23 schema note). Round so re-sync doesn't flip values on From ddfed17fa31c95010a468eacbe6dc85bc53439c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:16:33 +0200 Subject: [PATCH 07/79] feat(sleep): backfill existing WHOOP + Withings sleep rows after the stamp fix The WHOOP timeline reconstruction and the Withings END-stamp change the stored measuredAt (and, for WHOOP, the externalId), so existing rows keep the old broken shape until re-synced. An in-place update collides with the (userId, type, measuredAt, source, sleepStage) unique index, so the backfill deletes the affected SLEEP_DURATION rows for the source and re-syncs from scratch; the re-sync re-folds the rollup tier in its tail. Add a boot-time pg-boss one-shot per connection, modelled on the WHOOP / Fitbit backfills: discovery enqueues one job per (user, provider) where sleep_timeline_backfill_at is null, the per-user pass deletes + re-syncs and stamps the marker, and the pass is idempotent across reboots. The queue is registered in allQueues, wired to a handler, and enqueued at boot. --- .../migration.sql | 20 +++ prisma/schema.prisma | 14 ++ .../__tests__/sleep-timeline-backfill.test.ts | 149 +++++++++++++++ .../__tests__/sleep-timeline-queues.test.ts | 38 ++++ src/lib/jobs/reminder-worker.ts | 63 +++++++ src/lib/jobs/sleep-timeline-backfill.ts | 169 ++++++++++++++++++ 6 files changed, 453 insertions(+) create mode 100644 prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql create mode 100644 src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts create mode 100644 src/lib/jobs/__tests__/sleep-timeline-queues.test.ts create mode 100644 src/lib/jobs/sleep-timeline-backfill.ts diff --git a/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql b/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql new file mode 100644 index 00000000..b5ea8e75 --- /dev/null +++ b/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql @@ -0,0 +1,20 @@ +-- v1.17.1 — one-shot sleep-timeline backfill markers. +-- +-- WHOOP's v2 API returns only per-stage sleep DURATION totals (no onset +-- timestamps), and the pre-fix mapper stamped all five stage totals on the one +-- sleep-END instant. The hypnogram then reconstructed every stage as a span +-- touching the night's right edge — the stacked-bar artefact with no clock +-- times. The fix reconstructs an ordered, contiguous per-segment timeline. +-- +-- Withings stamped each sleep segment with its START while every reader treats +-- `measured_at` as the segment END, shifting each night one segment-length +-- earlier. The fix stamps the END. +-- +-- Both fixes change the stored row shape / instant, so existing rows must be +-- re-synced. The boot-time backfill (per connection) deletes the affected +-- SLEEP_DURATION rows, re-syncs with the corrected mapper, re-folds the rollup +-- tier, and stamps `sleep_timeline_backfill_at` so the discovery query drops +-- the connection. Null = backfill not yet run; the marker makes the one-shot +-- idempotent across reboots. +ALTER TABLE "whoop_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3); +ALTER TABLE "withings_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba56aa38..5558e34b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2150,6 +2150,13 @@ model WithingsConnection { /// has never re-authed since v1.4.25 — the UI surfaces a banner /// inviting them to reconnect. scope String? @map("scope") + /// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings + /// sleep rows were stamped with the segment START; the reader treats + /// `measuredAt` as the END, shifting every night one segment-length earlier. + /// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows + /// and re-syncs with the corrected END stamp, then sets this so the discovery + /// query drops the connection. Null = backfill not yet run. + sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -2226,6 +2233,13 @@ model WhoopConnection { /// Set once the self-converging history backfill walks every collection to /// completion. Null = backfill still in flight (or never started). backfillCompletedAt DateTime? @map("backfill_completed_at") + /// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP + /// sleep rows stamped all five stage totals on the one sleep-END instant, so + /// the hypnogram stacked them on the night's right edge. The boot-time + /// backfill deletes the legacy summary rows and re-syncs the reconstructed + /// per-segment timeline, then sets this so the discovery query drops the + /// connection. Null = backfill not yet run. + sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at") /// Max heart rate from the WHOOP body-measurement profile — a profile /// constant, not a time series, so it lives here rather than as a /// Measurement row. diff --git a/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts b/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts new file mode 100644 index 00000000..710f923b --- /dev/null +++ b/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts @@ -0,0 +1,149 @@ +/** + * v1.17.1 — sleep-timeline backfill self-convergence tests (mocked). + * - discovery only matches connections whose sleep rows predate the fix + * (`sleepTimelineBackfillAt IS NULL`), for both WHOOP and Withings; + * - a completed pass DELETES the source's SLEEP_DURATION rows, re-syncs, and + * stamps `sleepTimelineBackfillAt` so the next discovery drops the account + * (idempotent across reboots); + * - the discovery enqueue is singleton-keyed per (provider, user). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, bossSend, syncUserWhoop, syncWithingsSleep } = vi.hoisted( + () => ({ + prismaMock: { + whoopConnection: { findMany: vi.fn(), update: vi.fn() }, + withingsConnection: { findMany: vi.fn(), update: vi.fn() }, + measurement: { deleteMany: vi.fn() }, + }, + bossSend: vi.fn(), + syncUserWhoop: vi.fn(), + syncWithingsSleep: vi.fn(), + }), +); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ send: bossSend }), +})); + +vi.mock("@/lib/whoop/sync", () => ({ + syncUserWhoop: (...a: unknown[]) => syncUserWhoop(...a), +})); + +vi.mock("@/lib/withings/sync-sleep", () => ({ + syncUserSleep: (...a: unknown[]) => syncWithingsSleep(...a), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: () => {}, + getEvent: () => null, +})); + +import { + enqueueBootTimeSleepTimelineBackfill, + runSleepTimelineBackfillForUser, +} from "../sleep-timeline-backfill"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("enqueueBootTimeSleepTimelineBackfill — discovery", () => { + it("queries un-backfilled WHOOP + Withings connections and enqueues one job per (provider, user)", async () => { + prismaMock.whoopConnection.findMany.mockResolvedValue([{ userId: "w1" }]); + prismaMock.withingsConnection.findMany.mockResolvedValue([ + { userId: "v1" }, + ]); + bossSend.mockResolvedValue("job-id"); + + const result = await enqueueBootTimeSleepTimelineBackfill(); + + expect(prismaMock.whoopConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }), + ); + expect(prismaMock.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }), + ); + expect(result.enqueued).toBe(2); + expect(bossSend).toHaveBeenCalledWith( + "sleep-timeline-backfill", + expect.objectContaining({ userId: "w1", provider: "WHOOP" }), + expect.objectContaining({ + singletonKey: "sleep-timeline-backfill|WHOOP|w1", + }), + ); + expect(bossSend).toHaveBeenCalledWith( + "sleep-timeline-backfill", + expect.objectContaining({ userId: "v1", provider: "WITHINGS" }), + expect.objectContaining({ + singletonKey: "sleep-timeline-backfill|WITHINGS|v1", + }), + ); + }); + + it("self-converges: no un-backfilled connections → nothing enqueued", async () => { + prismaMock.whoopConnection.findMany.mockResolvedValue([]); + prismaMock.withingsConnection.findMany.mockResolvedValue([]); + + const result = await enqueueBootTimeSleepTimelineBackfill(); + + expect(result.enqueued).toBe(0); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("never throws — surfaces a discovery error through the result value", async () => { + prismaMock.whoopConnection.findMany.mockRejectedValue(new Error("db down")); + prismaMock.withingsConnection.findMany.mockResolvedValue([]); + + const result = await enqueueBootTimeSleepTimelineBackfill(); + + expect(result.error).toBe("db down"); + expect(result.enqueued).toBe(0); + }); +}); + +describe("runSleepTimelineBackfillForUser", () => { + it("WHOOP: deletes the source's sleep rows, full-syncs, and stamps the marker", async () => { + prismaMock.measurement.deleteMany.mockResolvedValue({ count: 5 }); + syncUserWhoop.mockResolvedValue(42); + prismaMock.whoopConnection.update.mockResolvedValue({}); + + const { deleted, imported } = await runSleepTimelineBackfillForUser( + "w1", + "WHOOP", + ); + + expect(deleted).toBe(5); + expect(imported).toBe(42); + expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({ + where: { userId: "w1", type: "SLEEP_DURATION", source: "WHOOP" }, + }); + expect(syncUserWhoop).toHaveBeenCalledWith("w1", { fullSync: true }); + const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0]; + expect(updateArg.where).toEqual({ userId: "w1" }); + expect(updateArg.data.sleepTimelineBackfillAt).toBeInstanceOf(Date); + }); + + it("WITHINGS: deletes the source's sleep rows, re-syncs, and stamps the marker", async () => { + prismaMock.measurement.deleteMany.mockResolvedValue({ count: 3 }); + syncWithingsSleep.mockResolvedValue(7); + prismaMock.withingsConnection.update.mockResolvedValue({}); + + const { deleted, imported } = await runSleepTimelineBackfillForUser( + "v1", + "WITHINGS", + ); + + expect(deleted).toBe(3); + expect(imported).toBe(7); + expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({ + where: { userId: "v1", type: "SLEEP_DURATION", source: "WITHINGS" }, + }); + expect(syncWithingsSleep).toHaveBeenCalledWith("v1", { fullSync: true }); + const updateArg = prismaMock.withingsConnection.update.mock.calls[0]![0]; + expect(updateArg.where).toEqual({ userId: "v1" }); + expect(updateArg.data.sleepTimelineBackfillAt).toBeInstanceOf(Date); + }); +}); diff --git a/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts b/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts new file mode 100644 index 00000000..2cbccc29 --- /dev/null +++ b/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts @@ -0,0 +1,38 @@ +/** + * v1.17.1 — pg-boss queue registration for the one-shot sleep-timeline backfill. + * + * The reminder-worker imports heavy infrastructure, so we read the source as + * text and assert the queue constant is declared, registered in `allQueues`, + * wired to a `boss.work` handler, and enqueued at boot — the fast guard that + * catches the v1.4.37 dead-queue class (a queue declared but never registered, + * which silently never drains). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); +const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); + +describe("reminder-worker — sleep-timeline backfill queue", () => { + it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => { + const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); + expect(block).not.toBeNull(); + expect(block![1]!).toContain("SLEEP_TIMELINE_BACKFILL_QUEUE"); + }); + + it("wires a boss.work handler to the per-user runner", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,260}SLEEP_TIMELINE_BACKFILL_QUEUE[\s\S]{0,260}runSleepTimelineBackfillForUser/, + ); + }); + + it("wires the boot discovery", () => { + expect(source).toMatch(/enqueueBootTimeSleepTimelineBackfill\(\)/); + }); + + it("imports the backfill exports", () => { + expect(source).toMatch(/from\s*["']@\/lib\/jobs\/sleep-timeline-backfill["']/); + }); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 57aa681f..056fbf57 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -21,6 +21,13 @@ import { enqueueBootTimeFitbitBackfill, type FitbitBackfillPayload, } from "@/lib/jobs/fitbit-backfill"; +import { + SLEEP_TIMELINE_BACKFILL_QUEUE, + SLEEP_TIMELINE_BACKFILL_CONCURRENCY, + runSleepTimelineBackfillForUser, + enqueueBootTimeSleepTimelineBackfill, + type SleepTimelineBackfillPayload, +} from "@/lib/jobs/sleep-timeline-backfill"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -629,6 +636,13 @@ export async function startReminderWorker() { FITBIT_SYNC_QUEUE, FITBIT_BACKFILL_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_QUEUE, + // v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings. + // Discovery enqueues one job per connection whose sleep rows predate the + // stamp/shape fix; the pass deletes the affected SLEEP_DURATION rows and + // re-syncs. Idempotent across reboots. The queue MUST be registered here or + // pg-boss never provisions it and the boot enqueue silently never drains + // (the v1.4.37 dead-queue class). + SLEEP_TIMELINE_BACKFILL_QUEUE, // v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no // backfill queue — the hourly window walks the recent SGV set). The queue // MUST be registered here or pg-boss never provisions it and the schedule @@ -1056,6 +1070,27 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleFitbitOAuthStateCleanup, ); + // v1.17.1 — one-shot sleep-timeline backfill. The boot enqueue below sends + // one job per (user, provider) whose sleep rows predate the stamp/shape fix; + // this handler deletes the affected rows, re-syncs, and stamps the marker so + // the discovery query drops the connection. + await boss.work( + SLEEP_TIMELINE_BACKFILL_QUEUE, + { localConcurrency: SLEEP_TIMELINE_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId, provider } = job.data; + const { deleted, imported } = await runSleepTimelineBackfillForUser( + userId, + provider, + ); + workerLog( + "info", + `[sleep-timeline-backfill] user=${userId} provider=${provider} deleted=${deleted} imported=${imported}`, + ); + } + }, + ); // v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no // `userId`) walks every configured instance; one user's unreachable host is // warned, not fatal. @@ -1882,6 +1917,34 @@ export async function startReminderWorker() { ); } + // v1.17.1 — fire-and-forget boot discovery for the one-shot sleep-timeline + // backfill. Finds every WHOOP + Withings connection whose sleep rows predate + // the stamp/shape fix and enqueues one job per (user, provider). Idempotent + // across reboots: a completed pass stamps `sleepTimelineBackfillAt`, dropping + // the connection from the discovery set. Errors come back through the + // helper's result value — the worker boot never fails because of a miss. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeSleepTimelineBackfill(); + if (error) { + workerLog( + "error", + `[sleep-timeline-backfill] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[sleep-timeline-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[sleep-timeline-backfill] boot discovery threw an unexpected error", + err, + ); + } + // v1.7.0 — fire-and-forget boot discovery for the daily-mean // consolidation pass. Finds every user holding live per-sample // high-frequency mean-type rows and enqueues one job per account. diff --git a/src/lib/jobs/sleep-timeline-backfill.ts b/src/lib/jobs/sleep-timeline-backfill.ts new file mode 100644 index 00000000..d3f49f15 --- /dev/null +++ b/src/lib/jobs/sleep-timeline-backfill.ts @@ -0,0 +1,169 @@ +/** + * v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings. + * + * Two ingest-side stamp/shape fixes changed the stored sleep rows: + * + * - WHOOP: the old mapper stamped all five per-stage DURATION totals on the + * one sleep-END instant (WHOOP v2 exposes no onset timestamps), so the + * hypnogram reconstructed every stage as a span touching the night's right + * edge — stacked, no clock times. The fix reconstructs an ordered, + * contiguous per-segment timeline with distinct `measuredAt` per segment and + * NEW indexed externalIds (`:seg::`). + * - Withings: each segment was stamped with its START while every reader + * treats `measuredAt` as the END, shifting each night one segment-length + * earlier. The fix stamps the END. + * + * Both change the row's `measuredAt` (and, for WHOOP, the externalId), and the + * unique index `(userId, type, measuredAt, source, sleepStage)` makes an + * in-place UPDATE collision-prone. So the per-connection pass DELETES the + * affected `SLEEP_DURATION` rows for that source, RE-SYNCS with the corrected + * mapper (which re-folds the rollup tier in its tail), and stamps + * `sleepTimelineBackfillAt` so the discovery query drops the connection. + * + * Idempotent across reboots: the discovery predicate is + * `sleep_timeline_backfill_at IS NULL`, and a completed pass stamps the marker. + * A reboot mid-pass re-runs delete + re-sync from scratch — the re-sync upserts + * are key-stable, so the result converges. + * + * Modelled on `whoop-backfill.ts` / `fitbit-backfill.ts`. The queue name MUST + * be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts` or pg-boss + * never provisions it and the boot enqueue silently never drains (the v1.4.37 + * dead-queue class). + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { syncUserWhoop } from "@/lib/whoop/sync"; +import { syncUserSleep as syncWithingsSleep } from "@/lib/withings/sync-sleep"; + +export const SLEEP_TIMELINE_BACKFILL_QUEUE = "sleep-timeline-backfill"; + +/** + * Serial concurrency — a pass deletes a source's sleep rows then walks a + * re-sync that is rate-bounded by the upstream API cap. Concurrency-1 keeps it + * from crowding the request pool, matching the other backfill queues. + */ +export const SLEEP_TIMELINE_BACKFILL_CONCURRENCY = 1; + +export type SleepTimelineProvider = "WHOOP" | "WITHINGS"; + +export interface SleepTimelineBackfillPayload { + userId: string; + provider: SleepTimelineProvider; + enqueuedAt: string; +} + +/** + * Delete the legacy `SLEEP_DURATION` rows for one source, then re-sync with the + * corrected mapper. WHOOP runs a full-history sync (the segment externalIds are + * new, so the deleted summary rows are not re-created); Withings re-syncs its + * default trailing window with the corrected END stamp. The re-sync re-folds + * the rollup tier in its own tail. + */ +export async function runSleepTimelineBackfillForUser( + userId: string, + provider: SleepTimelineProvider, +): Promise<{ deleted: number; imported: number }> { + const { count: deleted } = await prisma.measurement.deleteMany({ + where: { userId, type: "SLEEP_DURATION", source: provider }, + }); + + let imported = 0; + if (provider === "WHOOP") { + imported = await syncUserWhoop(userId, { fullSync: true }); + await prisma.whoopConnection.update({ + where: { userId }, + data: { sleepTimelineBackfillAt: new Date() }, + }); + } else { + imported = await syncWithingsSleep(userId, { fullSync: true }); + await prisma.withingsConnection.update({ + where: { userId }, + data: { sleepTimelineBackfillAt: new Date() }, + }); + } + + annotate({ + action: { + name: "sleep.timeline.backfill.complete", + details: { provider, deleted, imported }, + }, + }); + return { deleted, imported }; +} + +/** + * Boot-time discovery. Finds every WHOOP + Withings connection not yet + * backfilled (`sleep_timeline_backfill_at IS NULL`) and enqueues one job per + * (user, provider). Idempotent across reboots: a completed pass stamps the + * marker, dropping the connection from the discovery set. pg-boss + * `singletonKey` coalesces duplicate sends so a fast restart while a job is + * queued doesn't double up. + * + * Best-effort: errors are returned through the result value so worker boot + * never fails because of a backfill miss. + */ +export async function enqueueBootTimeSleepTimelineBackfill(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const [whoop, withings] = await Promise.all([ + prisma.whoopConnection.findMany({ + where: { sleepTimelineBackfillAt: null }, + select: { userId: true }, + }), + prisma.withingsConnection.findMany({ + where: { sleepTimelineBackfillAt: null }, + select: { userId: true }, + }), + ]); + + const targets: Array<{ userId: string; provider: SleepTimelineProvider }> = + [ + ...whoop.map((c) => ({ userId: c.userId, provider: "WHOOP" as const })), + ...withings.map((c) => ({ + userId: c.userId, + provider: "WITHINGS" as const, + })), + ]; + + if (targets.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const { userId, provider } of targets) { + const payload: SleepTimelineBackfillPayload = { + userId, + provider, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(SLEEP_TIMELINE_BACKFILL_QUEUE, payload, { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `sleep-timeline-backfill|${provider}|${userId}`, + }); + if (jobId) { + enqueued += 1; + } else { + skipped += 1; + } + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} From 38c7604ce41d2b0c1ae8eb3efa7c5e25ba28b969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:19:47 +0200 Subject: [PATCH 08/79] feat(integrations): per-user Polar and Oura OAuth credentials Polar and Oura resolved their OAuth client id/secret from the server environment only, forcing every user on a deploy to share one app. Both now read per-user BYO keys first, falling back to the shared env app when unset, mirroring the WHOOP and Fitbit model. - Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts, encrypting the client id/secret at rest on User. - Repoint the connect, callback, status routes and the Oura refresh path to the user-scoped resolver. - Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated input and userId narrowed from the session. - Render an optional 'your OAuth app credentials' form on the Polar and Oura cards; the status read exposes hasOwnCredentials for the saved placeholder. Existing env-only deploys keep working unchanged. - Cover both resolvers (DB hit, env fallback, no creds) and the card form with unit tests; add the new strings across all six locales. --- messages/de.json | 18 ++- messages/en.json | 18 ++- messages/es.json | 18 ++- messages/fr.json | 18 ++- messages/it.json | 18 ++- messages/pl.json | 18 ++- src/app/api/oura/callback/route.ts | 5 +- src/app/api/oura/connect/route.ts | 5 +- src/app/api/oura/credentials/route.ts | 81 ++++++++++ .../api/oura/status/__tests__/route.test.ts | 13 +- src/app/api/oura/status/route.ts | 27 +++- src/app/api/polar/callback/route.ts | 9 +- src/app/api/polar/connect/route.ts | 5 +- src/app/api/polar/credentials/route.ts | 81 ++++++++++ .../api/polar/status/__tests__/route.test.ts | 15 +- src/app/api/polar/status/route.ts | 32 ++-- .../__tests__/oauth-provider-card.test.tsx | 38 ++++- .../integrations/oauth-provider-card.tsx | 150 +++++++++++++++++- .../settings/integrations/oura-card.tsx | 4 +- .../settings/integrations/polar-card.tsx | 4 +- src/lib/oura/__tests__/credentials.test.ts | 80 ++++++++++ src/lib/oura/__tests__/sync.test.ts | 6 +- src/lib/oura/credentials.ts | 72 ++++++++- src/lib/oura/sync.ts | 9 +- src/lib/polar/__tests__/credentials.test.ts | 83 ++++++++++ src/lib/polar/credentials.ts | 73 ++++++++- src/lib/validations/oura.ts | 16 ++ src/lib/validations/polar.ts | 16 ++ 28 files changed, 856 insertions(+), 76 deletions(-) create mode 100644 src/app/api/oura/credentials/route.ts create mode 100644 src/app/api/polar/credentials/route.ts create mode 100644 src/lib/oura/__tests__/credentials.test.ts create mode 100644 src/lib/polar/__tests__/credentials.test.ts create mode 100644 src/lib/validations/oura.ts create mode 100644 src/lib/validations/polar.ts diff --git a/messages/de.json b/messages/de.json index 01784ec8..c910cf99 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4603,7 +4603,14 @@ "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.", "polarViewData": "Schlaf & Erholung ansehen", "polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", - "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.", + "polarCredentials": "API-Zugangsdaten", + "polarCredentialsHelp": "Optional: Registriere deine eigene Polar-AccessLink-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Polar-App des Servers zu nutzen.", + "polarClientId": "Client ID", + "polarClientSecret": "Client Secret", + "polarCredentialsSaved": "Zugangsdaten gespeichert", + "polarCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", + "polarSaveCredentials": "Speichern", + "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "polarConnect": "Polar verbinden", "polarDisconnect": "Trennen", "polarDisconnectTitle": "Polar trennen?", @@ -4624,7 +4631,14 @@ "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.", "ouraViewData": "Schlaf & Erholung ansehen", "ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", - "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.", + "ouraCredentials": "API-Zugangsdaten", + "ouraCredentialsHelp": "Optional: Registriere deine eigene Oura-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Oura-App des Servers zu nutzen.", + "ouraClientId": "Client ID", + "ouraClientSecret": "Client Secret", + "ouraCredentialsSaved": "Zugangsdaten gespeichert", + "ouraCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", + "ouraSaveCredentials": "Speichern", + "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "ouraConnect": "Oura verbinden", "ouraDisconnect": "Trennen", "ouraDisconnectTitle": "Oura trennen?", diff --git a/messages/en.json b/messages/en.json index a78cc1a2..f61d681e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4603,7 +4603,14 @@ "polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.", "polarViewData": "View your sleep & recovery", "polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.", - "polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.", + "polarCredentials": "API Credentials", + "polarCredentialsHelp": "Optional: register your own Polar AccessLink app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Polar app.", + "polarClientId": "Client ID", + "polarClientSecret": "Client Secret", + "polarCredentialsSaved": "Credentials saved", + "polarCredentialsSavedPlaceholder": "Saved — enter new to replace", + "polarSaveCredentials": "Save", + "polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.", "polarConnect": "Connect Polar", "polarDisconnect": "Disconnect", "polarDisconnectTitle": "Disconnect Polar?", @@ -4624,7 +4631,14 @@ "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.", "ouraViewData": "View your sleep & recovery", "ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.", - "ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.", + "ouraCredentials": "API Credentials", + "ouraCredentialsHelp": "Optional: register your own Oura app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Oura app.", + "ouraClientId": "Client ID", + "ouraClientSecret": "Client Secret", + "ouraCredentialsSaved": "Credentials saved", + "ouraCredentialsSavedPlaceholder": "Saved — enter new to replace", + "ouraSaveCredentials": "Save", + "ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.", "ouraConnect": "Connect Oura", "ouraDisconnect": "Disconnect", "ouraDisconnectTitle": "Disconnect Oura?", diff --git a/messages/es.json b/messages/es.json index 07ef722c..7f4564f1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4603,7 +4603,14 @@ "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.", "polarViewData": "Ver tu sueño y recuperación", "polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", - "polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.", + "polarCredentials": "Credenciales de API", + "polarCredentialsHelp": "Opcional: registra tu propia app de Polar AccessLink y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.", + "polarClientId": "Client ID", + "polarClientSecret": "Client Secret", + "polarCredentialsSaved": "Credenciales guardadas", + "polarCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", + "polarSaveCredentials": "Guardar", + "polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.", "polarConnect": "Conectar Polar", "polarDisconnect": "Desconectar", "polarDisconnectTitle": "¿Desconectar Polar?", @@ -4624,7 +4631,14 @@ "ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.", "ouraViewData": "Ver tu sueño y recuperación", "ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", - "ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.", + "ouraCredentials": "Credenciales de API", + "ouraCredentialsHelp": "Opcional: registra tu propia app de Oura y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.", + "ouraClientId": "Client ID", + "ouraClientSecret": "Client Secret", + "ouraCredentialsSaved": "Credenciales guardadas", + "ouraCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", + "ouraSaveCredentials": "Guardar", + "ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.", "ouraConnect": "Conectar Oura", "ouraDisconnect": "Desconectar", "ouraDisconnectTitle": "¿Desconectar Oura?", diff --git a/messages/fr.json b/messages/fr.json index acec48c7..dab67d9f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4603,7 +4603,14 @@ "polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.", "polarViewData": "Voir votre sommeil et récupération", "polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", - "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.", + "polarCredentials": "Identifiants API", + "polarCredentialsHelp": "Facultatif : enregistrez votre propre application Polar AccessLink et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Polar partagée du serveur.", + "polarClientId": "Client ID", + "polarClientSecret": "Client Secret", + "polarCredentialsSaved": "Identifiants enregistrés", + "polarCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", + "polarSaveCredentials": "Enregistrer", + "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.", "polarConnect": "Connecter Polar", "polarDisconnect": "Déconnecter", "polarDisconnectTitle": "Déconnecter Polar ?", @@ -4624,7 +4631,14 @@ "ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.", "ouraViewData": "Voir votre sommeil et récupération", "ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", - "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.", + "ouraCredentials": "Identifiants API", + "ouraCredentialsHelp": "Facultatif : enregistrez votre propre application Oura et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Oura partagée du serveur.", + "ouraClientId": "Client ID", + "ouraClientSecret": "Client Secret", + "ouraCredentialsSaved": "Identifiants enregistrés", + "ouraCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", + "ouraSaveCredentials": "Enregistrer", + "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.", "ouraConnect": "Connecter Oura", "ouraDisconnect": "Déconnecter", "ouraDisconnectTitle": "Déconnecter Oura ?", diff --git a/messages/it.json b/messages/it.json index e825a177..e4dcc3b2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4603,7 +4603,14 @@ "polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.", "polarViewData": "Vedi sonno e recupero", "polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", - "polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.", + "polarCredentials": "Credenziali API", + "polarCredentialsHelp": "Facoltativo: registra la tua app Polar AccessLink e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Polar condivisa del server.", + "polarClientId": "Client ID", + "polarClientSecret": "Client Secret", + "polarCredentialsSaved": "Credenziali salvate", + "polarCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", + "polarSaveCredentials": "Salva", + "polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.", "polarConnect": "Collega Polar", "polarDisconnect": "Disconnetti", "polarDisconnectTitle": "Disconnettere Polar?", @@ -4624,7 +4631,14 @@ "ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.", "ouraViewData": "Vedi sonno e recupero", "ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", - "ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.", + "ouraCredentials": "Credenziali API", + "ouraCredentialsHelp": "Facoltativo: registra la tua app Oura e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Oura condivisa del server.", + "ouraClientId": "Client ID", + "ouraClientSecret": "Client Secret", + "ouraCredentialsSaved": "Credenziali salvate", + "ouraCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", + "ouraSaveCredentials": "Salva", + "ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.", "ouraConnect": "Collega Oura", "ouraDisconnect": "Disconnetti", "ouraDisconnectTitle": "Disconnettere Oura?", diff --git a/messages/pl.json b/messages/pl.json index b551d7b0..d58a5999 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4603,7 +4603,14 @@ "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.", "polarViewData": "Zobacz sen i regenerację", "polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", - "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.", + "polarCredentials": "Dane API", + "polarCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Polar AccessLink i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Polar serwera.", + "polarClientId": "Client ID", + "polarClientSecret": "Client Secret", + "polarCredentialsSaved": "Dane zapisane", + "polarCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", + "polarSaveCredentials": "Zapisz", + "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.", "polarConnect": "Połącz Polar", "polarDisconnect": "Rozłącz", "polarDisconnectTitle": "Rozłączyć Polar?", @@ -4624,7 +4631,14 @@ "ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.", "ouraViewData": "Zobacz sen i regenerację", "ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", - "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.", + "ouraCredentials": "Dane API", + "ouraCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Oura i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Oura serwera.", + "ouraClientId": "Client ID", + "ouraClientSecret": "Client Secret", + "ouraCredentialsSaved": "Dane zapisane", + "ouraCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", + "ouraSaveCredentials": "Zapisz", + "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.", "ouraConnect": "Połącz Oura", "ouraDisconnect": "Rozłącz", "ouraDisconnectTitle": "Rozłączyć Oura?", diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index e3089916..9dd9a0fa 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -4,7 +4,8 @@ import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode, getOuraCredentials } from "@/lib/oura/client"; +import { exchangeCode } from "@/lib/oura/client"; +import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, stateMatchesCookie, @@ -65,7 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) return ERR("nocode"); try { - const creds = getOuraCredentials(); + const creds = await getOuraClientCredentials(userId); if (!creds) return ERR("nocreds"); const tokens = await exchangeCode(code, creds); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index fd67aa35..e7cd4f23 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl, getOuraCredentials } from "@/lib/oura/client"; +import { getAuthorizationUrl } from "@/lib/oura/client"; +import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { OAUTH_STATE_TTL_MS, mintSignedState, @@ -40,7 +41,7 @@ export const GET = apiHandler(async () => { ); } - const creds = getOuraCredentials(); + const creds = await getOuraClientCredentials(user.id); if (!creds) { return apiError("Oura integration is not configured on this server.", 400); } diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts new file mode 100644 index 00000000..6a3bad80 --- /dev/null +++ b/src/app/api/oura/credentials/route.ts @@ -0,0 +1,81 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + storeOuraClientCredentials, + clearOuraClientCredentials, +} from "@/lib/oura/credentials"; +import { ouraCredentialsSchema } from "@/lib/validations/oura"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +/** + * v1.17.1 — per-user Oura BYO-key credentials. + * + * Whether the user has their own Oura client id/secret stored. The + * connect/callback/status routes resolve credentials DB-first then env, so an + * unset pair simply falls back to the shared env app. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "oura.credentials.get" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + ouraClientIdEncrypted: true, + ouraClientSecretEncrypted: true, + }, + }); + + return apiSuccess({ + hasCredentials: + !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted, + }); +}); + +/** + * Save Oura OAuth client credentials (encrypted at rest). + */ +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "oura.credentials.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const result = z.safeParse(ouraCredentialsSchema, body); + if (!result.success) { + return apiError("Client ID and Client Secret are required", 422); + } + + await storeOuraClientCredentials( + user.id, + result.data.clientId, + result.data.clientSecret, + ); + + return apiSuccess({ updated: true }); +}); + +/** + * Delete Oura credentials and the active connection. + */ +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "oura.credentials.delete" } }); + + await prisma.user.update({ + where: { id: user.id }, + data: { + ouraAccessTokenEncrypted: null, + ouraRefreshTokenEncrypted: null, + }, + }); + await clearOuraClientCredentials(user.id); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/oura/status/__tests__/route.test.ts b/src/app/api/oura/status/__tests__/route.test.ts index b62b1d88..dc73142d 100644 --- a/src/app/api/oura/status/__tests__/route.test.ts +++ b/src/app/api/oura/status/__tests__/route.test.ts @@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null }), })); -vi.mock("@/lib/oura/client", () => ({ - getOuraCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })), +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: vi.fn(async () => ({ + clientId: "c", + clientSecret: "s", + })), })); vi.mock("@/lib/integrations/status", () => ({ @@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; -import { getOuraCredentials } from "@/lib/oura/client"; +import { getOuraClientCredentials } from "@/lib/oura/credentials"; const userFind = prisma.user.findUnique as ReturnType; -const credsMock = getOuraCredentials as ReturnType; +const credsMock = getOuraClientCredentials as ReturnType; type Body = { connected: boolean; @@ -49,7 +52,7 @@ const call = () => describe("GET /api/oura/status", () => { beforeEach(() => { vi.clearAllMocks(); - credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" }); + credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); }); it("reports not-connected but available", async () => { diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts index d5dcb648..f3d4d17f 100644 --- a/src/app/api/oura/status/route.ts +++ b/src/app/api/oura/status/route.ts @@ -2,15 +2,18 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { getOuraCredentials } from "@/lib/oura/client"; +import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; /** * v1.17.0 (F4) — Oura connection status for the current user. * - * `available` reports whether the server has the shared OAuth app configured. + * `available` reports whether usable OAuth credentials resolve for this user — + * per-user BYO keys (v1.17.1) first, then the shared env app. + * `hasOwnCredentials` reports whether the user has stored their own BYO pair. * `connected` is whether a token is stored. The ledger snapshot comes off the - * shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned. + * shared `oura` IntegrationKey. The access / refresh tokens + client secret are + * NEVER returned. */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -18,14 +21,25 @@ export const GET = apiHandler(async () => { const dbUser = await prisma.user.findUnique({ where: { id: user.id }, - select: { ouraAccessTokenEncrypted: true }, + select: { + ouraAccessTokenEncrypted: true, + ouraClientIdEncrypted: true, + ouraClientSecretEncrypted: true, + }, }); - const available = !!getOuraCredentials(); + const available = !!(await getOuraClientCredentials(user.id)); const connected = !!dbUser?.ouraAccessTokenEncrypted; + const hasOwnCredentials = + !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted; if (!connected) { - return apiSuccess({ connected: false, configured: false, available }); + return apiSuccess({ + connected: false, + configured: false, + available, + hasOwnCredentials, + }); } const status = await getIntegrationStatus(user.id, "oura"); @@ -34,6 +48,7 @@ export const GET = apiHandler(async () => { connected: true, configured: true, available, + hasOwnCredentials, state: status.state, lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index 34b084e4..53ed9d19 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -4,11 +4,8 @@ import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { - exchangeCode, - getPolarCredentials, - registerUser, -} from "@/lib/polar/client"; +import { exchangeCode, registerUser } from "@/lib/polar/client"; +import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { oauthStateCookieName, stateMatchesCookie, @@ -75,7 +72,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) return ERR("nocode"); try { - const creds = getPolarCredentials(); + const creds = await getPolarClientCredentials(userId); if (!creds) return ERR("nocreds"); const tokens = await exchangeCode(code, creds); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 5da06696..65d94e70 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client"; +import { getAuthorizationUrl } from "@/lib/polar/client"; +import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { OAUTH_STATE_TTL_MS, mintSignedState, @@ -44,7 +45,7 @@ export const GET = apiHandler(async () => { ); } - const creds = getPolarCredentials(); + const creds = await getPolarClientCredentials(user.id); if (!creds) { return apiError( "Polar integration is not configured on this server.", diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts new file mode 100644 index 00000000..9a92d467 --- /dev/null +++ b/src/app/api/polar/credentials/route.ts @@ -0,0 +1,81 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + storePolarClientCredentials, + clearPolarClientCredentials, +} from "@/lib/polar/credentials"; +import { polarCredentialsSchema } from "@/lib/validations/polar"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +/** + * v1.17.1 — per-user Polar BYO-key credentials. + * + * Whether the user has their own Polar AccessLink client id/secret stored. The + * connect/callback/status routes resolve credentials DB-first then env, so an + * unset pair simply falls back to the shared env app. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.credentials.get" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + polarClientIdEncrypted: true, + polarClientSecretEncrypted: true, + }, + }); + + return apiSuccess({ + hasCredentials: + !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted, + }); +}); + +/** + * Save Polar OAuth client credentials (encrypted at rest). + */ +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.credentials.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const result = z.safeParse(polarCredentialsSchema, body); + if (!result.success) { + return apiError("Client ID and Client Secret are required", 422); + } + + await storePolarClientCredentials( + user.id, + result.data.clientId, + result.data.clientSecret, + ); + + return apiSuccess({ updated: true }); +}); + +/** + * Delete Polar credentials and the active connection. + */ +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.credentials.delete" } }); + + await prisma.user.update({ + where: { id: user.id }, + data: { + polarAccessTokenEncrypted: null, + polarUserIdEncrypted: null, + }, + }); + await clearPolarClientCredentials(user.id); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/polar/status/__tests__/route.test.ts b/src/app/api/polar/status/__tests__/route.test.ts index e7e94d27..7a7d3016 100644 --- a/src/app/api/polar/status/__tests__/route.test.ts +++ b/src/app/api/polar/status/__tests__/route.test.ts @@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null }), })); -vi.mock("@/lib/polar/client", () => ({ - getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })), +vi.mock("@/lib/polar/credentials", () => ({ + getPolarClientCredentials: vi.fn(async () => ({ + clientId: "c", + clientSecret: "s", + })), })); vi.mock("@/lib/integrations/status", () => ({ @@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; -import { getPolarCredentials } from "@/lib/polar/client"; +import { getPolarClientCredentials } from "@/lib/polar/credentials"; const userFind = prisma.user.findUnique as ReturnType; -const credsMock = getPolarCredentials as ReturnType; +const credsMock = getPolarClientCredentials as ReturnType; type Body = { connected: boolean; @@ -49,7 +52,7 @@ const call = () => describe("GET /api/polar/status", () => { beforeEach(() => { vi.clearAllMocks(); - credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" }); + credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); }); it("reports not-connected but available when env is set and no token stored", async () => { @@ -60,7 +63,7 @@ describe("GET /api/polar/status", () => { }); it("reports available=false when the server has no Polar app configured", async () => { - credsMock.mockReturnValue(null); + credsMock.mockResolvedValue(null); userFind.mockResolvedValue(null); const res = await call(); expect(res.available).toBe(false); diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts index cfa8ad71..87d43742 100644 --- a/src/app/api/polar/status/route.ts +++ b/src/app/api/polar/status/route.ts @@ -2,17 +2,19 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { getPolarCredentials } from "@/lib/polar/client"; +import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; /** * v1.17.0 (F4) — Polar connection status for the current user. * - * `available` reports whether the server has the shared OAuth app configured - * (env client id/secret) so the card can grey out the connect button when the - * operator hasn't set it up. `connected` is whether a token is stored. The - * ledger snapshot (state / lastSuccessAt / lastError) comes off the shared - * `polar` IntegrationKey. The access token + member id are NEVER returned. + * `available` reports whether usable OAuth credentials resolve for this user — + * per-user BYO keys (v1.17.1) first, then the shared env app — so the card can + * grey out the connect button when neither is set. `hasOwnCredentials` reports + * whether the user has stored their own BYO pair (drives the saved-placeholder + * UI). `connected` is whether a token is stored. The ledger snapshot (state / + * lastSuccessAt / lastError) comes off the shared `polar` IntegrationKey. The + * access token, member id, and client secret are NEVER returned. */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -20,14 +22,25 @@ export const GET = apiHandler(async () => { const dbUser = await prisma.user.findUnique({ where: { id: user.id }, - select: { polarAccessTokenEncrypted: true }, + select: { + polarAccessTokenEncrypted: true, + polarClientIdEncrypted: true, + polarClientSecretEncrypted: true, + }, }); - const available = !!getPolarCredentials(); + const available = !!(await getPolarClientCredentials(user.id)); const connected = !!dbUser?.polarAccessTokenEncrypted; + const hasOwnCredentials = + !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted; if (!connected) { - return apiSuccess({ connected: false, configured: false, available }); + return apiSuccess({ + connected: false, + configured: false, + available, + hasOwnCredentials, + }); } const status = await getIntegrationStatus(user.id, "polar"); @@ -36,6 +49,7 @@ export const GET = apiHandler(async () => { connected: true, configured: true, available, + hasOwnCredentials, state: status.state, lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 1e6b801d..83090442 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -23,7 +23,7 @@ vi.mock("@tanstack/react-query", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { OAuthProviderCard } from "../oauth-provider-card"; -function render() { +function render({ credentials = false }: { credentials?: boolean } = {}) { return renderToStaticMarkup( , ); @@ -86,3 +87,38 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => { expect(html).toContain('data-testid="polar-connect"'); }); }); + +describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => { + it("renders the credentials form only when the `credentials` prop is set", () => { + statusPayload = { + connected: false, + configured: false, + available: true, + hasOwnCredentials: false, + }; + // Opt-in: the BYO client-id/secret form + save button appear. + const withForm = render({ credentials: true }); + expect(withForm).toContain('data-testid="polar-credentials"'); + expect(withForm).toContain('id="polar-clientid"'); + expect(withForm).toContain('id="polar-secret"'); + + // Default: no credential inputs (env-only behaviour preserved). + const withoutForm = render(); + expect(withoutForm).not.toContain('data-testid="polar-credentials"'); + expect(withoutForm).not.toContain('id="polar-clientid"'); + }); + + it("shows the saved-placeholder once the user has stored their own pair", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + hasOwnCredentials: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + }; + const html = render({ credentials: true }); + expect(html).toContain("Saved — enter new to replace"); + }); +}); diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index ea783d17..77b2e97f 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -1,16 +1,17 @@ "use client"; -// v1.17.0 (F4) — shared OAuth integration card for env-based providers (Polar, -// Oura). Unlike WHOOP / Fitbit (per-user BYO client id/secret), these use a -// single shared OAuth app the operator configures via env, so the card has NO -// credential inputs — just a connect button that redirects to the provider, a -// status pill off the self-contained status read, and a disconnect action. +// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura. +// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit): +// the card renders an optional "your OAuth app credentials" form (driven by the +// `credentials` prop) above the connect button. Credentials resolve DB-first +// then env on the server, so a user who pastes their own client id/secret uses +// their own app while existing env-configured deploys keep working unchanged. // Mirrors the Nightscout card's self-contained status pattern. import { useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowRight, Link2, Unlink, type LucideIcon } from "lucide-react"; +import { ArrowRight, Link2, Loader2, Save, Unlink, type LucideIcon } from "lucide-react"; import { AlertDialog, @@ -24,13 +25,16 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { IntegrationStatusPill, type IntegrationPillState, } from "@/components/settings/integration-status-pill"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; @@ -38,6 +42,8 @@ export interface OAuthProviderStatus { connected: boolean; configured: boolean; available: boolean; + /** Whether the user has stored their own BYO client id/secret pair. */ + hasOwnCredentials?: boolean; state?: | "connected" | "error_transient" @@ -74,6 +80,9 @@ export interface OAuthProviderCardProps { icon: LucideIcon; /** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */ dataHref: string; + /** When set, render a per-user BYO OAuth-credentials form above the connect + * button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */ + credentials?: boolean; enabled?: boolean; } @@ -83,11 +92,19 @@ export function OAuthProviderCard({ i18nPrefix, icon, dataHref, + credentials = false, enabled = true, }: OAuthProviderCardProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [msg, setMsg] = useState(null); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [credsSaving, setCredsSaving] = useState(false); + const [credsMsg, setCredsMsg] = useState(null); + const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( + null, + ); const { data: status } = useQuery({ queryKey: statusQueryKey, @@ -96,6 +113,42 @@ export function OAuthProviderCard({ refetchOnWindowFocus: true, }); + async function handleSaveCredentials(e: React.FormEvent) { + e.preventDefault(); + setCredsSaving(true); + setCredsMsg(null); + setCredsMsgType(null); + try { + const res = await apiFetchRaw(`/api/${provider}/credentials`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + if (res.ok) { + setCredsMsg(t(`${i18nPrefix}CredentialsSaved`)); + setCredsMsgType("success"); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + } else { + try { + const json = await res.json(); + setCredsMsg(json.error || t("settings.savingError")); + } catch { + setCredsMsg(t("settings.savingError")); + } + setCredsMsgType("error"); + } + } catch { + setCredsMsg(t("common.networkError")); + setCredsMsgType("error"); + } + setCredsSaving(false); + } + const disconnect = useMutation({ mutationFn: async () => { await apiPost(`/api/${provider}/disconnect`); @@ -182,6 +235,89 @@ export function OAuthProviderCard({ {t(`${i18nPrefix}Help`)}

+ {credentials && ( +
+

+ {t(`${i18nPrefix}Credentials`)} +

+

+ {t(`${i18nPrefix}CredentialsHelp`)} +

+
+
+
+ + setClientId(e.target.value)} + placeholder={ + status?.hasOwnCredentials + ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) + : t(`${i18nPrefix}ClientId`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
+
+ + setClientSecret(e.target.value)} + placeholder={ + status?.hasOwnCredentials + ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) + : t(`${i18nPrefix}ClientSecret`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +
+
+
+ +
+ {credsMsg && ( +

+ {credsMsg} +

+ )} +
+
+ )} + {serverUnavailable && (

); diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx index 621cd932..56a31021 100644 --- a/src/components/settings/integrations/polar-card.tsx +++ b/src/components/settings/integrations/polar-card.tsx @@ -1,7 +1,8 @@ "use client"; // v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the -// shared env-based OAuth card. +// shared OAuth card. +// v1.17.1 — per-user BYO OAuth credentials (DB-first then env). import { Watch } from "lucide-react"; @@ -16,6 +17,7 @@ export function PolarCard({ enabled = true }: { enabled?: boolean }) { i18nPrefix="settings.polar" icon={Watch} dataHref="/insights/sleep" + credentials enabled={enabled} /> ); diff --git a/src/lib/oura/__tests__/credentials.test.ts b/src/lib/oura/__tests__/credentials.test.ts new file mode 100644 index 00000000..5e63fc22 --- /dev/null +++ b/src/lib/oura/__tests__/credentials.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findUnique = vi.fn(); +const getOuraCredentials = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } }, +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: (cipher: string) => `dec(${cipher})`, + encrypt: (plain: string) => `enc(${plain})`, +})); + +// The env fallback lives in ./client; stub it so the precedence is observable. +vi.mock("../client", () => ({ + getOuraCredentials: () => getOuraCredentials(), +})); + +import { getOuraClientCredentials } from "../credentials"; + +describe("getOuraClientCredentials — DB-first then env", () => { + beforeEach(() => { + findUnique.mockReset(); + getOuraCredentials.mockReset(); + }); + + it("returns the per-user BYO client id/secret when both columns are set", async () => { + findUnique.mockResolvedValue({ + ouraClientIdEncrypted: "enc-id", + ouraClientSecretEncrypted: "enc-secret", + }); + const creds = await getOuraClientCredentials("user-1"); + expect(creds).toEqual({ + clientId: "dec(enc-id)", + clientSecret: "dec(enc-secret)", + }); + expect(getOuraCredentials).not.toHaveBeenCalled(); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user-1" }, + select: { + ouraClientIdEncrypted: true, + ouraClientSecretEncrypted: true, + }, + }); + }); + + it("falls back to the shared env app when the user has no BYO pair", async () => { + findUnique.mockResolvedValue({ + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + }); + getOuraCredentials.mockReturnValue({ + clientId: "env-id", + clientSecret: "env-secret", + }); + const creds = await getOuraClientCredentials("user-1"); + expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" }); + expect(getOuraCredentials).toHaveBeenCalledOnce(); + }); + + it("falls back to env when only one half of the BYO pair is present", async () => { + findUnique.mockResolvedValue({ + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: "enc-secret", + }); + getOuraCredentials.mockReturnValue({ + clientId: "env-id", + clientSecret: "env-secret", + }); + const creds = await getOuraClientCredentials("user-1"); + expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" }); + }); + + it("returns null when neither the user nor the env is configured", async () => { + findUnique.mockResolvedValue(null); + getOuraCredentials.mockReturnValue(null); + expect(await getOuraClientCredentials("ghost")).toBeNull(); + }); +}); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 2d0fc91e..ebaf221d 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -31,6 +31,7 @@ const { vi.mock("../credentials", () => ({ getOuraConnection: getConnMock, storeOuraTokens: storeTokensMock, + getOuraClientCredentials: getCredsMock, })); vi.mock("@/lib/db", () => ({ @@ -57,7 +58,6 @@ vi.mock("../client", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getOuraCredentials: getCredsMock, fetchReadiness: fetchReadinessMock, fetchSleep: fetchSleepMock, fetchDailyActivity: fetchActivityMock, @@ -73,7 +73,9 @@ const CONN = { accessToken: "acc", refreshToken: "ref" }; beforeEach(() => { getConnMock.mockReset(); storeTokensMock.mockReset().mockResolvedValue(undefined); - getCredsMock.mockReset().mockReturnValue({ clientId: "c", clientSecret: "s" }); + getCredsMock + .mockReset() + .mockResolvedValue({ clientId: "c", clientSecret: "s" }); fetchReadinessMock.mockReset().mockResolvedValue([]); fetchSleepMock.mockReset().mockResolvedValue([]); fetchActivityMock.mockReset().mockResolvedValue([]); diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index ecf9b420..4f95f9e1 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -1,14 +1,76 @@ /** * v1.17.0 (F4) — per-user Oura token accessors. + * v1.17.1 — per-user BYO client id/secret resolver (DB-first then env). * - * Oura uses a single shared OAuth app (env client id/secret via - * `getOuraCredentials`). Per-user state is the granted access + refresh token, - * stored encrypted on `User`. The merged schema carries NO token-expiry column, - * so the sync layer refreshes REACTIVELY on a 401 rather than proactively on an - * expiry timestamp (and persists both rotated tokens). + * Like WHOOP / Fitbit, Oura is now a per-user BYO-key integration: each user may + * register their own Oura app and store the client id/secret encrypted on + * `User`, with a fall-back to the shared env app for existing single-app + * deploys. Also per-user is the granted access + refresh token. The schema + * carries NO token-expiry column, so the sync layer refreshes REACTIVELY on a + * 401 rather than proactively on an expiry timestamp (persisting both rotated + * tokens). */ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; +import { getOuraCredentials, type OuraCredentials } from "./client"; + +/** + * Resolve the user's Oura OAuth client id/secret, DB-first then env. + * + * v1.17.1 makes Oura a per-user BYO-key integration (mirroring WHOOP / Fitbit): + * a self-hoster registers their own Oura app and pastes the client id/secret + * into Settings, stored encrypted on `User`. When the user has not configured + * per-user keys we fall back to the shared env app (`OURA_CLIENT_ID` / + * `OURA_CLIENT_SECRET`) so existing single-app deploys keep working. Returns + * null when neither source is configured. + */ +export async function getOuraClientCredentials( + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + ouraClientIdEncrypted: true, + ouraClientSecretEncrypted: true, + }, + }); + + if (user?.ouraClientIdEncrypted && user?.ouraClientSecretEncrypted) { + return { + clientId: decrypt(user.ouraClientIdEncrypted), + clientSecret: decrypt(user.ouraClientSecretEncrypted), + }; + } + + // Fall back to the shared env-configured OAuth app. + return getOuraCredentials(); +} + +/** Persist the user's Oura OAuth client id/secret, encrypted at rest. */ +export async function storeOuraClientCredentials( + userId: string, + clientId: string, + clientSecret: string, +): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { + ouraClientIdEncrypted: encrypt(clientId), + ouraClientSecretEncrypted: encrypt(clientSecret), + }, + }); +} + +/** Clear the user's stored Oura OAuth client id/secret. */ +export async function clearOuraClientCredentials(userId: string): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + }, + }); +} export interface OuraConnection { accessToken: string; diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 58d110e5..efadb080 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -35,14 +35,17 @@ import { fetchDailyActivity, fetchReadiness, fetchSleep, - getOuraCredentials, mapDailyActivity, mapReadiness, mapSleep, refreshAccessToken, type MappedMeasurement, } from "./client"; -import { getOuraConnection, storeOuraTokens } from "./credentials"; +import { + getOuraClientCredentials, + getOuraConnection, + storeOuraTokens, +} from "./credentials"; import { OuraApiError, classifyOuraError } from "./response-classifier"; /** Default lookback window (days) for an incremental sync. Oura finalises a @@ -118,7 +121,7 @@ async function fetchAll( // case — rethrow so the caller classifies it. if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err; - const creds = getOuraCredentials(); + const creds = await getOuraClientCredentials(userId); if (!creds) throw err; const rotated = await refreshAccessToken(refreshToken, creds); diff --git a/src/lib/polar/__tests__/credentials.test.ts b/src/lib/polar/__tests__/credentials.test.ts new file mode 100644 index 00000000..11f8d5b2 --- /dev/null +++ b/src/lib/polar/__tests__/credentials.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findUnique = vi.fn(); +const getPolarCredentials = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } }, +})); + +// decrypt() is the fail-closed AES-256-GCM reader; the resolver only needs it +// to round-trip the stored ciphertext, so a `dec()` shim is sufficient here. +vi.mock("@/lib/crypto", () => ({ + decrypt: (cipher: string) => `dec(${cipher})`, + encrypt: (plain: string) => `enc(${plain})`, +})); + +// The env fallback lives in ./client; stub it so the precedence is observable. +vi.mock("../client", () => ({ + getPolarCredentials: () => getPolarCredentials(), +})); + +import { getPolarClientCredentials } from "../credentials"; + +describe("getPolarClientCredentials — DB-first then env", () => { + beforeEach(() => { + findUnique.mockReset(); + getPolarCredentials.mockReset(); + }); + + it("returns the per-user BYO client id/secret when both columns are set", async () => { + findUnique.mockResolvedValue({ + polarClientIdEncrypted: "enc-id", + polarClientSecretEncrypted: "enc-secret", + }); + const creds = await getPolarClientCredentials("user-1"); + expect(creds).toEqual({ + clientId: "dec(enc-id)", + clientSecret: "dec(enc-secret)", + }); + // DB hit must not consult the env fallback. + expect(getPolarCredentials).not.toHaveBeenCalled(); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user-1" }, + select: { + polarClientIdEncrypted: true, + polarClientSecretEncrypted: true, + }, + }); + }); + + it("falls back to the shared env app when the user has no BYO pair", async () => { + findUnique.mockResolvedValue({ + polarClientIdEncrypted: null, + polarClientSecretEncrypted: null, + }); + getPolarCredentials.mockReturnValue({ + clientId: "env-id", + clientSecret: "env-secret", + }); + const creds = await getPolarClientCredentials("user-1"); + expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" }); + expect(getPolarCredentials).toHaveBeenCalledOnce(); + }); + + it("falls back to env when only one half of the BYO pair is present", async () => { + findUnique.mockResolvedValue({ + polarClientIdEncrypted: "enc-id", + polarClientSecretEncrypted: null, + }); + getPolarCredentials.mockReturnValue({ + clientId: "env-id", + clientSecret: "env-secret", + }); + const creds = await getPolarClientCredentials("user-1"); + expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" }); + }); + + it("returns null when neither the user nor the env is configured", async () => { + findUnique.mockResolvedValue(null); + getPolarCredentials.mockReturnValue(null); + expect(await getPolarClientCredentials("ghost")).toBeNull(); + }); +}); diff --git a/src/lib/polar/credentials.ts b/src/lib/polar/credentials.ts index ab1cd9fd..61a614e2 100644 --- a/src/lib/polar/credentials.ts +++ b/src/lib/polar/credentials.ts @@ -1,14 +1,75 @@ /** * v1.17.0 (F4) — per-user Polar token accessors. + * v1.17.1 — per-user BYO client id/secret resolver (DB-first then env). * - * Unlike WHOOP (per-user BYO client id/secret), Polar uses a single shared - * OAuth app whose client id/secret come from env (`getPolarCredentials`). What - * is per-user is the granted token + Polar member id, stored encrypted on - * `User`. Polar access tokens do not expire and carry no refresh token, so - * there is no refresh path — a revoked grant surfaces as a 401 on the next read. + * Like WHOOP / Fitbit, Polar is now a per-user BYO-key integration: each user + * may register their own AccessLink app and store the client id/secret encrypted + * on `User`, with a fall-back to the shared env app for existing single-app + * deploys. Also per-user is the granted token + Polar member id. Polar access + * tokens do not expire and carry no refresh token, so there is no refresh path — + * a revoked grant surfaces as a 401 on the next read. */ import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { getPolarCredentials, type PolarCredentials } from "./client"; + +/** + * Resolve the user's Polar OAuth client id/secret, DB-first then env. + * + * v1.17.1 makes Polar a per-user BYO-key integration (mirroring WHOOP / Fitbit): + * a self-hoster registers their own Polar AccessLink app and pastes the client + * id/secret into Settings, stored encrypted on `User`. When the user has not + * configured per-user keys we fall back to the shared env app + * (`POLAR_CLIENT_ID` / `POLAR_CLIENT_SECRET`) so existing single-app deploys + * keep working. Returns null when neither source is configured. + */ +export async function getPolarClientCredentials( + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + polarClientIdEncrypted: true, + polarClientSecretEncrypted: true, + }, + }); + + if (user?.polarClientIdEncrypted && user?.polarClientSecretEncrypted) { + return { + clientId: decrypt(user.polarClientIdEncrypted), + clientSecret: decrypt(user.polarClientSecretEncrypted), + }; + } + + // Fall back to the shared env-configured OAuth app. + return getPolarCredentials(); +} + +/** Persist the user's Polar OAuth client id/secret, encrypted at rest. */ +export async function storePolarClientCredentials( + userId: string, + clientId: string, + clientSecret: string, +): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { + polarClientIdEncrypted: encrypt(clientId), + polarClientSecretEncrypted: encrypt(clientSecret), + }, + }); +} + +/** Clear the user's stored Polar OAuth client id/secret. */ +export async function clearPolarClientCredentials(userId: string): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { + polarClientIdEncrypted: null, + polarClientSecretEncrypted: null, + }, + }); +} export interface PolarConnection { accessToken: string; diff --git a/src/lib/validations/oura.ts b/src/lib/validations/oura.ts new file mode 100644 index 00000000..b444ff74 --- /dev/null +++ b/src/lib/validations/oura.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; + +/** + * Per-user Oura BYO-key credentials. Each self-hoster registers their own Oura + * app and pastes the client id/secret into Settings. Stored encrypted on + * `User`. When unset, the integration falls back to the shared env-configured + * OAuth app. + */ +export const ouraCredentialsSchema = z.object({ + // Trimmed: a trailing space or newline from the portal's copy button reaches + // Oura verbatim and answers as "unknown client". + clientId: z.string().trim().min(1).max(200), + clientSecret: z.string().trim().min(1).max(200), +}); + +export type OuraCredentialsInput = z.infer; diff --git a/src/lib/validations/polar.ts b/src/lib/validations/polar.ts new file mode 100644 index 00000000..c21123c1 --- /dev/null +++ b/src/lib/validations/polar.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; + +/** + * Per-user Polar BYO-key credentials. Each self-hoster registers their own + * Polar AccessLink app and pastes the client id/secret into Settings. Stored + * encrypted on `User`. When unset, the integration falls back to the shared + * env-configured OAuth app. + */ +export const polarCredentialsSchema = z.object({ + // Trimmed: a trailing space or newline from the portal's copy button reaches + // Polar verbatim and answers as "unknown client". + clientId: z.string().trim().min(1).max(200), + clientSecret: z.string().trim().min(1).max(200), +}); + +export type PolarCredentialsInput = z.infer; From 80f3cd878019a26e2d8f7f267b10506de47e0eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:20:54 +0200 Subject: [PATCH 09/79] chore(migrations): renumber sleep-timeline backfill to 0163 for monotonic order --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename prisma/migrations/{0172_v1171_sleep_timeline_backfill => 0163_v1171_sleep_timeline_backfill}/migration.sql (100%) diff --git a/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql b/prisma/migrations/0163_v1171_sleep_timeline_backfill/migration.sql similarity index 100% rename from prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql rename to prisma/migrations/0163_v1171_sleep_timeline_backfill/migration.sql From 5b756f01f23e64528dcdf0474a3e69b1172711d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:21:39 +0200 Subject: [PATCH 10/79] feat(onboarding): add User.onboardingGoals for goal persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goals step let the user toggle goal slugs but both Skip and Next discarded the selection — the picker was decorative. Add the onboarding_goals text array the earlier dead code already referenced so the selection can persist and seed dashboard tiles + reminder suggestions. Not a clinical target store; targets stay fully derived from height/age/gender in /api/insights/targets. Empty array = no preference; purely-additive migration with an empty-array default, no backfill. --- .../0163_v1171_onboarding_goals/migration.sql | 15 +++++++++++++++ prisma/schema.prisma | 9 +++++++++ 2 files changed, 24 insertions(+) create mode 100644 prisma/migrations/0163_v1171_onboarding_goals/migration.sql diff --git a/prisma/migrations/0163_v1171_onboarding_goals/migration.sql b/prisma/migrations/0163_v1171_onboarding_goals/migration.sql new file mode 100644 index 00000000..2eba4558 --- /dev/null +++ b/prisma/migrations/0163_v1171_onboarding_goals/migration.sql @@ -0,0 +1,15 @@ +-- v1.17.1 — persist the onboarding goal selection. +-- +-- The GoalsChipPicker step let the user toggle goal slugs, but both +-- Skip and Next discarded the selection — the picker was decorative. +-- This adds the `onboarding_goals` text array the dead code already +-- referenced, so the selection can seed dashboard tile pinning and +-- reminder suggestions. +-- +-- Purely-additive: one new column on `users`, NOT NULL with a `'{}'` +-- (empty array) default, so every existing row keeps "no preference" +-- without a backfill pass. The column is a personalization seed, not a +-- clinical target store. + +ALTER TABLE "users" + ADD COLUMN "onboarding_goals" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 715611d1..7ca1c51e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -236,6 +236,15 @@ model User { // round-trip — the column stays at 4 for completed users) // Null = legacy pre-W14b user; the wizard treats null as 0 on mount. onboardingStep Int @default(0) @map("onboarding_step") + // v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker). + // Seeds dashboard tile pinning + reminder suggestions; NOT a clinical + // target store (targets are fully derived from height/age/gender + + // clinical refs in /api/insights/targets). Empty [] = skipped / no + // preference. The slug set is the closed enum + // `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[] + // (not a relation) mirrors `insightsExcludeMetrics` precedent — the + // set is small and fixed, never queried relationally. + onboardingGoals String[] @default([]) @map("onboarding_goals") // v1.4.15 Phase B5: tracks whether the user has finished (or // explicitly dismissed) the spotlight tour that overlays the // dashboard on first arrival. Distinct from the wizard at From 5f601426a97eb98fcd0cb0a2d6315bac1419f2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:21:48 +0200 Subject: [PATCH 11/79] feat(onboarding): persist goal selection and seed the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the goals step to actually keep its promise. The picker now posts the chosen slugs on the step-2 submit; the step endpoint validates them against the closed slug enum (422 on an unknown slug), persists them to User.onboardingGoals field-by-field, and on completion seeds dashboardWidgetsJson from the selection — promoting the goal-mapped tiles to the top and forcing them visible. The seed is one-time, gated on a null dashboardWidgetsJson via the updateMany WHERE clause, so a user who already arranged tiles is never clobbered. An empty or general-wellness-only selection leaves the column untouched (default layout). The resulting layout is what both the web dashboard and the iOS widgets contract read — no client recompute. Skip posts an explicit empty array (deliberate no preference). --- src/app/api/onboarding/__tests__/step.test.ts | 145 ++++++++++++++++++ src/app/api/onboarding/step/route.ts | 61 +++++++- src/components/onboarding/GoalsChipPicker.tsx | 28 ++-- src/lib/onboarding/__tests__/goals.test.ts | 104 +++++++++++++ src/lib/onboarding/goals.ts | 108 +++++++++++++ 5 files changed, 431 insertions(+), 15 deletions(-) create mode 100644 src/lib/onboarding/__tests__/goals.test.ts create mode 100644 src/lib/onboarding/goals.ts diff --git a/src/app/api/onboarding/__tests__/step.test.ts b/src/app/api/onboarding/__tests__/step.test.ts index 0f8f3b06..1700626a 100644 --- a/src/app/api/onboarding/__tests__/step.test.ts +++ b/src/app/api/onboarding/__tests__/step.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({ updateMany: vi.fn(), }, }, + toJson: (v: unknown) => v, })); vi.mock("@/lib/auth/session", () => ({ @@ -249,3 +250,147 @@ describe("POST /api/onboarding/step — rate limit", () => { expect(res.status).toBe(429); }); }); + +describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => { + it("persists the selected goal slugs on the step-2 submit", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + const res = await POST( + req({ step: 2, goals: ["weight-management", "bp-tracking"] }), + ); + expect(res.status).toBe(200); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).toMatchObject({ + onboardingStep: 2, + onboardingGoals: ["weight-management", "bp-tracking"], + }); + }); + + it("persists an explicit empty array on skip (no preference)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + await POST(req({ step: 2, goals: [] })); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).toMatchObject({ onboardingGoals: [] }); + }); + + it("rejects an unknown goal slug with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] })); + expect(res.status).toBe(422); + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + }); + + it("does NOT write onboardingGoals when the field is omitted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + await POST(req({ step: 2 })); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).not.toHaveProperty("onboardingGoals"); + }); +}); + +describe("POST /api/onboarding/step — dashboard seeding on completion", () => { + it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // 1st findUnique = the fresh step-read; 2nd = the seed-read. + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: ["glucose-tracking"], + dashboardWidgetsJson: null, + } as never); + // 1st updateMany = the step claim; 2nd = the gated seed write. + vi.mocked(prisma.user.updateMany) + .mockResolvedValueOnce({ count: 1 } as never) + .mockResolvedValueOnce({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + const res = await POST(req({ step: 4 })); + expect(res.status).toBe(200); + // The second updateMany is the seed; its WHERE pins the null gate + // and its data carries a dashboard layout. + expect(prisma.user.updateMany).toHaveBeenCalledTimes(2); + const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0]; + expect(seedCall.data).toHaveProperty("dashboardWidgetsJson"); + expect(seedCall.where).toHaveProperty("dashboardWidgetsJson"); + }); + + it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: ["glucose-tracking"], + dashboardWidgetsJson: { version: 1, widgets: [] }, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + const res = await POST(req({ step: 4 })); + expect(res.status).toBe(200); + // Only the step-claim updateMany fired — the seed was gated out + // because the user already has a layout. + expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); + }); + + it("does NOT seed when stored goals are empty (no preference)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: [], + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + await POST(req({ step: 4 })); + expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 50e73f71..0ef6ee0e 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -10,9 +10,14 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; -import { prisma } from "@/lib/db"; +import { prisma, toJson } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + ONBOARDING_GOAL_SLUGS, + buildGoalSeededDashboardLayout, +} from "@/lib/onboarding/goals"; /** * v1.4.25 W14b — POST /api/onboarding/step. @@ -42,6 +47,16 @@ import { checkRateLimit } from "@/lib/rate-limit"; const stepBodySchema = z.object({ step: z.number().int().min(1).max(4), + // v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2 + // submit). Validated against the closed slug enum so an unknown slug + // 422s; deduped + capped at the full set size. Persisted to + // `User.onboardingGoals` (field-by-field, no mass assignment) and on + // completion (step 4) seeds the dashboard layout when the user never + // customized it. Omitted leaves the stored goals untouched. + goals: z + .array(z.enum(ONBOARDING_GOAL_SLUGS)) + .max(ONBOARDING_GOAL_SLUGS.length) + .optional(), }); export const POST = apiHandler(async (request: NextRequest) => { @@ -74,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); return apiError("Invalid step payload", 422); } - const { step } = parsed.data; + const { step, goals } = parsed.data; // Re-fetch the fresh User row so concurrent step submissions in // separate tabs can't race past each other — the session.user @@ -126,9 +141,14 @@ export const POST = apiHandler(async (request: NextRequest) => { onboardingCompletedAt: null, onboardingStep: { in: [current] }, }, + // Field-by-field assembly — no mass assignment. `goals` only + // lands when the client sent it (the step-2 submit); it is already + // validated against the closed slug enum by the Zod schema, so the + // array reaching Prisma can never contain an out-of-set value. data: { onboardingStep: step, ...(completing ? { onboardingCompletedAt: new Date() } : {}), + ...(goals !== undefined ? { onboardingGoals: goals } : {}), }, }); if (claimed.count !== 1) { @@ -149,11 +169,41 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + let goalsSeeded = false; if (completing) { // Mirror /api/onboarding/complete — clear the proxy-readable // pending cookie so the next navigation drops the /onboarding // redirect immediately. await setOnboardingPendingCookie(false); + + // v1.17.1 — seed the dashboard from the stored goal selection. + // ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who + // already arranged tiles is never clobbered. The seed promotes the + // goal-mapped tiles to the top and forces them visible; an empty / + // general-wellness-only selection produces null and leaves the + // column untouched (default layout). The resulting layout is what + // both the web dashboard and the iOS widgets contract read — + // server-authoritative, no client recompute. + const seedRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { onboardingGoals: true, dashboardWidgetsJson: true }, + }); + if (seedRow && seedRow.dashboardWidgetsJson == null) { + const seededLayout = buildGoalSeededDashboardLayout( + seedRow.onboardingGoals, + ); + if (seededLayout) { + // updateMany so the `dashboardWidgetsJson: null` precondition + // rides in the WHERE clause — a concurrent Settings → Dashboard + // save that lands first leaves `count = 0` and the seed is + // skipped rather than overwriting the user's fresh layout. + const seeded = await prisma.user.updateMany({ + where: { id: user.id, dashboardWidgetsJson: { equals: Prisma.JsonNull } }, + data: { dashboardWidgetsJson: toJson(seededLayout) }, + }); + goalsSeeded = seeded.count === 1; + } + } } await auditLog("onboarding.step", { @@ -167,7 +217,12 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "onboarding.step" }, - meta: { outcome: completing ? "completed" : "advanced", step }, + meta: { + outcome: completing ? "completed" : "advanced", + step, + ...(goals !== undefined ? { goals_count: goals.length } : {}), + ...(completing ? { goals_seeded: goalsSeeded } : {}), + }, }); return apiSuccess({ diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx index 63708d0b..488acd62 100644 --- a/src/components/onboarding/GoalsChipPicker.tsx +++ b/src/components/onboarding/GoalsChipPicker.tsx @@ -24,22 +24,19 @@ import { apiPost } from "@/lib/api/api-fetch"; * v1.4.25 W14b-Content — onboarding step 1 (goals). * * Multi-select chip picker for the metrics the user wants to track. - * The goal slugs match a stable enum that v1.4.26 will land on the - * `User` row (`User.onboardingGoals`). Until that column ships the - * picker holds selection in component state only — there's no - * persistence layer to drop into. The earlier localStorage block - * had no consumer (no other surface reads the same key) and the - * single-step wizard makes pre-selection on resume unnecessary, so - * leaning on component state until the column lands keeps the code - * surface honest. + * The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS` + * (`src/lib/onboarding/goals.ts`). Selection lives in component state + * while the step is open; on advance it is persisted server-side to + * `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile + * layout on completion and feeds reminder suggestions. * * CTAs: * - Back → `/onboarding/0` - * - Skip → advance with empty goal set + * - Skip → advance with empty goal set (explicit "no preference") * - Next → advance with chosen goal set * - * Both Skip and Next call POST `/api/onboarding/step` with `{ step: 2 }` - * and `router.push("/onboarding/2")`. + * Both Skip and Next call POST `/api/onboarding/step` with + * `{ step: 2, goals }` and `router.push("/onboarding/2")`. */ export type OnboardingGoalSlug = @@ -122,7 +119,14 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) { if (advancing) return; setAdvancing(true); try { - await apiPost("/api/onboarding/step", { step: 2 }); + // v1.17.1 — persist the chosen goal slugs alongside the step + // write. Skip sends an empty array (explicit "no preference"), + // not omission, so a resume can read the deliberate empty choice. + // The server seeds the dashboard from these on completion. + await apiPost("/api/onboarding/step", { + step: 2, + goals: Array.from(selected), + }); await queryClient.invalidateQueries({ queryKey: ["auth"] }); router.push("/onboarding/2"); } catch (err) { diff --git a/src/lib/onboarding/__tests__/goals.test.ts b/src/lib/onboarding/__tests__/goals.test.ts new file mode 100644 index 00000000..039be8b0 --- /dev/null +++ b/src/lib/onboarding/__tests__/goals.test.ts @@ -0,0 +1,104 @@ +/** + * v1.17.1 — onboarding goal slug enum + dashboard seeding. + * + * Goals are a personalization seed, not a clinical target store: the + * only leverage they have is which dashboard tiles get promoted and + * made visible. These tests pin (a) the closed slug guard and (b) the + * one-time seed builder's promote/forces-visible/no-op contracts. + */ +import { describe, it, expect } from "vitest"; + +import { + ONBOARDING_GOAL_SLUGS, + GOAL_WIDGET_SEED_MAP, + isOnboardingGoalSlug, + buildGoalSeededDashboardLayout, +} from "../goals"; +import { + DEFAULT_DASHBOARD_LAYOUT, + type DashboardWidgetId, +} from "@/lib/dashboard-layout"; + +describe("isOnboardingGoalSlug", () => { + it("accepts every member of the closed slug set", () => { + for (const slug of ONBOARDING_GOAL_SLUGS) { + expect(isOnboardingGoalSlug(slug)).toBe(true); + } + }); + + it("rejects unknown / non-string values", () => { + expect(isOnboardingGoalSlug("not-a-goal")).toBe(false); + expect(isOnboardingGoalSlug("")).toBe(false); + expect(isOnboardingGoalSlug(null)).toBe(false); + expect(isOnboardingGoalSlug(42)).toBe(false); + }); +}); + +describe("GOAL_WIDGET_SEED_MAP", () => { + it("only maps to ids the dashboard layout actually knows", () => { + const known = new Set( + DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId), + ); + for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) { + for (const id of ids) expect(known.has(id)).toBe(true); + } + }); + + it("leaves general-wellness without a tile preference", () => { + expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]); + }); +}); + +describe("buildGoalSeededDashboardLayout", () => { + it("returns null for an empty selection (no seed)", () => { + expect(buildGoalSeededDashboardLayout([])).toBeNull(); + }); + + it("returns null for a general-wellness-only selection", () => { + expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull(); + }); + + it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => { + expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull(); + }); + + it("promotes glucose to the top and forces it visible", () => { + const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]); + expect(layout).not.toBeNull(); + const widgets = layout!.widgets; + // glucose is default-invisible + ordered low; the seed lifts it to + // order 0 and turns both surfaces on. + const glucose = widgets.find((w) => w.id === "glucose"); + expect(glucose).toMatchObject({ + order: 0, + visible: true, + tileVisible: true, + }); + }); + + it("promotes every mapped tile for a multi-goal selection", () => { + const layout = buildGoalSeededDashboardLayout([ + "weight-management", + "bp-tracking", + ]); + expect(layout).not.toBeNull(); + const widgets = layout!.widgets; + const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]); + // Every promoted id sits in the leading block and is visible. + for (const id of promotedIds) { + const w = widgets.find((x) => x.id === id); + expect(w?.visible).toBe(true); + expect(w?.tileVisible).toBe(true); + expect(w!.order).toBeLessThan(promotedIds.size); + } + }); + + it("keeps a dense, complete widget set (no tiles dropped)", () => { + const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]); + expect(layout!.widgets.length).toBe( + DEFAULT_DASHBOARD_LAYOUT.widgets.length, + ); + const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b); + expect(orders).toEqual(orders.map((_, i) => i)); + }); +}); diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts new file mode 100644 index 00000000..4a2fc7eb --- /dev/null +++ b/src/lib/onboarding/goals.ts @@ -0,0 +1,108 @@ +/** + * v1.17.1 — onboarding goal slugs + dashboard seeding. + * + * The closed set of goal slugs the GoalsChipPicker offers, plus the + * server-authoritative seeding that turns a goal selection into a + * personalization signal: which dashboard tiles get promoted to the + * top and made visible. Goals are NOT a clinical target store — the + * targets surface (`/api/insights/targets`) is fully derived from + * height/age/gender + clinical references. Their only leverage is + * (a) dashboard tile pinning (here) and (b) reminder suggestions + * (read by the reminders panel from `User.onboardingGoals`). + * + * One home for the slug enum so the API validation + * (`/api/onboarding/step`), the client picker, and the seeding all + * read the same source — same-key drift was the v1.4.16 dashboard-enum + * bug class. + */ +import { + DEFAULT_DASHBOARD_LAYOUT, + serializeDashboardLayout, + type DashboardLayout, + type DashboardWidgetId, +} from "@/lib/dashboard-layout"; + +/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */ +export const ONBOARDING_GOAL_SLUGS = [ + "weight-management", + "bp-tracking", + "glucose-tracking", + "sleep-improvement", + "medication-compliance", + "general-wellness", +] as const; + +export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number]; + +export function isOnboardingGoalSlug( + value: unknown, +): value is OnboardingGoalSlug { + return ( + typeof value === "string" && + (ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value) + ); +} + +/** + * Slug → dashboard widget ids to promote. `general-wellness` maps to + * nothing — it carries no tile preference, so a general-wellness-only + * selection leaves the default layout untouched. Ids must be members + * of `DASHBOARD_WIDGET_IDS`; the seed never invents an id. + */ +export const GOAL_WIDGET_SEED_MAP: Record< + OnboardingGoalSlug, + readonly DashboardWidgetId[] +> = { + "weight-management": ["weight", "bodyFat"], + "bp-tracking": ["bp", "bpInTarget"], + "glucose-tracking": ["glucose"], + "sleep-improvement": ["sleep"], + "medication-compliance": ["medications"], + "general-wellness": [], +}; + +/** + * Build the seeded dashboard layout for a goal selection. + * + * Returns a layout where the goal-mapped tiles are promoted to the top + * of the order (preserving their relative order within the default + * layout) and forced visible on both surfaces; every other tile keeps + * its default config, shifted down. Returns `null` when there is + * nothing to promote (empty selection or general-wellness only) so the + * caller can skip the write entirely and leave the column null → + * default layout. + * + * This is a ONE-TIME seed: the caller MUST gate it on + * `User.dashboardWidgetsJson == null` so it never clobbers a user who + * already arranged tiles. + */ +export function buildGoalSeededDashboardLayout( + goals: readonly string[] | null | undefined, +): DashboardLayout | null { + if (!goals || goals.length === 0) return null; + const promoted = new Set(); + for (const slug of goals) { + if (!isOnboardingGoalSlug(slug)) continue; + for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id); + } + if (promoted.size === 0) return null; + + const base = DEFAULT_DASHBOARD_LAYOUT.widgets; + const promotedWidgets = base.filter((w) => promoted.has(w.id as DashboardWidgetId)); + const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId)); + + const ordered = [...promotedWidgets, ...rest].map((w, index) => ({ + ...w, + // Force the goal-mapped tiles visible on both surfaces; leave the + // rest exactly as the default layout had them. + ...(promoted.has(w.id as DashboardWidgetId) + ? { visible: true, tileVisible: true } + : {}), + order: index, + })); + + return serializeDashboardLayout({ + ...DEFAULT_DASHBOARD_LAYOUT, + widgets: ordered, + }); +} From c8cd5aa22602a78f764d07d5c6a9be49ceb2e120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:21:58 +0200 Subject: [PATCH 12/79] feat(onboarding): add an optional anamnesis step on baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collapsible "About your health" card on the baseline step captures the genuinely-new baseline the app can act on — chronic conditions and allergies — without re-asking the height/sex/birthdate already gathered above. Both write through the existing encrypted self-context path (PUT /api/coach/about-me), so there is no new model and no new contract; the form preserves any existing free-text about-me on save and only sends a field the user actually typed. The card is collapsed by default with a clear optional framing, so the step stays clear and the progress bar keeps its five dots; every field is editable later in Settings. Copy is warm and medically grounded — context, not a clinical form — with keys across all six locales. Also re-truths the goals body copy now that the selection genuinely surfaces on the dashboard. --- messages/de.json | 13 +- messages/en.json | 13 +- messages/es.json | 13 +- messages/fr.json | 13 +- messages/it.json | 13 +- messages/pl.json | 13 +- src/components/onboarding/AnamnesisCard.tsx | 155 ++++++++++++++++++ src/components/onboarding/BaselineForm.tsx | 61 ++++++- .../__tests__/anamnesis-card.test.tsx | 79 +++++++++ 9 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 src/components/onboarding/AnamnesisCard.tsx create mode 100644 src/components/onboarding/__tests__/anamnesis-card.test.tsx diff --git a/messages/de.json b/messages/de.json index 01784ec8..3f3db97a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3648,7 +3648,7 @@ }, "goals": { "title": "Was möchtest du erfassen?", - "body": "Wähle die Bereiche, die dir wichtig sind – das hilft dir zu entscheiden, was du verfolgen und als Nächstes einrichten willst.", + "body": "Wähle die Bereiche, die dir wichtig sind – wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.", "helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.", "options": { "weightManagement": "Gewicht im Griff", @@ -3733,6 +3733,17 @@ "title": "Erkenne das Muster", "body": "Tageszusammenfassungen, Trend-Diagramme und ein Coach, der zeigt was sich verändert hat — alles auf deinem Gerät." } + }, + "anamnesis": { + "title": "Zu deiner Gesundheit (optional)", + "subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.", + "intro": "Teile so viel oder so wenig, wie du möchtest – jedes Feld ist optional und jederzeit in den Einstellungen änderbar.", + "conditionsLabel": "Bestehende Erkrankungen", + "conditionsPlaceholder": "z. B. Bluthochdruck, Typ-2-Diabetes", + "conditionsHint": "Hilft HealthLog, deine Werte im Kontext einzuordnen, statt Erwartbares zu melden.", + "allergiesLabel": "Allergien oder Unverträglichkeiten", + "allergiesPlaceholder": "z. B. Penicillin, Laktose", + "privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab – die kannst du jederzeit auf der Medikamenten-Seite ergänzen." } }, "gettingStarted": { diff --git a/messages/en.json b/messages/en.json index a78cc1a2..67925684 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3648,7 +3648,7 @@ }, "goals": { "title": "What would you like to track?", - "body": "Pick the areas you care about — it helps you decide what to track and set up next.", + "body": "Pick the areas you care about — we'll surface them first on your dashboard, and they help you decide what to set up next.", "helpHint": "One to three priorities tend to work best — you can always add more later.", "options": { "weightManagement": "Weight management", @@ -3733,6 +3733,17 @@ "title": "See the pattern", "body": "Daily summaries, trend charts, and a coach that surfaces what changed — all on your own device." } + }, + "anamnesis": { + "title": "About your health (optional)", + "subtitle": "A little context so HealthLog reads your numbers more fairly.", + "intro": "Share as much or as little as you like — every field is optional and you can edit it anytime in Settings.", + "conditionsLabel": "Ongoing conditions", + "conditionsPlaceholder": "e.g. high blood pressure, type 2 diabetes", + "conditionsHint": "Helps HealthLog frame your readings in context rather than flagging the expected.", + "allergiesLabel": "Allergies or intolerances", + "allergiesPlaceholder": "e.g. penicillin, lactose", + "privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here — add those anytime from the Medications page." } }, "gettingStarted": { diff --git a/messages/es.json b/messages/es.json index 07ef722c..2a34af1a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3648,7 +3648,7 @@ }, "goals": { "title": "¿Qué te gustaría registrar?", - "body": "Elige las áreas que te interesan: te ayuda a decidir qué seguir y qué configurar a continuación.", + "body": "Elige las áreas que te interesan: las mostraremos primero en tu panel y te ayudarán a decidir qué configurar a continuación.", "helpHint": "Una a tres prioridades suelen funcionar mejor — siempre podrás añadir más más adelante.", "options": { "weightManagement": "Control del peso", @@ -3733,6 +3733,17 @@ "title": "Descubre los patrones", "body": "Resúmenes diarios, gráficas de tendencias y un coach que destaca lo que cambió — todo en tu propio dispositivo." } + }, + "anamnesis": { + "title": "Sobre tu salud (opcional)", + "subtitle": "Un poco de contexto para que HealthLog interprete tus valores de forma más justa.", + "intro": "Comparte lo que quieras: cada campo es opcional y puedes editarlo cuando quieras en Ajustes.", + "conditionsLabel": "Afecciones actuales", + "conditionsPlaceholder": "p. ej. hipertensión, diabetes tipo 2", + "conditionsHint": "Ayuda a HealthLog a interpretar tus lecturas en contexto en lugar de marcar lo esperado.", + "allergiesLabel": "Alergias o intolerancias", + "allergiesPlaceholder": "p. ej. penicilina, lactosa", + "privacyNote": "Se almacena cifrado y solo se usa para darte mejor contexto. Aquí no te pedimos tu medicación: puedes añadirla cuando quieras desde la página de Medicación." } }, "gettingStarted": { diff --git a/messages/fr.json b/messages/fr.json index acec48c7..62b8a5f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3648,7 +3648,7 @@ }, "goals": { "title": "Que souhaitez-vous suivre ?", - "body": "Sélectionnez les domaines qui comptent pour vous : cela vous aide à décider quoi suivre et configurer ensuite.", + "body": "Sélectionnez les domaines qui comptent pour vous : nous les afficherons en premier sur votre tableau de bord, et ils vous aident à décider quoi configurer ensuite.", "helpHint": "Un à trois axes prioritaires fonctionnent généralement le mieux, mais vous pourrez en ajouter plus tard.", "options": { "weightManagement": "Gestion du poids", @@ -3733,6 +3733,17 @@ "title": "Repérez les tendances", "body": "Résumés quotidiens, graphiques de tendances et un coach qui met en avant ce qui a changé — le tout sur votre appareil." } + }, + "anamnesis": { + "title": "À propos de votre santé (facultatif)", + "subtitle": "Un peu de contexte pour que HealthLog interprète vos valeurs plus justement.", + "intro": "Partagez autant ou aussi peu que vous le souhaitez : chaque champ est facultatif et modifiable à tout moment dans les Réglages.", + "conditionsLabel": "Affections en cours", + "conditionsPlaceholder": "p. ex. hypertension, diabète de type 2", + "conditionsHint": "Aide HealthLog à replacer vos mesures dans leur contexte plutôt que de signaler l'attendu.", + "allergiesLabel": "Allergies ou intolérances", + "allergiesPlaceholder": "p. ex. pénicilline, lactose", + "privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici — ajoutez-les quand vous voulez depuis la page Médicaments." } }, "gettingStarted": { diff --git a/messages/it.json b/messages/it.json index e825a177..b65f81c2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3648,7 +3648,7 @@ }, "goals": { "title": "Cosa vuoi monitorare?", - "body": "Scegli gli ambiti che ti interessano: ti aiuta a decidere cosa monitorare e configurare in seguito.", + "body": "Scegli gli ambiti che ti interessano: li mostreremo per primi nella tua dashboard e ti aiutano a decidere cosa configurare in seguito.", "helpHint": "Da una a tre priorità funzionano di solito meglio — potrai aggiungerne altre in qualsiasi momento.", "options": { "weightManagement": "Gestione del peso", @@ -3733,6 +3733,17 @@ "title": "Scopri lo schema", "body": "Riepiloghi giornalieri, grafici di tendenza e un coach che evidenzia i cambiamenti — tutto sul tuo dispositivo." } + }, + "anamnesis": { + "title": "Sulla tua salute (facoltativo)", + "subtitle": "Un po' di contesto perché HealthLog interpreti i tuoi valori in modo più equo.", + "intro": "Condividi quanto vuoi: ogni campo è facoltativo e modificabile in qualsiasi momento nelle Impostazioni.", + "conditionsLabel": "Condizioni in corso", + "conditionsPlaceholder": "es. ipertensione, diabete di tipo 2", + "conditionsHint": "Aiuta HealthLog a inquadrare le tue misurazioni nel contesto invece di segnalare l'atteso.", + "allergiesLabel": "Allergie o intolleranze", + "allergiesPlaceholder": "es. penicillina, lattosio", + "privacyNote": "Memorizzato cifrato e usato solo per darti un contesto migliore. Qui non chiediamo i tuoi farmaci: aggiungili quando vuoi dalla pagina Farmaci." } }, "gettingStarted": { diff --git a/messages/pl.json b/messages/pl.json index b551d7b0..22ee2065 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3648,7 +3648,7 @@ }, "goals": { "title": "Co chcesz śledzić?", - "body": "Wybierz obszary, które są dla Ciebie ważne — to pomaga zdecydować, co śledzić i co skonfigurować w następnej kolejności.", + "body": "Wybierz obszary, które są dla Ciebie ważne — pokażemy je najpierw na pulpicie i pomogą Ci zdecydować, co skonfigurować dalej.", "helpHint": "Od jednego do trzech priorytetów zwykle sprawdza się najlepiej — możesz dodać kolejne w dowolnej chwili.", "options": { "weightManagement": "Kontrola wagi", @@ -3733,6 +3733,17 @@ "title": "Zobacz wzorce", "body": "Codzienne podsumowania, wykresy trendów i coach, który wskazuje co się zmieniło — wszystko na Twoim urządzeniu." } + }, + "anamnesis": { + "title": "O Twoim zdrowiu (opcjonalnie)", + "subtitle": "Trochę kontekstu, aby HealthLog rzetelniej odczytywał Twoje wyniki.", + "intro": "Podziel się tym, czym chcesz — każde pole jest opcjonalne i możesz je zmienić w dowolnej chwili w Ustawieniach.", + "conditionsLabel": "Aktualne schorzenia", + "conditionsPlaceholder": "np. nadciśnienie, cukrzyca typu 2", + "conditionsHint": "Pomaga HealthLog ujmować Twoje pomiary w kontekście, zamiast oznaczać to, czego należy się spodziewać.", + "allergiesLabel": "Alergie lub nietolerancje", + "allergiesPlaceholder": "np. penicylina, laktoza", + "privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki — dodasz je w dowolnej chwili na stronie Leki." } }, "gettingStarted": { diff --git a/src/components/onboarding/AnamnesisCard.tsx b/src/components/onboarding/AnamnesisCard.tsx new file mode 100644 index 00000000..723d6f98 --- /dev/null +++ b/src/components/onboarding/AnamnesisCard.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useId, useState } from "react"; +import { ChevronDown, HeartHandshake } from "lucide-react"; + +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.17.1 — optional "About your health" card on the onboarding + * Baseline step (3). + * + * Captures the genuinely-new baseline the app can act on — chronic + * conditions and allergies / intolerances — that the height/sex/dob + * fields above do NOT already cover. Both write through the existing + * encrypted self-context path (`PUT /api/coach/about-me`), so there is + * no new model and no new contract; the parent BaselineForm preserves + * any existing free-text `aboutMe` on save. + * + * Collapsed by default with a prominent skip framing — the step stays + * übersichtlich and every field is optional and editable later in + * Settings → AI. Warm, medically-grounded copy: framed as "so + * HealthLog reads your numbers in context," never as a clinical form. + * + * Controlled: the parent owns the values so it can persist them in the + * same submit as the profile fields. + */ +export interface AnamnesisValue { + conditions: string; + allergies: string; +} + +/** + * Build the `PUT /api/coach/about-me` body for the anamnesis answers, + * or `null` when the card was left untouched (so an untouched collapsed + * card never round-trips). Preserves the existing free-text `aboutMe` + * (the PUT schema requires it and an empty value clears it) and only + * includes conditions / allergies when the user typed something — so an + * unrelated stored value is never cleared. Field-by-field assembly: no + * mass-spread of the value object into the body. + */ +export function buildAnamnesisAboutMeBody( + baseAboutMe: string, + value: AnamnesisValue, +): Record | null { + const conditions = value.conditions.trim(); + const allergies = value.allergies.trim(); + if (!conditions && !allergies) return null; + const body: Record = { aboutMe: baseAboutMe }; + if (conditions) body.conditions = conditions; + if (allergies) body.allergies = allergies; + return body; +} + +export interface AnamnesisCardProps { + value: AnamnesisValue; + onChange: (next: AnamnesisValue) => void; + disabled?: boolean; +} + +/** Per-field cap mirrors ABOUT_ME_FIELD_MAX_CHARS on the server. */ +const FIELD_MAX = 500; + +export function AnamnesisCard({ value, onChange, disabled }: AnamnesisCardProps) { + const { t } = useTranslations(); + const [expanded, setExpanded] = useState(false); + const conditionsId = useId(); + const allergiesId = useId(); + const panelId = useId(); + + return ( +

+ + + {expanded ? ( +
+

+ {t("onboarding.anamnesis.intro")} +

+ +
+ +