From ba6d06be3b4395c4fa2b94dd5affd373bcdf57bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:01:13 +0200 Subject: [PATCH] feat(01-21): expose Google sync terminal status - return redacted per-resource outcomes from manual sync - poll owner-scoped progress and invalidate committed workout views --- src/app/api/google-health/sync/route.ts | 66 +++++++++- .../api/google-health/sync/status/route.ts | 98 ++++++++++++++ .../integrations/google-health-card.tsx | 122 +++++++++++++++++- 3 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 src/app/api/google-health/sync/status/route.ts diff --git a/src/app/api/google-health/sync/route.ts b/src/app/api/google-health/sync/route.ts index 2cb7b99b3..ee9a068fc 100644 --- a/src/app/api/google-health/sync/route.ts +++ b/src/app/api/google-health/sync/route.ts @@ -3,9 +3,64 @@ 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 type { + GoogleHealthReasonCode, + GoogleHealthResourceOutcome, + GoogleHealthResourceStatus, +} from "@/lib/google-health/sync-progress"; import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { NextRequest } from "next/server"; +const RESOURCE_STATUSES = new Set([ + "pending", + "complete", + "partial", + "empty", + "truncated", + "failed", +]); +const REASON_CODES = new Set([ + "collection_failed", + "token_failed", + "upsert_failed", + "rollup_failed", + "existing_page_limit", +]); + +function boundedInteger(value: unknown, maximum = 2_147_483_647): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(maximum, Math.max(0, Math.trunc(value))) + : 0; +} + +function publicResourceOutcome(value: unknown): GoogleHealthResourceOutcome { + const item = + value && typeof value === "object" + ? (value as Record) + : {}; + return { + resource: + typeof item.resource === "string" + ? item.resource + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .slice(0, 48) + : "unknown", + pages: boundedInteger(item.pages), + fetched: boundedInteger(item.fetched), + mapped: boundedInteger(item.mapped), + written: boundedInteger(item.written), + status: RESOURCE_STATUSES.has(item.status as GoogleHealthResourceStatus) + ? (item.status as GoogleHealthResourceStatus) + : "failed", + durationMs: boundedInteger(item.durationMs, 86_400_000), + truncated: item.truncated === true, + reasonCode: REASON_CODES.has(item.reasonCode as GoogleHealthReasonCode) + ? (item.reasonCode as GoogleHealthReasonCode) + : null, + }; +} + /** * Manually trigger a Google Health sync for the current user (v1.26.0). * @@ -63,5 +118,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (result.failed && result.imported === 0) { return apiError("Google Health sync failed", 502); } - return apiSuccess({ ...resolveSyncOutcome(result), fullSync }); + const resources = Array.isArray(result.resources) + ? result.resources.slice(0, 16).map(publicResourceOutcome) + : undefined; + return apiSuccess({ + ...resolveSyncOutcome(result), + ...(result.runId ? { runId: result.runId.slice(0, 128) } : {}), + ...(result.state ? { state: result.state } : {}), + fullSync, + ...(resources ? { resources } : {}), + }); }); diff --git a/src/app/api/google-health/sync/status/route.ts b/src/app/api/google-health/sync/status/route.ts new file mode 100644 index 000000000..c7b697739 --- /dev/null +++ b/src/app/api/google-health/sync/status/route.ts @@ -0,0 +1,98 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + readGoogleHealthSyncProgress, + type GoogleHealthResourceOutcome, +} from "@/lib/google-health/sync-progress"; +import { annotate } from "@/lib/logging/context"; +import type { NextRequest } from "next/server"; + +function count(value: unknown, maximum = 2_147_483_647): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(maximum, Math.max(0, Math.trunc(value))) + : 0; +} + +function resource(value: unknown): GoogleHealthResourceOutcome { + const item = + value && typeof value === "object" + ? (value as Record) + : {}; + const statuses = [ + "pending", + "complete", + "partial", + "empty", + "truncated", + "failed", + ]; + const reasons = [ + "collection_failed", + "token_failed", + "upsert_failed", + "rollup_failed", + "existing_page_limit", + ]; + return { + resource: + typeof item.resource === "string" + ? item.resource + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .slice(0, 48) + : "unknown", + pages: count(item.pages), + fetched: count(item.fetched), + mapped: count(item.mapped), + written: count(item.written), + status: statuses.includes(String(item.status)) + ? (item.status as GoogleHealthResourceOutcome["status"]) + : "failed", + durationMs: count(item.durationMs, 86_400_000), + truncated: item.truncated === true, + reasonCode: reasons.includes(String(item.reasonCode)) + ? (item.reasonCode as GoogleHealthResourceOutcome["reasonCode"]) + : null, + }; +} + +function publicProgress(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + const states = [ + "in_progress", + "complete", + "partial", + "zero", + "truncated", + "failed", + "interrupted", + ]; + return { + runId: typeof item.runId === "string" ? item.runId.slice(0, 128) : "", + state: states.includes(String(item.state)) ? item.state : "failed", + startedAt: typeof item.startedAt === "string" ? item.startedAt : "", + updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : "", + ...(typeof item.terminalAt === "string" + ? { terminalAt: item.terminalAt } + : {}), + imported: count(item.imported), + failed: item.failed === true, + resources: Array.isArray(item.resources) + ? item.resources.slice(0, 16).map(resource) + : [], + }; +} + +/** + * Return only the authenticated user's bounded current/most-recent run. + * Query parameters are intentionally ignored so this endpoint cannot enumerate + * another connection's progress. + */ +export const GET = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.sync.status" } }); + return apiSuccess( + publicProgress(await readGoogleHealthSyncProgress(user.id)), + ); +}); diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 9043d72a6..7f3169b60 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -73,6 +73,18 @@ import { } from "./sync-outcome"; import { IntegrationCardDescription } from "./setup-guide-link"; +interface GoogleHealthTerminalResource { + resource?: unknown; + written?: unknown; +} + +interface GoogleHealthTerminalProgress { + state: + "complete" | "partial" | "zero" | "truncated" | "failed" | "interrupted"; + imported: number; + resources: GoogleHealthTerminalResource[]; +} + export function GoogleHealthCard({ viewModel, }: { @@ -93,6 +105,92 @@ export function GoogleHealthCard({ const status = viewModel; + function invalidateCommittedWorkout( + resources: GoogleHealthTerminalResource[], + ) { + const workoutCommitted = resources.some( + (resource) => + resource.resource === "workout" && + typeof resource.written === "number" && + resource.written > 0, + ); + if (!workoutCommitted) return; + queryClient.invalidateQueries({ queryKey: queryKeys.workouts() }); + // prettier-ignore + queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + } + + function readTerminalProgress( + body: unknown, + ): GoogleHealthTerminalProgress | null { + if (!body || typeof body !== "object") return null; + const data = (body as { data?: unknown }).data; + if (!data || typeof data !== "object") return null; + const record = data as Record; + if ( + record.state !== "complete" && + record.state !== "partial" && + record.state !== "zero" && + record.state !== "truncated" && + record.state !== "failed" && + record.state !== "interrupted" + ) { + return null; + } + return { + state: record.state, + imported: + typeof record.imported === "number" && Number.isFinite(record.imported) + ? Math.max(0, Math.trunc(record.imported)) + : 0, + resources: Array.isArray(record.resources) + ? record.resources.slice(0, 16) + : [], + }; + } + + async function pollSyncStatus(): Promise { + for (let attempt = 0; attempt < 4; attempt++) { + try { + const response = await apiFetchRaw("/api/google-health/sync/status"); + const progress = readTerminalProgress(await response.json()); + if (response.ok && progress) return progress; + } catch { + // The POST may have committed even when its response was lost. Keep + // this short bounded poll best-effort; the next card refresh can retry. + } + await new Promise((resolve) => setTimeout(resolve, 750)); + } + return null; + } + + function showTerminalProgress(progress: GoogleHealthTerminalProgress): void { + const failed = + progress.state === "failed" || progress.state === "interrupted"; + const outcome = + progress.state === "partial" || progress.state === "truncated" + ? "partial" + : failed + ? "failed" + : progress.imported > 0 + ? "success" + : "empty"; + const resolved = { + imported: progress.imported, + failed: failed || outcome === "partial", + outcome, + } as const; + setSyncResult({ + outcome: resolved.outcome, + message: describeSyncOutcome( + resolved, + t("settings.googleHealthSyncResult", { count: progress.imported }), + ), + }); + invalidateCommittedWorkout(progress.resources); + } + const disconnect = useMutation({ mutationFn: async () => { await apiPost("/api/google-health/disconnect"); @@ -148,17 +246,29 @@ export function GoogleHealthCard({ queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); + const terminal = readTerminalProgress(json); + if (terminal) invalidateCommittedWorkout(terminal.resources); + } else { + const terminal = await pollSyncStatus(); + if (terminal) { + showTerminalProgress(terminal); + } else { + setSyncResult({ + outcome: "failed", + message: json?.error || t("settings.googleHealthSyncFailed"), + }); + } + } + } catch { + const terminal = await pollSyncStatus(); + if (terminal) { + showTerminalProgress(terminal); } else { setSyncResult({ outcome: "failed", - message: json?.error || t("settings.googleHealthSyncFailed"), + message: t("settings.googleHealthSyncFailed"), }); } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.googleHealthSyncFailed"), - }); } finally { setSyncing(false); }