From 0d3067837e9d76780d0dcc102a081eb39ca85ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 3 Jul 2026 14:41:53 +0200 Subject: [PATCH] fix(google-health): surface the connection status and add the resume and test routes Wire the settings card to the live connection so it reflects reality after a connect. - Emit a google-health entry on the consolidated integration-status envelope (configured, connected, last sync, backfill, and the needs-reconnect flag). Without it the card read an undefined status and showed a permanently disconnected connection even after a successful OAuth grant, hiding the sync, disconnect, and reconnect controls. - Add the resume and test-connection routes the card calls, mirroring the classic Fitbit ones. - Use the shared refresh icon on the reconnect action. - Drop the experimental-scope opt-in from the runbook and describe ECG and irregular-rhythm as a planned addition rather than a current toggle. --- docs/integrations/google-health.md | 11 +- .../google-health/resume/route.ts | 48 +++++++ .../integrations/google-health/test/route.ts | 132 ++++++++++++++++++ .../status/__tests__/route.test.ts | 1 + src/app/api/integrations/status/route.ts | 51 +++++++ .../integrations/google-health-card.tsx | 12 +- 6 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 src/app/api/integrations/google-health/resume/route.ts create mode 100644 src/app/api/integrations/google-health/test/route.ts diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 2d979f68b..7be58b2f8 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -82,10 +82,13 @@ 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. The optional ECG -and irregular-rhythm (Pixel Watch clinical) scopes stay off unless an operator -opts in via `GOOGLE_HEALTH_EXPERIMENTAL_SCOPES=true`; they widen the Restricted -review surface and only apply to Pixel Watch sources. +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 diff --git a/src/app/api/integrations/google-health/resume/route.ts b/src/app/api/integrations/google-health/resume/route.ts new file mode 100644 index 000000000..a22cf14fa --- /dev/null +++ b/src/app/api/integrations/google-health/resume/route.ts @@ -0,0 +1,48 @@ +/** + * Manual resume endpoint for a parked Google Health integration. + * + * Background: the `parked` state lives in + * `src/lib/integrations/status.ts`. Once a Google Health persistent-failure + * streak has been running for more than 24h, the status writer flips + * the row to `parked` and the sync entry-point short-circuits. The user + * (or an operator) calls this endpoint to clear the park and let the + * next cron tick attempt the sync again. + * + * Rate-limited (5/min per user) to match the sibling + * `/api/integrations/google-health/test` endpoint — there's no upstream + * call here, but a misbehaving client tab loop would still rack up + * audit-log rows if the limit was absent. + * + * Returns 200 with `{ resumed: true, wasParked: boolean }`. The + * `wasParked` flag lets the caller decide whether to surface a "you + * resumed it" toast or treat the action as a no-op (the row was + * already connected / disconnected). Mirrors the Fitbit resume route. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { resumeIntegrationFromPark } from "@/lib/integrations/status"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.google-health.resume" } }); + + const rl = await checkRateLimit(`google-health-resume:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many resume requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const result = await resumeIntegrationFromPark(user.id, "google-health"); + annotate({ + meta: { + google_health_resume_was_parked: result.wasParked, + }, + }); + + return apiSuccess({ resumed: true, wasParked: result.wasParked }); +}); diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts new file mode 100644 index 000000000..da8ee2b9e --- /dev/null +++ b/src/app/api/integrations/google-health/test/route.ts @@ -0,0 +1,132 @@ +import type { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { getValidToken } from "@/lib/google-health/sync"; +import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/client"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Cheap authenticated probe — the single (non-paginated) profile endpoint the +// Google Health client already calls via `fetchProfile`. +// Base from `src/lib/google-health/client.ts` (`GOOGLE_HEALTH_API_BASE`). +const GOOGLE_HEALTH_PROFILE_URL = `${GOOGLE_HEALTH_API_BASE}/users/me/profile`; +const TIMEOUT_MS = 5_000; + +type CategorisedError = { code: string; message: string }; + +function categoriseHttpStatus(status: number): CategorisedError { + if (status === 401 || status === 403) { + return { + code: "credentials_rejected", + message: "Google Health rejected the credentials", + }; + } + if (status === 429) { + return { + code: "rate_limited", + message: "Google Health rate-limited the request", + }; + } + if (status >= 500) { + return { + code: "upstream_error", + message: "Google Health returned a server error", + }; + } + return { + code: "connection_failed", + message: "Google Health connection failed", + }; +} + +export const POST = apiHandler(async (request: NextRequest) => { + void request; + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.google-health.test" } }); + + const rl = await checkRateLimit(`google-health-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const tokenInfo = await getValidToken(user.id); + if (!tokenInfo) { + return apiError("Google Health not connected", 422, { + errorCode: "not_configured", + }); + } + + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId: user.id }, + select: { lastSyncedAt: true }, + }); + + const start = performance.now(); + + try { + const res = await safeFetch( + GOOGLE_HEALTH_PROFILE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${tokenInfo.accessToken}`, + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { + google_health_test_status: res.status, + google_health_test_code: cat.code, + google_health_test_latency_ms: latencyMs, + }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ + ok: true, + lastSyncedAt: connection?.lastSyncedAt ?? null, + latencyMs, + }); + } catch (e) { + const err = e as Error; + // safeFetch wraps timeouts into SafeFetchError{kind:"timeout"}; + // legacy AbortError name is preserved for compatibility with any + // future direct-fetch path that still reaches this catch. + const isAbort = + (e instanceof SafeFetchError && e.kind === "timeout") || + err.name === "AbortError"; + const code = isAbort ? "timeout" : "connection_failed"; + annotate({ + meta: { + google_health_test_code: code, + google_health_test_error: redact(err.message).slice(0, 300), + }, + }); + return apiError( + isAbort + ? "Google Health request timed out" + : "Google Health connection failed", + 502, + { errorCode: code }, + ); + } +}); + +// Strip any `Bearer ` substring before annotating; the catch path can +// see DNS / TLS errors that include the request init via err.cause in some +// runtimes, and we never want a token in Loki. +function redact(text: string): string { + return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]"); +} diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index a95987e3a..d262cf792 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -11,6 +11,7 @@ vi.mock("@/lib/db", () => ({ withingsConnection: { findUnique: vi.fn(async () => null) }, whoopConnection: { findUnique: vi.fn(async () => null) }, fitbitConnection: { findUnique: vi.fn(async () => null) }, + googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, }, })); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 8c3b5eec9..1ed88a25a 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -40,12 +40,14 @@ export const GET = apiHandler(async () => { moodLogStatus, whoopStatus, fitbitStatus, + googleHealthStatus, polarStatus, ouraStatus, dbUser, withingsConn, whoopConn, fitbitConn, + googleHealthConn, moodLogEntryCount, // v1.17.1 — `available` reports whether usable OAuth credentials resolve // (per-user BYO first, then the shared env app), mirroring the per-card @@ -57,6 +59,7 @@ export const GET = apiHandler(async () => { getIntegrationStatus(user.id, "moodlog"), getIntegrationStatus(user.id, "whoop"), getIntegrationStatus(user.id, "fitbit"), + getIntegrationStatus(user.id, "google-health"), getIntegrationStatus(user.id, "polar"), getIntegrationStatus(user.id, "oura"), prisma.user.findUnique({ @@ -68,6 +71,8 @@ export const GET = apiHandler(async () => { whoopClientSecretEncrypted: true, fitbitClientIdEncrypted: true, fitbitClientSecretEncrypted: true, + googleHealthClientIdEncrypted: true, + googleHealthClientSecretEncrypted: true, polarAccessTokenEncrypted: true, polarClientIdEncrypted: true, polarClientSecretEncrypted: true, @@ -108,6 +113,16 @@ export const GET = apiHandler(async () => { backfillCompletedAt: true, }, }), + prisma.googleHealthConnection.findUnique({ + where: { userId: user.id }, + select: { + tokenExpiresAt: true, + lastSyncedAt: true, + createdAt: true, + backfillCompletedAt: true, + needsReauth: true, + }, + }), // v1.7.0 sync — exclude tombstoned rows from the entry count. prisma.moodEntry.count({ where: { userId: user.id, deletedAt: null }, @@ -183,6 +198,27 @@ export const GET = apiHandler(async () => { : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, } satisfies IntegrationViewModel & FitbitExtras, + { + ...googleHealthStatus, + configured: + !!dbUser?.googleHealthClientIdEncrypted && + !!dbUser?.googleHealthClientSecretEncrypted, + connected: !!googleHealthConn, + connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null, + legacyLastSyncedAt: + googleHealthConn?.lastSyncedAt?.toISOString() ?? null, + tokenExpiresAt: googleHealthConn?.tokenExpiresAt?.toISOString() ?? null, + tokenExpired: googleHealthConn + ? googleHealthConn.tokenExpiresAt.getTime() <= now + : null, + backfillCompleted: googleHealthConn + ? !!googleHealthConn.backfillCompletedAt + : null, + // v1.27.0 — `needsReauth` is the 7-day Testing-mode refresh expiry (or a + // user-revoked grant) surfaced from the connection row; the card reads it + // to raise a distinct "Reconnect" CTA separate from parked/disconnected. + needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false, + } satisfies IntegrationViewModel & GoogleHealthExtras, { ...polarStatus, // `connected` = a stored access token; `configured` mirrors it (the @@ -256,6 +292,21 @@ interface FitbitExtras { backfillCompleted: boolean | null; } +// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google +// expires the refresh token after 7 days in "Testing" publishing mode, so a +// connected user is periodically pushed back through OAuth. The card reads the +// flag to raise a distinct reconnect banner. +interface GoogleHealthExtras { + configured: boolean; + connected: boolean; + connectedAt: string | null; + legacyLastSyncedAt: string | null; + tokenExpiresAt: string | null; + tokenExpired: boolean | null; + backfillCompleted: boolean | null; + needsReauth: boolean; +} + // v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the // per-user BYO-key flags the shared OAuth card reads instead of the dedicated // /api//status round-trip the page used to fire per card. diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 6f07519f8..fda8a111f 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -17,15 +17,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - Link2, - Loader2, - RefreshCw, - RotateCw, - Save, - Unlink, - Watch, -} from "lucide-react"; +import { Link2, Loader2, RefreshCw, Save, Unlink, Watch } from "lucide-react"; import { AlertDialog, @@ -263,7 +255,7 @@ export function GoogleHealthCard({ data-testid="google-health-reconnect-button" className="min-h-11" > - + {t("settings.googleHealthReconnect")}