mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Integration syncs (Fitbit, Oura, Polar, WHOOP, Strava, Nightscout) now mark the per-user analytics cells stale and sweep the workouts cache at their sync tails — imported rows used to stay invisible to the cached dashboard, analytics and workout readers until the TTL lapsed. The WHOOP per-resource webhook/cron driver and the Polar workouts leg carry the same call, matching the posture Google Health and the workout batch route already had. Illness, cycle and encounter write routes hard-evict the analytics bucket through the new invalidateUserHealthContext helper: Rest Mode, the cycle context and the Today-rail appointment feed all read from cached cells under that bucket, and an interactive write must be true on the very next read.
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
/**
|
|
* WHOOP sync orchestrator.
|
|
*
|
|
* Shared token, cursor, upsert, status, and failure helpers live in
|
|
* `sync-core.ts`. Resource leaves depend only on that core; this parent owns
|
|
* the static leaf graph and the full per-user sequencing policy.
|
|
*/
|
|
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import {
|
|
isReauthRequired,
|
|
isSyncFailureRecorded,
|
|
recordSyncSuccess,
|
|
} from "@/lib/integrations/status";
|
|
import { syncUserRecovery } from "./sync-recovery";
|
|
import { syncUserSleep } from "./sync-sleep";
|
|
import { syncUserCycle } from "./sync-cycle";
|
|
import { syncUserWorkout } from "./sync-workout";
|
|
import { syncUserBody } from "./sync-body";
|
|
import {
|
|
hardFailStorage,
|
|
recordWhoopSyncFailure,
|
|
runWithWhoopSoftSkipTracking,
|
|
type HardFailTracker,
|
|
} from "./sync-core";
|
|
|
|
/**
|
|
* Full per-user sync across every WHOOP resource. Webhook-driven syncs enqueue
|
|
* a single per-resource job; this drives the hourly poll catch-all and the
|
|
* manual `/api/whoop/sync` trigger.
|
|
*
|
|
* Parks immediately when the connection is at `error_reauth` (the user must
|
|
* reconnect first) — returns `{ imported: 0, failed: true }`, matching the
|
|
* landed Fitbit verdict contract so the backfill caller can gate its marker.
|
|
*/
|
|
export async function syncUserWhoop(
|
|
userId: string,
|
|
opts: { fullSync?: boolean } = {},
|
|
): Promise<{ imported: number; failed: boolean }> {
|
|
if (await isReauthRequired(userId, "whoop")) {
|
|
getEvent()?.addWarning(
|
|
`whoop sync skipped for ${userId}: parked at error_reauth`,
|
|
);
|
|
return { imported: 0, failed: true };
|
|
}
|
|
|
|
const resources = [
|
|
syncUserRecovery,
|
|
syncUserSleep,
|
|
syncUserCycle,
|
|
syncUserWorkout,
|
|
syncUserBody,
|
|
];
|
|
|
|
let anyFailed = false;
|
|
// Nest the hard-fail ledger inside the soft-skip tracker (manual nest, not a
|
|
// combined wrapper) so a dead token noted by `getValidToken` — or a marked
|
|
// fetch hard-fail — fails the verdict just like a thrown resource error.
|
|
const hardFailTracker: HardFailTracker = { failures: [] };
|
|
const { result: total, softSkipCount } = await runWithWhoopSoftSkipTracking(
|
|
() =>
|
|
hardFailStorage.run(hardFailTracker, async () => {
|
|
let imported = 0;
|
|
for (const fn of resources) {
|
|
try {
|
|
imported += await fn(userId, opts);
|
|
} catch (err) {
|
|
anyFailed = true;
|
|
// WH-4: a write-path throw (`MeasurementReconciliationError` out of
|
|
// `upsertWhoopMeasurements`) reaches no ledger otherwise — a
|
|
// stale-green pill during a persistent write failure. Fetch
|
|
// hard-fails already recorded + marked in `handleCollectionFetchError`,
|
|
// so record only the UNMARKED escapes here (no double-record).
|
|
if (!isSyncFailureRecorded(err)) {
|
|
await recordWhoopSyncFailure(userId, err);
|
|
}
|
|
getEvent()?.addWarning(
|
|
`whoop ${fn.name} failed for ${userId}: ${err}`,
|
|
);
|
|
}
|
|
}
|
|
return imported;
|
|
}),
|
|
);
|
|
|
|
// A dead token / vanished connection notes the hard-fail ledger without
|
|
// throwing (each leaf short-circuits `if (!tokenInfo) return 0`); fold that
|
|
// into the verdict so an all-zeros dead-token cycle does not stamp success.
|
|
anyFailed = anyFailed || hardFailTracker.failures.length > 0;
|
|
|
|
// A genuine grant-revoke 403s EVERY collection: each resource soft-skips
|
|
// (returns 0, records no failure), so `anyFailed` stays false and `total` is
|
|
// 0 — yet the connection is dead until the token-refresh path next catches
|
|
// the 401 (up to ~1 h later). Don't stamp success when the whole cycle was
|
|
// soft-skipped and nothing imported; leave the status as-is so the
|
|
// "looks-healthy" window closes. A partial cycle (some rows imported, or at
|
|
// least one resource that did not soft-skip) stamps success as normal.
|
|
const allSoftSkipped = softSkipCount >= resources.length && total === 0;
|
|
|
|
if (!anyFailed && !allSoftSkipped) {
|
|
await recordSyncSuccess(userId, "whoop");
|
|
}
|
|
|
|
// Background-sync posture: mark the analytics cells stale (never hard-evict
|
|
// from a poll) and sweep the workouts cache so the imported rows reach the
|
|
// cached readers. Fires on a partial failure too — rows that DID land must
|
|
// not stay invisible for the rest of the TTL window. The per-resource
|
|
// webhook/cron jobs run their own call in `runWhoopResourceSync`.
|
|
if (total > 0) {
|
|
invalidateUserMeasurements(userId);
|
|
}
|
|
|
|
return { imported: total, failed: anyFailed || allSoftSkipped };
|
|
}
|