mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
9e6865841d
commit
686bc1bf30
16 files changed
+2050
-7
No files matched your search
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user