Files
HealthLog/src/lib/jobs/fitbit-backfill.ts
T
Marc-André Bombeck 686bc1bf30 feat(fitbit): sync Google Health metrics with backfill and poll jobs
Extend the Fitbit/Google Health provider with the read path: the
dataPoints.list client half, the metrics-bundle sync, the poll + backfill
jobs, and the connection-management routes. Builds on the existing OAuth +
credentials + token-refresh layer.

- client: paginated `fetchDataPoints` walker over `safeFetch` keyed on
  `nextPageToken`, encoding each data type kebab-case in the path and
  snake_case in the filter predicate; defensive per-type mappers (finite +
  positive guards) for weight, body fat, daily SpO2, daily HRV, daily resting
  heart rate, daily respiratory rate, intraday heart rate, sleep-temperature
  derivations, and a height profile-seed extractor. `FITBIT_FIELD_MAP` +
  `mapping.md` are the source of truth.
- mapping (no new MeasurementType): weight→WEIGHT, body-fat→BODY_FAT,
  SpO2→OXYGEN_SATURATION, HRV→HEART_RATE_VARIABILITY (SDNN slot, not
  HRV_RMSSD), resting-HR→RESTING_HEART_RATE, respiratory-rate→RESPIRATORY_RATE,
  heart-rate→PULSE, sleep-temp→WRIST_TEMPERATURE, height→User.heightCm when
  null. Steps/distance/energy/floors/VO2 + sleep + workouts stay for a later
  wave.
- sync: `syncUserFitbit` orchestrator with the reauth short-circuit, the
  per-data-class 403 soft-skip (the six Restricted bundles grant
  independently), the all-soft-skipped success guard, and incremental-vs-full
  windows; `upsertFitbitMeasurements` keyed (userId,type,FITBIT,externalId)
  with a 24 h overlap for after-the-fact daily re-rolls, recomputing rollups
  and invalidating status caches. Google refresh tokens do not rotate, so the
  stored refresh token is kept unless a new one is returned.
- jobs: hourly `fitbit-sync` poll (poll-only; Pub/Sub deferred), the
  self-converging boot backfill, and the daily OAuth-state sweep — every queue
  registered in both `allQueues` and a `boss.work` binding, staggered off the
  WHOOP/Withings crons.
- routes: status / sync / disconnect mirroring the WHOOP shape.

Tests cover the mappers, the casing invariant, `fetchDataPoints`, the
orchestrator soft-skip guard, the idempotent upsert, the backfill discovery,
and the queue registration; an integration test pins the upsert idempotency,
the DAY rollup fold, and the Fitbit-vs-Withings per-source no-collapse against
the Postgres testcontainer.
2026-06-04 22:17:55 +02:00

117 lines
3.6 KiB
TypeScript

/**
* v1.12.0 — pg-boss queue + boot-time self-converging backfill for newly
* connected Fitbit / Google Health accounts. Modelled on the WHOOP backfill
* (`src/lib/jobs/whoop-backfill.ts`): a discovery query enqueues one job per
* connection that has NOT yet been backfilled, and the pass is idempotent
* across reboots (the predicate `backfill_completed_at IS NULL` drops a
* connection once its backfill finishes).
*
* The queue name MUST be registered in `allQueues` in
* `src/lib/jobs/reminder-worker.ts` or pg-boss never provisions it and the boot
* enqueue silently never drains (the v1.4.37 dead-queue class).
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { syncUserFitbit } from "@/lib/fitbit/sync";
export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill";
/**
* Serial concurrency — a backfill walks years of history for one account and is
* rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding
* the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`.
*/
export const FITBIT_BACKFILL_CONCURRENCY = 1;
export interface FitbitBackfillPayload {
userId: string;
enqueuedAt: string;
}
/**
* Per-user backfill handler. Runs a full-history sync for one account and stamps
* `backfillCompletedAt` so the discovery query drops it. Idempotent: the
* per-resource upserts are key-stable, so a re-run (e.g. a reboot mid-walk)
* overwrites rather than duplicating. Mirrors `runWhoopBackfillForUser`.
*/
export async function runFitbitBackfillForUser(
userId: string,
): Promise<{ imported: number }> {
const imported = await syncUserFitbit(userId, { fullSync: true });
await prisma.fitbitConnection.update({
where: { userId },
data: { backfillCompletedAt: new Date() },
});
annotate({
action: {
name: "fitbit.backfill.complete",
details: { imported },
},
});
return { imported };
}
/**
* Boot-time discovery. Finds every Fitbit connection not yet backfilled
* (`backfill_completed_at IS NULL`) and enqueues one backfill job per account.
*
* Idempotent across reboots: once a connection's backfill completes,
* `backfillCompletedAt` is set and the predicate drops it from the discovery
* set. pg-boss `singletonKey` coalesces duplicate sends so a fast restart while
* a job is queued doesn't double up.
*
* Best-effort: errors are returned through the result value so the worker boot
* never fails because of a backfill miss.
*/
export async function enqueueBootTimeFitbitBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const connections = await prisma.fitbitConnection.findMany({
where: { backfillCompletedAt: null },
select: { userId: true },
});
if (connections.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId } of connections) {
const payload: FitbitBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(FITBIT_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `fitbit-backfill|${userId}`,
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}