mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
117 lines
3.6 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|