mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
422a6a7df0
commit
0d3067837e
6 files changed
+241
-14
No files matched your search
@@ -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) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user