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
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 15:01:13 +02:00
1 parent f3008c9ee0
commit ba6d06be3b
3 files changed
+279 -7

No files matched your search

+65 -1
View File
@@ -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<GoogleHealthResourceStatus>([
"pending",
"complete",
"partial",
"empty",
"truncated",
"failed",
]);
const REASON_CODES = new Set<GoogleHealthReasonCode>([
"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<string, unknown>)
: {};
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 } : {}),
});
});
@@ -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<string, unknown>)
: {};
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<string, unknown> | null {
if (!value || typeof value !== "object") return null;
const item = value as Record<string, unknown>;
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)),
);
});
@@ -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<string, unknown>;
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<GoogleHealthTerminalProgress | null> {
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);
}