mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
No user-facing change. Eight of the largest source files split along their natural seams into focused modules, every move verbatim with stable import paths: - mood analytics calculators (mood-tag-influence / mood-crosstab / mood-better-days / mood-patterns; hub 2229 → 770 lines) - insight status-invalidation machinery (status-invalidation.ts) - feature extraction (feature-blocks / signals-of-day) - doctor report types + pure helpers (doctor-report-types / doctor-report-helpers; collector 1756 → 1002) - Google Health mapping layer (mappers.ts; client 2153 → 1022, acyclic client → mappers with type-only back-imports) - coach chat bubbles + read-aloud controls (chat-bubble / read-aloud; message-thread 1713 → 464) - Telegram webhook handlers to lib (route 1307 → 92 — handler thin, logic in lib; rate-limit-before-secret ordering byte-identical) - coach snapshot cache, series helpers and the four largest per-metric blocks (snapshot-cache / snapshot-series / snapshot-blocks; 2691 → 1896) The glucose-panel-window structural test follows the moved alias; two unused imports left by the feature split are removed.
1023 lines
39 KiB
TypeScript
1023 lines
39 KiB
TypeScript
/**
|
||
* Google Health API client — OAuth + data-point reads (v1.27.0).
|
||
* Docs: https://developers.google.com/health (health.googleapis.com/v4, the
|
||
* successor to the Fitbit Web API, unifying Fitbit + Pixel Watch + Fitbit Air).
|
||
*
|
||
* Hand-rolled fetch over `safeFetch` (no SDK): the OAuth handshake
|
||
* (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), a single
|
||
* profile fetch for the connection's external user id, and the paginated
|
||
* `dataPoints.list` walker + per-type mappers.
|
||
*
|
||
* KEY OAUTH SEMANTICS (verified 2026 contract):
|
||
* - Access-token TTL = 1 h (contrast the classic Fitbit Web API's 8 h).
|
||
* - Refresh tokens do NOT rotate — a routine refresh returns a fresh
|
||
* `access_token` WITHOUT a `refresh_token`; the sync layer keeps the stored
|
||
* one (see `getValidToken` in `sync.ts`). Refresh tokens are time-based:
|
||
* they expire after 6 months of disuse, or — in a consent screen still in
|
||
* "Testing" publishing mode — after 7 DAYS, at which point the user must
|
||
* re-consent. A revoked / expired refresh token surfaces `invalid_grant`
|
||
* (or a 401) on the token endpoint; `postToken` lifts that onto the
|
||
* `reauth_required` class so the connection prompts a reconnect rather than
|
||
* a generic hard error.
|
||
* - PKCE (S256): the authorize request carries a `code_challenge`; the
|
||
* callback presents the matching `code_verifier` on exchange. Google's
|
||
* web-server (confidential) client also sends the client secret via HTTP
|
||
* Basic auth (RFC 6749 §2.3.1) — Basic + PKCE together.
|
||
* - `access_type=offline` + `prompt=consent` are required to reliably receive
|
||
* a refresh token (and force one on every re-consent).
|
||
*
|
||
* The pure mapping layer (payload → Measurement / Workout shapes) lives in
|
||
* `mappers.ts` and is re-exported below, so the sync layer and tests keep a
|
||
* single `./client` import surface. The shared wire-shape symbols
|
||
* (`GoogleHealthDataType`, `GOOGLE_HEALTH_DATA_TYPES`, the point types) live in
|
||
* `mappers.ts` and are imported here type-only — that direction keeps the
|
||
* module graph acyclic (`client` → `mappers`, never back).
|
||
*/
|
||
import { createHash, randomBytes } from "node:crypto";
|
||
import { annotate, getEvent } from "@/lib/logging/context";
|
||
import { safeFetch } from "@/lib/safe-fetch";
|
||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||
import {
|
||
GoogleHealthApiError,
|
||
classifyGoogleHealthResponse,
|
||
type GoogleHealthClassification,
|
||
} from "./response-classifier";
|
||
import type {
|
||
GoogleHealthDataPoint,
|
||
GoogleHealthDataType,
|
||
GoogleHealthRollupPoint,
|
||
} from "./mappers";
|
||
|
||
export * from "./mappers";
|
||
|
||
export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4";
|
||
const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||
|
||
export interface GoogleHealthCredentials {
|
||
clientId: string;
|
||
clientSecret: string;
|
||
}
|
||
|
||
/**
|
||
* Resolve the OAuth `redirect_uri`, then assert it against an allowlist
|
||
* (defence-in-depth).
|
||
*
|
||
* The value is operator-controlled config (`GOOGLE_HEALTH_REDIRECT_URI`, else
|
||
* derived from `NEXT_PUBLIC_APP_URL`), not user input, so Google's
|
||
* registered-redirect check is the real backstop. But a misconfigured or
|
||
* `Host`-coerced `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting
|
||
* a forwarded Host) would otherwise send the authorization code's landing URL
|
||
* off-origin. Pin the target here so a malformed origin fails fast at the
|
||
* handshake rather than silently redirecting elsewhere:
|
||
* - must be an absolute, parseable URL,
|
||
* - must be https (the one exception is a localhost/loopback dev host, which
|
||
* Google itself permits over http),
|
||
* - must land on the fixed `/api/google-health/callback` path,
|
||
* - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it.
|
||
*/
|
||
function getRedirectUri(): string {
|
||
const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI;
|
||
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||
const raw =
|
||
explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined);
|
||
|
||
if (!raw) {
|
||
throw new Error(
|
||
"Google Health redirect_uri is not configured — set GOOGLE_HEALTH_REDIRECT_URI or NEXT_PUBLIC_APP_URL",
|
||
);
|
||
}
|
||
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(raw);
|
||
} catch {
|
||
throw new Error(
|
||
`Google Health redirect_uri is not an absolute URL: ${raw}`,
|
||
);
|
||
}
|
||
|
||
const isLoopback =
|
||
parsed.hostname === "localhost" ||
|
||
parsed.hostname === "127.0.0.1" ||
|
||
parsed.hostname === "[::1]" ||
|
||
parsed.hostname === "::1";
|
||
if (
|
||
parsed.protocol !== "https:" &&
|
||
!(parsed.protocol === "http:" && isLoopback)
|
||
) {
|
||
throw new Error(
|
||
`Google Health redirect_uri must be https (or http on localhost): ${parsed.origin}`,
|
||
);
|
||
}
|
||
|
||
if (parsed.pathname !== "/api/google-health/callback") {
|
||
throw new Error(
|
||
`Google Health redirect_uri must target /api/google-health/callback, got ${parsed.pathname}`,
|
||
);
|
||
}
|
||
|
||
// When an explicit GOOGLE_HEALTH_REDIRECT_URI is set alongside
|
||
// NEXT_PUBLIC_APP_URL, require them to share an origin so the pinned value
|
||
// can't drift to an unexpected host relative to the app's own base URL.
|
||
if (explicit && appUrl) {
|
||
let appOrigin: string;
|
||
try {
|
||
appOrigin = new URL(appUrl).origin;
|
||
} catch {
|
||
throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`);
|
||
}
|
||
if (parsed.origin !== appOrigin) {
|
||
throw new Error(
|
||
`Google Health redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return parsed.toString();
|
||
}
|
||
|
||
/**
|
||
* The four core Restricted read scopes HealthLog requests for v1. Every scope
|
||
* is Restricted → the operator's OAuth client needs Google verification + an
|
||
* annual CASA assessment before it leaves "Testing" publishing mode (staying in
|
||
* Testing with ≤100 test users avoids CASA at the cost of a 7-day refresh-token
|
||
* expiry).
|
||
*/
|
||
export const GOOGLE_HEALTH_CORE_SCOPES = [
|
||
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
|
||
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
|
||
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
|
||
"https://www.googleapis.com/auth/googlehealth.profile.readonly",
|
||
] as const;
|
||
|
||
/**
|
||
* Resolve the scope list HealthLog requests: the four core Restricted bundles.
|
||
* ECG/IRN are a future enhancement — add those two Restricted read scopes only
|
||
* together with an ECG/IRN reader, never before (Google penalizes requesting a
|
||
* Restricted scope the app never consumes).
|
||
*/
|
||
export function resolveGoogleHealthScopes(): string[] {
|
||
return [...GOOGLE_HEALTH_CORE_SCOPES];
|
||
}
|
||
|
||
/** The space-separated scope string sent on the authorize request. */
|
||
export function getGoogleHealthScopeString(): string {
|
||
return resolveGoogleHealthScopes().join(" ");
|
||
}
|
||
|
||
// ─── PKCE ──────────────────────────────────────────────────────
|
||
//
|
||
// Google's authorization-code flow accepts S256 PKCE. The verifier is a
|
||
// high-entropy random string; the challenge is BASE64URL(SHA256(verifier)). The
|
||
// verifier is stashed on the OAuth-state row at connect and presented on the
|
||
// token exchange at callback — never in the cookie or the URL.
|
||
|
||
export interface GoogleHealthPkcePair {
|
||
verifier: string;
|
||
challenge: string;
|
||
}
|
||
|
||
/**
|
||
* Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars,
|
||
* comfortably inside the RFC 7636 43–128 char verifier range and well past the
|
||
* 256-bit entropy floor.
|
||
*/
|
||
export function generatePkcePair(): GoogleHealthPkcePair {
|
||
const verifier = randomBytes(64).toString("base64url");
|
||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||
return { verifier, challenge };
|
||
}
|
||
|
||
/**
|
||
* Build the Google authorization URL (a browser redirect, not a fetch). `state`
|
||
* is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the
|
||
* S256 PKCE challenge whose verifier the callback presents on token exchange.
|
||
*
|
||
* `access_type=offline` + `prompt=consent` are Google's requirement to receive
|
||
* a refresh token; `prompt=consent` forces the consent screen so a re-connect
|
||
* always returns a fresh refresh token even if the user previously granted.
|
||
*/
|
||
export function getAuthorizationUrl(
|
||
state: string,
|
||
creds: GoogleHealthCredentials,
|
||
codeChallenge: string,
|
||
): string {
|
||
const params = new URLSearchParams({
|
||
response_type: "code",
|
||
client_id: creds.clientId,
|
||
redirect_uri: getRedirectUri(),
|
||
scope: getGoogleHealthScopeString(),
|
||
access_type: "offline",
|
||
prompt: "consent",
|
||
code_challenge: codeChallenge,
|
||
code_challenge_method: "S256",
|
||
state,
|
||
});
|
||
return `${GOOGLE_OAUTH_AUTH_URL}?${params}`;
|
||
}
|
||
|
||
export interface GoogleHealthTokenResponse {
|
||
access_token: string;
|
||
/**
|
||
* Present on the initial code exchange and whenever Google issues a new
|
||
* refresh token; ABSENT on a routine refresh because Google does not rotate.
|
||
* The sync layer keeps the stored token when this is missing.
|
||
*/
|
||
refresh_token?: string;
|
||
expires_in: number;
|
||
scope?: string;
|
||
token_type?: string;
|
||
}
|
||
|
||
/** Basic-auth header carrying the confidential client credentials. */
|
||
function basicAuthHeader(creds: GoogleHealthCredentials): string {
|
||
const raw = `${creds.clientId}:${creds.clientSecret}`;
|
||
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
|
||
}
|
||
|
||
async function postToken(
|
||
params: URLSearchParams,
|
||
creds: GoogleHealthCredentials,
|
||
verb: string,
|
||
): Promise<GoogleHealthTokenResponse> {
|
||
const start = performance.now();
|
||
const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Authorization: basicAuthHeader(creds),
|
||
},
|
||
body: params.toString(),
|
||
});
|
||
|
||
const json = await res.json().catch(() => null);
|
||
const verdict = classifyGoogleHealthResponse(res.status);
|
||
const upstreamError =
|
||
typeof json?.error === "string" ? (json.error as string) : undefined;
|
||
|
||
// Google signals a revoked / expired refresh token — the user disconnected
|
||
// the app upstream, OR the 7-day "Testing"-mode refresh window lapsed — via
|
||
// `invalid_grant` on the token endpoint (sometimes a 400, sometimes a 401). A
|
||
// bare status classification buckets a 400 as `persistent` (never prompts a
|
||
// reconnect); lift an `invalid_grant` onto `reauth_required` so the connection
|
||
// surfaces the reconnect CTA. A 401 already classifies as `reauth_required`.
|
||
const classification: GoogleHealthClassification =
|
||
upstreamError === "invalid_grant"
|
||
? "reauth_required"
|
||
: verdict.classification;
|
||
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: verb,
|
||
duration_ms: Math.round(performance.now() - start),
|
||
status: res.status,
|
||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||
});
|
||
if (classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb,
|
||
classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError,
|
||
});
|
||
}
|
||
return json as GoogleHealthTokenResponse;
|
||
}
|
||
|
||
/**
|
||
* Exchange an authorization code for the initial token pair, presenting the
|
||
* PKCE verifier. `redirect_uri` must exactly match the one sent to the
|
||
* authorize endpoint.
|
||
*/
|
||
export async function exchangeCode(
|
||
code: string,
|
||
codeVerifier: string,
|
||
creds: GoogleHealthCredentials,
|
||
): Promise<GoogleHealthTokenResponse> {
|
||
return postToken(
|
||
new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
client_id: creds.clientId,
|
||
redirect_uri: getRedirectUri(),
|
||
code_verifier: codeVerifier,
|
||
}),
|
||
creds,
|
||
"exchangeCode",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Refresh an expired access token. Google does NOT rotate refresh tokens — the
|
||
* response carries a fresh `access_token` + `expires_in` but usually omits
|
||
* `refresh_token`. The caller persists the new access token + expiry and keeps
|
||
* the stored refresh token unless a new one is returned. The original scope is
|
||
* preserved by Google, so no `scope` param is re-sent. A revoked / expired
|
||
* refresh token throws a `GoogleHealthApiError` classified `reauth_required`.
|
||
*/
|
||
export async function refreshAccessToken(
|
||
refreshToken: string,
|
||
creds: GoogleHealthCredentials,
|
||
): Promise<GoogleHealthTokenResponse> {
|
||
return postToken(
|
||
new URLSearchParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: creds.clientId,
|
||
}),
|
||
creds,
|
||
"refreshAccessToken",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The Google Health profile carries the external user id used as the
|
||
* connection's `externalUserId`. A single GET, not a paginated collection.
|
||
*/
|
||
export interface GoogleHealthProfile {
|
||
/**
|
||
* External user identifier. Google returns `me`-relative profile data; the
|
||
* stable id is surfaced under `name` (a `users/{id}` resource name) or `id`
|
||
* depending on the API surface — both are captured here and resolved at the
|
||
* call site. Re-verify the exact field against a live account at build.
|
||
*/
|
||
name?: string;
|
||
id?: string;
|
||
}
|
||
|
||
export async function fetchProfile(
|
||
accessToken: string,
|
||
): Promise<GoogleHealthProfile> {
|
||
const start = performance.now();
|
||
const res = await safeFetch(`${GOOGLE_HEALTH_API_BASE}/users/me/profile`, {
|
||
method: "GET",
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
});
|
||
const json = (await res
|
||
.json()
|
||
.catch(() => null)) as GoogleHealthProfile | null;
|
||
const verdict = classifyGoogleHealthResponse(res.status);
|
||
const apiErrorDetail =
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: extractGoogleApiErrorDetail(json);
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: "fetchProfile",
|
||
duration_ms: Math.round(performance.now() - start),
|
||
status: res.status,
|
||
error:
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: apiErrorDetail
|
||
? `${verdict.reason} ${apiErrorDetail}`
|
||
: verdict.reason,
|
||
});
|
||
if (verdict.classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb: "fetchProfile",
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError: apiErrorDetail,
|
||
});
|
||
}
|
||
return (json ?? {}) as GoogleHealthProfile;
|
||
}
|
||
|
||
/**
|
||
* Resolve the stable external user id from a Google Health profile. The
|
||
* `users/{id}` resource name is the canonical anchor; fall back to a bare `id`
|
||
* or "me" so the connection always persists a non-empty `externalUserId`.
|
||
*/
|
||
export function resolveGoogleHealthUserId(
|
||
profile: GoogleHealthProfile,
|
||
): string {
|
||
if (profile.name) {
|
||
const tail = profile.name.split("/").pop();
|
||
if (tail) return tail;
|
||
}
|
||
if (profile.id) return profile.id;
|
||
return "me";
|
||
}
|
||
|
||
// ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ──────
|
||
//
|
||
// The Google Health API exposes one uniform `DataPoint` resource shape across
|
||
// every data type. Spot / daily-summary / session types are read through
|
||
// `GET /v4/users/me/dataTypes/{dataType}/dataPoints` with `nextPageToken`
|
||
// pagination; the cumulative activity types (steps / distance / active energy /
|
||
// floors) are read through `POST …/dataPoints:dailyRollUp` — their `list`
|
||
// surface returns minute-grain observation buckets (or, for floors, does not
|
||
// exist at all), so the daily totals MUST come from the rollup.
|
||
//
|
||
// Casing gotcha (three encodings per type, all pinned in
|
||
// `GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up):
|
||
// - request path: kebab-case (`body-fat`)
|
||
// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`) —
|
||
// EXCEPT daily-summary `.date` filters, where the docs contradict
|
||
// themselves and the worked example uses the camelCase payload key; the
|
||
// client sends camel first and falls back to snake on a first-page 400
|
||
// (see `GoogleHealthDateFilterStyle`).
|
||
// - response payload: camelCase — the `DataPoint` value is a union keyed by
|
||
// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with
|
||
// camelCase nested objects (`sampleTime.physicalTime`,
|
||
// `interval.startTime`, `civilStartTime.date`).
|
||
//
|
||
// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric
|
||
// extractor coerces numeric strings before the finite check.
|
||
|
||
/**
|
||
* Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to
|
||
* 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25. The launch
|
||
* metrics use the daily/spot reads, so the default page size is the larger.
|
||
*/
|
||
export const GOOGLE_HEALTH_PAGE_SIZE = 1000;
|
||
/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */
|
||
export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25;
|
||
|
||
/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */
|
||
interface GoogleHealthDataPointPage {
|
||
dataPoints?: GoogleHealthDataPoint[];
|
||
nextPageToken?: string | null;
|
||
}
|
||
|
||
interface DataPointQuery {
|
||
/** Lower-bound incremental cursor; omitted on a full backfill. */
|
||
start?: Date;
|
||
/** Page size (defaults to `GOOGLE_HEALTH_PAGE_SIZE`). */
|
||
pageSize?: number;
|
||
/** Hard ceiling on pages walked (defence against a runaway cursor). */
|
||
maxPages?: number;
|
||
/**
|
||
* The user's IANA zone — needed only by the `civilStart` filter (the
|
||
* offset-less civil bound must be the watermark's wall clock in the USER'S
|
||
* zone). Omitted → the bound forms in UTC.
|
||
*/
|
||
tz?: string;
|
||
/**
|
||
* Observes which daily-summary `.date` filter prefix style the walk settled
|
||
* on (`camel` worked-example vs `snake` fallback) — the structure probe
|
||
* surfaces it so a live account reports which grammar Google accepted.
|
||
*/
|
||
onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void;
|
||
}
|
||
|
||
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
||
|
||
/**
|
||
* Format a UTC instant as the offset-less civil wall clock an observer in `tz`
|
||
* reads at that moment (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) — the bound
|
||
* format the session `civil_start_time` filter expects. Without `tz` the bound
|
||
* forms in UTC.
|
||
*/
|
||
export function formatCivilBound(instant: Date, tz?: string): string {
|
||
if (!tz) return instant.toISOString().slice(0, 19);
|
||
const p = wallClockInTz(instant, tz);
|
||
return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`;
|
||
}
|
||
|
||
/**
|
||
* The daily-summary `.date` filter prefix style. The official docs contradict
|
||
* themselves on this one point:
|
||
* - The data-types index states "In a filter parameter … the data type name
|
||
* must be in snake case, such as `body_fat`" and its per-type "filter
|
||
* parameter" column lists `daily_heart_rate_variability` etc.
|
||
* - The `dataPoints.list` reference's ONLY worked daily-summary example is
|
||
* `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload
|
||
* union key, NOT snake_case.
|
||
* Live behaviour: the snake_case daily filter is accepted (HTTP 200) but has
|
||
* returned zero rows on accounts whose companion app visibly holds daily HRV /
|
||
* RHR — consistent with a vacuously-unmatched predicate. The client therefore
|
||
* sends the worked-example camelCase form first and falls back to snake_case
|
||
* once if the very first page is rejected with a 400 (see `fetchDataPoints`).
|
||
*/
|
||
export type GoogleHealthDateFilterStyle = "camel" | "snake";
|
||
|
||
/**
|
||
* Build the incremental `filter` field + bound for one list-read data type.
|
||
* Centralised so the predicate and the read-time anchor resolution can never
|
||
* drift. The legal filter fields are per-shape (anything else 400s):
|
||
* - `sample` → `{filter}.sample_time.physical_time` (RFC-3339).
|
||
* - `date` → `{key|filter}.date` (`YYYY-MM-DD`) — prefix style per
|
||
* `GoogleHealthDateFilterStyle` (docs conflict; camel is the worked
|
||
* example, snake the fallback).
|
||
* - `sessionEnd` → sleep only: `{filter}.interval.end_time` (RFC-3339) — the
|
||
* ONLY filterable time field on sleep; watermark semantics improve too (a
|
||
* night is fetched when it ENDS after the cursor).
|
||
* - `civilStart` → exercise: `{filter}.interval.civil_start_time` with an
|
||
* offset-less civil bound in the user's zone.
|
||
* `rollup` types never build a list filter — they read via `:dailyRollUp`.
|
||
*/
|
||
export function incrementalFilter(
|
||
dataType: GoogleHealthDataType,
|
||
start: Date,
|
||
tz?: string,
|
||
dateStyle: GoogleHealthDateFilterStyle = "camel",
|
||
): { field: string; bound: string } {
|
||
switch (dataType.timeField) {
|
||
case "sample":
|
||
return {
|
||
field: `${dataType.filter}.sample_time.physical_time`,
|
||
bound: start.toISOString(),
|
||
};
|
||
case "sessionEnd":
|
||
return {
|
||
field: `${dataType.filter}.interval.end_time`,
|
||
bound: start.toISOString(),
|
||
};
|
||
case "civilStart":
|
||
return {
|
||
field: `${dataType.filter}.interval.civil_start_time`,
|
||
bound: formatCivilBound(start, tz),
|
||
};
|
||
case "date":
|
||
return {
|
||
field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`,
|
||
bound: start.toISOString().slice(0, 10),
|
||
};
|
||
case "rollup":
|
||
throw new Error(
|
||
`Google Health data type ${dataType.path} reads via :dailyRollUp, not dataPoints.list`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Redact + bound an upstream Google API error message before it reaches an
|
||
* error object / wide event: strip bearer tokens, drop query strings from any
|
||
* embedded URL (they can carry filters echoing user data or tokens), cap at
|
||
* 200 chars.
|
||
*/
|
||
function redactApiErrorMessage(msg: string): string {
|
||
return msg
|
||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||
.replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]")
|
||
.slice(0, 200);
|
||
}
|
||
|
||
/**
|
||
* Collect the `google.rpc.BadRequest` field violations out of an AIP-193
|
||
* `error.details[]` array as `field: description` fragments. Only the field
|
||
* PATH and the description sentence survive — both run through the message
|
||
* redactor and a per-fragment cap, and at most three violations are kept, so
|
||
* no request value can ride an error string into the logs.
|
||
*/
|
||
function extractFieldViolations(details: unknown): string[] {
|
||
if (!Array.isArray(details)) return [];
|
||
const out: string[] = [];
|
||
for (const d of details) {
|
||
if (!d || typeof d !== "object") continue;
|
||
const fv = (d as { fieldViolations?: unknown }).fieldViolations;
|
||
if (!Array.isArray(fv)) continue;
|
||
for (const v of fv) {
|
||
if (out.length >= 3) return out;
|
||
if (!v || typeof v !== "object") continue;
|
||
const o = v as { field?: unknown; description?: unknown };
|
||
const field =
|
||
typeof o.field === "string"
|
||
? redactApiErrorMessage(o.field).slice(0, 80)
|
||
: undefined;
|
||
const description =
|
||
typeof o.description === "string"
|
||
? redactApiErrorMessage(o.description).slice(0, 120)
|
||
: undefined;
|
||
if (!field && !description) continue;
|
||
out.push([field, description].filter(Boolean).join(": "));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`)
|
||
* from a non-2xx Google Health body into a short redacted
|
||
* `STATUS: message [field: description]` detail string, or undefined when the
|
||
* body carries no such envelope. This is what makes a field-grammar 400
|
||
* (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the
|
||
* `details[]` BadRequest field violations name the exact offending request
|
||
* field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}`
|
||
* shape instead — handled in `postToken`.)
|
||
*/
|
||
export function extractGoogleApiErrorDetail(json: unknown): string | undefined {
|
||
if (!json || typeof json !== "object") return undefined;
|
||
const e = (json as { error?: unknown }).error;
|
||
if (!e || typeof e !== "object") return undefined;
|
||
const o = e as { status?: unknown; message?: unknown; details?: unknown };
|
||
const status = typeof o.status === "string" ? o.status : undefined;
|
||
const message =
|
||
typeof o.message === "string"
|
||
? redactApiErrorMessage(o.message)
|
||
: undefined;
|
||
const violations = extractFieldViolations(o.details);
|
||
if (!status && !message && violations.length === 0) return undefined;
|
||
const head = [status, message].filter(Boolean).join(": ");
|
||
const tail = violations.length > 0 ? `[${violations.join("; ")}]` : "";
|
||
return [head, tail].filter(Boolean).join(" ").slice(0, 500);
|
||
}
|
||
|
||
/**
|
||
* Walk every `DataPoint` for one data type since the incremental cursor via
|
||
* `dataPoints.list` with `nextPageToken` pagination. The data-type id is
|
||
* kebab-cased in the path; the `filter` predicate is built per-shape against
|
||
* the type's time anchor. `rollup` types refuse — they read via
|
||
* `fetchDailyRollUp`.
|
||
*
|
||
* DAILY-SUMMARY FILTER FALLBACK: the docs contradict themselves on the `.date`
|
||
* filter's type-name prefix (the index's "filter parameter" column says
|
||
* snake_case; the list reference's only worked example says camelCase — see
|
||
* `GoogleHealthDateFilterStyle`). The walk sends the camelCase worked-example
|
||
* form first; if the VERY FIRST request of the walk is rejected with a 400,
|
||
* the whole walk retries once with the snake_case form. The style that
|
||
* succeeded is annotated as `googleHealth.dateFilter.style` so live behaviour
|
||
* reports which grammar Google actually accepts. Any other failure, or a 400
|
||
* past the first request, propagates.
|
||
*/
|
||
export async function fetchDataPoints(
|
||
dataType: GoogleHealthDataType,
|
||
accessToken: string,
|
||
verb: string,
|
||
query: DataPointQuery = {},
|
||
): Promise<GoogleHealthDataPoint[]> {
|
||
const maxPages = query.maxPages ?? 1000;
|
||
const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE;
|
||
|
||
let requestCount = 0;
|
||
|
||
const walk = async (
|
||
dateStyle: GoogleHealthDateFilterStyle,
|
||
): Promise<GoogleHealthDataPoint[]> => {
|
||
const points: GoogleHealthDataPoint[] = [];
|
||
let pageToken: string | null | undefined;
|
||
let pageCount = 0;
|
||
|
||
do {
|
||
const params = new URLSearchParams({ pageSize: String(pageSize) });
|
||
if (query.start) {
|
||
const { field, bound } = incrementalFilter(
|
||
dataType,
|
||
query.start,
|
||
query.tz,
|
||
dateStyle,
|
||
);
|
||
params.set("filter", `${field} >= "${bound}"`);
|
||
}
|
||
if (pageToken) params.set("pageToken", pageToken);
|
||
|
||
requestCount += 1;
|
||
const pageStart = performance.now();
|
||
const res = await safeFetch(
|
||
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`,
|
||
{
|
||
method: "GET",
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
},
|
||
);
|
||
|
||
const json = (await res
|
||
.json()
|
||
.catch(() => null)) as GoogleHealthDataPointPage | null;
|
||
const verdict = classifyGoogleHealthResponse(res.status);
|
||
const apiErrorDetail =
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: extractGoogleApiErrorDetail(json);
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: `${verb}(page=${pageCount})`,
|
||
duration_ms: Math.round(performance.now() - pageStart),
|
||
status: res.status,
|
||
error:
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: apiErrorDetail
|
||
? `${verdict.reason} ${apiErrorDetail}`
|
||
: verdict.reason,
|
||
});
|
||
if (verdict.classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb,
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError: apiErrorDetail,
|
||
});
|
||
}
|
||
|
||
for (const p of json?.dataPoints ?? []) points.push(p);
|
||
pageToken = json?.nextPageToken ?? null;
|
||
pageCount += 1;
|
||
} while (pageToken && pageCount < maxPages);
|
||
|
||
return points;
|
||
};
|
||
|
||
// The fallback only exists for filtered daily-summary reads — every other
|
||
// shape has one documented, doc-consistent filter field.
|
||
const canFallBack =
|
||
dataType.timeField === "date" && query.start !== undefined;
|
||
|
||
let style: GoogleHealthDateFilterStyle = "camel";
|
||
let points: GoogleHealthDataPoint[];
|
||
try {
|
||
points = await walk("camel");
|
||
} catch (err) {
|
||
const firstRequestRejected =
|
||
canFallBack &&
|
||
requestCount === 1 &&
|
||
err instanceof GoogleHealthApiError &&
|
||
err.httpStatus === 400;
|
||
if (!firstRequestRejected) throw err;
|
||
style = "snake";
|
||
points = await walk("snake");
|
||
}
|
||
if (canFallBack) {
|
||
annotate({ meta: { "googleHealth.dateFilter.style": style } });
|
||
query.onDateFilterStyle?.(style);
|
||
}
|
||
return points;
|
||
}
|
||
|
||
// ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ─────────────────
|
||
|
||
/**
|
||
* Max civil days one dailyRollUp request range may span. Per the v4 reference
|
||
* (`users.dataTypes.dataPoints/dailyRollUp`, `range`): "The maximum range for
|
||
* `calories-in-heart-rate-zone`, `heart-rate`, `active-minutes` and
|
||
* `total-calories` is 14 days. The maximum range for all other data types is
|
||
* 90 days." The four types read here (steps / distance / active-energy-burned /
|
||
* floors) all sit in the 90-day class; the 14-day cap would only bind if
|
||
* `heart-rate` or `total-calories` ever moved onto the rollup path.
|
||
*/
|
||
export const GOOGLE_HEALTH_ROLLUP_RANGE_DAYS = 90;
|
||
|
||
/**
|
||
* Conservative chunk span for the one-shot fallback walk: the tightest range
|
||
* cap the dailyRollUp docs state for ANY data type (the 14-day class above).
|
||
* Used only after the standard 90-day first request is rejected with a 400 —
|
||
* see `fetchDailyRollUp`.
|
||
*/
|
||
export const GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS = 14;
|
||
|
||
/**
|
||
* Full-sync horizon for the rollup types, in civil days. The rollup read needs
|
||
* an explicit range (unlike the unbounded list walk), so the backfill is
|
||
* pinned: 5 years ≈ 21 chunks per type — bounded, and deeper than any
|
||
* wearable-history horizon the dashboard reads.
|
||
*/
|
||
export const GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS = 5 * 365;
|
||
|
||
/** A civil calendar date (1-based month), the dailyRollUp range unit. */
|
||
export interface GoogleHealthCivilDate {
|
||
year: number;
|
||
month: number;
|
||
day: number;
|
||
}
|
||
|
||
/** The civil date an observer in `tz` reads at `instant` (UTC without `tz`). */
|
||
export function civilDateInTz(
|
||
instant: Date,
|
||
tz?: string,
|
||
): GoogleHealthCivilDate {
|
||
if (!tz) {
|
||
return {
|
||
year: instant.getUTCFullYear(),
|
||
month: instant.getUTCMonth() + 1,
|
||
day: instant.getUTCDate(),
|
||
};
|
||
}
|
||
const p = wallClockInTz(instant, tz);
|
||
return { year: p.year, month: p.month, day: p.day };
|
||
}
|
||
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
|
||
function civilToUtcMs(d: GoogleHealthCivilDate): number {
|
||
return Date.UTC(d.year, d.month - 1, d.day);
|
||
}
|
||
|
||
function utcMsToCivil(ms: number): GoogleHealthCivilDate {
|
||
const d = new Date(ms);
|
||
return {
|
||
year: d.getUTCFullYear(),
|
||
month: d.getUTCMonth() + 1,
|
||
day: d.getUTCDate(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Split a closed-open civil range into ≤`maxDays` closed-open chunks — the
|
||
* dailyRollUp request range caps at 90 days, so a multi-year backfill walks the
|
||
* range in slices. Returns an empty list when `start >= end`.
|
||
*/
|
||
export function chunkCivilRange(
|
||
start: GoogleHealthCivilDate,
|
||
endExclusive: GoogleHealthCivilDate,
|
||
maxDays: number = GOOGLE_HEALTH_ROLLUP_RANGE_DAYS,
|
||
): Array<{ start: GoogleHealthCivilDate; end: GoogleHealthCivilDate }> {
|
||
const s = civilToUtcMs(start);
|
||
const e = civilToUtcMs(endExclusive);
|
||
const out: Array<{
|
||
start: GoogleHealthCivilDate;
|
||
end: GoogleHealthCivilDate;
|
||
}> = [];
|
||
for (let cur = s; cur < e; cur += maxDays * DAY_MS) {
|
||
const chunkEnd = Math.min(cur + maxDays * DAY_MS, e);
|
||
out.push({ start: utcMsToCivil(cur), end: utcMsToCivil(chunkEnd) });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** `dataPoints:dailyRollUp` envelope. */
|
||
interface GoogleHealthRollupPage {
|
||
rollupDataPoints?: GoogleHealthRollupPoint[];
|
||
nextPageToken?: string | null;
|
||
}
|
||
|
||
interface RollupQuery {
|
||
/** Lower-bound incremental cursor; a full backfill walks the pinned horizon. */
|
||
start?: Date;
|
||
/** The user's IANA zone — the civil range is user-local. */
|
||
tz?: string;
|
||
/**
|
||
* Observes which request shape the walk settled on (`days90` standard vs
|
||
* `days14` fallback) — the structure probe surfaces it so a live account
|
||
* reports which shape Google actually accepted.
|
||
*/
|
||
onShape?: (shape: GoogleHealthRollupShape) => void;
|
||
/**
|
||
* Observes the raw top-level KEY NAMES of the first response page (never
|
||
* values). A rollup read that parses zero points is ambiguous — the service
|
||
* either returned nothing (account-side materialisation) or returned points
|
||
* under an envelope key this reader does not know (a per-cohort naming
|
||
* drift, the class of mismatch this integration has hit three times). The
|
||
* structure probe surfaces these keys so a live account settles it in one
|
||
* report: `["rollupDataPoints"]` = genuinely empty, anything else = drift.
|
||
*/
|
||
onEnvelopeKeys?: (keys: string[]) => void;
|
||
}
|
||
|
||
/** The dailyRollUp request shape a walk settled on. */
|
||
export type GoogleHealthRollupShape = "days90" | "days14";
|
||
|
||
/**
|
||
* Build the documented dailyRollUp request body for one closed-open civil-day
|
||
* chunk (`end` exclusive). Doc-example parity (developers.google.com/health/
|
||
* endpoints, dailyRollUp sample): both range bounds carry explicit
|
||
* `{date, time}` CivilDateTime objects, and the `end` bound is the LAST civil
|
||
* day INSIDE the chunk at 23:59:59 — NOT the next day's midnight. The range
|
||
* validator counts the civil days the range touches against the documented
|
||
* cap, so an exclusive next-day-midnight end makes a maximal chunk read one
|
||
* day too wide. `windowSizeDays: 1` is the documented daily-total window;
|
||
* `pageSize` is omitted (the documented default of 1440 already covers the
|
||
* ≤90 daily windows a chunk can produce); `pageToken` rides along on
|
||
* follow-up pages ("All other request fields need to be the same as in the
|
||
* initial request when the page token is specified").
|
||
*/
|
||
export function buildDailyRollUpBody(
|
||
chunk: { start: GoogleHealthCivilDate; end: GoogleHealthCivilDate },
|
||
pageToken?: string,
|
||
): Record<string, unknown> {
|
||
const lastDay = utcMsToCivil(civilToUtcMs(chunk.end) - DAY_MS);
|
||
const body: Record<string, unknown> = {
|
||
range: {
|
||
start: {
|
||
date: chunk.start,
|
||
time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 },
|
||
},
|
||
end: {
|
||
date: lastDay,
|
||
time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 },
|
||
},
|
||
},
|
||
windowSizeDays: 1,
|
||
};
|
||
if (pageToken) body.pageToken = pageToken;
|
||
return body;
|
||
}
|
||
|
||
/**
|
||
* Read one cumulative data type's daily totals via `POST …/dataPoints:dailyRollUp`
|
||
* with `windowSizeDays: 1`. The civil range is user-local, chunked at ≤90 days
|
||
* per request (the documented cap for these types); without an incremental
|
||
* `start` the walk covers the pinned backfill horizon. A `nextPageToken` is
|
||
* honoured defensively (the dailyRollUp response is documented without one,
|
||
* but the request accepts page tokens and the sibling `:rollUp` documents the
|
||
* response field).
|
||
*
|
||
* FALLBACK: if the very first request of a walk is rejected with a 400
|
||
* (INVALID_ARGUMENT — a range/shape constraint, since the body already
|
||
* mirrors the documented example), the whole range is re-walked once with the
|
||
* most conservative documented span (14 days, the tightest cap the docs state
|
||
* for any type). The shape that succeeded is annotated as
|
||
* `googleHealth.rollup.shape` so live behaviour reports which constraint
|
||
* actually binds. Any other failure, or a 400 past the first request,
|
||
* propagates.
|
||
*/
|
||
export async function fetchDailyRollUp(
|
||
dataType: GoogleHealthDataType,
|
||
accessToken: string,
|
||
verb: string,
|
||
query: RollupQuery = {},
|
||
): Promise<GoogleHealthRollupPoint[]> {
|
||
const now = new Date();
|
||
const from =
|
||
query.start ??
|
||
new Date(now.getTime() - GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS * DAY_MS);
|
||
// End exclusive at tomorrow (user-local) so today's running total is covered.
|
||
const startCivil = civilDateInTz(from, query.tz);
|
||
const endCivil = civilDateInTz(new Date(now.getTime() + DAY_MS), query.tz);
|
||
|
||
let requestCount = 0;
|
||
|
||
const walk = async (maxDays: number): Promise<GoogleHealthRollupPoint[]> => {
|
||
const points: GoogleHealthRollupPoint[] = [];
|
||
let chunkIndex = 0;
|
||
for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) {
|
||
let pageToken: string | null | undefined;
|
||
let pageCount = 0;
|
||
do {
|
||
const body = buildDailyRollUpBody(chunk, pageToken ?? undefined);
|
||
|
||
requestCount += 1;
|
||
const reqStart = performance.now();
|
||
const res = await safeFetch(
|
||
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(body),
|
||
},
|
||
);
|
||
const json = (await res
|
||
.json()
|
||
.catch(() => null)) as GoogleHealthRollupPage | null;
|
||
const verdict = classifyGoogleHealthResponse(res.status);
|
||
const apiErrorDetail =
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: extractGoogleApiErrorDetail(json);
|
||
getEvent()?.addExternalCall({
|
||
service: "google-health",
|
||
method: `${verb}(chunk=${chunkIndex},page=${pageCount})`,
|
||
duration_ms: Math.round(performance.now() - reqStart),
|
||
status: res.status,
|
||
error:
|
||
verdict.classification === "success"
|
||
? undefined
|
||
: apiErrorDetail
|
||
? `${verdict.reason} ${apiErrorDetail}`
|
||
: verdict.reason,
|
||
});
|
||
if (verdict.classification !== "success") {
|
||
throw new GoogleHealthApiError({
|
||
verb,
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError: apiErrorDetail,
|
||
});
|
||
}
|
||
|
||
// Surface the raw envelope key names of the very first page (names
|
||
// only, never values) — see `RollupQuery.onEnvelopeKeys`.
|
||
if (
|
||
chunkIndex === 0 &&
|
||
pageCount === 0 &&
|
||
json &&
|
||
typeof json === "object"
|
||
) {
|
||
query.onEnvelopeKeys?.(Object.keys(json));
|
||
}
|
||
for (const p of json?.rollupDataPoints ?? []) points.push(p);
|
||
pageToken = json?.nextPageToken ?? null;
|
||
pageCount += 1;
|
||
} while (pageToken && pageCount < 100);
|
||
chunkIndex += 1;
|
||
}
|
||
return points;
|
||
};
|
||
|
||
let shape: GoogleHealthRollupShape = "days90";
|
||
let points: GoogleHealthRollupPoint[];
|
||
try {
|
||
points = await walk(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS);
|
||
} catch (err) {
|
||
const firstRequestRejected =
|
||
requestCount === 1 &&
|
||
err instanceof GoogleHealthApiError &&
|
||
err.httpStatus === 400;
|
||
if (!firstRequestRejected) throw err;
|
||
shape = "days14";
|
||
points = await walk(GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS);
|
||
}
|
||
annotate({ meta: { "googleHealth.rollup.shape": shape } });
|
||
query.onShape?.(shape);
|
||
return points;
|
||
}
|