Files
HealthLog/src/app/api/fitbit/sync/route.ts
T
Marc-André Bombeck b6e34d1264 Close five honesty gaps on the integration surface
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.

The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.

Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.

The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.

Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.

The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.

The contract follows the behaviour in the same commit.
2026-08-22 13:13:18 +02:00

60 lines
2.3 KiB
TypeScript

import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
/**
* Manually trigger a Fitbit sync for the current user.
*
* Mirrors the WHOOP manual-sync route: incremental by default, full history when
* `{ fullSync: true }` is posted.
*
* Rate-limited: every sibling Fitbit route (connect / test / resume) is limited;
* this one was the outlier, while `{ fullSync: true }` drives four resource syncs
* over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a
* stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s
* bucket (matching the test route) gates the route, and the expensive `fullSync`
* path carries a tighter 1/hour bucket of its own.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.sync" } });
const rl = await checkRateLimit(`fitbit-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
const body = await readSyncTriggerBody(request);
if (body.error) return body.error;
const { fullSync } = body;
// The full-history walk is the expensive path (four paginated resource
// walkers); cap it well below the incremental bucket so a re-trigger loop
// cannot churn the Google quota.
if (fullSync) {
const fullRl = await checkRateLimit(
`fitbit-sync-full:${user.id}`,
1,
60 * 60_000,
);
if (!fullRl.allowed) {
return apiError("Full sync is limited to once per hour", 429, {
errorCode: "rate_limited_self",
});
}
}
// The verdict is resolved here, not in the card: a run that imported nothing
// because every resource failed is not the same answer as a quiet hour, and
// both used to render as a green tick with a count.
const result = await syncUserFitbit(user.id, { fullSync });
return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});