mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
f3008c9ee0
commit
ba6d06be3b
3 files changed
+279
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user