diff --git a/CHANGELOG.md b/CHANGELOG.md index e308b49c7..f8846a835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.27.0] — 2026-07-03 — Fitbit and Pixel Watch through Google Health + +### Added + +- A new Google Health connection reads your Fitbit, Pixel Watch, and Fitbit Air data through your Google account: heart rate and resting heart rate, heart-rate variability, blood-oxygen, respiratory rate, steps, distance, floors climbed, active energy, cardio fitness, sleep, and weight. It runs alongside the existing Fitbit connection and is the path forward as Google retires the older Fitbit Web API in September 2026 — connect through Google and your devices keep syncing. You register your own Google client once, and the setup guide walks through it step by step. Two things are stated plainly up front: stress and readiness scores are not offered by this interface, and Google may ask you to reconnect from time to time — the connection shows a clear prompt when it does. + ## [1.26.1] — 2026-07-02 — Fitbit setup guide fix ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c3a2880cc..73144fe66 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.26.1 + version: 1.27.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2171,6 +2171,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH - in: query name: valueMin schema: @@ -3784,6 +3785,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH activeEnergy: maxItems: 8 type: array @@ -3802,6 +3804,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH walkingRunningDistance: maxItems: 8 type: array @@ -3820,6 +3823,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH flightsClimbed: maxItems: 8 type: array @@ -3838,6 +3842,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleep: maxItems: 8 type: array @@ -3856,6 +3861,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH weight: maxItems: 8 type: array @@ -3874,6 +3880,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bloodPressure: maxItems: 8 type: array @@ -3892,6 +3899,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH pulse: maxItems: 8 type: array @@ -3910,6 +3918,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyFat: maxItems: 8 type: array @@ -3928,6 +3937,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyTemperature: maxItems: 8 type: array @@ -3946,6 +3956,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH spo2: maxItems: 8 type: array @@ -3964,6 +3975,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH hrv: maxItems: 8 type: array @@ -3982,6 +3994,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH restingHeartRate: maxItems: 8 type: array @@ -4000,6 +4013,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH vo2Max: maxItems: 8 type: array @@ -4018,6 +4032,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH skinTemperature: maxItems: 8 type: array @@ -4036,6 +4051,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH respiratoryRate: maxItems: 8 type: array @@ -4054,6 +4070,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH recovery: maxItems: 8 type: array @@ -4072,6 +4089,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH stress: maxItems: 8 type: array @@ -4090,6 +4108,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleepDebt: maxItems: 8 type: array @@ -4108,6 +4127,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH metricPriority: type: object properties: @@ -4129,6 +4149,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH activeEnergy: maxItems: 8 type: array @@ -4147,6 +4168,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH walkingRunningDistance: maxItems: 8 type: array @@ -4165,6 +4187,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH flightsClimbed: maxItems: 8 type: array @@ -4183,6 +4206,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleep: maxItems: 8 type: array @@ -4201,6 +4225,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH weight: maxItems: 8 type: array @@ -4219,6 +4244,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bloodPressure: maxItems: 8 type: array @@ -4237,6 +4263,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH pulse: maxItems: 8 type: array @@ -4255,6 +4282,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyFat: maxItems: 8 type: array @@ -4273,6 +4301,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyTemperature: maxItems: 8 type: array @@ -4291,6 +4320,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH spo2: maxItems: 8 type: array @@ -4309,6 +4339,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH hrv: maxItems: 8 type: array @@ -4327,6 +4358,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH restingHeartRate: maxItems: 8 type: array @@ -4345,6 +4377,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH vo2Max: maxItems: 8 type: array @@ -4363,6 +4396,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH skinTemperature: maxItems: 8 type: array @@ -4381,6 +4415,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH respiratoryRate: maxItems: 8 type: array @@ -4399,6 +4434,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH recovery: maxItems: 8 type: array @@ -4417,6 +4453,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH stress: maxItems: 8 type: array @@ -4435,6 +4472,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleepDebt: maxItems: 8 type: array @@ -4453,6 +4491,7 @@ paths: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH deviceTypePriority: type: object properties: @@ -13876,6 +13915,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH notes: anyOf: - type: string @@ -14624,6 +14664,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH notes: anyOf: - type: string @@ -14868,6 +14909,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH - type: "null" start: type: string @@ -14996,6 +15038,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH - type: "null" start: type: string @@ -15175,6 +15218,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would be a device-native debt. Clients explain the figure when it is `COMPUTED`. @@ -15389,6 +15433,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH externalId: anyOf: - type: string @@ -15499,6 +15544,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH externalId: anyOf: - type: string @@ -17767,6 +17813,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH activeEnergy: maxItems: 8 type: array @@ -17785,6 +17832,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH walkingRunningDistance: maxItems: 8 type: array @@ -17803,6 +17851,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH flightsClimbed: maxItems: 8 type: array @@ -17821,6 +17870,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleep: maxItems: 8 type: array @@ -17839,6 +17889,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH weight: maxItems: 8 type: array @@ -17857,6 +17908,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bloodPressure: maxItems: 8 type: array @@ -17875,6 +17927,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH pulse: maxItems: 8 type: array @@ -17893,6 +17946,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyFat: maxItems: 8 type: array @@ -17911,6 +17965,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyTemperature: maxItems: 8 type: array @@ -17929,6 +17984,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH spo2: maxItems: 8 type: array @@ -17947,6 +18003,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH hrv: maxItems: 8 type: array @@ -17965,6 +18022,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH restingHeartRate: maxItems: 8 type: array @@ -17983,6 +18041,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH vo2Max: maxItems: 8 type: array @@ -18001,6 +18060,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH skinTemperature: maxItems: 8 type: array @@ -18019,6 +18079,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH respiratoryRate: maxItems: 8 type: array @@ -18037,6 +18098,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH recovery: maxItems: 8 type: array @@ -18055,6 +18117,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH stress: maxItems: 8 type: array @@ -18073,6 +18136,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleepDebt: maxItems: 8 type: array @@ -18091,6 +18155,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH metricPriority: type: object properties: @@ -18112,6 +18177,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH activeEnergy: maxItems: 8 type: array @@ -18130,6 +18196,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH walkingRunningDistance: maxItems: 8 type: array @@ -18148,6 +18215,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH flightsClimbed: maxItems: 8 type: array @@ -18166,6 +18234,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleep: maxItems: 8 type: array @@ -18184,6 +18253,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH weight: maxItems: 8 type: array @@ -18202,6 +18272,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bloodPressure: maxItems: 8 type: array @@ -18220,6 +18291,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH pulse: maxItems: 8 type: array @@ -18238,6 +18310,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyFat: maxItems: 8 type: array @@ -18256,6 +18329,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyTemperature: maxItems: 8 type: array @@ -18274,6 +18348,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH spo2: maxItems: 8 type: array @@ -18292,6 +18367,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH hrv: maxItems: 8 type: array @@ -18310,6 +18386,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH restingHeartRate: maxItems: 8 type: array @@ -18328,6 +18405,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH vo2Max: maxItems: 8 type: array @@ -18346,6 +18424,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH skinTemperature: maxItems: 8 type: array @@ -18364,6 +18443,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH respiratoryRate: maxItems: 8 type: array @@ -18382,6 +18462,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH recovery: maxItems: 8 type: array @@ -18400,6 +18481,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH stress: maxItems: 8 type: array @@ -18418,6 +18500,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleepDebt: maxItems: 8 type: array @@ -18436,6 +18519,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH additionalProperties: false deviceTypePriority: type: object @@ -18503,6 +18587,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH activeEnergy: maxItems: 8 type: array @@ -18521,6 +18606,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH walkingRunningDistance: maxItems: 8 type: array @@ -18539,6 +18625,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH flightsClimbed: maxItems: 8 type: array @@ -18557,6 +18644,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleep: maxItems: 8 type: array @@ -18575,6 +18663,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH weight: maxItems: 8 type: array @@ -18593,6 +18682,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bloodPressure: maxItems: 8 type: array @@ -18611,6 +18701,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH pulse: maxItems: 8 type: array @@ -18629,6 +18720,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyFat: maxItems: 8 type: array @@ -18647,6 +18739,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyTemperature: maxItems: 8 type: array @@ -18665,6 +18758,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH spo2: maxItems: 8 type: array @@ -18683,6 +18777,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH hrv: maxItems: 8 type: array @@ -18701,6 +18796,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH restingHeartRate: maxItems: 8 type: array @@ -18719,6 +18815,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH vo2Max: maxItems: 8 type: array @@ -18737,6 +18834,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH skinTemperature: maxItems: 8 type: array @@ -18755,6 +18853,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH respiratoryRate: maxItems: 8 type: array @@ -18773,6 +18872,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH recovery: maxItems: 8 type: array @@ -18791,6 +18891,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH stress: maxItems: 8 type: array @@ -18809,6 +18910,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleepDebt: maxItems: 8 type: array @@ -18827,6 +18929,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH metricPriority: type: object properties: @@ -18848,6 +18951,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH activeEnergy: maxItems: 8 type: array @@ -18866,6 +18970,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH walkingRunningDistance: maxItems: 8 type: array @@ -18884,6 +18989,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH flightsClimbed: maxItems: 8 type: array @@ -18902,6 +19008,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleep: maxItems: 8 type: array @@ -18920,6 +19027,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH weight: maxItems: 8 type: array @@ -18938,6 +19046,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bloodPressure: maxItems: 8 type: array @@ -18956,6 +19065,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH pulse: maxItems: 8 type: array @@ -18974,6 +19084,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyFat: maxItems: 8 type: array @@ -18992,6 +19103,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH bodyTemperature: maxItems: 8 type: array @@ -19010,6 +19122,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH spo2: maxItems: 8 type: array @@ -19028,6 +19141,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH hrv: maxItems: 8 type: array @@ -19046,6 +19160,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH restingHeartRate: maxItems: 8 type: array @@ -19064,6 +19179,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH vo2Max: maxItems: 8 type: array @@ -19082,6 +19198,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH skinTemperature: maxItems: 8 type: array @@ -19100,6 +19217,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH respiratoryRate: maxItems: 8 type: array @@ -19118,6 +19236,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH recovery: maxItems: 8 type: array @@ -19136,6 +19255,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH stress: maxItems: 8 type: array @@ -19154,6 +19274,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH sleepDebt: maxItems: 8 type: array @@ -19172,6 +19293,7 @@ components: - OURA - TELEGRAM - MCP + - GOOGLE_HEALTH additionalProperties: false deviceTypePriority: type: object diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 48a36173c..a32793a04 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -1,16 +1,143 @@ -# Google Health / Fitbit integration +# Google Health integration (Fitbit + Pixel Watch + Fitbit Air) -> **This page moved.** HealthLog connects Fitbit and Pixel Watch data through the -> classic **Fitbit Web API** (an app registered at -> [dev.fitbit.com](https://dev.fitbit.com/apps/new)), **not** through a Google -> Cloud OAuth client. If you were told to create a Google Cloud client and hit -> `unauthorized_client — Invalid client_id`, that is why. +HealthLog reads Fitbit, Pixel Watch and Fitbit Air data through the **Google +Health API** (`health.googleapis.com/v4`), the successor to the classic Fitbit +Web API. Google retires the classic Fitbit Web API in **September 2026**, and +Fitbit access/refresh tokens do not carry over — every account re-consents +through Google OAuth. This integration is that path. -See **[Fitbit integration (Fitbit & Pixel)](./fitbit.md)** for the correct setup. +The OAuth app is a **Google Cloud** OAuth client, not a Fitbit developer-console +app. Credentials are brought **per user**: each user registers their own Google +Cloud OAuth client and pastes the client id/secret into Settings. The internal +integration key is `google-health`; it runs alongside the classic `fitbit` +integration without touching it. For the classic connection (a developer app +registered on the Fitbit side), see [fitbit.md](./fitbit.md). -Background: Google is retiring the classic Fitbit Web API in September 2026 in -favour of the Google Health API behind Google sign-in. That replacement requires -Restricted-scope brand verification plus an annual CASA security assessment, -which does not currently fit a self-hosted bring-your-own-credentials model, so -until the sunset the Fitbit developer-app path documented above is the supported -way to connect. +> **Beta.** Field mapping is still being verified against live Google Health +> accounts, so some values may be missing or shift in a later update. + +## What you get — and what you don't + +Weight, body fat, heart rate, resting heart rate, heart-rate variability, blood +oxygen, respiratory rate, sleep stages, steps, distance, active energy, floors, +VO₂ max and workouts flow in on a periodic pull. + +Two honest limits, set by the API itself: + +- **Stress and readiness are not available.** The Google Health API exposes no + stress or daily-readiness data type. Nothing HealthLog can do surfaces them. +- **Skin temperature is a nightly deviation**, not a raw stream — a single + per-night value rather than a continuous trace. + +There is **no webhook** at launch: Google Health syncs on the safety-net cron +pull, the same model as Oura and Polar. + +## 1. Create a Google Cloud project and enable the Health API + +1. In the **Google Cloud console** () create a + project (or pick an existing one). +2. Under **APIs & Services → Library**, find and **enable the Google Health + API** for that project. + +## 2. Configure the OAuth consent screen — keep it in "Testing" + +1. Under **APIs & Services → OAuth consent screen**, configure the screen for + the project. +2. Leave the publishing status on **Testing**. This is the deliberate choice for + a self-hoster: a Testing-mode client avoids Google verification and the + annual third-party CASA security assessment entirely. +3. On the **Audience** page, add every account that will connect as a **test + user** (**+ Add users**). An unverified client is capped at **100 users** — + staying at or below that ceiling keeps you verification-free. + +The cost of staying in Testing is the **7-day refresh-token expiry** — see the +caveat at the end. Going past 100 users, or publishing the client, requires +Google Trust & Safety verification **plus an annual CASA assessment**, because +every Google Health scope is Restricted. + +## 3. Create a "Web Server" OAuth client + +1. Under **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of + type **Web application** (a "Web Server" client). +2. **Authorized redirect URI:** + `https://your-instance.example.com/api/google-health/callback` + Use your instance's real origin; the path is always + `/api/google-health/callback`. +3. Save. Google issues a **Client ID** and **Client Secret** — keep the secret + out of any chat log. + +HealthLog drives the standard Google OAuth 2.0 web-server flow: it sends the +browser to the authorization endpoint +`https://accounts.google.com/o/oauth2/v2/auth` (with `access_type=offline`, +`prompt=consent` and an S256 PKCE challenge), then exchanges the returned code at +the token endpoint `https://oauth2.googleapis.com/token`. + +## 4. Scopes + +HealthLog requests exactly these four read-only **Restricted** scopes: + +``` +https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly +https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly +https://www.googleapis.com/auth/googlehealth.sleep.readonly +https://www.googleapis.com/auth/googlehealth.profile.readonly +``` + +No write scopes are requested — the connection is read-only. These four core +scopes are the full set the client requests today. + +ECG and irregular-rhythm notifications (the Pixel Watch clinical data types) are +a **planned** future addition, not a current option — there is no reader for them +yet, so no scope is requested for either. When that support lands the runbook +will document how to opt in; until then leave them out of your OAuth client. + +## 5. Wire the credentials in Settings → Integrations + +Credentials are stored per user, not in `.env`. Sign in as the target user, open +**Settings → Integrations → Google Health**, and paste the Client ID and Client +Secret. They are encrypted AES-256-GCM at rest. + +## 6. Connect + +Click **Connect with Google**. HealthLog redirects to Google's consent screen, +exchanges the returned code for an access + refresh token, stores them encrypted, +and runs an initial backfill in the background. + +## Redirect URI is validated strictly + +The resolved `redirect_uri` is asserted at connect time to be absolute, `https` +(or `http` on localhost), to target exactly `/api/google-health/callback`, and — +when `GOOGLE_HEALTH_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to +share its origin. A misconfigured value fails closed rather than redirecting +elsewhere. + +## Optional instance env var + +```env +# Only when the callback origin differs from NEXT_PUBLIC_APP_URL: +GOOGLE_HEALTH_REDIRECT_URI="https://your-instance.example.com/api/google-health/callback" +``` + +The Google Cloud client id/secret are per-user (Settings), not env vars. + +## The 7-day re-consent caveat + +While the OAuth consent screen stays in **Testing** publishing mode, Google +**expires the refresh token after 7 days**. When that happens the sync stops with +a soft "needs reconnect" state rather than a hard error, and the Google Health +card shows a **Reconnect** banner. Click it, complete the Google consent once +more, and syncing resumes. This is the price of avoiding CASA — it is expected, +not a bug. (Publishing the client past verification removes the 7-day expiry but +triggers the verification + CASA requirements above.) + +## Source overlap + +If Google Health and another source (Withings, WHOOP, Apple Health) both supply +the same vital — resting heart rate, blood oxygen, respiratory rate — the source +priority ladder in **Settings → Integrations → Sources** decides which one wins +per metric. + +## Disconnect + +The Settings **Disconnect** button removes the connection and its stored tokens. +Previously synced measurements stay in the database. diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 37f199529..9c2125dfd 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -142,9 +142,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => { await page.waitForLoadState("networkidle"); // Wait for the pills to mount (queries resolve after first paint). + // One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar, + // Oura, Nightscout. const pills = page.locator('[data-testid="integration-status-pill"]'); - // pills. - await expect(pills).toHaveCount(6, { timeout: 10_000 }); + await expect(pills).toHaveCount(7, { timeout: 10_000 }); // Withings is connected in this fixture; WHOOP is a BYO-keys card that // stays dormant until an operator adds credentials, so assert the one diff --git a/messages/de.json b/messages/de.json index d8c57323b..edc5f2450 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4822,6 +4822,45 @@ "fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…", "fitbitOauthConnected": "Fitbit verbunden — die erste Synchronisierung startet im Hintergrund.", "fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen", + "googleHealth": "Google Health", + "googleHealthTag": "Fitbit & Pixel", + "googleHealthBetaBadge": "Beta", + "googleHealthDescription": "Verbinde Fitbit, Pixel Watch und Fitbit Air über dein Google-Konto — Gewicht, Herzfrequenz, Schlaf und Aktivität. Das ersetzt die klassische Fitbit-Verbindung, die Google im September 2026 abschaltet.", + "googleHealthDescriptionSecondary": "Du registrierst deinen eigenen Google-Cloud-Client und hinterlegst die Zugangsdaten hier. Stress und Erholung liefert die API nicht, und Google kann dich von Zeit zu Zeit erneut um Zustimmung bitten. Neu bei der Einrichtung? Folge der", + "googleHealthCredentials": "API-Zugangsdaten", + "googleHealthClientId": "Client-ID", + "googleHealthClientSecret": "Client-Secret", + "googleHealthCredentialsSaved": "Zugangsdaten gespeichert", + "googleHealthCredentialsSavedPlaceholder": "Gespeichert — zum Ersetzen neu eingeben", + "googleHealthCredentialsSavedPlaceholderSecret": "Gespeichert — zum Ersetzen neu eingeben", + "googleHealthSaveCredentials": "Speichern", + "googleHealthSync": "Jetzt synchronisieren", + "googleHealthFullSync": "Alle Daten synchronisieren", + "googleHealthFullSyncTitle": "Vollständige Synchronisierung?", + "googleHealthFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Je nach Verlauf kann das eine Weile dauern.", + "googleHealthSyncResult": "{count} Messwerte synchronisiert", + "googleHealthSyncFailed": "Synchronisierung fehlgeschlagen", + "googleHealthSynchronize": "Synchronisieren", + "googleHealthDisconnect": "Trennen", + "googleHealthDisconnectTitle": "Google Health trennen?", + "googleHealthDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Bereits synchronisierte Daten bleiben erhalten.", + "googleHealthConnect": "Mit Google verbinden", + "googleHealthNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.", + "googleHealthBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…", + "googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen — das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.", + "googleHealthReconnect": "Neu verbinden", + "googleHealthOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.", + "googleHealthOauthFailed": "Verbindung zu Google Health fehlgeschlagen", + "googleHealthOauthError": { + "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", + "state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.", + "expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.", + "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung von deinem eigenen Konto aus neu.", + "nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.", + "nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.", + "token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.", + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + }, "fitbitOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", "state": "Die Antwort von Fitbit passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.", diff --git a/messages/en.json b/messages/en.json index 468dcbe75..58ab35eeb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4822,6 +4822,45 @@ "fitbitBackfillInProgress": "Importing your Fitbit history in the background…", "fitbitOauthConnected": "Fitbit connected — the first sync starts in the background.", "fitbitOauthFailed": "Fitbit connection failed", + "googleHealth": "Google Health", + "googleHealthTag": "Fitbit & Pixel", + "googleHealthBetaBadge": "Beta", + "googleHealthDescription": "Connect Fitbit, Pixel Watch and Fitbit Air through your Google account — weight, heart rate, sleep and activity. This replaces the classic Fitbit connection, which Google retires in September 2026.", + "googleHealthDescriptionSecondary": "You register your own Google Cloud client and bring the credentials here. Stress and readiness are not offered by the API, and Google may ask you to reconnect from time to time. New to the setup? Follow the", + "googleHealthCredentials": "API Credentials", + "googleHealthClientId": "Client ID", + "googleHealthClientSecret": "Client Secret", + "googleHealthCredentialsSaved": "Credentials saved", + "googleHealthCredentialsSavedPlaceholder": "Saved — enter new to replace", + "googleHealthCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "googleHealthSaveCredentials": "Save", + "googleHealthSync": "Sync now", + "googleHealthFullSync": "Sync all data", + "googleHealthFullSyncTitle": "Full synchronization?", + "googleHealthFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.", + "googleHealthSyncResult": "{count} measurements synchronized", + "googleHealthSyncFailed": "Sync failed", + "googleHealthSynchronize": "Synchronize", + "googleHealthDisconnect": "Disconnect", + "googleHealthDisconnectTitle": "Disconnect Google Health?", + "googleHealthDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.", + "googleHealthConnect": "Connect with Google", + "googleHealthNoCredentials": "Please enter your API credentials above to connect Google Health.", + "googleHealthBackfillInProgress": "Importing your Google Health history in the background…", + "googleHealthReauthBanner": "Google has expired the connection — this happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.", + "googleHealthReconnect": "Reconnect", + "googleHealthOauthConnected": "Google Health connected — the first sync starts in the background.", + "googleHealthOauthFailed": "Google Health connection failed", + "googleHealthOauthError": { + "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", + "state": "The response from Google did not match any open connection request. Please start the connection again.", + "expired": "The connection request expired before it completed. Please start the connection again.", + "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.", + "nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.", + "nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.", + "token": "The token exchange with Google failed. Please start the connection again.", + "generic": "Unknown error while connecting. Please start the connection again." + }, "fitbitOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", "state": "The response from Fitbit did not match any open connection request. Please start the connection again.", diff --git a/messages/es.json b/messages/es.json index 0389da016..eca0a43ce 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4822,6 +4822,45 @@ "fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…", "fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.", "fitbitOauthFailed": "Error al conectar con Fitbit", + "googleHealth": "Google Health", + "googleHealthTag": "Fitbit y Pixel", + "googleHealthBetaBadge": "Beta", + "googleHealthDescription": "Conecta Fitbit, Pixel Watch y Fitbit Air a través de tu cuenta de Google: peso, frecuencia cardíaca, sueño y actividad. Sustituye a la conexión clásica de Fitbit, que Google retira en septiembre de 2026.", + "googleHealthDescriptionSecondary": "Registras tu propio cliente de Google Cloud y traes aquí las credenciales. La API no ofrece estrés ni preparación, y Google puede pedirte que vuelvas a conectar de vez en cuando. ¿Es tu primera vez? Sigue la", + "googleHealthCredentials": "Credenciales de API", + "googleHealthClientId": "ID de cliente", + "googleHealthClientSecret": "Secreto de cliente", + "googleHealthCredentialsSaved": "Credenciales guardadas", + "googleHealthCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", + "googleHealthCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar", + "googleHealthSaveCredentials": "Guardar", + "googleHealthSync": "Sincronizar ahora", + "googleHealthFullSync": "Sincronizar todos los datos", + "googleHealthFullSyncTitle": "¿Sincronización completa?", + "googleHealthFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Puede tardar según tu historial.", + "googleHealthSyncResult": "{count} mediciones sincronizadas", + "googleHealthSyncFailed": "Error de sincronización", + "googleHealthSynchronize": "Sincronizar", + "googleHealthDisconnect": "Desconectar", + "googleHealthDisconnectTitle": "¿Desconectar Google Health?", + "googleHealthDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos ya sincronizados se conservarán.", + "googleHealthConnect": "Conectar con Google", + "googleHealthNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.", + "googleHealthBackfillInProgress": "Importando tu historial de Google Health en segundo plano…", + "googleHealthReauthBanner": "Google ha caducado la conexión: ocurre de forma periódica mientras tu app de OAuth permanece en modo de prueba. Vuelve a conectar para reanudar la sincronización.", + "googleHealthReconnect": "Volver a conectar", + "googleHealthOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.", + "googleHealthOauthFailed": "Error al conectar con Google Health", + "googleHealthOauthError": { + "csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Vuelve a iniciar la conexión en el mismo navegador.", + "state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Vuelve a iniciar la conexión.", + "expired": "La solicitud de conexión caducó antes de completarse. Vuelve a iniciar la conexión.", + "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.", + "nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y concede el acceso en el diálogo de Google.", + "nocreds": "Google Health no está configurado. Añade arriba tus credenciales de OAuth de Google e inténtalo de nuevo.", + "token": "El intercambio de token con Google falló. Vuelve a iniciar la conexión.", + "generic": "Error desconocido al conectar. Vuelve a iniciar la conexión." + }, "fitbitOauthError": { "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", "state": "La respuesta de Fitbit no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", diff --git a/messages/fr.json b/messages/fr.json index fa4add4df..f3dc0a915 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4822,6 +4822,45 @@ "fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…", "fitbitOauthConnected": "Fitbit connecté — la première synchronisation démarre en arrière-plan.", "fitbitOauthFailed": "Échec de la connexion à Fitbit", + "googleHealth": "Google Health", + "googleHealthTag": "Fitbit et Pixel", + "googleHealthBetaBadge": "Bêta", + "googleHealthDescription": "Connecte Fitbit, Pixel Watch et Fitbit Air via ton compte Google — poids, fréquence cardiaque, sommeil et activité. Cela remplace la connexion Fitbit classique, que Google arrête en septembre 2026.", + "googleHealthDescriptionSecondary": "Tu enregistres ton propre client Google Cloud et saisis les identifiants ici. L'API ne fournit ni stress ni disponibilité, et Google peut te demander de te reconnecter de temps en temps. Première configuration ? Suis le", + "googleHealthCredentials": "Identifiants d'API", + "googleHealthClientId": "ID client", + "googleHealthClientSecret": "Secret client", + "googleHealthCredentialsSaved": "Identifiants enregistrés", + "googleHealthCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", + "googleHealthCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer", + "googleHealthSaveCredentials": "Enregistrer", + "googleHealthSync": "Synchroniser maintenant", + "googleHealthFullSync": "Synchroniser toutes les données", + "googleHealthFullSyncTitle": "Synchronisation complète ?", + "googleHealthFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon ton historique.", + "googleHealthSyncResult": "{count} mesures synchronisées", + "googleHealthSyncFailed": "Échec de la synchronisation", + "googleHealthSynchronize": "Synchroniser", + "googleHealthDisconnect": "Déconnecter", + "googleHealthDisconnectTitle": "Déconnecter Google Health ?", + "googleHealthDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.", + "googleHealthConnect": "Se connecter avec Google", + "googleHealthNoCredentials": "Saisis tes identifiants d'API ci-dessus pour connecter Google Health.", + "googleHealthBackfillInProgress": "Importation de ton historique Google Health en arrière-plan…", + "googleHealthReauthBanner": "Google a expiré la connexion — cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.", + "googleHealthReconnect": "Se reconnecter", + "googleHealthOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.", + "googleHealthOauthFailed": "Échec de la connexion à Google Health", + "googleHealthOauthError": { + "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était manquant. Relance la connexion dans le même navigateur.", + "state": "La réponse de Google ne correspondait à aucune demande de connexion ouverte. Relance la connexion.", + "expired": "La demande de connexion a expiré avant d'aboutir. Relance la connexion.", + "cross_user": "La réponse appartient à une demande d'un autre compte. Relance la connexion depuis ton propre compte.", + "nocode": "Google n'a pas renvoyé de code d'autorisation. Réessaie et autorise l'accès dans la fenêtre Google.", + "nocreds": "Google Health n'est pas configuré. Ajoute tes identifiants OAuth Google ci-dessus et réessaie.", + "token": "L'échange de jeton avec Google a échoué. Relance la connexion.", + "generic": "Erreur inconnue lors de la connexion. Relance la connexion." + }, "fitbitOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", "state": "La réponse de Fitbit ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.", diff --git a/messages/it.json b/messages/it.json index a888be6cb..bd725b9e4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4822,6 +4822,45 @@ "fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…", "fitbitOauthConnected": "Fitbit collegato — la prima sincronizzazione parte in background.", "fitbitOauthFailed": "Connessione a Fitbit non riuscita", + "googleHealth": "Google Health", + "googleHealthTag": "Fitbit e Pixel", + "googleHealthBetaBadge": "Beta", + "googleHealthDescription": "Collega Fitbit, Pixel Watch e Fitbit Air tramite il tuo account Google — peso, frequenza cardiaca, sonno e attività. Sostituisce la connessione Fitbit classica, che Google ritira a settembre 2026.", + "googleHealthDescriptionSecondary": "Registri il tuo client Google Cloud e inserisci qui le credenziali. L'API non offre stress né prontezza, e Google può chiederti di riconnetterti di tanto in tanto. Prima configurazione? Segui la", + "googleHealthCredentials": "Credenziali API", + "googleHealthClientId": "ID client", + "googleHealthClientSecret": "Client secret", + "googleHealthCredentialsSaved": "Credenziali salvate", + "googleHealthCredentialsSavedPlaceholder": "Salvate — inseriscine di nuove per sostituirle", + "googleHealthCredentialsSavedPlaceholderSecret": "Salvate — inseriscine di nuove per sostituirle", + "googleHealthSaveCredentials": "Salva", + "googleHealthSync": "Sincronizza ora", + "googleHealthFullSync": "Sincronizza tutti i dati", + "googleHealthFullSyncTitle": "Sincronizzazione completa?", + "googleHealthFullSyncDescription": "Tutta la cronologia disponibile di Google Health verrà sincronizzata completamente. Può richiedere del tempo in base alla tua cronologia.", + "googleHealthSyncResult": "{count} misurazioni sincronizzate", + "googleHealthSyncFailed": "Sincronizzazione non riuscita", + "googleHealthSynchronize": "Sincronizza", + "googleHealthDisconnect": "Disconnetti", + "googleHealthDisconnectTitle": "Disconnettere Google Health?", + "googleHealthDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.", + "googleHealthConnect": "Connetti con Google", + "googleHealthNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.", + "googleHealthBackfillInProgress": "Importazione della cronologia di Google Health in background…", + "googleHealthReauthBanner": "Google ha fatto scadere la connessione — accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.", + "googleHealthReconnect": "Riconnetti", + "googleHealthOauthConnected": "Google Health connesso — la prima sincronizzazione parte in background.", + "googleHealthOauthFailed": "Connessione a Google Health non riuscita", + "googleHealthOauthError": { + "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Riavvia la connessione nello stesso browser.", + "state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.", + "expired": "La richiesta di connessione è scaduta prima del completamento. Riavvia la connessione.", + "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.", + "nocode": "Google non ha restituito un codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Google.", + "nocreds": "Google Health non è configurato. Aggiungi qui sopra le tue credenziali OAuth di Google e riprova.", + "token": "Lo scambio del token con Google non è riuscito. Riavvia la connessione.", + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + }, "fitbitOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", "state": "La risposta di Fitbit non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.", diff --git a/messages/pl.json b/messages/pl.json index be44a8a3c..45153ba77 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4822,6 +4822,45 @@ "fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…", "fitbitOauthConnected": "Fitbit połączony — pierwsza synchronizacja startuje w tle.", "fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się", + "googleHealth": "Google Health", + "googleHealthTag": "Fitbit i Pixel", + "googleHealthBetaBadge": "Beta", + "googleHealthDescription": "Połącz Fitbit, Pixel Watch i Fitbit Air przez konto Google — waga, tętno, sen i aktywność. Zastępuje to klasyczne połączenie Fitbit, które Google wyłącza we wrześniu 2026.", + "googleHealthDescriptionSecondary": "Rejestrujesz własnego klienta Google Cloud i wprowadzasz tutaj dane logowania. API nie udostępnia stresu ani gotowości, a Google może od czasu do czasu poprosić o ponowne połączenie. Pierwsza konfiguracja? Skorzystaj z", + "googleHealthCredentials": "Dane API", + "googleHealthClientId": "Identyfikator klienta", + "googleHealthClientSecret": "Sekret klienta", + "googleHealthCredentialsSaved": "Dane logowania zapisane", + "googleHealthCredentialsSavedPlaceholder": "Zapisane — wpisz nowe, aby zastąpić", + "googleHealthCredentialsSavedPlaceholderSecret": "Zapisane — wpisz nowe, aby zastąpić", + "googleHealthSaveCredentials": "Zapisz", + "googleHealthSync": "Synchronizuj teraz", + "googleHealthFullSync": "Synchronizuj wszystkie dane", + "googleHealthFullSyncTitle": "Pełna synchronizacja?", + "googleHealthFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. Może to chwilę potrwać w zależności od historii.", + "googleHealthSyncResult": "Zsynchronizowano pomiary: {count}", + "googleHealthSyncFailed": "Synchronizacja nie powiodła się", + "googleHealthSynchronize": "Synchronizuj", + "googleHealthDisconnect": "Rozłącz", + "googleHealthDisconnectTitle": "Rozłączyć Google Health?", + "googleHealthDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", + "googleHealthConnect": "Połącz z Google", + "googleHealthNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć Google Health.", + "googleHealthBackfillInProgress": "Importowanie historii Google Health w tle…", + "googleHealthReauthBanner": "Google unieważnił połączenie — dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.", + "googleHealthReconnect": "Połącz ponownie", + "googleHealthOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.", + "googleHealthOauthFailed": "Połączenie z Google Health nie powiodło się", + "googleHealthOauthError": { + "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", + "state": "Odpowiedź od Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", + "expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.", + "cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.", + "nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.", + "nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.", + "token": "Wymiana tokenu z Google nie powiodła się. Rozpocznij połączenie ponownie.", + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + }, "fitbitOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", "state": "Odpowiedź z Fitbit nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", diff --git a/package.json b/package.json index 637b934b5..5413ad7e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.26.1", + "version": "1.27.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0224_v127_google_health/migration.sql b/prisma/migrations/0224_v127_google_health/migration.sql new file mode 100644 index 000000000..e345d3232 --- /dev/null +++ b/prisma/migrations/0224_v127_google_health/migration.sql @@ -0,0 +1,86 @@ +-- v1.27.0 — Google Health API integration (Fitbit + Pixel Watch + Fitbit Air) +-- over `health.googleapis.com/v4` (schema + data layer). +-- +-- A separate, coexisting provider alongside the classic Fitbit Web API +-- integration (0117) — its own connection table, `GOOGLE_HEALTH` source enum +-- value, and OAuth client. Purely-additive: one enum-extension, two new tables, +-- and two new nullable columns on `users`. No backfill, no existing row touched. +-- +-- 1. `measurement_source` += `GOOGLE_HEALTH` for the server-side native ingest. +-- 2. `google_health_connections` — per-user encrypted token row (1:1 with +-- users), mirroring `fitbit_connections` plus a `needs_reauth` flag for the +-- Testing-mode 7-day refresh-expiry re-consent state. +-- 3. `google_health_oauth_states` — short-lived `(nonce → userId)` CSRF +-- ledger with the PKCE `code_verifier`, identical in shape to +-- `fitbit_oauth_states`. +-- 4. `users` += `google_health_client_id_encrypted` / +-- `google_health_client_secret_encrypted` — per-user BYO-keys (the CASA-free +-- "Testing" consent path caps a single OAuth client at ≤100 test users). +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: Postgres cannot remove an enum value, so `GOOGLE_HEALTH` stays; +-- with no rows carrying it it is inert. The two tables drop with `DROP TABLE IF +-- EXISTS`, the two columns with `DROP COLUMN IF EXISTS`. + +-- ── 1. measurement_source — append the Google Health server-owned source ── +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'GOOGLE_HEALTH'; + +-- ── 2. google_health_connections ────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "google_health_connections" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "google_user_id" TEXT NOT NULL, + "access_token" TEXT NOT NULL, + "refresh_token" TEXT NOT NULL, + "token_expires_at" TIMESTAMP(3) NOT NULL, + "scope" TEXT, + "needs_reauth" BOOLEAN NOT NULL DEFAULT false, + "last_synced_at" TIMESTAMP(3), + "backfill_completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "google_health_connections_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "google_health_connections_user_id_key" + ON "google_health_connections" ("user_id"); + +DO $$ BEGIN + ALTER TABLE "google_health_connections" + ADD CONSTRAINT "google_health_connections_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- ── 3. google_health_oauth_states ───────────────────────────────────── +CREATE TABLE IF NOT EXISTS "google_health_oauth_states" ( + "nonce" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "code_verifier" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "google_health_oauth_states_pkey" PRIMARY KEY ("nonce") +); + +CREATE INDEX IF NOT EXISTS "google_health_oauth_states_expires_at_idx" + ON "google_health_oauth_states" ("expires_at"); + +DO $$ BEGIN + ALTER TABLE "google_health_oauth_states" + ADD CONSTRAINT "google_health_oauth_states_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- ── 4. users — per-user Google Health BYO-key columns (encrypted at app level) ─ +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "google_health_client_id_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "google_health_client_secret_encrypted" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eb2ff9e3d..e2b483a3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,6 +140,14 @@ model User { fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted") fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted") + // v1.27.0 — Google Health API OAuth credentials (per-user BYO-keys, encrypted + // at app level). A separate provider from the classic Fitbit Web API columns + // above: the CASA-free "Testing" consent path caps a single OAuth client at + // ≤100 test users, so each self-hoster registers their own Google Cloud OAuth + // client and pastes the client id/secret into Settings. + googleHealthClientIdEncrypted String? @map("google_health_client_id_encrypted") + googleHealthClientSecretEncrypted String? @map("google_health_client_secret_encrypted") + // v1.17.0 — Nightscout glucose integration (F1, per-user, encrypted at app // level). A self-hoster points HealthLog at their own Nightscout instance: // the base URL + the API token / secret are stored encrypted. By default @@ -626,6 +634,11 @@ model User { // OAuth-state ledger (1:N) relations. fitbitConnection FitbitConnection? fitbitOAuthStates FitbitOAuthState[] + // v1.27.0 — Google Health API integration (separate provider from the classic + // Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger + // (1:N) relations. + googleHealthConnection GoogleHealthConnection? + googleHealthOAuthStates GoogleHealthOAuthState[] moodEntries MoodEntry[] mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") @@ -1453,6 +1466,13 @@ enum MeasurementSource { // surfaces still reject it (server-resolved only); iOS DTO mirrors this // spelling so the /api/sync/changes delta feed decodes the rows. MCP + // v1.27.0 — Google Health API integration (Fitbit + Pixel Watch + Fitbit + // Air) over `health.googleapis.com/v4`. A separate, coexisting provider + // alongside the classic Fitbit Web API integration (source `FITBIT`) with + // its own connection table and OAuth client. Server-owned (no client write + // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO + // mirrors this exact spelling so /api/sync/changes decodes the rows. + GOOGLE_HEALTH @@map("measurement_source") } @@ -3218,6 +3238,66 @@ model FitbitOAuthState { @@map("fitbit_oauth_states") } +/// v1.27.0 — Google Health connection (Fitbit + Pixel Watch + Fitbit Air) over +/// the Google Health API (`health.googleapis.com/v4`). A separate, coexisting +/// provider alongside the classic Fitbit Web API integration (`FitbitConnection`) +/// — its own token row, `GOOGLE_HEALTH` source enum value, and OAuth client. +/// Tokens are encrypted at the app level before persistence. Google refresh +/// tokens do NOT rotate per use, so `refreshToken` is overwritten only when a +/// token response carries a fresh one. +/// +/// `needsReauth` flags the CASA-free "Testing" consent path, where refresh +/// tokens expire after 7 days: the sync loop sets it on an `invalid_grant` +/// refresh failure so the card can surface a soft "reconnect" CTA rather than a +/// hard error, and the callback clears it on a fresh consent. +model GoogleHealthConnection { + id String @id @default(cuid()) + userId String @unique @map("user_id") + googleUserId String @map("google_user_id") // Google external account id + accessToken String @map("access_token") // encrypted at app level + refreshToken String @map("refresh_token") // encrypted at app level + tokenExpiresAt DateTime @map("token_expires_at") + /// Space-separated OAuth scope string granted by the Google authorisation + /// flow. Null = legacy connection that predates scope tracking. + scope String? @map("scope") + /// Set true when a refresh fails with `invalid_grant` (7-day Testing-mode + /// refresh expiry) so the connection reads as a soft disconnect awaiting + /// re-consent; cleared on a successful reconnect. + needsReauth Boolean @default(false) @map("needs_reauth") + /// Single high-water mark on interval `start_time` — Google exposes no sync + /// cursor, so incremental sync walks forward from here. + lastSyncedAt DateTime? @map("last_synced_at") + /// Set once the self-converging history backfill walks every data type to + /// completion. Null = backfill still in flight (or never started). + backfillCompletedAt DateTime? @map("backfill_completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("google_health_connections") +} + +/// v1.27.0 — short-lived `(nonce → userId)` ledger backing the Google Health +/// OAuth state check, identical in shape + lifecycle to `FitbitOAuthState`. +/// Google's OAuth is Authorization Code + PKCE (S256), so the connect route +/// mints a `code_verifier` alongside the nonce and stashes it here; the callback +/// reads it back to present on the token exchange. +model GoogleHealthOAuthState { + nonce String @id + userId String @map("user_id") + /// PKCE code_verifier carried from connect to callback. Null when no PKCE + /// handshake is in flight (legacy rows). + codeVerifier String? @map("code_verifier") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([expiresAt]) + @@map("google_health_oauth_states") +} + // ─── Mood Entries (moodLog) ─────────────────────────────────── model MoodEntry { diff --git a/public/sw.js b/public/sw.js index 29ba0993b..1b9ede5aa 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.25.13"; + /* @sw-version-fallback */ "v1.27.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 648fbb4f2..047dd0ec3 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -178,6 +178,8 @@ async function main() { "whoopClientSecretEncrypted", "fitbitClientIdEncrypted", "fitbitClientSecretEncrypted", + "googleHealthClientIdEncrypted", + "googleHealthClientSecretEncrypted", "nightscoutUrlEncrypted", "nightscoutTokenEncrypted", "polarAccessTokenEncrypted", @@ -223,6 +225,13 @@ async function main() { prisma.fitbitConnection, ), ); + results.push( + await rotateStringColumn( + "GoogleHealthConnection", + field, + prisma.googleHealthConnection, + ), + ); } // ───── AppSettings — operator credentials (String) ───── diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 743a46c16..9a3b00612 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -93,6 +93,7 @@ const LEGIT_IDENTICAL = new Set([ "measurements.typeVo2Max", "settings.sections.sources.metrics.vo2Max", // Brand / product / proper nouns. + "settings.googleHealth", "admin.glitchtipDsn", "admin.integrationWebPush", "admin.offlineGeoEnabled", diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts new file mode 100644 index 000000000..a278ffd9e --- /dev/null +++ b/src/app/api/google-health/callback/route.ts @@ -0,0 +1,208 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { encrypt } from "@/lib/crypto"; +import { + exchangeCode, + fetchProfile, + resolveGoogleHealthUserId, + getGoogleHealthScopeString, +} from "@/lib/google-health/client"; +import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials"; +import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE } from "@/lib/google-health/oauth-state"; +import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { markReconnected } from "@/lib/integrations/status"; +import { Prisma } from "@/generated/prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; + +/** + * OAuth callback from Google for the Google Health integration (v1.26.0). + * Mirrors the Fitbit callback with PKCE: the `state` param is a random + * base64url nonce keyed against the `GoogleHealthOAuthState` ledger, which also + * carries the PKCE `code_verifier`. The in-flight user is resolved via the + * row's `userId` (never parsed from the cookie value) and cross-checked against + * the session. The row is consumed (deleted) atomically on every exit branch so + * a replay of the same nonce fails the second time. + * + * Reason tags distinguish the post-delete branches for the audit trail: + * `csrf1` (URL/cookie mismatch, short-circuit before delete), `replay` (P2025 — + * nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user` + * (valid row, session userId mismatch). + * + * On success: exchange the code with the stored verifier, fetch the Google + * Health profile for the external `googleUserId`, persist the encrypted + * `GoogleHealthConnection`, clear any prior reauth state (`needsReauth:false` + + * the status ledger), and enqueue the self-converging history backfill. + */ +const ERR = (reason: string) => + NextResponse.redirect( + new URL( + `/settings/integrations?googleHealth=error&reason=${reason}`, + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.callback" } }); + + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const storedState = request.cookies.get( + GOOGLE_HEALTH_OAUTH_STATE_COOKIE, + )?.value; + + // CSRF leg 1: URL state must match the cookie state byte-for-byte + // (timing-safe). Runs BEFORE the atomic delete so a probe with arbitrary + // state values can't grief legitimate ledger rows. + if ( + !state || + !storedState || + state.length !== storedState.length || + !timingSafeEqual(Buffer.from(state), Buffer.from(storedState)) + ) { + annotate({ meta: { reason: "csrf1" } }); + return ERR("csrf1"); + } + + // CSRF leg 2: atomically consume the ledger row. `delete` returns the row on + // success so the `expiresAt` + `userId` checks (and the PKCE verifier read) + // run against the consumed payload. P2025 means the nonce was already + // consumed (replay) or never existed — benign. Atomic at the Postgres row + // level: two concurrent callbacks with the same nonce can't both pass before + // either deletes. + let stateRow: { + userId: string; + expiresAt: Date; + codeVerifier: string | null; + } | null = null; + try { + stateRow = await prisma.googleHealthOAuthState.delete({ + where: { nonce: state }, + }); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2025" + ) { + annotate({ meta: { reason: "replay" } }); + return ERR("replay"); + } + const errName = err instanceof Error ? err.name : "unknown"; + getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`); + annotate({ meta: { reason: "state" } }); + return ERR("state"); + } + + if (stateRow.expiresAt <= new Date()) { + annotate({ meta: { reason: "expired" } }); + return ERR("expired"); + } + if (stateRow.userId !== user.id) { + annotate({ meta: { reason: "cross_user" } }); + return ERR("cross_user"); + } + if (!stateRow.codeVerifier) { + annotate({ meta: { reason: "no_verifier" } }); + return ERR("no_verifier"); + } + + if (!code) { + return ERR("nocode"); + } + + try { + const creds = await getUserGoogleHealthCredentials(user.id); + if (!creds) { + return ERR("nocreds"); + } + + const tokens = await exchangeCode(code, stateRow.codeVerifier, creds); + + // The initial code exchange MUST return a refresh token (the connect route + // forces `prompt=consent` + `access_type=offline`). If Google ever omits + // it, refuse to persist — a stored empty refresh token silently bricks + // every future token refresh (the refresh POST then 400s and the + // connection dies the moment the access token expires). Error out cleanly + // so the user re-consents rather than landing a dead connection. + if (!tokens.refresh_token) { + getEvent()?.addWarning( + `google-health callback: token response carried no refresh_token for user ${user.id}`, + ); + annotate({ meta: { reason: "no_refresh_token" } }); + return ERR("no_refresh_token"); + } + + const profile = await fetchProfile(tokens.access_token); + const googleUserId = resolveGoogleHealthUserId(profile); + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + const scope = tokens.scope ?? getGoogleHealthScopeString(); + + await prisma.googleHealthConnection.upsert({ + where: { userId: user.id }, + update: { + googleUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope, + // Fresh consent clears the soft-disconnect flag so the card drops the + // reconnect CTA and the sync path stops short-circuiting. + needsReauth: false, + backfillCompletedAt: null, + }, + create: { + userId: user.id, + googleUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope, + needsReauth: false, + }, + }); + + await auditLog("google_health.connect", { + userId: user.id, + details: { googleUserId }, + }); + + // Re-completing OAuth clears any prior reauth-required state on the status + // ledger too (the `needsReauth` column above is the connection-local flag; + // this resets the integration-status streak). + await markReconnected(user.id, "google-health"); + + // Enqueue the self-converging history backfill. Best-effort: the boot-time + // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a + // missing boss instance here doesn't strand the connection. + const boss = getGlobalBoss(); + if (boss) { + await boss + .send(GOOGLE_HEALTH_BACKFILL_QUEUE, { + userId: user.id, + enqueuedAt: new Date().toISOString(), + }) + .catch((err) => + getEvent()?.addWarning( + `google-health-backfill enqueue failed: ${err}`, + ), + ); + } + + const response = NextResponse.redirect( + new URL( + "/settings/integrations?googleHealth=connected", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + response.cookies.delete(GOOGLE_HEALTH_OAUTH_STATE_COOKIE); + return response; + } catch (err) { + getEvent()?.setError(err); + return ERR("token"); + } +}); diff --git a/src/app/api/google-health/connect/route.ts b/src/app/api/google-health/connect/route.ts new file mode 100644 index 000000000..f22cf1e73 --- /dev/null +++ b/src/app/api/google-health/connect/route.ts @@ -0,0 +1,100 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + generatePkcePair, + getAuthorizationUrl, +} from "@/lib/google-health/client"; +import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials"; +import { + GOOGLE_HEALTH_OAUTH_STATE_COOKIE, + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS, + mintGoogleHealthOAuthStateNonce, +} from "@/lib/google-health/oauth-state"; +import { NextRequest, NextResponse } from "next/server"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; + +/** + * Redirect the user to the Google OAuth consent page for the Google Health + * integration (v1.26.0). + * + * Mirrors the Fitbit / WHOOP connect route with PKCE added: a fully-random + * base64url state nonce backed by a 10-minute `GoogleHealthOAuthState` ledger + * row carries the `(nonce → userId)` mapping AND the PKCE `code_verifier`, so + * neither the user id nor the verifier ever travels in the OAuth `state` param + * (which can land in request logs / network captures). The httpOnly + Secure + * cookie carries JUST the nonce; the callback resolves the user via the row's + * `userId` and reads the verifier off the same row. + * + * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam + * ledger rows for the full 10-min TTL window. Both the rate-limit and + * create-failure paths redirect (not JSON) because the entry point is a browser + * navigation — a 429 envelope would surface as a blank page. + */ +const CONNECT_RATE_LIMIT = 10; +const CONNECT_WINDOW_MS = 60_000; + +export const GET = apiHandler(async (req: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.connect" } }); + + const rl = await checkRateLimit( + `google-health:connect:${user.id}`, + CONNECT_RATE_LIMIT, + CONNECT_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ action: { name: "google_health.connect.rate_limited" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?googleHealth=error&reason=rate_limited", + req.url, + ), + ); + } + + const creds = await getUserGoogleHealthCredentials(user.id); + if (!creds) { + return apiError( + "Please configure your Google OAuth Client ID and Client Secret in Settings first.", + 400, + ); + } + + const nonce = mintGoogleHealthOAuthStateNonce(); + const { verifier, challenge } = generatePkcePair(); + try { + await prisma.googleHealthOAuthState.create({ + data: { + nonce, + userId: user.id, + codeVerifier: verifier, + expiresAt: new Date(Date.now() + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS), + }, + }); + } catch (err) { + getEvent()?.setError(err); + annotate({ action: { name: "google_health.connect.create_failed" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?googleHealth=error&reason=connect", + req.url, + ), + ); + } + + const url = getAuthorizationUrl(nonce, creds, challenge); + + const response = NextResponse.redirect(url); + response.cookies.set(GOOGLE_HEALTH_OAUTH_STATE_COOKIE, nonce, { + httpOnly: true, + secure: shouldEmitSecureCookie(), + sameSite: "lax", + maxAge: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000), + path: "/", + }); + + return response; +}); diff --git a/src/app/api/google-health/credentials/route.ts b/src/app/api/google-health/credentials/route.ts new file mode 100644 index 000000000..17a5d619d --- /dev/null +++ b/src/app/api/google-health/credentials/route.ts @@ -0,0 +1,103 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { encrypt } from "@/lib/crypto"; +import { markDisconnected } from "@/lib/integrations/status"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +/** + * Per-user Google Health BYO-key credentials. Each self-hoster registers their + * own Google Cloud OAuth client (the Restricted-scope brand verification + CASA + * assessment is per-OAuth-client, so a single shared app is unworkable for a + * multi-operator product) and pastes the client id/secret into Settings. Stored + * encrypted on `User`. + */ +const googleHealthCredentialsSchema = z.object({ + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(200), +}); + +/** + * Check whether the user has Google Health BYO-key credentials stored + * (v1.26.0). + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.credentials.get" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + googleHealthClientIdEncrypted: true, + googleHealthClientSecretEncrypted: true, + }, + }); + + return apiSuccess({ + hasCredentials: + !!dbUser?.googleHealthClientIdEncrypted && + !!dbUser?.googleHealthClientSecretEncrypted, + }); +}); + +/** + * Save Google Health API credentials (encrypted at rest). + */ +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.credentials.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const result = z.safeParse(googleHealthCredentialsSchema, body); + if (!result.success) { + return apiError("Client ID and Client Secret are required", 422); + } + + // Field-by-field assignment — never spread the parsed object, so no future + // schema addition can silently mass-assign an unexpected column. + await prisma.user.update({ + where: { id: user.id }, + data: { + googleHealthClientIdEncrypted: encrypt(result.data.clientId), + googleHealthClientSecretEncrypted: encrypt(result.data.clientSecret), + }, + }); + + return apiSuccess({ updated: true }); +}); + +/** + * Delete Google Health credentials and the active connection. + * + * Audits the teardown and parks the integration ledger at `disconnected` for + * parity with the Fitbit / Polar / Oura credential-DELETE so a sensitive op + * leaves a uniform audit trail and the status snapshot does not linger at a + * stale state. + */ +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.credentials.delete" } }); + + await prisma.googleHealthConnection + .delete({ where: { userId: user.id } }) + .catch(() => {}); + + await prisma.user.update({ + where: { id: user.id }, + data: { + googleHealthClientIdEncrypted: null, + googleHealthClientSecretEncrypted: null, + }, + }); + await auditLog("google_health.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "google-health"); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/google-health/disconnect/route.ts b/src/app/api/google-health/disconnect/route.ts new file mode 100644 index 000000000..41c5fbba5 --- /dev/null +++ b/src/app/api/google-health/disconnect/route.ts @@ -0,0 +1,42 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { markDisconnected } from "@/lib/integrations/status"; + +/** + * Disconnect the Google Health integration for the current user (v1.26.0). + * + * Clears the stored OAuth tokens by deleting the `GoogleHealthConnection` row + * and parks the integration status at `disconnected`. There is no per-user + * subscription to tear down (Google Health is poll-only at launch — no Pub/Sub + * push), so no upstream unsubscribe call is needed; deleting the encrypted + * token pair is the effective revocation. The BYO-key credentials on `User` are + * left intact so a reconnect doesn't force the user to re-paste them; use the + * credentials DELETE endpoint to remove those too. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.disconnect" } }); + + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId: user.id }, + }); + + if (!connection) { + return apiError("No Google Health connection", 404); + } + + await prisma.googleHealthConnection.delete({ + where: { userId: user.id }, + }); + + await auditLog("google_health.disconnect", { + userId: user.id, + }); + + await markDisconnected(user.id, "google-health"); + + return apiSuccess({ disconnected: true }); +}); diff --git a/src/app/api/google-health/status/route.ts b/src/app/api/google-health/status/route.ts new file mode 100644 index 000000000..38b002f82 --- /dev/null +++ b/src/app/api/google-health/status/route.ts @@ -0,0 +1,62 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; + +/** + * Get Google Health connection status for the current user (v1.26.0). + * + * Mirrors the Fitbit status route: `configured` checks the per-user BYO-key + * credentials; the connection row reports last-sync + token expiry + backfill + * progress. `tokenExpired` is reported straight off the row; the sync path + * refreshes lazily via `getValidToken`. `needsReauth` surfaces the soft + * disconnect (a refresh that failed with `invalid_grant` — the Testing-mode + * 7-day refresh expiry) so the card can render a reconnect CTA. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.status" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + googleHealthClientIdEncrypted: true, + googleHealthClientSecretEncrypted: true, + }, + }); + + const configured = + !!dbUser?.googleHealthClientIdEncrypted && + !!dbUser?.googleHealthClientSecretEncrypted; + + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId: user.id }, + select: { + googleUserId: true, + lastSyncedAt: true, + tokenExpiresAt: true, + backfillCompletedAt: true, + needsReauth: true, + createdAt: true, + scope: true, + }, + }); + + if (!connection) { + return apiSuccess({ connected: false, configured }); + } + + const tokenExpired = connection.tokenExpiresAt.getTime() <= Date.now(); + + return apiSuccess({ + connected: true, + configured, + lastSyncedAt: connection.lastSyncedAt, + connectedAt: connection.createdAt, + tokenExpired, + tokenExpiresAt: connection.tokenExpiresAt, + backfillCompleted: !!connection.backfillCompletedAt, + needsReauth: connection.needsReauth, + scope: connection.scope, + }); +}); diff --git a/src/app/api/google-health/sync/route.ts b/src/app/api/google-health/sync/route.ts new file mode 100644 index 000000000..f15a8fc23 --- /dev/null +++ b/src/app/api/google-health/sync/route.ts @@ -0,0 +1,60 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { syncUserGoogleHealth } from "@/lib/google-health/sync"; +import { NextRequest } from "next/server"; + +/** + * Manually trigger a Google Health sync for the current user (v1.26.0). + * + * Mirrors the Fitbit manual-sync route: incremental by default, full history + * when `{ fullSync: true }` is posted. + * + * Rate-limited: a baseline 5/60s bucket gates the route, and the expensive + * `fullSync` path (which drives paginated Google Health walkers across every + * data type, each capped at 1000 pages) carries a tighter 1/hour bucket of its + * own so a re-trigger loop cannot pin Prisma or churn the Google Health quota. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.sync" } }); + + const rl = await checkRateLimit(`google-health-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + + let fullSync = false; + try { + const raw = await request.text(); + // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); + fullSync = body?.fullSync === true; + } catch { + // no body provided -> default incremental sync + } + + // The full-history walk is the expensive path; cap it well below the + // incremental bucket so a re-trigger loop cannot churn the Google quota. + if (fullSync) { + const fullRl = await checkRateLimit( + `google-health-sync-full:${user.id}`, + 1, + 60 * 60_000, + ); + if (!fullRl.allowed) { + return apiError("Full sync is limited to once per hour", 429, { + errorCode: "rate_limited_self", + }); + } + } + + const imported = await syncUserGoogleHealth(user.id, { fullSync }); + return apiSuccess({ imported, fullSync }); +}); diff --git a/src/app/api/integrations/google-health/resume/route.ts b/src/app/api/integrations/google-health/resume/route.ts new file mode 100644 index 000000000..a22cf14fa --- /dev/null +++ b/src/app/api/integrations/google-health/resume/route.ts @@ -0,0 +1,48 @@ +/** + * Manual resume endpoint for a parked Google Health integration. + * + * Background: the `parked` state lives in + * `src/lib/integrations/status.ts`. Once a Google Health persistent-failure + * streak has been running for more than 24h, the status writer flips + * the row to `parked` and the sync entry-point short-circuits. The user + * (or an operator) calls this endpoint to clear the park and let the + * next cron tick attempt the sync again. + * + * Rate-limited (5/min per user) to match the sibling + * `/api/integrations/google-health/test` endpoint — there's no upstream + * call here, but a misbehaving client tab loop would still rack up + * audit-log rows if the limit was absent. + * + * Returns 200 with `{ resumed: true, wasParked: boolean }`. The + * `wasParked` flag lets the caller decide whether to surface a "you + * resumed it" toast or treat the action as a no-op (the row was + * already connected / disconnected). Mirrors the Fitbit resume route. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { resumeIntegrationFromPark } from "@/lib/integrations/status"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.google-health.resume" } }); + + const rl = await checkRateLimit(`google-health-resume:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many resume requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const result = await resumeIntegrationFromPark(user.id, "google-health"); + annotate({ + meta: { + google_health_resume_was_parked: result.wasParked, + }, + }); + + return apiSuccess({ resumed: true, wasParked: result.wasParked }); +}); diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts new file mode 100644 index 000000000..da8ee2b9e --- /dev/null +++ b/src/app/api/integrations/google-health/test/route.ts @@ -0,0 +1,132 @@ +import type { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { getValidToken } from "@/lib/google-health/sync"; +import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/client"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Cheap authenticated probe — the single (non-paginated) profile endpoint the +// Google Health client already calls via `fetchProfile`. +// Base from `src/lib/google-health/client.ts` (`GOOGLE_HEALTH_API_BASE`). +const GOOGLE_HEALTH_PROFILE_URL = `${GOOGLE_HEALTH_API_BASE}/users/me/profile`; +const TIMEOUT_MS = 5_000; + +type CategorisedError = { code: string; message: string }; + +function categoriseHttpStatus(status: number): CategorisedError { + if (status === 401 || status === 403) { + return { + code: "credentials_rejected", + message: "Google Health rejected the credentials", + }; + } + if (status === 429) { + return { + code: "rate_limited", + message: "Google Health rate-limited the request", + }; + } + if (status >= 500) { + return { + code: "upstream_error", + message: "Google Health returned a server error", + }; + } + return { + code: "connection_failed", + message: "Google Health connection failed", + }; +} + +export const POST = apiHandler(async (request: NextRequest) => { + void request; + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.google-health.test" } }); + + const rl = await checkRateLimit(`google-health-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const tokenInfo = await getValidToken(user.id); + if (!tokenInfo) { + return apiError("Google Health not connected", 422, { + errorCode: "not_configured", + }); + } + + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId: user.id }, + select: { lastSyncedAt: true }, + }); + + const start = performance.now(); + + try { + const res = await safeFetch( + GOOGLE_HEALTH_PROFILE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${tokenInfo.accessToken}`, + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { + google_health_test_status: res.status, + google_health_test_code: cat.code, + google_health_test_latency_ms: latencyMs, + }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ + ok: true, + lastSyncedAt: connection?.lastSyncedAt ?? null, + latencyMs, + }); + } catch (e) { + const err = e as Error; + // safeFetch wraps timeouts into SafeFetchError{kind:"timeout"}; + // legacy AbortError name is preserved for compatibility with any + // future direct-fetch path that still reaches this catch. + const isAbort = + (e instanceof SafeFetchError && e.kind === "timeout") || + err.name === "AbortError"; + const code = isAbort ? "timeout" : "connection_failed"; + annotate({ + meta: { + google_health_test_code: code, + google_health_test_error: redact(err.message).slice(0, 300), + }, + }); + return apiError( + isAbort + ? "Google Health request timed out" + : "Google Health connection failed", + 502, + { errorCode: code }, + ); + } +}); + +// Strip any `Bearer ` substring before annotating; the catch path can +// see DNS / TLS errors that include the request init via err.cause in some +// runtimes, and we never want a token in Loki. +function redact(text: string): string { + return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]"); +} diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index a95987e3a..d262cf792 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -11,6 +11,7 @@ vi.mock("@/lib/db", () => ({ withingsConnection: { findUnique: vi.fn(async () => null) }, whoopConnection: { findUnique: vi.fn(async () => null) }, fitbitConnection: { findUnique: vi.fn(async () => null) }, + googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, }, })); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 8c3b5eec9..1ed88a25a 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -40,12 +40,14 @@ export const GET = apiHandler(async () => { moodLogStatus, whoopStatus, fitbitStatus, + googleHealthStatus, polarStatus, ouraStatus, dbUser, withingsConn, whoopConn, fitbitConn, + googleHealthConn, moodLogEntryCount, // v1.17.1 — `available` reports whether usable OAuth credentials resolve // (per-user BYO first, then the shared env app), mirroring the per-card @@ -57,6 +59,7 @@ export const GET = apiHandler(async () => { getIntegrationStatus(user.id, "moodlog"), getIntegrationStatus(user.id, "whoop"), getIntegrationStatus(user.id, "fitbit"), + getIntegrationStatus(user.id, "google-health"), getIntegrationStatus(user.id, "polar"), getIntegrationStatus(user.id, "oura"), prisma.user.findUnique({ @@ -68,6 +71,8 @@ export const GET = apiHandler(async () => { whoopClientSecretEncrypted: true, fitbitClientIdEncrypted: true, fitbitClientSecretEncrypted: true, + googleHealthClientIdEncrypted: true, + googleHealthClientSecretEncrypted: true, polarAccessTokenEncrypted: true, polarClientIdEncrypted: true, polarClientSecretEncrypted: true, @@ -108,6 +113,16 @@ export const GET = apiHandler(async () => { backfillCompletedAt: true, }, }), + prisma.googleHealthConnection.findUnique({ + where: { userId: user.id }, + select: { + tokenExpiresAt: true, + lastSyncedAt: true, + createdAt: true, + backfillCompletedAt: true, + needsReauth: true, + }, + }), // v1.7.0 sync — exclude tombstoned rows from the entry count. prisma.moodEntry.count({ where: { userId: user.id, deletedAt: null }, @@ -183,6 +198,27 @@ export const GET = apiHandler(async () => { : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, } satisfies IntegrationViewModel & FitbitExtras, + { + ...googleHealthStatus, + configured: + !!dbUser?.googleHealthClientIdEncrypted && + !!dbUser?.googleHealthClientSecretEncrypted, + connected: !!googleHealthConn, + connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null, + legacyLastSyncedAt: + googleHealthConn?.lastSyncedAt?.toISOString() ?? null, + tokenExpiresAt: googleHealthConn?.tokenExpiresAt?.toISOString() ?? null, + tokenExpired: googleHealthConn + ? googleHealthConn.tokenExpiresAt.getTime() <= now + : null, + backfillCompleted: googleHealthConn + ? !!googleHealthConn.backfillCompletedAt + : null, + // v1.27.0 — `needsReauth` is the 7-day Testing-mode refresh expiry (or a + // user-revoked grant) surfaced from the connection row; the card reads it + // to raise a distinct "Reconnect" CTA separate from parked/disconnected. + needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false, + } satisfies IntegrationViewModel & GoogleHealthExtras, { ...polarStatus, // `connected` = a stored access token; `configured` mirrors it (the @@ -256,6 +292,21 @@ interface FitbitExtras { backfillCompleted: boolean | null; } +// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google +// expires the refresh token after 7 days in "Testing" publishing mode, so a +// connected user is periodically pushed back through OAuth. The card reads the +// flag to raise a distinct reconnect banner. +interface GoogleHealthExtras { + configured: boolean; + connected: boolean; + connectedAt: string | null; + legacyLastSyncedAt: string | null; + tokenExpiresAt: string | null; + tokenExpired: boolean | null; + backfillCompleted: boolean | null; + needsReauth: boolean; +} + // v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the // per-user BYO-key flags the shared OAuth card reads instead of the dedicated // /api//status round-trip the page used to fire per card. diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index 69b49d1fb..e6795d39d 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -131,10 +131,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }); const html = render(); - // Exactly one pill per card → 6 pills total (Withings, WHOOP, - // Fitbit / Google Health, Polar, Oura, Nightscout). The moodLog integration - // was removed; Polar + Oura (F4) were added in v1.17.0. - expect(count(html, 'data-testid="integration-status-pill"')).toBe(6); + // Exactly one pill per card → 7 pills total (Withings, WHOOP, Fitbit, + // Google Health, Polar, Oura, Nightscout). The moodLog integration was + // removed; Polar + Oura (F4) were added in v1.17.0; Google Health in v1.27.0. + expect(count(html, 'data-testid="integration-status-pill"')).toBe(7); // The redundant banner from v1.4.15 is gone. expect(html).not.toContain('data-testid="integration-status-banner"'); // Card-body "letzter Sync" repetition is gone — no @@ -300,8 +300,8 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { const html = render(); // Every integration card includes the section divider data-testid so the // header → body separation is visually consistent (Withings, WHOOP, - // Fitbit / Google Health). The moodLog integration was removed. - expect(count(html, 'data-testid="integration-card-divider"')).toBe(3); + // Fitbit, Google Health). The moodLog integration was removed. + expect(count(html, 'data-testid="integration-card-divider"')).toBe(4); }); // v1.17.1 — every integration card carries the same discreet "Setup guide" @@ -326,12 +326,13 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }); const html = render(); - // One setup-guide link per card → six providers. - expect(count(html, 'data-slot="integration-setup-guide"')).toBe(6); + // One setup-guide link per card → seven providers. + expect(count(html, 'data-slot="integration-setup-guide"')).toBe(7); for (const provider of [ "withings", "whoop", "fitbit", + "google-health", "polar", "oura", "nightscout", diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index 8148e0e2b..ef8d164c6 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -17,6 +17,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; +import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card"; import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; import { OuraCard } from "@/components/settings/integrations/oura-card"; @@ -60,7 +61,17 @@ type WithingsOauthOutcome = * per-card status query; WHOOP/Fitbit read off the consolidated envelope — both * are invalidated on a successful return so the card repaints either way. */ -const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const; +// v1.27.0 — `googleHealth` joins the outcome set. Its callback redirects with +// `?googleHealth=connected|error&reason=` (the fitbit→googleHealth +// identifier rename), and the i18n toast keys resolve under the same camelCase +// prefix (`settings.googleHealthOauth*`). +const OAUTH_OUTCOME_PROVIDERS = [ + "polar", + "oura", + "whoop", + "fitbit", + "googleHealth", +] as const; type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number]; const OAUTH_OUTCOME_KEYS: Record< @@ -71,6 +82,7 @@ const OAUTH_OUTCOME_KEYS: Record< oura: queryKeys.oura, whoop: queryKeys.whoop, fitbit: queryKeys.fitbit, + googleHealth: queryKeys.googleHealth, }; /** @@ -240,6 +252,9 @@ export function ConnectionsPanel() { const withingsViewModel = pickStatus(integrationStatus, "withings"); const whoopViewModel = pickStatus(integrationStatus, "whoop"); const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); + // v1.27.0 — Google Health reads off the same consolidated envelope; the + // ledger integration key is the hyphenated `google-health`. + const googleHealthViewModel = pickStatus(integrationStatus, "google-health"); // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards // no longer fire their own /api//status round-trip. const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); @@ -250,6 +265,7 @@ export function ConnectionsPanel() { + diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx new file mode 100644 index 000000000..fda8a111f --- /dev/null +++ b/src/components/settings/integrations/google-health-card.tsx @@ -0,0 +1,512 @@ +"use client"; + +// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) card. Reads +// Fitbit and Pixel Watch data through the successor Google Health API — a +// separate, coexisting integration from the classic `fitbit` transport, which +// sunsets Sept 2026. Mirrors the Fitbit card anatomy: a BYO Google-Cloud +// client-id/secret form first, then an OAuth connect, then the +// sync/test/disconnect action row + parked-resume banner. Status reads off the +// consolidated /api/integrations/status envelope (no per-card round-trip). +// +// One thing this card carries that the classic Fitbit card does not: a distinct +// RE-CONSENT CTA. Google expires the refresh token after 7 days while the +// operator's OAuth client stays in "Testing" publishing mode (the CASA-free +// path), so a connected user is periodically pushed back through OAuth. When the +// envelope reports `needsReauth`, the card surfaces a clear "Reconnect" banner +// separate from the connect/disconnect state. + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link2, Loader2, RefreshCw, Save, Unlink, Watch } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +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 { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; +import type { IntegrationPillState } from "@/components/settings/integration-status-pill"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +import { + IntegrationErrorMessage, + pillStateFor, + type IntegrationStatusViewModel, +} from "./shared"; +import { IntegrationCardDescription } from "./setup-guide-link"; + +export function GoogleHealthCard({ + viewModel, +}: { + viewModel: IntegrationStatusViewModel | undefined; +}) { + const { t } = useTranslations(); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( + 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 queryClient = useQueryClient(); + + const status = viewModel; + + const disconnect = useMutation({ + mutationFn: async () => { + await apiPost("/api/google-health/disconnect"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + }, + }); + + // Clear a parked integration via the resume endpoint. The CTA is rendered + // inside the parked banner below; success invalidates both the per-card + // status and the cross-integration envelope so any other view picks up the + // change on its next focus. + const resume = useMutation({ + mutationFn: async () => { + return apiPost<{ resumed: boolean; wasParked: boolean }>( + "/api/integrations/google-health/resume", + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + }, + }); + + async function handleSync(fullSync = false) { + setSyncing(true); + setSyncMsg(null); + setSyncMsgType(null); + try { + const res = await apiFetchRaw("/api/google-health/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + }); + const json = await res.json(); + if (res.ok) { + setSyncMsg( + t("settings.googleHealthSyncResult", { count: json.data.imported }), + ); + setSyncMsgType("success"); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncMsg(json.error || t("settings.googleHealthSyncFailed")); + setSyncMsgType("error"); + } + } catch { + setSyncMsg(t("settings.googleHealthSyncFailed")); + setSyncMsgType("error"); + } finally { + setSyncing(false); + } + } + + async function handleSaveCredentials(e: React.FormEvent) { + e.preventDefault(); + setCredsSaving(true); + setCredsMsg(null); + setCredsMsgType(null); + + try { + const res = await apiFetchRaw("/api/google-health/credentials", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + + if (res.ok) { + setCredsMsg(t("settings.googleHealthCredentialsSaved")); + setCredsMsgType("success"); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); + } 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 pillState: IntegrationPillState = status?.connected + ? pillStateFor(viewModel) + : "disconnected"; + const pillLastSyncAt = + status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null; + const errorMessage = + (pillState === "error" || pillState === "parked") && viewModel?.lastError + ? viewModel.lastError + : null; + // The re-consent banner only makes sense while the connection still exists — + // a disconnected card has no token to renew. `needsReauth` is the 7-day + // Testing-mode expiry (or a revoked grant) surfaced by the status route. + const showReauth = Boolean(status?.connected && status?.needsReauth); + + return ( + + + + {t("settings.googleHealthTag")} + + + {t("settings.googleHealthBetaBadge")} + + + } + description={ + + } + status={ + + } + /> + +
+ +
+ {errorMessage && } + + {/* Re-consent CTA — distinct from parked/disconnected. Google expires + the refresh token after 7 days in "Testing" publishing mode (the + CASA-free path), so a connected user is periodically pushed back + through OAuth. Re-running connect mints a fresh grant. */} + {showReauth && ( +
+ + {t("settings.googleHealthReauthBanner")} + + +
+ )} + + {/* Parked-integration resume CTA — surfaces only when the row state is + `parked` (>24h of persistent failures). */} + {pillState === "parked" && ( +
+ + {t("settings.integrationPill.parkedReconnect")} + + +
+ )} + {resume.isError && ( +

+ {t("settings.integrationPill.resumeError")} +

+ )} + {resume.isSuccess && resume.data?.wasParked && ( +

+ {t("settings.integrationPill.resumeSuccess")} +

+ )} + +
+

