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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 22:17:55 +02:00
1 parent 9e6865841d
commit 686bc1bf30
16 files changed
+2050 -7

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* Disconnect the Fitbit / Google Health integration for the current user
* (v1.12.0).
*
* Clears the stored OAuth tokens by deleting the `FitbitConnection` row and
* parks the integration status at `disconnected`. There is no per-user
* subscription to tear down (Pub/Sub is deferred — poll-only at launch), so no
* upstream unsubscribe call is needed. The BYO-key credentials on `User` are
* left intact so a reconnect doesn't force the user to re-paste them; use the
* credentials DELETE endpoint to remove those too.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.disconnect" } });
const connection = await prisma.fitbitConnection.findUnique({
where: { userId: user.id },
});
if (!connection) {
return apiError("No Fitbit connection", 404);
}
await prisma.fitbitConnection.delete({
where: { userId: user.id },
});
await auditLog("fitbit.disconnect", {
userId: user.id,
});
await markDisconnected(user.id, "fitbit");
return apiSuccess({ disconnected: true });
});
+57
View File
@@ -0,0 +1,57 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
/**
* Get Fitbit / Google Health connection status for the current user (v1.12.0).
*
* Mirrors the WHOOP status route: `configured` checks the per-user BYO-key
* credentials, the connection row reports last-sync + token expiry + backfill
* progress. `tokenExpired` is reported straight off the row; the sync path
* refreshes lazily via `getValidToken`.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
},
});
const configured =
!!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted;
const connection = await prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: {
fitbitUserId: true,
lastSyncedAt: true,
tokenExpiresAt: true,
backfillCompletedAt: true,
createdAt: true,
scope: true,
},
});
if (!connection) {
return apiSuccess({ connected: false, configured });
}
const tokenExpired = connection.tokenExpiresAt.getTime() <= Date.now();
return apiSuccess({
connected: true,
configured,
lastSyncedAt: connection.lastSyncedAt,
connectedAt: connection.createdAt,
tokenExpired,
tokenExpiresAt: connection.tokenExpiresAt,
backfillCompleted: !!connection.backfillCompletedAt,
scope: connection.scope,
});
});
+27
View File
@@ -0,0 +1,27 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { NextRequest } from "next/server";
/**
* Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0).
*
* Mirrors the WHOOP manual-sync route: incremental by default, full history when
* `{ fullSync: true }` is posted.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.sync" } });
let fullSync = false;
try {
const body = await request.json();
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
}
const imported = await syncUserFitbit(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
});