mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge pull request #400 from MBombeck/release/v1.27.0
Release v1.27.0 — Fitbit and Pixel Watch through Google Health
This commit is contained in:
56 files changed
+6271
-44
No files matched your search
@@ -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
|
||||
|
||||
+123
-1
@@ -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
|
||||
|
||||
@@ -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** (<https://console.cloud.google.com>) 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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) ─────
|
||||
|
||||
@@ -93,6 +93,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"measurements.typeVo2Max",
|
||||
"settings.sections.sources.metrics.vo2Max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
"admin.glitchtipDsn",
|
||||
"admin.integrationWebPush",
|
||||
"admin.offlineGeoEnabled",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 <token>` 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]");
|
||||
}
|
||||
@@ -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) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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/<provider>/status round-trip the page used to fire per card.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=<tag>` (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/<provider>/status round-trip.
|
||||
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
|
||||
@@ -250,6 +265,7 @@ export function ConnectionsPanel() {
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
|
||||
@@ -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<string | null>(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<string | null>(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 (
|
||||
<SettingsCard data-testid="google-health-card">
|
||||
<SettingsCardHeader
|
||||
icon={Watch}
|
||||
title={t("settings.googleHealth")}
|
||||
titleAccessory={
|
||||
<>
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
{t("settings.googleHealthTag")}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="google-health-beta-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{t("settings.googleHealthBetaBadge")}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<IntegrationCardDescription
|
||||
i18nPrefix="settings.googleHealth"
|
||||
provider="google-health"
|
||||
/>
|
||||
}
|
||||
status={
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{errorMessage && <IntegrationErrorMessage message={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 && (
|
||||
<div
|
||||
data-testid="google-health-reauth-banner"
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.googleHealthReauthBanner")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
data-testid="google-health-reconnect-button"
|
||||
className="min-h-11"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("settings.googleHealthReconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parked-integration resume CTA — surfaces only when the row state is
|
||||
`parked` (>24h of persistent failures). */}
|
||||
{pillState === "parked" && (
|
||||
<div
|
||||
data-testid="google-health-parked-banner"
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.integrationPill.parkedReconnect")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resume.mutate()}
|
||||
disabled={resume.isPending}
|
||||
data-testid="google-health-resume-button"
|
||||
className="min-h-11"
|
||||
>
|
||||
{resume.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.integrationPill.resumeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{resume.isError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-testid="google-health-resume-error"
|
||||
>
|
||||
{t("settings.integrationPill.resumeError")}
|
||||
</p>
|
||||
)}
|
||||
{resume.isSuccess && resume.data?.wasParked && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-success text-xs"
|
||||
data-testid="google-health-resume-success"
|
||||
>
|
||||
{t("settings.integrationPill.resumeSuccess")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.googleHealthCredentials")}
|
||||
</h3>
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="google-health-clientid">
|
||||
{t("settings.googleHealthClientId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="google-health-clientid"
|
||||
value={clientId}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="google-health-secret">
|
||||
{t("settings.googleHealthClientSecret")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="google-health-secret"
|
||||
value={clientSecret}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={
|
||||
credsSaving || !clientId.trim() || !clientSecret.trim()
|
||||
}
|
||||
>
|
||||
{credsSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.googleHealthSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{credsMsg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{status?.connected ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() => handleSync(false)}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.googleHealthSync")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.googleHealthFullSync")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.googleHealthFullSyncTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.googleHealthFullSyncDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleSync(true)}>
|
||||
{t("settings.googleHealthSynchronize")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/google-health/test"
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t("settings.googleHealthDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.googleHealthDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.googleHealthDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.googleHealthDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{status?.backfillCompleted === false && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.googleHealthBackfillInProgress")}
|
||||
</p>
|
||||
)}
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.googleHealthConnect")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
{t("settings.googleHealthNoCredentials")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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:<tag>:<YYYY-MM-DD>` 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<GoogleHealthCredentials | null> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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 = <anchor>:<fieldTag>` (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:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched day **overwrites** in
|
||||
place rather than minting a duplicate, matching the Apple-Health
|
||||
`stats:<HK>:<YYYY-MM-DD>` 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 =
|
||||
`<session-anchor>:sleep_<stage>:<i>` (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:<startISO>` 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 = <anchor>:<fieldTag>`. Daily cumulative activity rows:
|
||||
`externalId = stats:<fieldTag>:<YYYY-MM-DD>` (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.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 <verb> error: <status>` 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 <verb> error:
|
||||
* <status>"` 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)
|
||||
);
|
||||
}
|
||||
@@ -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:<fieldTag>:<YYYY-MM-DD>`
|
||||
* — so a re-fetched day OVERWRITES the existing row rather than minting a
|
||||
* duplicate, matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` 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<string, unknown>,
|
||||
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 `<tag>:<YYYY-MM-DD>` form).
|
||||
*/
|
||||
function externalIdFor(m: GoogleHealthMappedMeasurement): string {
|
||||
return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag;
|
||||
}
|
||||
|
||||
export async function syncUserActivity(
|
||||
userId: string,
|
||||
opts: GoogleHealthResourceSyncOptions = {},
|
||||
): Promise<number> {
|
||||
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<string, unknown>[];
|
||||
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;
|
||||
}
|
||||
@@ -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 (`<anchor>:<fieldTag>`) 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<string, unknown>) => 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<number> {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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;
|
||||
}
|
||||
@@ -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 =
|
||||
* `<session-anchor>:sleep_<stage>:<i>` — 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<number> {
|
||||
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<string, unknown>[];
|
||||
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;
|
||||
}
|
||||
@@ -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<number> {
|
||||
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<string, unknown>[];
|
||||
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;
|
||||
}
|
||||
@@ -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<GoogleHealthTokenInfo | null> {
|
||||
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<SoftSkipTracker>();
|
||||
|
||||
/**
|
||||
* 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<RollupDeferTracker>();
|
||||
|
||||
/**
|
||||
* 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<number> {
|
||||
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 (`<anchor>:<fieldTag>`).
|
||||
*/
|
||||
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<string, { id: string }>();
|
||||
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<string>();
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<number>;
|
||||
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 };
|
||||
}
|
||||
@@ -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<IntegrationKey, string> = {
|
||||
nightscout: "Nightscout",
|
||||
polar: "Polar",
|
||||
oura: "Oura",
|
||||
"google-health": "Google Health",
|
||||
moodlog: "moodLog",
|
||||
};
|
||||
|
||||
|
||||
@@ -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["']/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<number> {
|
||||
const { count } = await prisma.googleHealthOAuthState.deleteMany({
|
||||
where: { expiresAt: { lt: now } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -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<GoogleHealthSyncPayload>[],
|
||||
) {
|
||||
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<GoogleHealthOAuthStateCleanupPayload>[],
|
||||
) {
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<GoogleHealthSyncPayload>(
|
||||
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<GoogleHealthBackfillPayload>(
|
||||
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<GoogleHealthOAuthStateCleanupPayload>(
|
||||
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<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -234,11 +234,24 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// 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<MetricPriority> = {
|
||||
"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<MetricPriority> = {
|
||||
// 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<MetricPriority> = {
|
||||
"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",
|
||||
|
||||
Reference in new issue
Block a user