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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-03 14:41:53 +02:00
1 parent 422a6a7df0
commit 0d3067837e
6 files changed
+241 -14

No files matched your search

+7 -4
View File
@@ -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
@@ -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) },
},
}));
+51
View File
@@ -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.
@@ -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"
>
<RotateCw className="h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
{t("settings.googleHealthReconnect")}
</Button>
</div>