+ {t("settings.googleHealthCredentials")} +

+
+
+
+ + setClientId(e.target.value)} + placeholder={ + status?.configured + ? t("settings.googleHealthCredentialsSavedPlaceholder") + : t("settings.googleHealthClientId") + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
+
+ + setClientSecret(e.target.value)} + placeholder={ + status?.configured + ? t( + "settings.googleHealthCredentialsSavedPlaceholderSecret", + ) + : t("settings.googleHealthClientSecret") + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +
+
+

+ {t("settings.integrationCredentialsHint")} +

+
+ +
+ {credsMsg && ( +

+ {credsMsg} +

+ )} +
+
+ + {status?.connected ? ( + <> +
+ + + + + + + + + {t("settings.googleHealthFullSyncTitle")} + + + {t("settings.googleHealthFullSyncDescription")} + + + + {t("common.cancel")} + handleSync(true)}> + {t("settings.googleHealthSynchronize")} + + + + + + + + + + + + + {t("settings.googleHealthDisconnectTitle")} + + + {t("settings.googleHealthDisconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t("settings.googleHealthDisconnect")} + + + + +
+ {status?.backfillCompleted === false && ( +

+ {t("settings.googleHealthBackfillInProgress")} +

+ )} + {syncMsg && ( +

+ {syncMsg} +

+ )} + + ) : status?.configured ? ( + + ) : ( +
+ {t("settings.googleHealthNoCredentials")} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx index f2fc7ce0d..07cbbd9fd 100644 --- a/src/components/settings/integrations/setup-guide-link.tsx +++ b/src/components/settings/integrations/setup-guide-link.tsx @@ -23,7 +23,14 @@ import { useTranslations } from "@/lib/i18n/context"; export const INTEGRATION_DOCS_BASE = "https://docs.healthlog.dev/integrations"; export type IntegrationDocsProvider = - "whoop" | "withings" | "fitbit" | "polar" | "oura" | "nightscout"; + | "whoop" + | "withings" + | "fitbit" + | "polar" + | "oura" + | "nightscout" + // v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health). + | "google-health"; export function integrationDocsHref(provider: IntegrationDocsProvider): string { return `${INTEGRATION_DOCS_BASE}/${provider}`; diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 846f3ee33..32902d8cb 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -19,7 +19,14 @@ import { queryKeys } from "@/lib/query-keys"; // IntegrationStatusPill now owns state + last-sync presentation, and // the actionable error message is shown inline above the action row. export type IntegrationKey = - "withings" | "whoop" | "fitbit" | "moodlog" | "polar" | "oura"; + | "withings" + | "whoop" + | "fitbit" + | "moodlog" + | "polar" + | "oura" + // v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air). + | "google-health"; export type IntegrationState = "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; @@ -46,6 +53,11 @@ export interface IntegrationStatusViewModel { hasActivityScope?: boolean; // WHOOP / Fitbit backfill-in-progress note. backfillCompleted?: boolean | null; + // v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has + // lapsed (the 7-day "Testing"-mode expiry, or a revoked grant). The card + // paints a distinct re-consent CTA off this flag, separate from the parked + // state. Server-populated on the `/api/integrations/status` envelope. + needsReauth?: boolean; // moodLog webhook secret + entry count. webhookSecret?: string | null; entryCount?: number; diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index db33126bb..69a72866c 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -55,6 +55,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "User", field: "whoopClientSecretEncrypted", kind: "string" }, { model: "User", field: "fitbitClientIdEncrypted", kind: "string" }, { model: "User", field: "fitbitClientSecretEncrypted", kind: "string" }, + // v1.27.0 — Google Health (Fitbit / Pixel Watch) BYO OAuth client. + { model: "User", field: "googleHealthClientIdEncrypted", kind: "string" }, + { model: "User", field: "googleHealthClientSecretEncrypted", kind: "string" }, { model: "User", field: "nightscoutUrlEncrypted", kind: "string" }, { model: "User", field: "nightscoutTokenEncrypted", kind: "string" }, { model: "User", field: "polarAccessTokenEncrypted", kind: "string" }, @@ -82,6 +85,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "WhoopConnection", field: "refreshToken", kind: "string" }, { model: "FitbitConnection", field: "accessToken", kind: "string" }, { model: "FitbitConnection", field: "refreshToken", kind: "string" }, + { model: "GoogleHealthConnection", field: "accessToken", kind: "string" }, + { model: "GoogleHealthConnection", field: "refreshToken", kind: "string" }, // ───── AppSettings — operator credentials ───── { model: "AppSettings", field: "adminAiKeyEncrypted", kind: "string" }, diff --git a/src/lib/fitbit/__tests__/setup-docs-parity.test.ts b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts index d057402fa..6dffa3198 100644 --- a/src/lib/fitbit/__tests__/setup-docs-parity.test.ts +++ b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/client"; + import { FITBIT_OAUTH_SCOPE } from "../client"; /** @@ -39,11 +41,17 @@ describe("Fitbit setup docs stay in sync with the code", () => { expect(fitbitDoc).not.toContain("googleapis.com/auth/googlehealth"); }); - it("keeps the old google-health page as a pointer to the Fitbit setup", () => { - const legacy = readDoc("google-health.md"); - expect(legacy).toContain("fitbit.md"); - // The legacy page must not re-introduce the wrong Google Cloud walkthrough. - expect(legacy).not.toContain("console.cloud.google"); - expect(legacy).not.toContain("googleapis.com/auth/googlehealth"); + it("keeps the Google Health runbook on Google endpoints (the inverse mix-up)", () => { + // Since v1.27.0 google-health.md documents the real Google Health + // integration — a Google Cloud OAuth client. The guard flips: this page + // must never send the user to the classic Fitbit app registry, and the + // scopes it lists must match what the google-health client requests. + const googleHealthDoc = readDoc("google-health.md"); + expect(googleHealthDoc).toContain("accounts.google.com/o/oauth2/v2/auth"); + for (const scope of GOOGLE_HEALTH_CORE_SCOPES) { + expect(googleHealthDoc).toContain(scope); + } + expect(googleHealthDoc).not.toContain("dev.fitbit.com"); + expect(googleHealthDoc).not.toContain("fitbit.com/oauth2/authorize"); }); }); diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts new file mode 100644 index 000000000..8c5f97abf --- /dev/null +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -0,0 +1,290 @@ +/** + * Representative-payload tests for the Google Health data-point mappers. + * + * The Google value-field JSON is undocumented, so each mapper hands a small set + * of candidate shapes; these tests pin the launch-metric happy paths — the + * MeasurementType, unit, value (incl. km→m conversion), the resolved + * `measuredAt` timestamp/timezone, and the externalId `fieldTag` grain (spot + * anchor vs the `stats:`-style daily-total overwrite key vs per-sleep-stage). + */ +import { describe, expect, it } from "vitest"; + +import { + mapActiveEnergy, + mapDistance, + mapGoogleHealthSleepStage, + mapGoogleHealthSportType, + mapOxygenSaturation, + mapRestingHeartRate, + mapSleepSession, + mapSteps, + mapWeight, + mapWorkout, +} from "../client"; + +describe("mapWeight — spot sample", () => { + it("maps a kilograms sample to a WEIGHT reading anchored on its physical time", () => { + const rows = mapWeight({ + weight: { + kilograms: 72.53, + sample_time: { physical_time: "2026-06-01T08:00:00.000Z" }, + }, + }); + expect(rows).toHaveLength(1); + const r = rows[0]!; + expect(r.type).toBe("WEIGHT"); + expect(r.unit).toBe("kg"); + expect(r.value).toBe(72.53); + expect(r.measuredAt.toISOString()).toBe("2026-06-01T08:00:00.000Z"); + // Spot readings anchor on the instant → first-write-wins immutable externalId. + expect(r.fieldTag).toBe("2026-06-01T08:00:00.000Z:weight"); + expect(r.cumulativeDaily).toBeUndefined(); + }); + + it("returns nothing when no value parses", () => { + expect(mapWeight({ weight: { sample_time: {} } })).toEqual([]); + }); +}); + +describe("mapOxygenSaturation — daily civil-date summary", () => { + it("maps a nightly average to a percentage anchored on the civil day", () => { + const rows = mapOxygenSaturation({ + oxygen_saturation: { average_percentage: 97.4, date: "2026-06-01" }, + }); + expect(rows).toHaveLength(1); + const r = rows[0]!; + expect(r.type).toBe("OXYGEN_SATURATION"); + expect(r.unit).toBe("%"); + expect(r.value).toBe(97.4); + // A civil-date summary anchors at UTC midnight and keys per-day. + expect(r.measuredAt.toISOString()).toBe("2026-06-01T00:00:00.000Z"); + expect(r.fieldTag).toBe("2026-06-01:spo2"); + }); +}); + +describe("mapRestingHeartRate", () => { + it("maps beats_per_minute to a RESTING_HEART_RATE reading in bpm", () => { + const rows = mapRestingHeartRate({ + daily_resting_heart_rate: { beats_per_minute: 54, date: "2026-06-01" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "RESTING_HEART_RATE", + unit: "bpm", + value: 54, + fieldTag: "2026-06-01:rhr", + }); + }); +}); + +describe("mapSteps — daily cumulative total", () => { + it("maps an interval daily total to a cumulative ACTIVITY_STEPS row", () => { + const rows = mapSteps({ + steps: { + count: 8500, + interval: { + start_time: "2026-06-01T00:00:00.000Z", + end_time: "2026-06-01T23:59:59.000Z", + }, + }, + }); + expect(rows).toHaveLength(1); + const r = rows[0]!; + expect(r.type).toBe("ACTIVITY_STEPS"); + expect(r.unit).toBe("steps"); + expect(r.value).toBe(8500); + // Daily totals carry the overwrite-in-place grain (cumulativeDaily flag + + // per-day fieldTag) so a re-fetched day replaces rather than duplicates. + expect(r.cumulativeDaily).toBe(true); + expect(r.fieldTag).toBe("steps:2026-06-01"); + expect(r.measuredAt.toISOString()).toBe("2026-06-01T00:00:00.000Z"); + }); +}); + +describe("mapSteps — civil-day keying for a non-UTC-midnight interval", () => { + it("keys the day + measuredAt on the CIVIL day, not the physical start instant", () => { + const rows = mapSteps({ + steps: { + count: 12000, + interval: { + // Physical instant is 15:00Z on 2026-06-01, but the civil day the + // total belongs to is 2026-06-02 (a positive-UTC-offset user's local + // midnight). The day-key must follow civil_start_time so Google/Apple/ + // Fitbit `stats::` keys agree — start_time alone would + // off-by-one the day. + start_time: "2026-06-01T15:00:00.000Z", + civil_start_time: "2026-06-02", + }, + }, + }); + expect(rows).toHaveLength(1); + const r = rows[0]!; + expect(r.type).toBe("ACTIVITY_STEPS"); + expect(r.value).toBe(12000); + expect(r.cumulativeDaily).toBe(true); + // Day-key follows the civil day, not the 2026-06-01 physical instant. + expect(r.fieldTag).toBe("steps:2026-06-02"); + // Anchored at UTC-midday so a tz shift can't roll the civil day. + expect(r.measuredAt.toISOString()).toBe("2026-06-02T12:00:00.000Z"); + }); +}); + +describe("mapDistance — unit conversion", () => { + it("converts a kilometers daily total to metres", () => { + const rows = mapDistance({ + distance: { + kilometers: 5.2, + interval: { start_time: "2026-06-01T00:00:00.000Z" }, + }, + }); + expect(rows).toHaveLength(1); + const r = rows[0]!; + expect(r.type).toBe("WALKING_RUNNING_DISTANCE"); + expect(r.unit).toBe("m"); + expect(r.value).toBe(5200); // 5.2 km → 5200 m + expect(r.cumulativeDaily).toBe(true); + }); + + it("prefers an explicit metres field when present", () => { + const rows = mapDistance({ + distance: { + meters: 8000, + interval: { start_time: "2026-06-01T00:00:00.000Z" }, + }, + }); + expect(rows[0]!.value).toBe(8000); + }); +}); + +describe("mapActiveEnergy — preserves a legitimate zero", () => { + it("keeps a rest-day 0 kcal (a real reading, not a gap)", () => { + const rows = mapActiveEnergy({ + active_energy_burned: { + active_kilocalories: 0, + interval: { start_time: "2026-06-01T00:00:00.000Z" }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + value: 0, + cumulativeDaily: true, + }); + }); +}); + +describe("mapSleepSession — per-stage segments", () => { + it("emits one SLEEP_DURATION row per known stage, skipping unknown labels", () => { + const rows = mapSleepSession({ + sleep: { + interval: { end_time: "2026-06-02T06:30:00.000Z" }, + stages: [ + { + stage: "deep", + start_time: "2026-06-02T02:00:00.000Z", + end_time: "2026-06-02T02:45:00.000Z", + }, + { + stage: "rem", + start_time: "2026-06-02T03:00:00.000Z", + end_time: "2026-06-02T03:30:00.000Z", + }, + { + stage: "totally_unknown_label", + start_time: "2026-06-02T04:00:00.000Z", + end_time: "2026-06-02T04:10:00.000Z", + }, + ], + }, + }); + expect(rows).toHaveLength(2); + + const deep = rows[0]!; + expect(deep.type).toBe("SLEEP_DURATION"); + expect(deep.unit).toBe("minutes"); + expect(deep.value).toBe(45); + expect(deep.sleepStage).toBe("DEEP"); + expect(deep.measuredAt.toISOString()).toBe("2026-06-02T02:45:00.000Z"); + // Per-stage rows key off the session anchor + an indexed stage tag. + expect(deep.fieldTag).toBe("2026-06-02T06:30:00.000Z:sleep_deep:0"); + + const rem = rows[1]!; + expect(rem.value).toBe(30); + expect(rem.sleepStage).toBe("REM"); + expect(rem.fieldTag).toBe("2026-06-02T06:30:00.000Z:sleep_rem:1"); + }); + + it("maps a 'light' stage onto the shared CORE band", () => { + expect(mapGoogleHealthSleepStage("light")).toBe("CORE"); + expect(mapGoogleHealthSleepStage("REM")).toBe("REM"); + expect(mapGoogleHealthSleepStage("in-bed")).toBe("IN_BED"); + expect(mapGoogleHealthSleepStage("nonsense")).toBeNull(); + }); +}); + +describe("mapWorkout — exercise session", () => { + it("maps a running session to a Workout with duration, energy, distance and HR", () => { + const w = mapWorkout({ + exercise: { + activity_type: "running", + session_id: "abc123", + interval: { + start_time: "2026-06-03T07:00:00.000Z", + end_time: "2026-06-03T07:45:00.000Z", + }, + active_kilocalories: 420, + distance: { meters: 8000 }, + average_heart_rate: { beats_per_minute: 150 }, + maximum_heart_rate: { beats_per_minute: 172 }, + }, + }); + expect(w).not.toBeNull(); + expect(w).toMatchObject({ + externalId: "abc123", + sportType: "running", + durationSec: 2700, // 45 min + totalEnergyKcal: 420, + totalDistanceM: 8000, + avgHeartRate: 150, + maxHeartRate: 172, + minHeartRate: null, + }); + expect(w!.startedAt.toISOString()).toBe("2026-06-03T07:00:00.000Z"); + expect(w!.endedAt.toISOString()).toBe("2026-06-03T07:45:00.000Z"); + }); + + it("falls back to a start-anchored externalId when no session id is present", () => { + const w = mapWorkout({ + exercise: { + type: "walk", + interval: { + start_time: "2026-06-03T09:00:00.000Z", + end_time: "2026-06-03T09:30:00.000Z", + }, + }, + }); + expect(w!.externalId).toBe("exercise:2026-06-03T09:00:00.000Z"); + expect(w!.sportType).toBe("walking"); + }); + + it("returns null for a session with no usable time span", () => { + expect(mapWorkout({ exercise: {} })).toBeNull(); + expect( + mapWorkout({ + exercise: { + interval: { + start_time: "2026-06-03T09:00:00.000Z", + end_time: "2026-06-03T09:00:00.000Z", // zero-length + }, + }, + }), + ).toBeNull(); + }); + + it("resolves unknown sport types to a generic label", () => { + expect(mapGoogleHealthSportType("kitesurfing")).toBe("other"); + expect(mapGoogleHealthSportType("")).toBe("other"); + expect(mapGoogleHealthSportType("Cycling")).toBe("cycling"); + }); +}); diff --git a/src/lib/google-health/__tests__/response-classifier.test.ts b/src/lib/google-health/__tests__/response-classifier.test.ts new file mode 100644 index 000000000..5b2a5ab70 --- /dev/null +++ b/src/lib/google-health/__tests__/response-classifier.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { + GoogleHealthApiError, + classifyGoogleHealthError, + classifyGoogleHealthResponse, + isGoogleHealthInvalidGrant, + isGoogleHealthReauthRequired, +} from "../response-classifier"; + +describe("classifyGoogleHealthResponse", () => { + it("classifies 2xx as success", () => { + for (const status of [200, 201, 204, 299]) { + const v = classifyGoogleHealthResponse(status); + expect(v.classification).toBe("success"); + expect(v.httpStatus).toBe(status); + } + }); + + it("classifies 429 as transient (rate-limited — honour Google backoff)", () => { + const v = classifyGoogleHealthResponse(429); + expect(v.classification).toBe("transient"); + expect(v.reason).toBe("http_429"); + }); + + it("classifies 5xx as transient", () => { + for (const status of [500, 502, 503, 504]) { + expect(classifyGoogleHealthResponse(status).classification).toBe( + "transient", + ); + } + }); + + it("classifies 401 and 403 as reauth_required", () => { + expect(classifyGoogleHealthResponse(401).classification).toBe( + "reauth_required", + ); + expect(classifyGoogleHealthResponse(403).classification).toBe( + "reauth_required", + ); + }); + + it("classifies other 4xx as persistent (hard reject)", () => { + for (const status of [400, 404, 422]) { + expect(classifyGoogleHealthResponse(status).classification).toBe( + "persistent", + ); + } + }); +}); + +describe("classifyGoogleHealthError", () => { + it("reads the verdict off a typed GoogleHealthApiError", () => { + const err = new GoogleHealthApiError({ + verb: "fetchWeight", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }); + expect(classifyGoogleHealthError(err)).toBe("transient"); + }); + + it("defaults a plain Error to transient (never hard-disables on an unknown)", () => { + expect(classifyGoogleHealthError(new Error("socket hang up"))).toBe( + "transient", + ); + }); + + it("recovers the verdict from the legacy message shape across a lost prototype", () => { + // A pg-boss retry can strip the prototype; the message-shape fallback still + // recovers a 401 → reauth_required. + const plain = new Error("GoogleHealth fetchSleep error: 401"); + expect(classifyGoogleHealthError(plain)).toBe("reauth_required"); + }); +}); + +describe("isGoogleHealthInvalidGrant / isGoogleHealthReauthRequired", () => { + it("lifts a 400 + invalid_grant onto reauth_required (the 7-day expiry signal)", () => { + const err = new GoogleHealthApiError({ + verb: "refreshToken", + classification: "persistent", // a bare 400 classifies persistent… + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_grant", + }); + expect(isGoogleHealthInvalidGrant(err)).toBe(true); + // …but the reauth predicate still routes it to the reconnect CTA. + expect(isGoogleHealthReauthRequired(err)).toBe(true); + }); + + it("keeps a 400 invalid_client as persistent (bad secret ≠ reconnect)", () => { + const err = new GoogleHealthApiError({ + verb: "refreshToken", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_client", + }); + expect(isGoogleHealthInvalidGrant(err)).toBe(false); + expect(isGoogleHealthReauthRequired(err)).toBe(false); + }); + + it("treats a 401 as reauth_required", () => { + const err = new GoogleHealthApiError({ + verb: "fetchProfile", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }); + expect(isGoogleHealthReauthRequired(err)).toBe(true); + }); + + it("does not flag a transient 503 as reauth", () => { + const err = new GoogleHealthApiError({ + verb: "fetchSteps", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }); + expect(isGoogleHealthReauthRequired(err)).toBe(false); + }); +}); diff --git a/src/lib/google-health/__tests__/scopes.test.ts b/src/lib/google-health/__tests__/scopes.test.ts new file mode 100644 index 000000000..824511b9d --- /dev/null +++ b/src/lib/google-health/__tests__/scopes.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + GOOGLE_HEALTH_CORE_SCOPES, + getGoogleHealthScopeString, + resolveGoogleHealthScopes, +} from "../client"; + +describe("Google Health scope resolver", () => { + it("ships exactly the four core Restricted read scopes", () => { + const scopes = resolveGoogleHealthScopes(); + expect(scopes).toEqual([...GOOGLE_HEALTH_CORE_SCOPES]); + expect(scopes).toHaveLength(4); + // Every core scope is a Restricted read-only googlehealth scope. + for (const s of scopes) { + expect(s).toMatch( + /^https:\/\/www\.googleapis\.com\/auth\/googlehealth\..+\.readonly$/, + ); + } + }); + + it("requests no ECG/IRN Restricted scope (no reader consumes them yet)", () => { + for (const s of resolveGoogleHealthScopes()) { + expect(s).not.toContain("googlehealth.ecg"); + expect(s).not.toContain("googlehealth.irn"); + } + }); + + it("joins the resolved scopes with a single space for the authorize request", () => { + expect(getGoogleHealthScopeString()).toBe( + GOOGLE_HEALTH_CORE_SCOPES.join(" "), + ); + expect(getGoogleHealthScopeString().split(" ")).toHaveLength(4); + }); +}); diff --git a/src/lib/google-health/__tests__/setup-docs-parity.test.ts b/src/lib/google-health/__tests__/setup-docs-parity.test.ts new file mode 100644 index 000000000..4f295e2db --- /dev/null +++ b/src/lib/google-health/__tests__/setup-docs-parity.test.ts @@ -0,0 +1,76 @@ +/** + * Doc ↔ code parity guard for the Google Health operator runbook. + * + * The setup-guide link on the Settings card sends a mid-setup user to + * `docs.healthlog.dev/integrations/google-health`, rendered from + * `docs/integrations/google-health.md`. If the client ever requests a scope the + * runbook doesn't list, or the walkthrough regresses back to a wrong provider + * (the file was once a Fitbit-Web-API walkthrough), a self-hoster follows a + * broken recipe and the OAuth consent screen rejects the connection. + * + * This test pins the doc to the code: every core scope the client requests must + * appear verbatim in the runbook, alongside the real Google OAuth endpoints, the + * fixed callback path, and the Google Cloud console setup. It also asserts the + * provider key is wired into the setup-guide link so the card actually points + * here. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { GOOGLE_HEALTH_CORE_SCOPES } from "../client"; +import { + INTEGRATION_DOCS_BASE, + integrationDocsHref, +} from "@/components/settings/integrations/setup-guide-link"; + +const ROOT = join(__dirname, "../../../.."); +const DOC_PATH = join(ROOT, "docs/integrations/google-health.md"); +const doc = readFileSync(DOC_PATH, "utf8"); + +describe("google-health setup runbook parity", () => { + it("mentions every core Restricted scope the client requests", () => { + for (const scope of GOOGLE_HEALTH_CORE_SCOPES) { + expect(doc).toContain(scope); + } + }); + + it("documents the real Google OAuth endpoints", () => { + expect(doc).toContain("https://accounts.google.com/o/oauth2/v2/auth"); + expect(doc).toContain("https://oauth2.googleapis.com/token"); + }); + + it("documents the fixed callback path", () => { + expect(doc).toContain("/api/google-health/callback"); + }); + + it("walks the Google Cloud console setup (not the retired Fitbit walkthrough)", () => { + expect(doc).toContain("console.cloud.google.com"); + expect(doc.toLowerCase()).toContain("google health api"); + // Guard against the pre-rewrite Fitbit-developer-console walkthrough + // creeping back in. + expect(doc).not.toContain("dev.fitbit.com"); + expect(doc).not.toContain("api.fitbit.com"); + }); + + it("documents the honest data + re-consent caveats", () => { + const lower = doc.toLowerCase(); + expect(lower).toContain("stress"); + expect(lower).toContain("readiness"); + expect(doc).toContain("7-day"); + expect(doc).toContain("GOOGLE_HEALTH_REDIRECT_URI"); + }); + + it("names the 100-user / CASA verification ceiling", () => { + expect(doc).toContain("100 users"); + expect(doc).toContain("CASA"); + }); + + it("resolves the setup-guide href for the google-health provider", () => { + // The literal argument also compile-checks that "google-health" is a member + // of the IntegrationDocsProvider union. + expect(integrationDocsHref("google-health")).toBe( + `${INTEGRATION_DOCS_BASE}/google-health`, + ); + }); +}); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts new file mode 100644 index 000000000..3ebc413a9 --- /dev/null +++ b/src/lib/google-health/client.ts @@ -0,0 +1,1558 @@ +/** + * Google Health API client — OAuth + data-point reads (v1.27.0). + * Docs: https://developers.google.com/health (health.googleapis.com/v4, the + * successor to the Fitbit Web API, unifying Fitbit + Pixel Watch + Fitbit Air). + * + * Hand-rolled fetch over `safeFetch` (no SDK): the OAuth handshake + * (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), a single + * profile fetch for the connection's external user id, and the paginated + * `dataPoints.list` walker + per-type mappers. + * + * KEY OAUTH SEMANTICS (verified 2026 contract): + * - Access-token TTL = 1 h (contrast the classic Fitbit Web API's 8 h). + * - Refresh tokens do NOT rotate — a routine refresh returns a fresh + * `access_token` WITHOUT a `refresh_token`; the sync layer keeps the stored + * one (see `getValidToken` in `sync.ts`). Refresh tokens are time-based: + * they expire after 6 months of disuse, or — in a consent screen still in + * "Testing" publishing mode — after 7 DAYS, at which point the user must + * re-consent. A revoked / expired refresh token surfaces `invalid_grant` + * (or a 401) on the token endpoint; `postToken` lifts that onto the + * `reauth_required` class so the connection prompts a reconnect rather than + * a generic hard error. + * - PKCE (S256): the authorize request carries a `code_challenge`; the + * callback presents the matching `code_verifier` on exchange. Google's + * web-server (confidential) client also sends the client secret via HTTP + * Basic auth (RFC 6749 §2.3.1) — Basic + PKCE together. + * - `access_type=offline` + `prompt=consent` are required to reliably receive + * a refresh token (and force one on every re-consent). + */ +import { createHash, randomBytes } from "node:crypto"; +import { getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { + GoogleHealthApiError, + classifyGoogleHealthResponse, + type GoogleHealthClassification, +} from "./response-classifier"; + +export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; +const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; + +export interface GoogleHealthCredentials { + clientId: string; + clientSecret: string; +} + +/** + * Resolve the OAuth `redirect_uri`, then assert it against an allowlist + * (defence-in-depth). + * + * The value is operator-controlled config (`GOOGLE_HEALTH_REDIRECT_URI`, else + * derived from `NEXT_PUBLIC_APP_URL`), not user input, so Google's + * registered-redirect check is the real backstop. But a misconfigured or + * `Host`-coerced `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting + * a forwarded Host) would otherwise send the authorization code's landing URL + * off-origin. Pin the target here so a malformed origin fails fast at the + * handshake rather than silently redirecting elsewhere: + * - must be an absolute, parseable URL, + * - must be https (the one exception is a localhost/loopback dev host, which + * Google itself permits over http), + * - must land on the fixed `/api/google-health/callback` path, + * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. + */ +function getRedirectUri(): string { + const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI; + const appUrl = process.env.NEXT_PUBLIC_APP_URL; + const raw = + explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined); + + if (!raw) { + throw new Error( + "Google Health redirect_uri is not configured — set GOOGLE_HEALTH_REDIRECT_URI or NEXT_PUBLIC_APP_URL", + ); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error( + `Google Health redirect_uri is not an absolute URL: ${raw}`, + ); + } + + const isLoopback = + parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "[::1]" || + parsed.hostname === "::1"; + if ( + parsed.protocol !== "https:" && + !(parsed.protocol === "http:" && isLoopback) + ) { + throw new Error( + `Google Health redirect_uri must be https (or http on localhost): ${parsed.origin}`, + ); + } + + if (parsed.pathname !== "/api/google-health/callback") { + throw new Error( + `Google Health redirect_uri must target /api/google-health/callback, got ${parsed.pathname}`, + ); + } + + // When an explicit GOOGLE_HEALTH_REDIRECT_URI is set alongside + // NEXT_PUBLIC_APP_URL, require them to share an origin so the pinned value + // can't drift to an unexpected host relative to the app's own base URL. + if (explicit && appUrl) { + let appOrigin: string; + try { + appOrigin = new URL(appUrl).origin; + } catch { + throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`); + } + if (parsed.origin !== appOrigin) { + throw new Error( + `Google Health redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`, + ); + } + } + + return parsed.toString(); +} + +/** + * The four core Restricted read scopes HealthLog requests for v1. Every scope + * is Restricted → the operator's OAuth client needs Google verification + an + * annual CASA assessment before it leaves "Testing" publishing mode (staying in + * Testing with ≤100 test users avoids CASA at the cost of a 7-day refresh-token + * expiry). + */ +export const GOOGLE_HEALTH_CORE_SCOPES = [ + "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", + "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", + "https://www.googleapis.com/auth/googlehealth.sleep.readonly", + "https://www.googleapis.com/auth/googlehealth.profile.readonly", +] as const; + +/** + * Resolve the scope list HealthLog requests: the four core Restricted bundles. + * ECG/IRN are a future enhancement — add those two Restricted read scopes only + * together with an ECG/IRN reader, never before (Google penalizes requesting a + * Restricted scope the app never consumes). + */ +export function resolveGoogleHealthScopes(): string[] { + return [...GOOGLE_HEALTH_CORE_SCOPES]; +} + +/** The space-separated scope string sent on the authorize request. */ +export function getGoogleHealthScopeString(): string { + return resolveGoogleHealthScopes().join(" "); +} + +// ─── PKCE ────────────────────────────────────────────────────── +// +// Google's authorization-code flow accepts S256 PKCE. The verifier is a +// high-entropy random string; the challenge is BASE64URL(SHA256(verifier)). The +// verifier is stashed on the OAuth-state row at connect and presented on the +// token exchange at callback — never in the cookie or the URL. + +export interface GoogleHealthPkcePair { + verifier: string; + challenge: string; +} + +/** + * Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars, + * comfortably inside the RFC 7636 43–128 char verifier range and well past the + * 256-bit entropy floor. + */ +export function generatePkcePair(): GoogleHealthPkcePair { + const verifier = randomBytes(64).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +/** + * Build the Google authorization URL (a browser redirect, not a fetch). `state` + * is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the + * S256 PKCE challenge whose verifier the callback presents on token exchange. + * + * `access_type=offline` + `prompt=consent` are Google's requirement to receive + * a refresh token; `prompt=consent` forces the consent screen so a re-connect + * always returns a fresh refresh token even if the user previously granted. + */ +export function getAuthorizationUrl( + state: string, + creds: GoogleHealthCredentials, + codeChallenge: string, +): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: creds.clientId, + redirect_uri: getRedirectUri(), + scope: getGoogleHealthScopeString(), + access_type: "offline", + prompt: "consent", + code_challenge: codeChallenge, + code_challenge_method: "S256", + state, + }); + return `${GOOGLE_OAUTH_AUTH_URL}?${params}`; +} + +export interface GoogleHealthTokenResponse { + access_token: string; + /** + * Present on the initial code exchange and whenever Google issues a new + * refresh token; ABSENT on a routine refresh because Google does not rotate. + * The sync layer keeps the stored token when this is missing. + */ + refresh_token?: string; + expires_in: number; + scope?: string; + token_type?: string; +} + +/** Basic-auth header carrying the confidential client credentials. */ +function basicAuthHeader(creds: GoogleHealthCredentials): string { + const raw = `${creds.clientId}:${creds.clientSecret}`; + return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`; +} + +async function postToken( + params: URLSearchParams, + creds: GoogleHealthCredentials, + verb: string, +): Promise { + const start = performance.now(); + const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: basicAuthHeader(creds), + }, + body: params.toString(), + }); + + const json = await res.json().catch(() => null); + const verdict = classifyGoogleHealthResponse(res.status); + const upstreamError = + typeof json?.error === "string" ? (json.error as string) : undefined; + + // Google signals a revoked / expired refresh token — the user disconnected + // the app upstream, OR the 7-day "Testing"-mode refresh window lapsed — via + // `invalid_grant` on the token endpoint (sometimes a 400, sometimes a 401). A + // bare status classification buckets a 400 as `persistent` (never prompts a + // reconnect); lift an `invalid_grant` onto `reauth_required` so the connection + // surfaces the reconnect CTA. A 401 already classifies as `reauth_required`. + const classification: GoogleHealthClassification = + upstreamError === "invalid_grant" + ? "reauth_required" + : verdict.classification; + + getEvent()?.addExternalCall({ + service: "google-health", + method: verb, + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError, + }); + } + return json as GoogleHealthTokenResponse; +} + +/** + * Exchange an authorization code for the initial token pair, presenting the + * PKCE verifier. `redirect_uri` must exactly match the one sent to the + * authorize endpoint. + */ +export async function exchangeCode( + code: string, + codeVerifier: string, + creds: GoogleHealthCredentials, +): Promise { + return postToken( + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: creds.clientId, + redirect_uri: getRedirectUri(), + code_verifier: codeVerifier, + }), + creds, + "exchangeCode", + ); +} + +/** + * Refresh an expired access token. Google does NOT rotate refresh tokens — the + * response carries a fresh `access_token` + `expires_in` but usually omits + * `refresh_token`. The caller persists the new access token + expiry and keeps + * the stored refresh token unless a new one is returned. The original scope is + * preserved by Google, so no `scope` param is re-sent. A revoked / expired + * refresh token throws a `GoogleHealthApiError` classified `reauth_required`. + */ +export async function refreshAccessToken( + refreshToken: string, + creds: GoogleHealthCredentials, +): Promise { + return postToken( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: creds.clientId, + }), + creds, + "refreshAccessToken", + ); +} + +/** + * The Google Health profile carries the external user id used as the + * connection's `externalUserId`. A single GET, not a paginated collection. + */ +export interface GoogleHealthProfile { + /** + * External user identifier. Google returns `me`-relative profile data; the + * stable id is surfaced under `name` (a `users/{id}` resource name) or `id` + * depending on the API surface — both are captured here and resolved at the + * call site. Re-verify the exact field against a live account at build. + */ + name?: string; + id?: string; +} + +export async function fetchProfile( + accessToken: string, +): Promise { + const start = performance.now(); + const res = await safeFetch(`${GOOGLE_HEALTH_API_BASE}/users/me/profile`, { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const json = (await res + .json() + .catch(() => null)) as GoogleHealthProfile | null; + const verdict = classifyGoogleHealthResponse(res.status); + getEvent()?.addExternalCall({ + service: "google-health", + method: "fetchProfile", + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb: "fetchProfile", + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } + return (json ?? {}) as GoogleHealthProfile; +} + +/** + * Resolve the stable external user id from a Google Health profile. The + * `users/{id}` resource name is the canonical anchor; fall back to a bare `id` + * or "me" so the connection always persists a non-empty `externalUserId`. + */ +export function resolveGoogleHealthUserId( + profile: GoogleHealthProfile, +): string { + if (profile.name) { + const tail = profile.name.split("/").pop(); + if (tail) return tail; + } + if (profile.id) return profile.id; + return "me"; +} + +// ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ────── +// +// The Google Health API exposes one uniform `DataPoint` resource shape across +// every data type, read through `GET /v4/users/me/dataTypes/{dataType}/dataPoints` +// with `nextPageToken` pagination. Google's value-field JSON is NOT fully +// published, so the mappers below are intentionally defensive: every value is +// pulled out of a small set of candidate field shapes and guarded by a +// finite-positive check before it becomes a Measurement. Capture the real +// per-type value-field JSON into `mapping.md` against a live test account at +// build and tighten the extractors then. +// +// Casing gotcha: the data-type id is **kebab-case in the path** (`body-fat`) +// and **snake_case in the `filter`** (`body_fat`). `GOOGLE_HEALTH_DATA_TYPES` +// pins both forms so a fetcher can never encode the wrong one. Every launch +// data type is read through `dataPoints.list`. + +/** + * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to + * 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25. The launch + * metrics use the daily/spot reads, so the default page size is the larger. + */ +export const GOOGLE_HEALTH_PAGE_SIZE = 1000; +/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ +export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; + +/** + * One data type's on-the-wire encodings. `path` is the kebab-case segment + * spliced into the request URL; `filter` is the snake_case prefix used to build + * the incremental `filter=` predicate. `timeField` names the value object's + * time anchor. Every launch data type reads through `dataPoints.list`. + */ +export interface GoogleHealthDataType { + /** kebab-case segment for the request path. */ + path: string; + /** snake_case prefix for the `filter` predicate. */ + filter: string; + /** + * Which time anchor the filter / measuredAt resolution targets: + * - `sample` → spot reading (`{type}.sample_time.physical_time`). + * - `date` → daily summary keyed on a civil date (`{type}.date`). + * - `interval` → an INTERVAL data type (steps / distance / active energy / + * floors, sleep, exercise). The cumulative daily totals key on the CIVIL + * day: `{type}.interval.civil_start_time` (anchored at UTC-midday) with + * `{type}.interval.start_time` (the physical instant) as the fallback — + * NOT a `sample_time` (which 400s/empties for interval types and stalls + * the incremental filter) and NOT a bare `date`. + */ + timeField: "sample" | "date" | "interval"; +} + +/** + * The launch data types. Each entry pins the kebab-path + snake-filter pair so + * the two encodings can never drift. Identifiers verified against the 2026 + * Google Health contract (API-RESEARCH §3/§4). + */ +export const GOOGLE_HEALTH_DATA_TYPES = { + weight: { path: "weight", filter: "weight", timeField: "sample" }, + bodyFat: { path: "body-fat", filter: "body_fat", timeField: "sample" }, + oxygenSaturation: { + path: "oxygen-saturation", + filter: "oxygen_saturation", + timeField: "date", + }, + heartRateVariability: { + path: "heart-rate-variability", + filter: "heart_rate_variability", + timeField: "date", + }, + restingHeartRate: { + path: "daily-resting-heart-rate", + filter: "daily_resting_heart_rate", + timeField: "date", + }, + respiratoryRate: { + path: "respiratory-rate", + filter: "respiratory_rate", + timeField: "date", + }, + heartRate: { path: "heart-rate", filter: "heart_rate", timeField: "sample" }, + height: { path: "height", filter: "height", timeField: "sample" }, + // Skin temperature (`daily-sleep-temperature-derivations`) is intentionally + // OMITTED: Google surfaces a nightly signed DEVIATION from baseline, not an + // absolute reading — a future enhancement needing a signed-delta model, not an + // absolute WRIST_TEMPERATURE row. + // ── Activity bundle — daily cumulative totals ────────────────── + // Scope: `googlehealth.activity_and_fitness.readonly`. These are INTERVAL + // data types: Google buckets a daily total into an `interval` (a `start_time` + // + `end_time`, with `civil_start_time` for the calendar-day grain). The + // incremental filter targets `interval.start_time` and the externalId carries + // the `stats:` prefix so a re-fetched day overwrites in place (mirrors the + // Apple-Health `stats::` daily-total overwrite contract). + steps: { path: "steps", filter: "steps", timeField: "interval" }, + distance: { path: "distance", filter: "distance", timeField: "interval" }, + // Active energy — canonical id `active-energy-burned` (release-notes + // 2026-05-26); this is the ACTIVE portion only, NOT `total-calories` (which + // folds in BMR and is roll-up-only, see below). + activeEnergy: { + path: "active-energy-burned", + filter: "active_energy_burned", + timeField: "interval", + }, + floors: { path: "floors", filter: "floors", timeField: "interval" }, + // VO2 max is a daily-summary metric (one civil-date reading), not an interval + // bucket — keep the `date` anchor. + vo2Max: { path: "vo2-max", filter: "vo2_max", timeField: "date" }, + // ── Sleep bundle ─────────────────────────────────────────────── + // Scope: `googlehealth.sleep.readonly`. A sleep session is an INTERVAL data + // type (a start + end span carrying a per-stage breakdown); the incremental + // filter must target `interval.start_time`, not a `sample_time` (which 400s + // for interval types). Mapped to per-stage SLEEP_DURATION rows. + sleep: { path: "sleep", filter: "sleep", timeField: "interval" }, + // ── Exercise bundle ──────────────────────────────────────────── + // Scope: `googlehealth.activity_and_fitness.readonly`. An exercise session is + // an INTERVAL data type (a start + end span) → a `Workout` row (NOT a + // Measurement). The incremental filter targets `interval.start_time`. + exercise: { path: "exercise", filter: "exercise", timeField: "interval" }, +} as const satisfies Record; + +/** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */ +export interface GoogleHealthDataPoint { + [key: string]: unknown; +} + +/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ +interface GoogleHealthDataPointPage { + dataPoints?: GoogleHealthDataPoint[]; + nextPageToken?: string | null; +} + +interface DataPointQuery { + /** Lower-bound incremental cursor; omitted on a full backfill. */ + start?: Date; + /** Page size (defaults to `GOOGLE_HEALTH_PAGE_SIZE`). */ + pageSize?: number; + /** Hard ceiling on pages walked (defence against a runaway cursor). */ + maxPages?: number; +} + +/** + * Build the incremental `filter` field + bound for one data type. Centralised + * so the predicate and the read-time anchor resolution can never drift. + * - `sample` → spot reading, filter on `{filter}.sample_time.physical_time`. + * - `interval` → INTERVAL type, filter on `{filter}.interval.start_time`. + * - `date` → daily civil-date summary, filter on `{filter}.date`. + */ +export function incrementalFilter( + dataType: GoogleHealthDataType, + start: Date, +): { field: string; bound: string } { + switch (dataType.timeField) { + case "sample": + return { + field: `${dataType.filter}.sample_time.physical_time`, + bound: start.toISOString(), + }; + case "interval": + return { + field: `${dataType.filter}.interval.start_time`, + bound: start.toISOString(), + }; + case "date": + return { + field: `${dataType.filter}.date`, + bound: start.toISOString().slice(0, 10), + }; + } +} + +/** + * Walk every `DataPoint` for one data type since the incremental cursor via + * `dataPoints.list` with `nextPageToken` pagination. The data-type id is + * kebab-cased in the path; the `filter` predicate is built from the snake_case + * form against the type's time anchor. + */ +export async function fetchDataPoints( + dataType: GoogleHealthDataType, + accessToken: string, + verb: string, + query: DataPointQuery = {}, +): Promise { + const points: GoogleHealthDataPoint[] = []; + let pageToken: string | null | undefined; + let pageCount = 0; + const maxPages = query.maxPages ?? 1000; + const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; + + do { + const params = new URLSearchParams({ pageSize: String(pageSize) }); + if (query.start) { + const { field, bound } = incrementalFilter(dataType, query.start); + params.set("filter", `${field} >= "${bound}"`); + } + if (pageToken) params.set("pageToken", pageToken); + + const pageStart = performance.now(); + const res = await safeFetch( + `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, + { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + + const json = (await res + .json() + .catch(() => null)) as GoogleHealthDataPointPage | null; + const verdict = classifyGoogleHealthResponse(res.status); + getEvent()?.addExternalCall({ + service: "google-health", + method: `${verb}(page=${pageCount})`, + duration_ms: Math.round(performance.now() - pageStart), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } + + for (const p of json?.dataPoints ?? []) points.push(p); + pageToken = json?.nextPageToken ?? null; + pageCount += 1; + } while (pageToken && pageCount < maxPages); + + return points; +} + +// ─── Field → Measurement mapping ─────────────────────────────── +// The single source of truth is `mapping.md` — keep both in sync when adding +// entries. + +/** Metres → centimetres (Google `height` → `User.heightCm`). */ +const M_TO_CM = 100; + +function round2(n: number): number { + return parseFloat(n.toFixed(2)); +} + +/** Finite + strictly-positive guard. */ +function positive(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n) && n > 0; +} + +/** + * A single mapped reading destined for one `Measurement` row. The `source` + * (`GOOGLE_HEALTH`) and `externalId` (`:`) are stamped by the + * sync layer; the mapper emits only type/value/unit/measuredAt + the field-tag + * that disambiguates the externalId. + */ +export interface GoogleHealthMappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + /** Disambiguator appended to the per-point anchor to form the externalId. */ + fieldTag: string; + /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ + sleepStage?: GoogleHealthSleepStage; + /** + * `true` when the externalId carries the `stats:` daily-total prefix (the + * cumulative activity metrics). The sync layer stamps the externalId; this + * flag lets it pick the right shape (`stats::` vs the + * `:` spot shape) without re-deriving the grain. + */ + cumulativeDaily?: boolean; +} + +/** HealthLog `SleepStage` values a Google sleep stage maps onto. */ +export type GoogleHealthSleepStage = + "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP"; + +/** + * Pull the first finite number out of a list of candidate value paths on a + * `DataPoint`. The Google value-field JSON is undocumented, so each mapper hands + * the small set of shapes the field is likely to take (a bare `value`, a typed + * sub-object, a `{ value: { fpVal } }` wrapper, …); the first finite hit wins. + */ +function firstNumber( + point: GoogleHealthDataPoint, + paths: string[], +): number | null { + for (const path of paths) { + const v = readPath(point, path); + if (positive(v)) return v; + // A value can legitimately be zero for some metrics, but the launch metrics + // (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height) are all + // strictly positive — a zero is a garbage/empty reading, so `positive` is + // the right guard. + } + return null; +} + +/** Resolve a dotted path against a nested object; undefined on any miss. */ +function readPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const seg of path.split(".")) { + if (cur === null || typeof cur !== "object") return undefined; + cur = (cur as Record)[seg]; + } + return cur; +} + +/** Parse a `{year,month,day}` civil-date object into a UTC-midday Date, or null. */ +function parseCivilDateObject(val: unknown): Date | null { + if (!val || typeof val !== "object") return null; + const o = val as Record; + if ( + typeof o.year === "number" && + typeof o.month === "number" && + typeof o.day === "number" + ) { + // Google civil dates are 1-based months; anchor at UTC midday so a timezone + // shift can't roll the civil day across a boundary. + return new Date(Date.UTC(o.year, o.month - 1, o.day, 12)); + } + return null; +} + +/** + * Parse a Google civil START anchor — the calendar day a cumulative daily total + * belongs to — into a UTC-midday Date, or null. Accepts a `{year,month,day}` + * object OR a civil string (`YYYY-MM-DD`, optionally carrying a time suffix like + * `2026-06-02T00:00:00`); only the Y-M-D is kept, anchored at UTC midday so a + * timezone shift can't roll the civil day. Mirrors the Fitbit `parseCivilDate` + * UTC-midday convention. + */ +function parseCivilStart(val: unknown): Date | null { + if (typeof val === "string") { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(val.trim()); + if (!m) return null; + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); + } + return parseCivilDateObject(val); +} + +/** + * Matches an offset-less local ISO wall-clock string (`2026-06-02T03:02:30` / + * `...T03:02:30.000`). No trailing `Z`, no `±hh:mm` — those denote an absolute + * instant and are honoured verbatim. + */ +const OFFSET_LESS_LOCAL_ISO = + /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/; + +/** + * Parse a Google timestamp into a UTC instant. Sleep segments + exercise + * sessions can emit LOCAL wall-clock strings WITHOUT an offset — the night / + * session belongs to the user's local clock, so an offset-less string is + * resolved against the USER'S timezone, not the process zone (a bare + * `new Date(iso)` parses an offset-less string in the host zone, which shifts a + * non-UTC user by their offset and can flip a near-midnight wake-day). When `tz` + * is omitted the host-local fallback preserves the prior behaviour. Strings that + * DO carry an offset/`Z` are absolute and parsed as-is. Mirrors the Fitbit + * `parseLocalInstant`. Returns null on a miss. + */ +function parseLocalInstant(iso: string, tz?: string): Date | null { + const m = OFFSET_LESS_LOCAL_ISO.exec(iso.trim()); + if (m) { + return zonedWallClockToUtc( + { + year: Number(m[1]), + month: Number(m[2]), + day: Number(m[3]), + hour: Number(m[4]), + minute: Number(m[5]), + second: m[6] ? Number(m[6]) : 0, + }, + tz, + ); + } + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Resolve a `DataPoint`'s measurement timestamp. + * - `sample` → `sample_time.physical_time` (a spot instant; offset-less + * strings resolve against `tz`). + * - `interval` → the cumulative daily total keys on the CIVIL day: + * `interval.civil_start_time` anchored at UTC-midday (so a tz shift can't + * roll the day and the day-key aligns with the Apple/Fitbit `stats:` civil + * convention), falling back to `interval.start_time` (the physical instant) + * only when civil is absent. `start_time` alone off-by-ones the day for + * positive-UTC-offset users. + * - `date` → `date` (a civil string or `{year,month,day}` object). + * Falls back to `fallback` only when nothing parses, so a row is never dropped + * for a missing anchor. + */ +function resolveMeasuredAt( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + fallback: Date, + tz?: string, +): Date { + if (dataType.timeField === "sample") { + const t = readPath(point, `${dataType.filter}.sample_time.physical_time`); + if (typeof t === "string") { + const d = parseLocalInstant(t, tz); + if (d) return d; + } + } else if (dataType.timeField === "interval") { + // Civil day first — the day the total belongs to, tz-invariant at UTC-midday. + const civil = readPath( + point, + `${dataType.filter}.interval.civil_start_time`, + ); + const civilDate = parseCivilStart(civil); + if (civilDate) return civilDate; + // Fall back to the physical instant only when civil is absent. + const t = readPath(point, `${dataType.filter}.interval.start_time`); + if (typeof t === "string") { + const d = parseLocalInstant(t, tz); + if (d) return d; + } + } else { + const dateVal = readPath(point, `${dataType.filter}.date`); + if (typeof dateVal === "string") { + const d = new Date(dateVal); + if (!Number.isNaN(d.getTime())) return d; + } + const civilObj = parseCivilDateObject(dateVal); + if (civilObj) return civilObj; + } + return fallback; +} + +/** + * Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its + * sample time; a daily summary on its civil date. Combined with a type-specific + * field-tag this makes the upsert key idempotent across re-fetches. + */ +function externalAnchor( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + tz?: string, +): string { + const at = resolveMeasuredAt(point, dataType, new Date(0), tz); + // The mapper-driven interval types (steps/distance/calories/floors) are daily + // totals, so they share the civil-day externalId grain with `date` summaries — + // a re-fetched day overwrites in place. (Sleep/exercise are interval too but + // mint their own per-session anchors and never reach this helper.) + if (dataType.timeField === "date" || dataType.timeField === "interval") { + return at.toISOString().slice(0, 10); + } + return at.toISOString(); +} + +/** + * Map one data point of a simple single-value metric into a Measurement + * reading. `valuePaths` lists the candidate value shapes; the first + * finite-positive hit wins. Returns an empty array when no value parses. + */ +function mapSimple( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + spec: { + type: string; + unit: string; + fieldTag: string; + valuePaths: string[]; + factor?: number; + }, +): GoogleHealthMappedMeasurement[] { + let value = firstNumber(point, spec.valuePaths); + if (value === null) return []; + if (spec.factor) value = value * spec.factor; + return [ + { + type: spec.type, + value: round2(value), + unit: spec.unit, + measuredAt: resolveMeasuredAt(point, dataType, new Date()), + fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`, + }, + ]; +} + +/** Candidate value-field shapes a Google `DataPoint` is likely to carry. */ +function valuePaths(filterKey: string, leaf: string): string[] { + return [ + `${filterKey}.${leaf}`, + `${filterKey}.value.${leaf}`, + `${filterKey}.value`, + `value.${leaf}`, + `value`, + ]; +} + +export function mapWeight( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.weight; + return mapSimple(point, dt, { + type: "WEIGHT", + unit: "kg", + fieldTag: "weight", + valuePaths: valuePaths(dt.filter, "kilograms"), + }); +} + +export function mapBodyFat( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.bodyFat; + return mapSimple(point, dt, { + type: "BODY_FAT", + unit: "%", + fieldTag: "body_fat", + valuePaths: valuePaths(dt.filter, "percentage"), + }); +} + +export function mapOxygenSaturation( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation; + return mapSimple(point, dt, { + type: "OXYGEN_SATURATION", + unit: "%", + fieldTag: "spo2", + valuePaths: [ + ...valuePaths(dt.filter, "average_percentage"), + ...valuePaths(dt.filter, "percentage"), + ], + }); +} + +export function mapHeartRateVariability( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability; + // Google reports a nightly RMSSD-style HRV. Per the design decision it lands + // in the SDNN-lineage `HEART_RATE_VARIABILITY` slot (Apple-comparable), NOT + // WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native estimator). Re-confirm + // the estimator against a live account and revisit if it warrants `HRV_RMSSD`. + return mapSimple(point, dt, { + type: "HEART_RATE_VARIABILITY", + unit: "ms", + fieldTag: "hrv", + valuePaths: [ + ...valuePaths(dt.filter, "rmssd_milliseconds"), + ...valuePaths(dt.filter, "milliseconds"), + ], + }); +} + +export function mapRestingHeartRate( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.restingHeartRate; + return mapSimple(point, dt, { + type: "RESTING_HEART_RATE", + unit: "bpm", + fieldTag: "rhr", + valuePaths: valuePaths(dt.filter, "beats_per_minute"), + }); +} + +export function mapRespiratoryRate( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.respiratoryRate; + return mapSimple(point, dt, { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + fieldTag: "resp_rate", + valuePaths: [ + ...valuePaths(dt.filter, "breaths_per_minute"), + ...valuePaths(dt.filter, "average_breaths_per_minute"), + ], + }); +} + +export function mapHeartRate( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.heartRate; + return mapSimple(point, dt, { + type: "PULSE", + unit: "bpm", + fieldTag: "hr", + valuePaths: valuePaths(dt.filter, "beats_per_minute"), + }); +} + +/** + * Extract the profile height (in cm) from a Google `height` data point, or null + * when nothing parses. Height is a one-time `User.heightCm` profile seed (written + * only when the user has no height yet) — NOT a Measurement. Returns cm. + */ +export function mapHeightCm(point: GoogleHealthDataPoint): number | null { + const dt = GOOGLE_HEALTH_DATA_TYPES.height; + // Google may report height in metres or centimetres; try cm-direct first, then + // metres × 100. Both pass through the positive guard. + const cm = firstNumber(point, valuePaths(dt.filter, "centimeters")); + if (cm !== null) return round2(cm); + const m = firstNumber(point, valuePaths(dt.filter, "meters")); + if (m !== null) return round2(m * M_TO_CM); + return null; +} + +// ─── Activity mappers: daily cumulative ──────────────────────── + +/** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */ +function nonNegative(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n) && n >= 0; +} + +/** + * Pull the first finite NON-negative number out of a list of candidate value + * paths. Unlike `firstNumber` (strictly positive), this admits a legitimate + * zero — a day of rest still records 0 steps / 0 floors / 0 active kcal, and + * dropping the zero would leave a hole the chart misreads as missing data. + */ +function firstNonNegativeNumber( + point: GoogleHealthDataPoint, + paths: string[], +): number | null { + for (const path of paths) { + const v = readPath(point, path); + if (nonNegative(v)) return v; + } + return null; +} + +/** + * Map one daily cumulative-activity data point into a single Measurement + * reading. The externalId is the `stats:`-prefixed daily-total shape so a + * re-fetched day overwrites in place rather than minting a duplicate — the same + * overwrite contract the Apple-Health `stats::` daily totals + * use. A zero is preserved (a rest day is real data, not a gap). `latestWins` + * (VO2 max) carries the same daily anchor; it is daily latest-wins rather than a + * running sum, but the per-day overwrite key is identical. + */ +function mapDailyCumulative( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + spec: { + type: string; + unit: string; + fieldTag: string; + valuePaths: string[]; + factor?: number; + /** VO2 max is daily latest-wins, not a running total — still one row/day. */ + latestWins?: boolean; + }, + tz?: string, +): GoogleHealthMappedMeasurement[] { + // VO2 max is strictly positive; the running totals admit a legitimate zero. + let value = spec.latestWins + ? firstNumber(point, spec.valuePaths) + : firstNonNegativeNumber(point, spec.valuePaths); + if (value === null) return []; + if (spec.factor) value = value * spec.factor; + const dayKey = externalAnchor(point, dataType, tz); // civil YYYY-MM-DD + return [ + { + type: spec.type, + value: round2(value), + unit: spec.unit, + measuredAt: resolveMeasuredAt(point, dataType, new Date(), tz), + // `stats::` — the sync layer reads `cumulativeDaily` + // to assemble the externalId, matching the Apple-Health daily-total shape. + fieldTag: `${spec.fieldTag}:${dayKey}`, + cumulativeDaily: true, + }, + ]; +} + +export function mapSteps( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.steps; + return mapDailyCumulative( + point, + dt, + { + type: "ACTIVITY_STEPS", + unit: "steps", + fieldTag: "steps", + valuePaths: [ + ...valuePaths(dt.filter, "count"), + ...valuePaths(dt.filter, "steps"), + ], + }, + tz, + ); +} + +export function mapDistance( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.distance; + // Google may report metres or kilometres; prefer the explicit-metres field, + // else convert km → m. Both pass the non-negative guard. + const meters = firstNonNegativeNumber(point, valuePaths(dt.filter, "meters")); + if (meters !== null) { + return [ + { + type: "WALKING_RUNNING_DISTANCE", + value: round2(meters), + unit: "m", + measuredAt: resolveMeasuredAt(point, dt, new Date(), tz), + fieldTag: `distance:${externalAnchor(point, dt, tz)}`, + cumulativeDaily: true, + }, + ]; + } + return mapDailyCumulative( + point, + dt, + { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + fieldTag: "distance", + valuePaths: valuePaths(dt.filter, "kilometers"), + factor: 1000, + }, + tz, + ); +} + +export function mapActiveEnergy( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.activeEnergy; + // ACTIVE energy only — NOT total-calories (which folds in BMR). The candidate + // paths target the active-portion field explicitly. + return mapDailyCumulative( + point, + dt, + { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + fieldTag: "active_energy", + valuePaths: [ + ...valuePaths(dt.filter, "active_kilocalories"), + ...valuePaths(dt.filter, "kilocalories"), + ...valuePaths(dt.filter, "calories"), + ], + }, + tz, + ); +} + +export function mapFloors( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.floors; + return mapDailyCumulative( + point, + dt, + { + type: "FLIGHTS_CLIMBED", + unit: "flights", + fieldTag: "floors", + valuePaths: [ + ...valuePaths(dt.filter, "count"), + ...valuePaths(dt.filter, "floors"), + ], + }, + tz, + ); +} + +export function mapVo2Max( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.vo2Max; + return mapDailyCumulative( + point, + dt, + { + type: "VO2_MAX", + unit: "mL/(kg·min)", + fieldTag: "vo2_max", + valuePaths: [ + ...valuePaths(dt.filter, "milliliters_per_kilogram_per_minute"), + ...valuePaths(dt.filter, "value"), + ], + latestWins: true, // strictly positive, one daily reading; not a running sum + }, + tz, + ); +} + +// ── Sleep ────────────────────────────────────────────────────── +// +// A Google Health sleep session carries a list of per-stage segments, each with +// a stage label + a start + end. HealthLog stores one SLEEP_DURATION row per +// stage segment with `measuredAt = stage END` (so the night-total + hypnogram +// readers consume the same enum WHOOP / Apple write). The stage labels are +// harmonised onto the shared `SleepStage` enum. + +/** Google Health sleep stage label → HealthLog `SleepStage`. */ +const GOOGLE_HEALTH_SLEEP_STAGE_MAP: Record = { + // Canonical Google Health stage names (snake / lower variants accepted). + in_bed: "IN_BED", + inbed: "IN_BED", + awake: "AWAKE", + wake: "AWAKE", + light: "CORE", // "light" ↔ Apple "core" (same shallow-NREM band) + core: "CORE", + rem: "REM", + deep: "DEEP", + // The classic (non-stages) sleep log uses asleep/restless/wake. + asleep: "ASLEEP", + restless: "AWAKE", +} as const; + +/** + * Normalise a raw Google sleep-stage label to a `SleepStage`, or null for an + * unknown label (skipped rather than mis-bucketed). + */ +export function mapGoogleHealthSleepStage( + raw: unknown, +): GoogleHealthSleepStage | null { + if (typeof raw !== "string") return null; + const key = raw + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); + return ( + GOOGLE_HEALTH_SLEEP_STAGE_MAP[key] ?? + GOOGLE_HEALTH_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ?? + null + ); +} + +/** One sleep-stage segment pulled defensively off a Google sleep session. */ +interface GoogleHealthSleepSegment { + stage: string; + startTime?: string; + endTime?: string; +} + +/** Minutes between two ISO instants, or null if either is unparseable. */ +function minutesBetween(startIso?: string, endIso?: string): number | null { + if (!startIso || !endIso) return null; + const s = new Date(startIso).getTime(); + const e = new Date(endIso).getTime(); + if (Number.isNaN(s) || Number.isNaN(e) || e <= s) return null; + return (e - s) / 60_000; +} + +/** + * Read the per-stage segments off a Google sleep `DataPoint`, tolerating the + * undocumented JSON shape. Candidate locations: `sleep.segments`, + * `sleep.stages`, `sleep.levels.data`, or a bare top-level `segments`/`stages`. + */ +function readSleepSegments( + point: GoogleHealthDataPoint, +): GoogleHealthSleepSegment[] { + const candidates = [ + readPath(point, "sleep.segments"), + readPath(point, "sleep.stages"), + readPath(point, "sleep.levels.data"), + readPath(point, "segments"), + readPath(point, "stages"), + readPath(point, "levels.data"), + ]; + for (const c of candidates) { + if (!Array.isArray(c)) continue; + const out: GoogleHealthSleepSegment[] = []; + for (const raw of c) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + const stage = + (typeof o.stage === "string" && o.stage) || + (typeof o.level === "string" && o.level) || + (typeof o.type === "string" && o.type) || + ""; + const startTime = + (typeof o.startTime === "string" && o.startTime) || + (typeof o.start_time === "string" && o.start_time) || + (typeof o.dateTime === "string" && o.dateTime) || + undefined; + const endTime = + (typeof o.endTime === "string" && o.endTime) || + (typeof o.end_time === "string" && o.end_time) || + undefined; + if (stage) out.push({ stage, startTime, endTime }); + } + if (out.length > 0) return out; + } + return []; +} + +/** + * The stable session anchor for a sleep `DataPoint`'s externalId. The session + * end (or start) ISO instant is unique per night, so per-stage rows key as + * `:sleep_` — a re-scored night overwrites in place. + */ +function sleepSessionAnchor(point: GoogleHealthDataPoint, tz?: string): string { + const end = + readPath(point, "sleep.interval.end_time") ?? + readPath(point, "interval.end_time") ?? + readPath(point, "sleep.endTime") ?? + readPath(point, "sleep.end_time") ?? + readPath(point, "endTime") ?? + readPath(point, "end_time"); + if (typeof end === "string") { + const d = parseLocalInstant(end, tz); + if (d) return d.toISOString(); + } + const start = + readPath(point, "sleep.interval.start_time") ?? + readPath(point, "interval.start_time") ?? + readPath(point, "sleep.startTime") ?? + readPath(point, "sleep.start_time") ?? + readPath(point, "startTime"); + if (typeof start === "string") { + const d = parseLocalInstant(start, tz); + if (d) return d.toISOString(); + } + return new Date(0).toISOString(); +} + +/** + * 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 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. + * + * Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's + * stored zone) anchors them to the correct UTC instant rather than the process + * zone — without it a non-UTC user's near-midnight segment END would shift by + * their offset. Timestamps carrying an explicit offset/`Z` are honoured as-is. + */ +export function mapSleepSession( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedMeasurement[] { + const segments = readSleepSegments(point); + if (segments.length === 0) return []; + + const anchor = sleepSessionAnchor(point, tz); + const out: GoogleHealthMappedMeasurement[] = []; + let segIndex = 0; + for (const seg of segments) { + const stage = mapGoogleHealthSleepStage(seg.stage); + if (!stage) continue; + const mins = minutesBetween(seg.startTime, seg.endTime); + if (mins === null || !(mins > 0)) continue; + const end = parseLocalInstant(seg.endTime as string, tz); + if (!end) continue; + out.push({ + type: "SLEEP_DURATION", + value: round2(mins), + unit: "minutes", + measuredAt: end, + fieldTag: `${anchor}:sleep_${stage.toLowerCase()}:${segIndex}`, + sleepStage: stage, + }); + segIndex += 1; + } + return out; +} + +// ── Workouts (exercise sessions) ─────────────────────────────── + +/** + * Google Health exercise-activity-type → HealthLog `WorkoutSportType`. Unknown + * types fall through to a generic label; the column is free-text so an unmapped + * type still persists (just not under a canonical sport bucket). + */ +const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { + walk: "walking", + walking: "walking", + run: "running", + running: "running", + treadmill: "running", + bike: "cycling", + biking: "cycling", + cycling: "cycling", + spinning: "cycling", + hike: "hiking", + hiking: "hiking", + swim: "swimming", + swimming: "swimming", + rowing: "rowing", + elliptical: "elliptical", + stairclimber: "stairClimber", + yoga: "yoga", + pilates: "mindAndBody", + weights: "strength", + strength: "strength", + workout: "strength", + hiit: "hiit", + interval_workout: "hiit", + dance: "dance", + golf: "golf", + tennis: "tennis", + basketball: "basketball", + soccer: "soccer", + bootcamp: "crossTraining", + circuit_training: "crossTraining", + sport: "mixedCardio", +} as const; + +/** Resolve a Google exercise activity type to a canonical sport label. */ +export function mapGoogleHealthSportType(raw: unknown): string { + if (typeof raw !== "string" || raw.trim() === "") return "other"; + const key = raw + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); + return ( + GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key] ?? + GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key.replace(/_/g, "")] ?? + "other" + ); +} + +/** One mapped Google exercise session destined for a `Workout` row. */ +export interface GoogleHealthMappedWorkout { + externalId: string; + sportType: string; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + minHeartRate: number | null; +} + +/** Read a finite number off a list of candidate paths (any sign), or null. */ +function readNumber( + point: GoogleHealthDataPoint, + paths: string[], +): number | null { + for (const path of paths) { + const v = readPath(point, path); + if (typeof v === "number" && Number.isFinite(v)) return v; + } + return null; +} + +/** + * Read an instant off a list of candidate paths, or null. Offset-less strings + * resolve against `tz` (the user's stored zone) rather than the process zone; + * strings carrying an explicit offset/`Z` are honoured as-is. + */ +function readInstant( + point: GoogleHealthDataPoint, + paths: string[], + tz?: string, +): Date | null { + for (const path of paths) { + const v = readPath(point, path); + if (typeof v === "string") { + const d = parseLocalInstant(v, tz); + if (d) return d; + } + } + return null; +} + +/** + * Map one Google exercise session `DataPoint` into a `Workout` shape. Returns + * null when there is no usable start/end (a session with no time span is not a + * workout). The externalId anchors on the session id when present, else on the + * start instant, so a re-fetch overwrites the same `Workout` row in place. + * Energy is the active session energy in kcal; HR fields are optional. + * + * Session start/end can arrive OFFSET-LESS (local wall clock); `tz` anchors them + * to the correct UTC instant rather than the process zone. + */ +export function mapWorkout( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedWorkout | null { + const f = GOOGLE_HEALTH_DATA_TYPES.exercise.filter; + const startedAt = readInstant( + point, + [ + `${f}.interval.start_time`, + `${f}.startTime`, + `${f}.start_time`, + `${f}.sample_time.physical_time`, + "interval.start_time", + "startTime", + "start_time", + ], + tz, + ); + const endedAt = readInstant( + point, + [ + `${f}.interval.end_time`, + `${f}.endTime`, + `${f}.end_time`, + "interval.end_time", + "endTime", + "end_time", + ], + tz, + ); + if (!startedAt || !endedAt || endedAt <= startedAt) return null; + + const durationSec = Math.round( + (endedAt.getTime() - startedAt.getTime()) / 1000, + ); + + const sessionId = + (typeof readPath(point, `${f}.session_id`) === "string" && + (readPath(point, `${f}.session_id`) as string)) || + (typeof readPath(point, `${f}.id`) === "string" && + (readPath(point, `${f}.id`) as string)) || + (typeof readPath(point, "name") === "string" && + (readPath(point, "name") as string)) || + null; + const externalId = sessionId ?? `exercise:${startedAt.toISOString()}`; + + const sportRaw = + readPath(point, `${f}.activity_type`) ?? + readPath(point, `${f}.exercise_type`) ?? + readPath(point, `${f}.type`) ?? + readPath(point, "activityType"); + + const energyKcal = readNumber(point, [ + `${f}.active_kilocalories`, + `${f}.calories`, + `${f}.energy.kilocalories`, + ]); + const distanceM = readNumber(point, [ + `${f}.distance.meters`, + `${f}.distance_meters`, + `${f}.distance`, + ]); + const avgHr = readNumber(point, [ + `${f}.average_heart_rate.beats_per_minute`, + `${f}.average_heart_rate`, + `${f}.heart_rate.average`, + ]); + const maxHr = readNumber(point, [ + `${f}.maximum_heart_rate.beats_per_minute`, + `${f}.max_heart_rate`, + `${f}.heart_rate.maximum`, + ]); + const minHr = readNumber(point, [ + `${f}.minimum_heart_rate.beats_per_minute`, + `${f}.min_heart_rate`, + `${f}.heart_rate.minimum`, + ]); + + return { + externalId, + sportType: mapGoogleHealthSportType(sportRaw), + startedAt, + endedAt, + durationSec, + totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, + totalDistanceM: + distanceM !== null && distanceM >= 0 ? round2(distanceM) : null, + avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, + maxHeartRate: maxHr !== null && maxHr > 0 ? Math.round(maxHr) : null, + minHeartRate: minHr !== null && minHr > 0 ? Math.round(minHr) : null, + }; +} diff --git a/src/lib/google-health/credentials.ts b/src/lib/google-health/credentials.ts new file mode 100644 index 000000000..c39a6a4b8 --- /dev/null +++ b/src/lib/google-health/credentials.ts @@ -0,0 +1,41 @@ +/** + * Helper to retrieve per-user Google Health OAuth credentials from the database. + * Mirrors `src/lib/whoop/credentials.ts`. + * + * Google Health ships per-user BYO-keys: each self-hoster registers their own + * Google Cloud OAuth client and pastes the client id/secret into Settings, + * stored encrypted on `User` (the Restricted-scope verification + annual CASA + * assessment is per-OAuth-client, so a single shared app is unworkable for a + * multi-operator product). + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import type { GoogleHealthCredentials } from "./client"; + +/** + * Fetch and decrypt the user's Google Health API credentials. + * Returns null if the user has not configured credentials. + */ +export async function getUserGoogleHealthCredentials( + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + googleHealthClientIdEncrypted: true, + googleHealthClientSecretEncrypted: true, + }, + }); + + if ( + !user?.googleHealthClientIdEncrypted || + !user?.googleHealthClientSecretEncrypted + ) { + return null; + } + + return { + clientId: decrypt(user.googleHealthClientIdEncrypted), + clientSecret: decrypt(user.googleHealthClientSecretEncrypted), + }; +} diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md new file mode 100644 index 000000000..6cbc585e4 --- /dev/null +++ b/src/lib/google-health/mapping.md @@ -0,0 +1,135 @@ +# Google Health field → Measurement mapping + +Single source of truth for the Google Health API v4 data-type → HealthLog +`Measurement` mapping. Keep this in sync with the per-type mappers in `client.ts`. +Every row ingests server-side with `source = GOOGLE_HEALTH` and +`externalId = :` (the anchor is the data point's sample time for +spot readings, its civil date for daily summaries, or — for INTERVAL types +(steps/distance/energy/floors, sleep, exercise) — its `interval.civil_start_time` +anchored at UTC-midday, falling back to `interval.start_time` only when the civil +date is absent; the field-tag disambiguates the metric). + +Google Health is a **separate, coexisting provider** from the classic Fitbit +integration (`src/lib/fitbit/*`, `source = FITBIT`). They never share a token, +connection row, source enum, or cookie. + +## Time anchor by grain + +Google Health exposes three time-anchor shapes. The incremental `filter` and the +read-time `measuredAt`/externalId resolution must target the right one or the +incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type): + +- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`. +- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, VO2 max); + anchor `{filter}.date` (a civil date / `{year,month,day}`). +- **interval** — INTERVAL type (steps/distance/energy/floors daily totals, plus + sleep + exercise sessions); the incremental `filter` targets + `{filter}.interval.start_time`, but the read-time `measuredAt`/day-key derive + from `{filter}.interval.civil_start_time` anchored at UTC-midday, so a daily + total keys on the civil day (aligning with the Apple/Fitbit `stats:` contract); + the physical `start_time` is the fallback only when civil is absent. + +Transport docs: `developers.google.com/health` (v4). The per-type value-field +JSON is **NOT fully published** — the mappers read a small set of candidate value +paths defensively (`firstNumber` walks each shape and takes the first +finite-positive hit). **Re-verify against a live test account at build** and +tighten the extractors. + +## Read method + +Every launch data type supports `dataPoints.list` (`readMethod: "list"`, the only +method the transport uses). The rollup-only types (`total-calories`, +`calories-in-heart-rate-zone`, which have no `:list`) are deliberately **not** in +the launch set: active energy already covers the active-calories slot, and +`total-calories` folds in BMR and needs a modelling decision before it lands as a +metric. Adding either later means adding a `:dailyRollUp` reader together with it. + +## Casing gotcha + +The data-type id is **kebab-case in the request path** (`body-fat`) and +**snake_case in the `filter` predicate** (`body_fat`). `GOOGLE_HEALTH_DATA_TYPES` +pins both forms per type so a fetcher can never encode the wrong one. + +## Health-metrics bundle + +Scope: `googlehealth.health_metrics_and_measurements.readonly`. Identifiers +verified against the 2026 contract (API-RESEARCH §3/§4). + +| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note | +| ------------------------------------------------------- | ------------------------ | ----------- | ----------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Google Health. | +| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | | +| `oxygen-saturation` / `oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. | +| `heart-rate-variability` / `heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Google reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider if warranted. | +| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | | +| `respiratory-rate` / `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | Identifier appears in spec prose; confirm `:list` support at build (API-RESEARCH OPEN §4). | +| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. | +| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. | + +Skin temperature (`daily-sleep-temperature-derivations`) is intentionally **not** +in the launch set — see **Deferred** below. + +Every value passes a finite + strictly-positive guard (the launch metrics are all +positive — a zero/NaN is a garbage/empty reading and is dropped). + +## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set) + +`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`, +`basal-body-temperature` → `BODY_TEMPERATURE`, `electrocardiogram` (ECG) and +`irregular-rhythm-notification`. Skin-temperature deviation +(`daily-sleep-temperature-derivations`) also sits here: Google reports a signed +nightly delta from baseline, not an absolute reading, so it needs a signed-delta +model before it can land — mapping it into `WRIST_TEMPERATURE` would store a delta +as an absolute temperature. ECG/IRN light up when Google ships the data types and +a reader is added together with its Restricted scopes. + +## Activity bundle — daily cumulative + +Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary +(one value per calendar day). The externalId carries the `stats:` daily-total +prefix — `stats::` — so a re-fetched day **overwrites** in +place rather than minting a duplicate, matching the Apple-Health +`stats::` daily-total overwrite contract. The running totals +(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data, +not a gap); VO2 max stays strictly positive (daily latest-wins). + +| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note | +| ----------------------------------------------- | -------------------------- | ----------- | --------------- | ------------------------------------------------------------------- | +| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid | +| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) | +| `active-energy-burned` / `active_energy_burned` | `ACTIVE_ENERGY_BURNED` | kcal | `active_energy` | **ACTIVE portion only** — NOT `total-calories` (which folds in BMR) | +| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid | +| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive | + +## Sleep bundle + +Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments +(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage +SEGMENT, `measuredAt = that segment's END instant`, harmonised onto the shared +`SleepStage` enum the night-total + hypnogram readers consume. externalId = +`:sleep_:` (session end ISO instant + indexed segment), +so a re-scored night overwrites in place. Stage map: `light → CORE` ("light" ↔ +Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`, +`awake`/`wake`/`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`. +Unknown stage labels are skipped, not mis-bucketed. + +## Exercise bundle — Workouts + +Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one +`Workout` row (NOT a Measurement), keyed `(userId, source: "GOOGLE_HEALTH", +externalId)` where externalId is the session id (or `exercise:` when +absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`, +`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active session +energy), totalDistanceM, avg/max/min HR (optional). A Google Health run and the +same run via Apple Health / WHOOP stay distinct rows; the read-time +`pickCanonicalWorkoutRows` picker collapses the cross-source twin per the user's +source ladder. + +## Idempotency + +`(userId, type, source: "GOOGLE_HEALTH", externalId)` unique. Spot/daily metric +rows: `externalId = :`. Daily cumulative activity rows: +`externalId = stats::` (Apple-Health overwrite shape). +Workouts: `(userId, source: "GOOGLE_HEALTH", externalId)` on the `Workout` table. +A re-fetch of the same window (daily summaries re-roll after the fact, so the +incremental overlap is 24 h) overwrites in place. diff --git a/src/lib/google-health/oauth-state.ts b/src/lib/google-health/oauth-state.ts new file mode 100644 index 000000000..2f8a9fc5a --- /dev/null +++ b/src/lib/google-health/oauth-state.ts @@ -0,0 +1,38 @@ +/** + * Shared constants + mint helper for the Google Health OAuth state nonce ledger. + * Mirrors `src/lib/whoop/oauth-state.ts`. + * + * The nonce is random + opaque; a short-lived `GoogleHealthOAuthState` row + * carries the `(nonce → userId)` mapping (plus the PKCE code verifier) so the + * user id and the verifier never leak into request logs or network captures via + * the OAuth `state` param. The connect route, the callback route, and the + * cleanup cron all read these constants so the contract has one source of truth + * and the unit tests can pin it without importing either route file. + * + * The string keys are deliberately distinct from the classic Fitbit provider's + * (`fitbit_state`) so the two coexisting integrations never collide on a cookie. + */ +import { randomBytes } from "node:crypto"; + +/** + * Cookie name carrying the in-flight state nonce. httpOnly + Secure + + * sameSite:lax at the connect route (mirrors the WHOOP cookie shape). Distinct + * from the classic Fitbit `fitbit_state` cookie. + */ +export const GOOGLE_HEALTH_OAUTH_STATE_COOKIE = "google_health_state" as const; + +/** + * 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to + * cover a user approving the Google consent prompt; short enough that an + * abandoned handshake doesn't strand a row for hours. + */ +export const GOOGLE_HEALTH_OAUTH_STATE_TTL_MS = 10 * 60 * 1000; + +/** + * Mint a fresh state nonce. 16 random bytes → 22 base64url chars without + * padding — 128 bits of entropy, aligned with the OAuth 2.0 §10.10 "at least + * 128 bits" recommendation. + */ +export function mintGoogleHealthOAuthStateNonce(): string { + return randomBytes(16).toString("base64url"); +} diff --git a/src/lib/google-health/response-classifier.ts b/src/lib/google-health/response-classifier.ts new file mode 100644 index 000000000..75104614c --- /dev/null +++ b/src/lib/google-health/response-classifier.ts @@ -0,0 +1,114 @@ +/** + * Off-response classifier for Google Health API replies (v1.27.0). + * + * Like WHOOP / Fitbit, Google signals failure through the HTTP status code + * itself (no `status` field embedded in a 200 body), so the classification is + * status-driven. The shared core lives in + * `src/lib/integrations/http-status-classifier.ts`; this module re-exports it + * under the Google-Health-specific names callers already use. + * + * - `success` → HTTP 2xx + * - `transient` → retryable: HTTP 429 (rate-limited — honour Google's + * truncated exponential backoff at the call site), 5xx + * upstream outages, 3xx CDN/network glitches, and an + * unknown status where we never reached the envelope. + * - `reauth_required` → HTTP 401 / 403 — the access token is rejected or the + * grant was revoked; the user must redo OAuth. (The + * per-data-class 403 soft-skip lives in `sync.ts`'s + * `isCollectionForbidden`, not here, so a Restricted + * scope granted independently doesn't park the whole + * connection.) + * - `persistent` → any other 4xx (400, 404, 422, …) — a malformed + * request or a contract mismatch retrying won't fix. + * + * NEEDS-REAUTH SIGNAL. Google surfaces a revoked / expired refresh token — a + * user-revoked grant OR the 7-day "Testing"-mode refresh-token expiry — via + * `invalid_grant` on the token endpoint (sometimes a 400, sometimes a 401). A + * plain status classification would bucket that 400 as `persistent` and never + * prompt a reconnect; the `client.ts` token path lifts an `invalid_grant` onto + * `reauth_required` before constructing the error, and a 401 already classifies + * that way. Downstream, `reauth_required` maps through + * `classificationToFailureKind` → `FailureKind "reauth_required"` → + * `IntegrationState "error_reauth"`, which the status card renders as a + * "reconnect" CTA. Use `isGoogleHealthReauthRequired()` to read that signal off + * any caught error. + */ + +import { + classifyHttpStatus, + classifyIntegrationError, + isOAuthInvalidGrant, + IntegrationApiError, + type ClassifiedHttpResponse, + type IntegrationClassification, +} from "@/lib/integrations/http-status-classifier"; + +/** Wire label used in the error-message prefix + the message-shape fallbacks. */ +export const GOOGLE_HEALTH_VENDOR = "GoogleHealth"; + +/** Outcome buckets for a Google Health response. */ +export type GoogleHealthClassification = IntegrationClassification; + +export type ClassifiedGoogleHealthResponse = ClassifiedHttpResponse; + +/** Classify a single Google Health API response by its HTTP status. */ +export function classifyGoogleHealthResponse( + httpStatus: number, +): ClassifiedGoogleHealthResponse { + return classifyHttpStatus(httpStatus); +} + +/** + * Typed error carrying the classification verdict so downstream `try/catch` + * blocks can branch without re-parsing the message. Extends the shared base + * (message shape `GoogleHealth error: ` is preserved). + */ +export class GoogleHealthApiError extends IntegrationApiError { + constructor(opts: { + verb: string; + classification: GoogleHealthClassification; + httpStatus: number | undefined; + reason: string; + upstreamError?: string; + }) { + super({ vendor: GOOGLE_HEALTH_VENDOR, ...opts }); + this.name = "GoogleHealthApiError"; + } +} + +/** + * Read the classification from any caught error. Returns `transient` for a + * non-`GoogleHealthApiError` input so a call site surfacing a plain `Error` + * retries rather than permanently disabling the integration. Falls back to + * parsing the HTTP status out of the legacy `"GoogleHealth error: + * "` message shape for callers that lose the original prototype across a + * pg-boss retry. + */ +export function classifyGoogleHealthError( + err: unknown, +): GoogleHealthClassification { + return classifyIntegrationError(err, GOOGLE_HEALTH_VENDOR); +} + +/** + * True when a caught error carries an OAuth `invalid_grant` on a 400 from the + * token endpoint — the canonical signal that the stored refresh token was + * revoked or expired (a user-revoked grant, or the 7-day "Testing"-mode + * expiry). Robust to a lost prototype across a pg-boss retry. + */ +export function isGoogleHealthInvalidGrant(err: unknown): boolean { + return isOAuthInvalidGrant(err, GOOGLE_HEALTH_VENDOR); +} + +/** + * True when a caught error means the user must reconnect — either a rejected + * token / revoked grant (401/403 → `reauth_required`) or a token-endpoint + * `invalid_grant`. This is the single predicate downstream routes / UI consume + * to decide whether to show the "reconnect" CTA. + */ +export function isGoogleHealthReauthRequired(err: unknown): boolean { + return ( + classifyGoogleHealthError(err) === "reauth_required" || + isGoogleHealthInvalidGrant(err) + ); +} diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts new file mode 100644 index 000000000..974a4f56b --- /dev/null +++ b/src/lib/google-health/sync-activity.ts @@ -0,0 +1,150 @@ +/** + * Google Health activity-bundle sync (v1.27.0). + * + * Reads the daily cumulative-activity data types from the + * `activity_and_fitness.readonly` Restricted bundle and upserts each mapped + * daily total as `source = GOOGLE_HEALTH`: + * + * - steps → ACTIVITY_STEPS (count) + * - distance → WALKING_RUNNING_DISTANCE (metres) + * - active-energy-burned→ ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only) + * - floors → FLIGHTS_CLIMBED (count) + * - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins) + * + * These are per-day summaries (one value per calendar day). The externalId is + * minted with the `stats:` daily-total prefix — `stats::` + * — so a re-fetched day OVERWRITES the existing row rather than minting a + * duplicate, matching the Apple-Health `stats::` overwrite + * contract. A day of rest legitimately records 0 steps / 0 floors / 0 active + * kcal, so the cumulative mappers preserve a zero; VO2 max stays strictly + * positive. + * + * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection + * connected) — the Restricted bundles are granted independently. + */ +import { + GOOGLE_HEALTH_DATA_TYPES, + type GoogleHealthDataType, + type GoogleHealthMappedMeasurement, + fetchDataPoints, + mapActiveEnergy, + mapDistance, + mapFloors, + mapSteps, + mapVo2Max, +} from "./client"; +import { + getValidToken, + handleCollectionFetchError, + upsertGoogleHealthMeasurements, + type GoogleHealthMeasurementUpsert, + type GoogleHealthResourceSyncOptions, +} from "./sync"; +import { annotate } from "@/lib/logging/context"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +/** One mappable activity metric: its data-type encoding + the mapper + a verb. */ +interface ActivityResource { + dataType: GoogleHealthDataType; + map: ( + point: Record, + tz?: string, + ) => GoogleHealthMappedMeasurement[]; + verb: string; +} + +const ACTIVITY_RESOURCES: ActivityResource[] = [ + { + dataType: GOOGLE_HEALTH_DATA_TYPES.steps, + map: mapSteps, + verb: "fetchSteps", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.distance, + map: mapDistance, + verb: "fetchDistance", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.activeEnergy, + map: mapActiveEnergy, + verb: "fetchActiveEnergy", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.floors, + map: mapFloors, + verb: "fetchFloors", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.vo2Max, + map: mapVo2Max, + verb: "fetchVo2Max", + }, +]; + +/** + * Assemble the stored externalId for a mapped activity reading. A cumulative + * daily metric carries the `cumulativeDaily` flag from its mapper — its + * externalId gets the `stats:` daily-total prefix so a re-fetched day overwrites + * in place (the mapper's `fieldTag` is already the `:` form). + */ +function externalIdFor(m: GoogleHealthMappedMeasurement): string { + return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag; +} + +export async function syncUserActivity( + userId: string, + opts: GoogleHealthResourceSyncOptions = {}, +): Promise { + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + // A cumulative daily total whose `interval.start_time` sits at the user's + // local midnight can carry an offset-less civil anchor; resolve the user's + // stored zone so the day-key lands on the correct civil day rather than the + // process zone's. + const tz = await resolveUserTimezone(userId); + + // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined + // on a full/backfill run. + const start = opts.start; + + let imported = 0; + for (const resource of ACTIVITY_RESOURCES) { + let points: Record[]; + try { + points = await fetchDataPoints( + resource.dataType, + tokenInfo.accessToken, + resource.verb, + { start }, + ); + } catch (err) { + imported += await handleCollectionFetchError(resource.verb, userId, err); + continue; + } + + const readings: GoogleHealthMeasurementUpsert[] = []; + for (const point of points) { + for (const m of resource.map(point, tz)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: externalIdFor(m), + }); + } + } + imported += ( + await upsertGoogleHealthMeasurements(userId, readings, { + deferRollup: opts.deferRollup, + }) + ).imported; + } + + // `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`). + annotate({ + action: { name: "googleHealth.activity.sync", details: { imported } }, + }); + return imported; +} diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts new file mode 100644 index 000000000..36e105966 --- /dev/null +++ b/src/lib/google-health/sync-metrics.ts @@ -0,0 +1,191 @@ +/** + * Google Health "health metrics & measurements" bundle sync (v1.27.0). + * + * Reads the launch metric data types from the + * `health_metrics_and_measurements.readonly` Restricted bundle and upserts each + * mapped reading as `source = GOOGLE_HEALTH`: + * + * - weight → WEIGHT + * - body-fat → BODY_FAT + * - oxygen-saturation → OXYGEN_SATURATION + * - heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper) + * - daily-resting-HR → RESTING_HEART_RATE + * - respiratory-rate → RESPIRATORY_RATE + * - heart-rate (intraday) → PULSE + * - height → User.heightCm (profile seed, NOT a Measurement) + * + * Each data point yields at most one Measurement row, disambiguated by the + * per-point anchor + field-tag in the externalId (`:`) so a + * re-fetch of the same window overwrites in place rather than duplicating. + * + * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection + * connected) rather than parking the whole integration — the Restricted bundles + * can be granted independently in the Google consent flow. + */ +import { + GOOGLE_HEALTH_DATA_TYPES, + type GoogleHealthDataType, + type GoogleHealthMappedMeasurement, + fetchDataPoints, + mapBodyFat, + mapHeartRate, + mapHeartRateVariability, + mapHeightCm, + mapOxygenSaturation, + mapRespiratoryRate, + mapRestingHeartRate, + mapWeight, +} from "./client"; +import { + getValidToken, + handleCollectionFetchError, + upsertGoogleHealthMeasurements, + type GoogleHealthMeasurementUpsert, + type GoogleHealthResourceSyncOptions, +} from "./sync"; +import { getEvent } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; + +/** One mappable metric: its data-type encoding + the per-point mapper + a verb. */ +interface MetricResource { + dataType: GoogleHealthDataType; + map: (point: Record) => GoogleHealthMappedMeasurement[]; + verb: string; +} + +/** The launch metric resources (Measurement-producing). Height handled separately. */ +const METRIC_RESOURCES: MetricResource[] = [ + { + dataType: GOOGLE_HEALTH_DATA_TYPES.weight, + map: mapWeight, + verb: "fetchWeight", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.bodyFat, + map: mapBodyFat, + verb: "fetchBodyFat", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation, + map: mapOxygenSaturation, + verb: "fetchSpo2", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.heartRateVariability, + map: mapHeartRateVariability, + verb: "fetchHrv", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.restingHeartRate, + map: mapRestingHeartRate, + verb: "fetchRhr", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.respiratoryRate, + map: mapRespiratoryRate, + verb: "fetchRespiratoryRate", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.heartRate, + map: mapHeartRate, + verb: "fetchHeartRate", + }, + // Skin temperature is intentionally omitted for v1 — Google surfaces a nightly + // signed DEVIATION from baseline, a future enhancement needing a signed-delta + // model (see `GOOGLE_HEALTH_DATA_TYPES` in `client.ts`). +]; + +export async function syncUserMetrics( + userId: string, + opts: GoogleHealthResourceSyncOptions = {}, +): Promise { + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + // The incremental lower bound is the cycle-wide watermark snapshotted once by + // `syncUserGoogleHealth` — never re-read here, so a sibling resource's stamp + // can't shrink this one's window. Undefined on a full/backfill run. + const start = opts.start; + + let imported = 0; + + // Each metric data type is fetched + mapped independently so a per-class 403 + // soft-skips only that class. + for (const resource of METRIC_RESOURCES) { + let points: Record[]; + try { + points = await fetchDataPoints( + resource.dataType, + tokenInfo.accessToken, + resource.verb, + { start }, + ); + } catch (err) { + imported += await handleCollectionFetchError(resource.verb, userId, err); + continue; + } + + const readings: GoogleHealthMeasurementUpsert[] = []; + for (const point of points) { + for (const m of resource.map(point)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + }); + } + } + imported += ( + await upsertGoogleHealthMeasurements(userId, readings, { + deferRollup: opts.deferRollup, + }) + ).imported; + } + + // Height → User.heightCm, only when the user has no height yet. Never mint a + // Measurement; never overwrite a user-set value. Mirrors WHOOP's mapBody seed. + try { + let heightPoints: Record[]; + try { + heightPoints = await fetchDataPoints( + GOOGLE_HEALTH_DATA_TYPES.height, + tokenInfo.accessToken, + "fetchHeight", + { start }, + ); + } catch (err) { + heightPoints = []; + await handleCollectionFetchError("fetchHeight", userId, err); + } + + // Latest non-null parsed height wins (the points are time-ordered ascending). + let heightCm: number | null = null; + for (const point of heightPoints) { + const cm = mapHeightCm(point); + if (cm !== null) heightCm = cm; + } + if (heightCm !== null) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { heightCm: true }, + }); + if (user && user.heightCm === null) { + await prisma.user.update({ + where: { id: userId }, + data: { heightCm }, + }); + } + } + } catch (err) { + getEvent()?.addWarning( + `google-health: failed to seed heightCm for ${userId}: ${err}`, + ); + } + + // `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`), stamped + // once at the end of the cycle — never here, so the watermark can't move + // mid-cycle. + return imported; +} diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts new file mode 100644 index 000000000..5ea710694 --- /dev/null +++ b/src/lib/google-health/sync-sleep.ts @@ -0,0 +1,89 @@ +/** + * Google Health sleep-bundle sync (v1.27.0). + * + * Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps 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 = GOOGLE_HEALTH`. + * + * 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. + */ +import { + GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, + GOOGLE_HEALTH_DATA_TYPES, + fetchDataPoints, + mapSleepSession, +} from "./client"; +import { + getValidToken, + handleCollectionFetchError, + upsertGoogleHealthMeasurements, + type GoogleHealthMeasurementUpsert, + type GoogleHealthResourceSyncOptions, +} from "./sync"; +import { annotate } from "@/lib/logging/context"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export async function syncUserSleep( + userId: string, + opts: GoogleHealthResourceSyncOptions = {}, +): Promise { + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + // Sleep-segment timestamps can arrive offset-less; anchor them against the + // user's stored zone so a near-midnight segment END lands on the correct + // wake-day rather than being shifted by the process zone. + const tz = await resolveUserTimezone(userId); + + // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined + // on a full/backfill run. + const start = opts.start; + + let points: Record[]; + try { + points = await fetchDataPoints( + GOOGLE_HEALTH_DATA_TYPES.sleep, + tokenInfo.accessToken, + "fetchSleep", + { start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE }, + ); + } catch (err) { + return handleCollectionFetchError("fetchSleep", userId, err); + } + + const readings: GoogleHealthMeasurementUpsert[] = []; + for (const point of points) { + for (const m of mapSleepSession(point, tz)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + sleepStage: m.sleepStage ?? null, + }); + } + } + + const imported = ( + await upsertGoogleHealthMeasurements(userId, readings, { + deferRollup: opts.deferRollup, + }) + ).imported; + // `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`). + annotate({ + action: { name: "googleHealth.sleep.sync", details: { imported } }, + }); + return imported; +} diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts new file mode 100644 index 000000000..cd4ead5f9 --- /dev/null +++ b/src/lib/google-health/sync-workout.ts @@ -0,0 +1,110 @@ +/** + * Google Health exercise-session sync (v1.27.0). + * + * Reads exercise sessions from the `activity_and_fitness.readonly` Restricted + * bundle and upserts each into the `Workout` table as `source = GOOGLE_HEALTH`, + * keyed on `(userId, source, externalId)` so a re-fetch overwrites in place. + * + * A Google Health run and the same run via Apple Health (or WHOOP) stay distinct + * `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows` + * picker collapses the cross-source twin at read time per the user's source + * ladder. There is no ingest-time collapse for a server-owned source pair. + * + * A per-data-class 403 soft-skips the resource — the activity/fitness bundle is + * granted independently in the Google consent flow. + */ +import { + GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, + GOOGLE_HEALTH_DATA_TYPES, + fetchDataPoints, + mapWorkout, + type GoogleHealthMappedWorkout, +} from "./client"; +import { + getValidToken, + handleCollectionFetchError, + type GoogleHealthResourceSyncOptions, +} from "./sync"; +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export async function syncUserWorkout( + userId: string, + opts: GoogleHealthResourceSyncOptions = {}, +): Promise { + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + // Session start/end can arrive offset-less; anchor them against the user's + // stored zone rather than the process zone. + const tz = await resolveUserTimezone(userId); + + // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined + // on a full/backfill run. + const start = opts.start; + + let points: Record[]; + try { + points = await fetchDataPoints( + GOOGLE_HEALTH_DATA_TYPES.exercise, + tokenInfo.accessToken, + "fetchExercise", + { start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE }, + ); + } catch (err) { + return handleCollectionFetchError("fetchExercise", userId, err); + } + + let imported = 0; + for (const point of points) { + const w: GoogleHealthMappedWorkout | null = mapWorkout(point, tz); + if (!w) continue; // no usable time span — not a workout + + try { + await prisma.workout.upsert({ + where: { + userId_source_externalId: { + userId, + source: "GOOGLE_HEALTH", + externalId: w.externalId, + }, + }, + create: { + userId, + source: "GOOGLE_HEALTH", + externalId: w.externalId, + sportType: w.sportType, + startedAt: w.startedAt, + endedAt: w.endedAt, + durationSec: w.durationSec, + totalEnergyKcal: w.totalEnergyKcal, + totalDistanceM: w.totalDistanceM, + avgHeartRate: w.avgHeartRate, + maxHeartRate: w.maxHeartRate, + minHeartRate: w.minHeartRate, + }, + update: { + sportType: w.sportType, + startedAt: w.startedAt, + endedAt: w.endedAt, + durationSec: w.durationSec, + totalEnergyKcal: w.totalEnergyKcal, + totalDistanceM: w.totalDistanceM, + avgHeartRate: w.avgHeartRate, + maxHeartRate: w.maxHeartRate, + minHeartRate: w.minHeartRate, + }, + }); + imported++; + } catch (err) { + getEvent()?.addWarning(`google-health: failed to upsert workout: ${err}`); + } + } + + // `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`). + annotate({ + action: { name: "googleHealth.workout.sync", details: { imported } }, + }); + return imported; +} diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts new file mode 100644 index 000000000..b161bd269 --- /dev/null +++ b/src/lib/google-health/sync.ts @@ -0,0 +1,729 @@ +/** + * Google Health sync service (v1.27.0). + * + * Token-management + per-resource orchestration: + * - `getValidToken` decrypts the stored token, refreshes at + * `tokenExpiresAt - 5 min`, persists the new access token + expiry, and — + * following Google's semantics — only overwrites the stored refresh token + * when the response carries a fresh one (Google does NOT rotate refresh + * tokens). A revoked / expired refresh token (the 7-day "Testing"-mode + * expiry, or a user-revoked grant) surfaces `invalid_grant`/401 → + * `reauth_required`, which parks the connection at `error_reauth` so the + * card prompts a reconnect. + * - `recordGoogleHealthSyncFailure` / `classificationToFailureKind` map a + * classified API error onto the shared integration-status ledger. + * - `syncUserGoogleHealth` drives the hourly poll, the manual trigger, and the + * boot-time backfill across every resource. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import pLimit from "p-limit"; +import { prisma } from "@/lib/db"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { + isReauthRequired, + recordSyncFailure, + recordSyncSuccess, + type FailureKind, +} from "@/lib/integrations/status"; +import { + collapseToTypeDayKeys, + recomputeBucketsForMeasurement, + recomputeUserRollups, +} from "@/lib/rollups/measurement-rollups"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { refreshAccessToken } from "./client"; +import { getUserGoogleHealthCredentials } from "./credentials"; +import { + GoogleHealthApiError, + classifyGoogleHealthError, + isGoogleHealthReauthRequired, + type GoogleHealthClassification, +} from "./response-classifier"; + +/** + * The status-ledger key for this integration. Distinct from the classic Fitbit + * `"fitbit"` key so the two coexisting providers keep independent status rows. + */ +export const GOOGLE_HEALTH_INTEGRATION_KEY = "google-health"; + +/** Refresh the access token this many ms before `tokenExpiresAt`. */ +const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; + +/** + * Overlap window for the incremental sync, in ms. Google re-rolls daily + * summaries after the fact (a daily SpO2 / HRV / RHR can finalise hours after + * the night), so the default overlap is a full 24 h to make sure the re-rolled + * row is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap. + */ +export const GOOGLE_HEALTH_DEFAULT_OVERLAP_MS = 24 * 60 * 60 * 1000; + +export interface GoogleHealthTokenInfo { + accessToken: string; + connection: { id: string; googleUserId: string }; +} + +/** + * Resolve a valid Google Health access token for a user, refreshing if it is + * within the 5-minute expiry buffer. Returns null when there is no connection, + * no credentials, or the refresh fails (the failure is recorded so scheduled + * syncs back off). + * + * Google does not rotate refresh tokens. On refresh, persist the new access + * token + expiry and overwrite the stored `refreshToken` ONLY when the response + * carries a fresh one — otherwise keep the existing refresh token so the next + * refresh still authenticates. + */ +export async function getValidToken( + userId: string, +): Promise { + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId }, + }); + if (!connection) return null; + + const accessToken = decrypt(connection.accessToken); + const refreshToken = decrypt(connection.refreshToken); + + if ( + connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS < + Date.now() + ) { + try { + const creds = await getUserGoogleHealthCredentials(userId); + if (!creds) { + getEvent()?.addWarning( + `No Google Health credentials found for user ${userId} during token refresh`, + ); + await recordSyncFailure({ + userId, + integration: GOOGLE_HEALTH_INTEGRATION_KEY, + kind: "reauth_required", + message: "Google Health credentials missing — token refresh skipped", + errorCode: "credentials_missing", + }); + return null; + } + + const newTokens = await refreshAccessToken(refreshToken, creds); + const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); + + // Google does NOT rotate refresh tokens: a routine refresh returns a new + // access token but usually omits `refresh_token`. Persist the new access + // token + expiry and only overwrite the stored refresh token when the + // response actually carries a fresh one — otherwise keep the existing one. + await prisma.googleHealthConnection.update({ + where: { id: connection.id }, + data: { + accessToken: encrypt(newTokens.access_token), + tokenExpiresAt: expiresAt, + ...(newTokens.refresh_token + ? { refreshToken: encrypt(newTokens.refresh_token) } + : {}), + }, + }); + + return { + accessToken: newTokens.access_token, + connection: { + id: connection.id, + googleUserId: connection.googleUserId, + }, + }; + } catch (err) { + getEvent()?.addWarning( + `Google Health token refresh failed for user ${userId}: ${err}`, + ); + // A revoked / expired refresh token (`invalid_grant` / 401 — the 7-day + // Testing-mode refresh expiry, or a user-revoked grant) is a SOFT + // disconnect: flag `needsReauth` on the connection so the card surfaces a + // "reconnect" CTA rather than a hard error. The callback clears it on a + // fresh consent. A transient refresh failure (network blip, 5xx) leaves + // the flag untouched so we don't nag the user to reconnect needlessly. + if (isGoogleHealthReauthRequired(err)) { + await prisma.googleHealthConnection + .update({ + where: { id: connection.id }, + data: { needsReauth: true }, + }) + .catch((e) => + getEvent()?.addWarning( + `google-health: needsReauth flag write failed for ${userId}: ${e}`, + ), + ); + } + await recordGoogleHealthSyncFailure(userId, err); + return null; + } + } + + return { + accessToken, + connection: { + id: connection.id, + googleUserId: connection.googleUserId, + }, + }; +} + +/** + * True when a caught error is a per-data-class 403 (forbidden). The Google + * Health Restricted bundles are granted independently in the consent flow, so a + * 403 on ONE data class is a scope gate on THAT class — soft-skip it and keep + * the connection connected rather than parking the whole integration at + * `error_reauth`. Connection-wide reauth is reserved for a 401 (token rejected) + * and for a 403 on the token-refresh / profile path (a genuine grant revoke), + * which run outside the per-resource catch blocks. + */ +export function isCollectionForbidden(err: unknown): boolean { + return err instanceof GoogleHealthApiError && err.httpStatus === 403; +} + +/** + * Per-`syncUserGoogleHealth`-cycle counter for collection-403 soft-skips. + * Scoped by AsyncLocalStorage so the count never bleeds across concurrent + * per-user pg-boss jobs. Mirrors the WHOOP `softSkipStorage` tracker. + */ +interface SoftSkipTracker { + count: number; +} +const softSkipStorage = new AsyncLocalStorage(); + +/** + * Per-cycle accumulator of the `(type, day)` keys every resource's writes + * touched. Only populated on a `fullSync` backfill (when the inline per-day + * rollup hook is deferred); the orchestrator drains it into ONE + * `recomputeUserRollups(from, to)` pass at the end of the cycle. Scoped by + * AsyncLocalStorage so concurrent per-user jobs never cross-pollinate. + */ +interface RollupDeferTracker { + keys: Array<{ type: MeasurementType; measuredAt: Date }>; +} +const rollupDeferStorage = new AsyncLocalStorage(); + +/** + * Single-source the per-resource collection-fetch error handling. A 403 on one + * data class soft-skips it (warn + return 0) so sibling resources still sync; + * anything else records a classified sync failure and rethrows. A soft-skip + * increments the ambient tracker so `syncUserGoogleHealth` can refuse to stamp + * success on an all-403 grant-revoke cycle that imported nothing. + */ +export async function handleCollectionFetchError( + resource: string, + userId: string, + err: unknown, +): Promise { + if (isCollectionForbidden(err)) { + getEvent()?.addWarning( + `google-health ${resource} sync skipped for ${userId}: collection 403 (soft-skip)`, + ); + const tracker = softSkipStorage.getStore(); + if (tracker) tracker.count += 1; + return 0; + } + await recordGoogleHealthSyncFailure(userId, err); + throw err; +} + +/** + * Options threaded from `syncUserGoogleHealth` to each per-resource sync. The + * `start` watermark is snapshotted ONCE by the orchestrator (a single + * `lastSyncedAt` read) so every resource fetches from the same lower bound; no + * resource re-reads `lastSyncedAt` or stamps `markSynced` itself. On a + * full/backfill run `start` is undefined (no lower bound). The orchestrator owns + * the single end-of-cycle `markSynced`. + */ +export interface GoogleHealthResourceSyncOptions { + fullSync?: boolean; + /** The incremental lower bound, snapshotted once by the orchestrator. */ + start?: Date; + /** + * When true, `upsertGoogleHealthMeasurements` writes the rows but SKIPS the + * inline per-(type,day) DAY-rollup recompute + status-insight invalidate, + * returning the touched type-days to the caller instead. The orchestrator + * collapses them into ONE `recomputeUserRollups(from, to)` pass at the end of + * a `fullSync` cycle, so a multi-year backfill pays a single range-recompute + * rather than thousands of per-day round-trips. The incremental path leaves + * this unset and keeps the inline per-day hook. + */ + deferRollup?: boolean; +} + +/** + * Compute the incremental `start` for a resource sync. `fullSync` returns + * undefined (the backfill walks deep history without a lower bound). Otherwise + * start from `lastSyncedAt - overlap`, or 30 days back on the very first + * incremental tick. Mirrors WHOOP's `incrementalStart`. + */ +export function incrementalStart( + lastSyncedAt: Date | null, + opts: { fullSync?: boolean; overlapMs?: number } = {}, +): Date | undefined { + if (opts.fullSync) return undefined; + const overlap = opts.overlapMs ?? GOOGLE_HEALTH_DEFAULT_OVERLAP_MS; + if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap); + return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); +} + +/** + * One mapped reading destined for a `Measurement` row, with the per-point anchor + * already resolved into a full `externalId`. The per-resource syncs build these + * from the client mappers (`:`). + */ +export interface GoogleHealthMeasurementUpsert { + type: string; + value: number; + unit: string; + measuredAt: Date; + externalId: string; + /** + * Per-stage sleep rows carry the `SleepStage` axis so the (up to six) stage + * rows for one night stay distinct under the dedup key; every other row omits + * it (null). Sleep is the only producer. + */ + sleepStage?: "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP" | null; +} + +/** Chunk size for the batched `createMany` insert of fresh readings. */ +const GOOGLE_HEALTH_CREATE_CHUNK = 500; + +/** + * Write a batch of mapped Google Health readings for one user and (unless + * deferred) fold the rollup tier + invalidate status-insight caches once at the + * end (mirrors the WHOOP / Withings sync tail). Returns the count of rows + * written plus the distinct `(type, day)` keys the write touched. + * + * TOMBSTONE-SAFE: the live-row probe filters `deletedAt: null`, so a row the + * user soft-deleted (iOS LWW reconciler) is NOT matched — a fresh insert is + * created instead of resurrecting the tombstone on the next hourly sync. The + * partial unique index keeps the fresh insert from colliding with the live set. + * + * OVERWRITE CONTRACT preserved: a re-fetched daily summary (`stats:`-keyed and + * every other stable per-point anchor) maps to the SAME live `externalId`, so + * the probe finds the existing live row and takes the in-place update branch. + * + * BATCHING: the existence probe is a single `findMany` over the batch's + * externalIds; fresh rows go through chunked `createMany`, and only the + * already-live rows take a per-row `update` for their differing values. + * + * Best-effort on the rollup fold + insight invalidate — a populator hiccup never + * fails the user's sync. + */ +export async function upsertGoogleHealthMeasurements( + userId: string, + readings: GoogleHealthMeasurementUpsert[], + opts: { deferRollup?: boolean } = {}, +): Promise<{ + imported: number; + touched: Array<{ type: MeasurementType; measuredAt: Date }>; +}> { + if (readings.length === 0) return { imported: 0, touched: [] }; + + // Probe the LIVE rows (deletedAt: null) for every externalId in the batch in + // a single query. A tombstoned row deliberately does NOT appear here, so it is + // treated as absent → a fresh insert, not a resurrecting update. + const externalIds = readings.map((r) => r.externalId); + let liveByKey = new Map(); + try { + const existing = await prisma.measurement.findMany({ + where: { + userId, + source: "GOOGLE_HEALTH", + deletedAt: null, + externalId: { in: externalIds }, + }, + select: { id: true, type: true, externalId: true }, + }); + liveByKey = new Map( + existing + .filter((e) => e.externalId !== null) + .map((e) => [`${e.type} ${e.externalId}`, { id: e.id }]), + ); + } catch (err) { + // A probe failure must not strand the whole batch; fall back to treating + // every row as fresh (the partial unique index still rejects a genuine + // live-row collision, which the per-create catch swallows). + getEvent()?.addWarning(`google-health: live-row probe failed: ${err}`); + } + + const toCreate: Array<{ + type: MeasurementType; + value: number; + unit: string; + measuredAt: Date; + externalId: string; + sleepStage: GoogleHealthMeasurementUpsert["sleepStage"]; + }> = []; + const toUpdate: Array<{ id: string; r: GoogleHealthMeasurementUpsert }> = []; + const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; + + // A batch can carry the same (type, externalId) twice (an overlap re-fetch); + // collapse to last-write-wins so we never emit two creates for one live key. + const plannedCreateKeys = new Set(); + for (const r of readings) { + const type = r.type as MeasurementType; + const key = `${type} ${r.externalId}`; + const live = liveByKey.get(key); + if (live) { + toUpdate.push({ id: live.id, r }); + } else if (!plannedCreateKeys.has(key)) { + plannedCreateKeys.add(key); + toCreate.push({ + type, + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }); + } else { + // A duplicate fresh key inside the same batch — overwrite the planned + // create's payload so last-write-wins, matching the prior upsert loop. + const idx = toCreate.findIndex( + (c) => `${c.type} ${c.externalId}` === key, + ); + if (idx >= 0) { + toCreate[idx] = { + type, + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }; + } + } + } + + let imported = 0; + + // Fresh inserts: chunked `createMany` (server-owned rows, field-by-field). + // `skipDuplicates` guards the partial-unique index in the rare race where a + // concurrent run inserted the same live key between the probe and the write. + for (let i = 0; i < toCreate.length; i += GOOGLE_HEALTH_CREATE_CHUNK) { + const chunk = toCreate.slice(i, i + GOOGLE_HEALTH_CREATE_CHUNK); + try { + const res = await prisma.measurement.createMany({ + data: chunk.map((c) => ({ + userId, + type: c.type, + source: "GOOGLE_HEALTH" as const, + value: c.value, + unit: c.unit, + measuredAt: c.measuredAt, + externalId: c.externalId, + sleepStage: c.sleepStage, + })), + skipDuplicates: true, + }); + imported += res.count; + for (const c of chunk) { + touched.push({ type: c.type, measuredAt: c.measuredAt }); + } + } catch (err) { + getEvent()?.addWarning( + `google-health: failed to create measurements: ${err}`, + ); + } + } + + // Live-row overwrites: per-row update (differing values) on the live id, so + // the re-fetched daily summary overwrites in place and bumps `syncVersion`. + for (const { id, r } of toUpdate) { + try { + await prisma.measurement.update({ + where: { id }, + data: { + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + sleepStage: r.sleepStage ?? null, + // Surface the server-side mutation to the iOS LWW reconciler. + syncVersion: { increment: 1 }, + }, + }); + touched.push({ + type: r.type as MeasurementType, + measuredAt: r.measuredAt, + }); + imported++; + } catch (err) { + getEvent()?.addWarning( + `google-health: failed to update measurement: ${err}`, + ); + } + } + + // On a `fullSync` backfill the caller defers the rollup fold: it collapses + // every resource's touched type-days into a SINGLE range-recompute at the end + // of the cycle. The incremental path keeps the inline per-day hook here. + if (opts.deferRollup) { + const tracker = rollupDeferStorage.getStore(); + if (tracker) tracker.keys.push(...touched); + return { imported, touched }; + } + + try { + const keys = collapseToTypeDayKeys(touched); + for (const k of keys) { + await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); + } + invalidateStatusInsightsForTypes( + userId, + keys.map((k) => k.type), + ).catch((err) => { + getEvent()?.addWarning( + `google-health: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); + } catch (err) { + getEvent()?.addWarning( + `google-health: rollup recompute failed for ${userId}: ${err}`, + ); + } + + return { imported, touched }; +} + +/** + * Stamp `lastSyncedAt = now`. Called ONCE per cycle by `syncUserGoogleHealth` + * after a non-degenerate run — never per resource, so the watermark can't move + * mid-cycle and shrink a later resource's fetch window. + */ +export async function markSynced(userId: string): Promise { + await prisma.googleHealthConnection.update({ + where: { userId }, + data: { lastSyncedAt: new Date() }, + }); +} + +/** + * Full per-user sync across every Google Health resource. Drives the hourly + * poll catch-all, the manual `/api/google-health/sync` trigger, and the + * boot-time backfill (`fullSync: true`). Poll-only — there is no Google Health + * webhook at launch (the Pub/Sub-style webhook uses a Tink keyset rotating every + * 30 days, deferred), so there is no per-resource webhook enqueue path. + * + * Parks immediately when the connection is at `error_reauth` (the user must + * reconnect first) — returns 0, matching the WHOOP / Withings no-op contract. + * + * Resources: health-metrics, daily-cumulative activity, per-stage sleep, and + * exercise workouts. Each runs independently so a per-resource 403 (a Restricted + * bundle the user did not grant) soft-skips only that resource. + * + * WATERMARK: the incremental `start` is snapshotted ONCE here and threaded to + * every resource, and `markSynced` is stamped ONCE at the end. A per-resource + * read+stamp would let the first resource move `lastSyncedAt` to now() so later + * resources only see the last overlap window — silently dropping the gap after + * an outage longer than the overlap. + */ +export async function syncUserGoogleHealth( + userId: string, + opts: { fullSync?: boolean } = {}, +): Promise { + if (await isReauthRequired(userId, GOOGLE_HEALTH_INTEGRATION_KEY)) { + getEvent()?.addWarning( + `google-health sync skipped for ${userId}: parked at error_reauth`, + ); + return 0; + } + + // Snapshot the incremental watermark ONCE for the whole cycle. Every resource + // sees the same `start`; no resource re-reads `lastSyncedAt` mid-cycle. On a + // full/backfill run `incrementalStart` ignores the snapshot and returns + // undefined (no lower bound). + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true }, + }); + if (!connection) return 0; + const start = incrementalStart(connection.lastSyncedAt, { + fullSync: opts.fullSync, + }); + const resourceOpts: GoogleHealthResourceSyncOptions = { + fullSync: opts.fullSync, + start, + // A backfill walks years of daily summaries — defer the per-(type,day) + // rollup hook on every write and run ONE range-recompute at the end of the + // cycle. The hourly incremental keeps the inline hook (small touched set). + deferRollup: opts.fullSync === true, + }; + + const [ + { syncUserMetrics }, + { syncUserActivity }, + { syncUserSleep }, + { syncUserWorkout }, + ] = await Promise.all([ + import("./sync-metrics"), + import("./sync-activity"), + import("./sync-sleep"), + import("./sync-workout"), + ]); + + const resources = [ + syncUserMetrics, + syncUserActivity, + syncUserSleep, + syncUserWorkout, + ]; + + const tracker: SoftSkipTracker = { count: 0 }; + const deferTracker: RollupDeferTracker = { keys: [] }; + let total = 0; + let anyFailed = false; + await softSkipStorage.run(tracker, async () => { + await rollupDeferStorage.run(deferTracker, async () => { + for (const fn of resources) { + try { + total += await fn(userId, resourceOpts); + } catch (err) { + anyFailed = true; + getEvent()?.addWarning( + `google-health ${fn.name} failed for ${userId}: ${err}`, + ); + } + } + }); + }); + + // On a `fullSync` backfill the per-write inline rollup hook was deferred; the + // accumulated touched type-days collapse into ONE range-recompute spanning the + // touched days. Best-effort: a populator hiccup never fails the backfill. + if (opts.fullSync && deferTracker.keys.length > 0) { + try { + const days = collapseToTypeDayKeys(deferTracker.keys); + const types = Array.from(new Set(days.map((k) => k.type))); + const sorted = days + .map((k) => k.measuredAt.getTime()) + .sort((a, b) => a - b); + const from = new Date(sorted[0]!); + // The keys are UTC day-starts; extend `to` past the last touched day so + // the aggregator's `< to` upper bound covers it. + const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); + await recomputeUserRollups(userId, { types, from, to }); + invalidateStatusInsightsForTypes(userId, types).catch((err) => { + getEvent()?.addWarning( + `google-health: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); + } catch (err) { + getEvent()?.addWarning( + `google-health: backfill rollup recompute failed for ${userId}: ${err}`, + ); + } + } + + // A genuine grant-revoke 403s EVERY collection: each resource soft-skips + // (returns 0, records no failure), so `anyFailed` stays false and `total` is + // 0 — yet the connection is dead until the token-refresh path next catches the + // 401. Don't stamp success when the whole cycle was soft-skipped and nothing + // imported; leave the status as-is so the "looks-healthy" window closes. + const allSoftSkipped = tracker.count >= resources.length && total === 0; + + if (!anyFailed && !allSoftSkipped) { + // Stamp the watermark ONCE for the whole cycle, only on a non-degenerate + // run. Every resource already saw the snapshot `start`, so stamping now() + // here can't shrink a later resource's window. + await markSynced(userId); + await recordSyncSuccess(userId, GOOGLE_HEALTH_INTEGRATION_KEY); + } + + annotate({ + action: { name: "googleHealth.sync", details: { imported: total } }, + }); + return total; +} + +/** + * Map a Google Health response classification onto a `FailureKind` and record + * it. Shared by the token-refresh path and every per-resource catch block. + */ +export async function recordGoogleHealthSyncFailure( + userId: string, + err: unknown, +): Promise { + const message = err instanceof Error ? err.message : String(err); + await recordSyncFailure({ + userId, + integration: GOOGLE_HEALTH_INTEGRATION_KEY, + kind: classificationToFailureKind(classifyGoogleHealthError(err)), + message, + errorCode: + err instanceof GoogleHealthApiError + ? err.httpStatus?.toString() + : undefined, + }); +} + +export function classificationToFailureKind( + classification: GoogleHealthClassification, +): FailureKind { + switch (classification) { + case "reauth_required": + return "reauth_required"; + case "persistent": + return "persistent"; + case "transient": + return "transient"; + case "success": + // A caller asking for the FailureKind of a success is a contract bug; + // surface it as transient so the audit log still records the anomaly. + return "transient"; + } +} + +/** + * Bounded fan-out width for the hourly Google Health cohort poll. The cron tick + * carries no `userId`, so the worker resolves every connection and hands the + * cohort here. A small pool (rather than a strict serial loop) means one slow + * Google response can't stall the whole cohort, while the cap keeps the burst of + * per-user resource fetches + rollup writes from crowding the worker's DB pool. + */ +export const GOOGLE_HEALTH_POLL_CONCURRENCY = 4; + +/** + * Run a Google Health hourly-poll cohort with bounded concurrency + per-user + * error isolation. Each user's `syncUserGoogleHealth` runs inside the pool; a + * single user's failure (or a slow Google response) is captured through + * `onUserError` and never aborts the rest of the pass. Returns the per-cohort + * totals for the wide-event envelope. + */ +export async function runGoogleHealthPollCohort( + userIds: string[], + opts: { + concurrency?: number; + sync?: (userId: string) => Promise; + onUserError?: (userId: string, err: unknown) => void; + /** Invoked after a user's sync lands `imported` measurements, so the worker + * can fire the eventful Vorsorge satisfaction enqueue without the + * google-health lib reaching into the job layer. */ + onUserSynced?: (userId: string, imported: number) => void; + } = {}, +): Promise<{ usersSynced: number; measurementsImported: number }> { + const sync = opts.sync ?? ((userId: string) => syncUserGoogleHealth(userId)); + const limit = pLimit(opts.concurrency ?? GOOGLE_HEALTH_POLL_CONCURRENCY); + + let usersSynced = 0; + let measurementsImported = 0; + await Promise.all( + userIds.map((userId) => + limit(async () => { + try { + // Read the accumulators AFTER the await resolves. A compound + // assignment (`x += await f()`) captures the left value BEFORE the + // await and would lose increments under overlapping pool tasks. + const n = await sync(userId); + measurementsImported = measurementsImported + n; + usersSynced = usersSynced + 1; + opts.onUserSynced?.(userId, n); + } catch (err) { + opts.onUserError?.(userId, err); + } + }), + ), + ); + + return { usersSynced, measurementsImported }; +} diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 3b9188580..8688e7010 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -48,6 +48,10 @@ export type IntegrationKey = | "nightscout" | "polar" | "oura" + // v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) via the + // successor Google Health API. Separate connection from the classic + // `fitbit` transport, which sunsets Sept 2026. + | "google-health" | "moodlog"; /** @@ -809,6 +813,7 @@ const INTEGRATION_LABELS: Record = { nightscout: "Nightscout", polar: "Polar", oura: "Oura", + "google-health": "Google Health", moodlog: "moodLog", }; diff --git a/src/lib/jobs/__tests__/google-health-queues.test.ts b/src/lib/jobs/__tests__/google-health-queues.test.ts new file mode 100644 index 000000000..f0de1fa62 --- /dev/null +++ b/src/lib/jobs/__tests__/google-health-queues.test.ts @@ -0,0 +1,90 @@ +/** + * v1.26.0 — pg-boss queue registration for the Google Health sync layer. + * + * The reminder-worker imports heavy infrastructure (pg-boss, Prisma adapter, + * notification dispatchers, …) so we don't boot the whole worker here. Instead + * we read the source as text and assert the queue constants + cron schedules + + * handler wiring + allQueues + boot-enqueue are present — the fast guard that + * catches the v1.4.37 dead-queue class (a queue declared but never registered + * in `allQueues`, which silently never drains). + * + * Google Health is poll-only at launch (no webhook — Pub/Sub deferred), so a + * single hourly `google-health-sync` queue drives the per-user + * `syncUserGoogleHealth` driver across every connection, alongside the backfill + * + the daily OAuth-state sweep. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REGISTRAR_PATH = join( + __dirname, + "..", + "reminder", + "register-integration-sync.ts", +); +const source = + readFileSync(REGISTRAR_PATH, "utf8") + + readFileSync( + join(__dirname, "..", "reminder", "google-health-sync.ts"), + "utf8", + ); + +const GOOGLE_HEALTH_QUEUE_CONSTS = [ + "GOOGLE_HEALTH_SYNC_QUEUE", + "GOOGLE_HEALTH_BACKFILL_QUEUE", + "GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE", +] as const; + +describe("reminder-worker — Google Health sync queues", () => { + it("declares the Google Health sync + oauth-state-cleanup queue constants", () => { + expect(source).toMatch( + /GOOGLE_HEALTH_SYNC_QUEUE\s*=\s*["']google-health-sync["']/, + ); + expect(source).toMatch( + /GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE\s*=\s*["']google-health-oauth-state-cleanup["']/, + ); + }); + + it("registers EVERY Google Health queue in allQueues (v1.4.37 dead-queue guard)", () => { + const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); + expect(block).not.toBeNull(); + const body = block![1]!; + for (const q of GOOGLE_HEALTH_QUEUE_CONSTS) { + expect(body).toContain(q); + } + }); + + it("schedules the hourly poll cron + the daily oauth-state sweep", () => { + expect(source).toMatch( + /\[GOOGLE_HEALTH_SYNC_QUEUE,\s*GOOGLE_HEALTH_SYNC_CRON\]/, + ); + expect(source).toMatch( + /\[\s*GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,\s*GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON,?\s*\]/, + ); + }); + + it("registers a boss.work handler for every Google Health queue", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}GOOGLE_HEALTH_SYNC_QUEUE[\s\S]{0,200}handleGoogleHealthSync/, + ); + expect(source).toMatch( + /boss\.work[\s\S]{0,320}GOOGLE_HEALTH_BACKFILL_QUEUE[\s\S]{0,320}runGoogleHealthBackfillForUser/, + ); + expect(source).toMatch( + /boss\.work[\s\S]{0,200}GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE[\s\S]{0,200}handleGoogleHealthOAuthStateCleanup/, + ); + }); + + it("wires the self-converging Google Health backfill boot discovery", () => { + expect(source).toMatch(/enqueueBootTimeGoogleHealthBackfill\(\)/); + }); + + it("imports the Google Health sync driver + backfill exports", () => { + expect(source).toMatch(/from\s*["']@\/lib\/google-health\/sync["']/); + expect(source).toMatch( + /from\s*["']@\/lib\/jobs\/google-health-backfill["']/, + ); + }); +}); diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts new file mode 100644 index 000000000..5a09c741d --- /dev/null +++ b/src/lib/jobs/google-health-backfill.ts @@ -0,0 +1,117 @@ +/** + * v1.26.0 — pg-boss queue + boot-time self-converging backfill for newly + * connected Google Health accounts. Modelled on the Fitbit / WHOOP backfill + * (`src/lib/jobs/fitbit-backfill.ts`): a discovery query enqueues one job per + * connection that has NOT yet been backfilled, and the pass is idempotent + * across reboots (the predicate `backfill_completed_at IS NULL` drops a + * connection once its backfill finishes). + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder/register-integration-sync.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 { syncUserGoogleHealth } from "@/lib/google-health/sync"; + +export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; + +/** + * Serial concurrency — a backfill walks years of history for one account and is + * rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding + * the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`. + */ +export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1; + +export interface GoogleHealthBackfillPayload { + userId: string; + enqueuedAt: string; +} + +/** + * Per-user backfill handler. Runs a full-history sync for one account and + * stamps `backfillCompletedAt` so the discovery query drops it. Idempotent: the + * per-resource upserts are key-stable, so a re-run (e.g. a reboot mid-walk) + * overwrites rather than duplicating. Mirrors `runFitbitBackfillForUser`. + */ +export async function runGoogleHealthBackfillForUser( + userId: string, +): Promise<{ imported: number }> { + const imported = await syncUserGoogleHealth(userId, { fullSync: true }); + + await prisma.googleHealthConnection.update({ + where: { userId }, + data: { backfillCompletedAt: new Date() }, + }); + + annotate({ + action: { + name: "google_health.backfill.complete", + details: { imported }, + }, + }); + return { imported }; +} + +/** + * Boot-time discovery. Finds every Google Health connection not yet backfilled + * (`backfill_completed_at IS NULL`) and enqueues one backfill job per account. + * + * Idempotent across reboots: once a connection's backfill completes, + * `backfillCompletedAt` is set and the predicate drops it 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 the worker boot + * never fails because of a backfill miss. + */ +export async function enqueueBootTimeGoogleHealthBackfill(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const connections = await prisma.googleHealthConnection.findMany({ + where: { backfillCompletedAt: null }, + select: { userId: true }, + }); + + if (connections.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const { userId } of connections) { + const payload: GoogleHealthBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(GOOGLE_HEALTH_BACKFILL_QUEUE, payload, { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `google-health-backfill|${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), + }; + } +} diff --git a/src/lib/jobs/google-health-oauth-state-cleanup.ts b/src/lib/jobs/google-health-oauth-state-cleanup.ts new file mode 100644 index 000000000..7f807b379 --- /dev/null +++ b/src/lib/jobs/google-health-oauth-state-cleanup.ts @@ -0,0 +1,23 @@ +/** + * v1.26.0 — daily cleanup for the `google_health_oauth_states` table. + * + * Mirrors `fitbit-oauth-state-cleanup`. The OAuth state ledger is single-use — + * every happy-path + error-path branch in `google-health/callback` consumes its + * row, so an abandoned row only lingers when the user closed the Google approval + * tab without bouncing back to the callback URL. The cookie + row TTL is 10 + * minutes, so once the timestamp blows the row is dead weight. + * + * Idempotent: a re-run within the same window matches zero rows the second time + * because the first pass deleted everything older than `now()`. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +export async function cleanupExpiredGoogleHealthOAuthStates( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const { count } = await prisma.googleHealthOAuthState.deleteMany({ + where: { expiresAt: { lt: now } }, + }); + return count; +} diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts new file mode 100644 index 000000000..66b921332 --- /dev/null +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -0,0 +1,101 @@ +/** + * Google Health hourly poll-cohort sync and OAuth-state cleanup handlers. + * + * Extracted alongside the Fitbit handlers; register-integration-sync.ts owns the + * queue names, cron schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { runGoogleHealthPollCohort } from "@/lib/google-health/sync"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { cleanupExpiredGoogleHealthOAuthStates } from "@/lib/jobs/google-health-oauth-state-cleanup"; +import { getWorkerPrisma } from "./shared"; + +/** + * v1.26.0 — Google Health poll-sync payload. Poll-only (no webhook — Pub/Sub is + * deferred): the single hourly cron tick carries no `userId`, so the handler + * iterates every Google Health connection and re-syncs each via + * `syncUserGoogleHealth`. One user's parked-at-reauth state never starves the + * rest of the cohort. + */ +export interface GoogleHealthSyncPayload { + userId?: string; +} + +export async function handleGoogleHealthSync( + jobs: Job[], +) { + await withBackgroundEvent("job.google_health_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const targets: Array<{ userId: string }> = []; + for (const job of jobs) { + if (job.data?.userId) targets.push({ userId: job.data.userId }); + } + if (targets.length === 0) { + const connections = await prisma.googleHealthConnection.findMany({ + select: { userId: true }, + }); + targets.push(...connections); + } + if (targets.length === 0) return; + + // Fan the cohort out with bounded concurrency + per-user error isolation: + // one slow Google response can't stall the whole cohort, and a single + // user's failure is warned without aborting the pass. + const { usersSynced, measurementsImported } = + await runGoogleHealthPollCohort( + targets.map((t) => t.userId), + { + onUserError: (userId, err) => + evt.addWarning( + `job.google_health_sync failed for user ${userId}: ${err}`, + ), + // A fresh reading landed; resolve the user's Vorsorge reminders + // eventfully. Fire-and-forget; the cron is the net. + onUserSynced: (userId, imported) => { + if (imported > 0) { + void enqueueReminderSatisfy(userId).catch(() => {}); + } + }, + }, + ); + + evt.setBackground({ + task_name: "job.google_health_sync", + result: { + users_synced: usersSynced, + total: targets.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +export interface GoogleHealthOAuthStateCleanupPayload { + triggeredAt?: string; +} + +export async function handleGoogleHealthOAuthStateCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent( + "job.google_health_oauth_state_cleanup", + async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupExpiredGoogleHealthOAuthStates(p); + evt.addMeta("google_health_oauth_state_cleanup_deleted", deleted); + } catch (err) { + evt.addWarning(`google-health-oauth-state-cleanup failed: ${err}`); + } + }, + ); +} diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index 6c3bdb802..1724ae31b 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -28,6 +28,13 @@ import { enqueueBootTimeFitbitBackfill, type FitbitBackfillPayload, } from "@/lib/jobs/fitbit-backfill"; +import { + GOOGLE_HEALTH_BACKFILL_QUEUE, + GOOGLE_HEALTH_BACKFILL_CONCURRENCY, + runGoogleHealthBackfillForUser, + enqueueBootTimeGoogleHealthBackfill, + type GoogleHealthBackfillPayload, +} from "@/lib/jobs/google-health-backfill"; import { SLEEP_TIMELINE_BACKFILL_QUEUE, SLEEP_TIMELINE_BACKFILL_CONCURRENCY, @@ -75,6 +82,12 @@ import { FitbitOAuthStateCleanupPayload, handleFitbitOAuthStateCleanup, } from "./fitbit-sync"; +import { + GoogleHealthSyncPayload, + handleGoogleHealthSync, + GoogleHealthOAuthStateCleanupPayload, + handleGoogleHealthOAuthStateCleanup, +} from "./google-health-sync"; const WITHINGS_SYNC_QUEUE = "withings-fallback-sync"; @@ -150,6 +163,23 @@ const FITBIT_SYNC_CRON = "8 * * * *"; // every hour at :08 const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup"; const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *"; +// v1.26.0 — Google Health / Fitbit-Pixel-Wear-OS poll-only sync. There is no +// Google Health webhook at launch (Pub/Sub deferred), so a single hourly cron +// drives the per-user `syncUserGoogleHealth` driver across every connection. +// Minute staggered off the WHOOP (:05/:20/:35/:50), Fitbit (:08), Nightscout +// (:11), Polar (:13), and Oura (:15) slots so the hourly ticks don't pile up on +// one boss poll. + +const GOOGLE_HEALTH_SYNC_QUEUE = "google-health-sync"; + +const GOOGLE_HEALTH_SYNC_CRON = "18 * * * *"; // every hour at :18 +// v1.26.0 — daily sweep for the Google Health OAuth state ledger. Slots at +// 03:26, next to the Fitbit sweep (03:24), inside the maintenance window. + +const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE = + "google-health-oauth-state-cleanup"; + +const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON = "26 3 * * *"; // v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook): one hourly tick // pulls the recent SGV window per configured instance. :11 staggers off the // WHOOP (:05), Fitbit (:08), and Withings sync ticks so the hourly polls don't @@ -195,6 +225,11 @@ const allQueues = [ FITBIT_SYNC_QUEUE, FITBIT_BACKFILL_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_QUEUE, + // v1.26.0 — Google Health poll-only sync (no webhook at launch), + // self-converging boot backfill, and the daily OAuth-state ledger sweep. + GOOGLE_HEALTH_SYNC_QUEUE, + GOOGLE_HEALTH_BACKFILL_QUEUE, + GOOGLE_HEALTH_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 @@ -230,6 +265,14 @@ const schedules: ScheduleEntry[] = [ // daily 03:24 Europe/Berlin prune for expired Fitbit OAuth states. [FITBIT_SYNC_QUEUE, FITBIT_SYNC_CRON], [FITBIT_OAUTH_STATE_CLEANUP_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_CRON], + // v1.26.0 — hourly Google Health poll (:18, staggered off WHOOP/Fitbit/ + // Nightscout/Polar/Oura) + the daily 03:26 Europe/Berlin prune for expired + // Google Health OAuth states. + [GOOGLE_HEALTH_SYNC_QUEUE, GOOGLE_HEALTH_SYNC_CRON], + [ + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON, + ], // v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync // ticks). [NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON], @@ -341,6 +384,36 @@ export async function registerIntegrationSyncQueues( { localConcurrency: 1 }, handleFitbitOAuthStateCleanup, ); + // v1.26.0 — Google Health poll-sync (cron full-iteration; no webhook). Serial + // concurrency so a backfill-heavy tick never crowds the request pool. + await boss.work( + GOOGLE_HEALTH_SYNC_QUEUE, + { localConcurrency: 1 }, + handleGoogleHealthSync, + ); + // v1.26.0 — self-converging Google Health backfill. The boot enqueue below + // sends one full-history sync per un-backfilled connection; this handler runs + // it and stamps `backfillCompletedAt` so the discovery query drops the + // account. + await boss.work( + GOOGLE_HEALTH_BACKFILL_QUEUE, + { localConcurrency: GOOGLE_HEALTH_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + const { imported } = await runGoogleHealthBackfillForUser(userId); + workerLog( + "info", + `[google-health-backfill] user=${userId} imported=${imported}`, + ); + } + }, + ); + await boss.work( + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleGoogleHealthOAuthStateCleanup, + ); // 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 @@ -458,6 +531,30 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise { ); } + // v1.26.0 — Google Health backfill. Finds every Google Health connection not + // yet backfilled and enqueues one full-history sync per account. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeGoogleHealthBackfill(); + if (error) { + workerLog( + "error", + `[google-health-backfill] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[google-health-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[google-health-backfill] boot discovery threw an unexpected error", + err, + ); + } + // v1.17.1 — 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). A completed pass stamps `sleepTimelineBackfillAt`. diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 361372960..7900b40bc 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -36,6 +36,13 @@ export const integrationKeys = { */ fitbitStatus: () => ["fitbit", "status"] as const, + // v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) integration + // card. Reads status off the consolidated /api/integrations/status envelope + // (like Fitbit/WHOOP); the connect/disconnect/credentials/sync mutations + // invalidate this key so the card repaints. Distinct hyphenated array so it + // never collides with the classic `fitbit` key. + googleHealth: () => ["google-health"] as const, + // v1.17.0 — Nightscout CGM integration card. Self-contained status read // (not the consolidated /api/integrations/status envelope), so the // connect/disconnect mutations invalidate this single key. diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index f8fc675f2..0266827e6 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -204,6 +204,12 @@ export const measurementSourceEnum = z.enum([ // of this enum so the read/response shapes (and the iOS decoder) can decode // the rows it surfaces. "MCP", + // v1.26.0 — Fitbit/Pixel + Wear OS via the Google Health API. Server-owned + // (no client write path), exactly like WHOOP/FITBIT. Part of this enum so the + // read/response shapes (and the iOS decoder) can decode the rows it surfaces; + // deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` + the batch + // allowlist so a client can never forge a GOOGLE_HEALTH-attributed row. + "GOOGLE_HEALTH", ]); /** diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index e1d7614ab..1c71cf700 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -234,11 +234,24 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // they rank below the phone-aggregated Apple Health (which sees all-day // steps) but as legitimate sources above MANUAL, so an Oura/Polar-only day // is not dropped when another source coexists. - steps: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "OURA", "POLAR", "MANUAL"], + // v1.26.0 — Google Health surfaces Fitbit/Pixel/Wear OS device data (the + // successor API to the classic Fitbit Web API); it rides in the same + // wrist-wearable class as FITBIT and ranks immediately below it in every + // device ladder, above OURA/POLAR and MANUAL. + steps: [ + "APPLE_HEALTH", + "WITHINGS", + "FITBIT", + "GOOGLE_HEALTH", + "OURA", + "POLAR", + "MANUAL", + ], activeEnergy: [ "APPLE_HEALTH", "WITHINGS", "FITBIT", + "GOOGLE_HEALTH", "OURA", "POLAR", "MANUAL", @@ -247,11 +260,18 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { "APPLE_HEALTH", "WITHINGS", "FITBIT", + "GOOGLE_HEALTH", "OURA", "POLAR", "MANUAL", ], - flightsClimbed: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"], + flightsClimbed: [ + "APPLE_HEALTH", + "WITHINGS", + "FITBIT", + "GOOGLE_HEALTH", + "MANUAL", + ], // v1.11.0 — WHOOP leads the recovery-input ladders (sleep / HRV / RHR): // a worn-all-night strap has higher-resolution overnight sampling than // the iPhone-relayed HealthKit summary or the Withings nightly summary. @@ -260,26 +280,64 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // v1.17.0 — Polar + Oura are worn wearables in the same overnight-sampling // class as WHOOP/Fitbit; they rank below the established straps but above // the iPhone-relayed HealthKit summary and the Withings nightly summary. - sleep: ["WHOOP", "FITBIT", "OURA", "POLAR", "APPLE_HEALTH", "WITHINGS"], - hrv: ["WHOOP", "FITBIT", "OURA", "POLAR", "APPLE_HEALTH", "WITHINGS"], + sleep: [ + "WHOOP", + "FITBIT", + "GOOGLE_HEALTH", + "OURA", + "POLAR", + "APPLE_HEALTH", + "WITHINGS", + ], + hrv: [ + "WHOOP", + "FITBIT", + "GOOGLE_HEALTH", + "OURA", + "POLAR", + "APPLE_HEALTH", + "WITHINGS", + ], restingHeartRate: [ "WHOOP", "FITBIT", + "GOOGLE_HEALTH", "OURA", "POLAR", "APPLE_HEALTH", "WITHINGS", ], // A real scale beats a strap's body-measurement estimate for weight. - weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "WHOOP", "FITBIT"], + weight: [ + "WITHINGS", + "APPLE_HEALTH", + "MANUAL", + "WHOOP", + "FITBIT", + "GOOGLE_HEALTH", + ], bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"], // v1.17.0 — continuous optical-HR wearables (Fitbit / Polar / Oura) rank // above a hand-typed MANUAL pulse; the device-source order matches the // rhr / hrv ladders (FITBIT > OURA > POLAR). Withings BPM cuff + the // HealthKit relay stay the primary point-measurement sources. - pulse: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "OURA", "POLAR", "MANUAL"], - bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], - bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], + pulse: [ + "WITHINGS", + "APPLE_HEALTH", + "FITBIT", + "GOOGLE_HEALTH", + "OURA", + "POLAR", + "MANUAL", + ], + bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT", "GOOGLE_HEALTH"], + bodyTemperature: [ + "WITHINGS", + "APPLE_HEALTH", + "MANUAL", + "FITBIT", + "GOOGLE_HEALTH", + ], // Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second, // Fitbit third, Oura's ring fourth. v1.17.1 — Polar Elixir pulse-ox slots // below the established straps and above the iPhone-relay / manual sources. @@ -287,19 +345,30 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { "WITHINGS", "WHOOP", "FITBIT", + "GOOGLE_HEALTH", "OURA", "POLAR", "APPLE_HEALTH", "MANUAL", ], - vo2Max: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "OURA", "MANUAL"], + vo2Max: [ + "WITHINGS", + "APPLE_HEALTH", + "FITBIT", + "GOOGLE_HEALTH", + "OURA", + "MANUAL", + ], // v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the // primary skin-temperature sensor; WHOOP's strap is second, Fitbit third, // Oura's ring fourth. + // GOOGLE_HEALTH intentionally absent — it produces no skin-temperature row for + // v1 (Google surfaces a nightly signed deviation, not an absolute reading). skinTemperature: ["WITHINGS", "WHOOP", "FITBIT", "OURA", "APPLE_HEALTH"], respiratoryRate: [ "WHOOP", "FITBIT", + "GOOGLE_HEALTH", "OURA", "POLAR", "APPLE_HEALTH",