Files
HealthLog/src/lib/withings/sync.ts
T
Marc-André Bombeck cd8849cd51 feat(insights): drop the per-metric assessment cache on fresh measurements
Hang `invalidateStatusInsightsForTypes` off every measurement ingest
path so a new reading of a type drops the cached assessment rows that
reading dirties — the next mount or nightly warm pass then regenerates
them against the new data instead of serving the pre-measurement text
for the rest of the day.

Wired into both `/api/measurements` modes (single + multi-entry batch),
`/api/measurements/batch`, and the three Withings sync paths
(measures + activity + sleep), reusing the `collapseToTypeDayKeys`
result each already computes for its rollup recompute. The call is
fire-and-forget after the user's write commits: idempotent, never a
blocker on the request path, and a failure only warns.
2026-05-31 22:05:31 +02:00

424 lines
15 KiB
TypeScript

/**
* Withings sync service — handles fetching and storing measurements,
* token refresh, and webhook subscription management.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { encrypt, decrypt } from "@/lib/crypto";
import { getUnitForType } from "@/lib/validations/measurement";
import {
fetchMeasurements,
refreshAccessToken,
subscribeWebhook,
} from "./client";
import { getUserWithingsCredentials } from "./credentials";
import { getEvent } from "@/lib/logging/context";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
type FailureKind,
} from "@/lib/integrations/status";
import {
classifyError,
WithingsApiError,
type WithingsClassification,
} from "./response-classifier";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
/**
* Build the callback URL handed to Withings at `Notify.subscribe` time.
*
* v1.4.25 W17a moved the shared secret from `?secret=…` (query
* parameter, captured by every reverse-proxy access log) to a path
* segment (`/api/withings/webhook/<secret>`). Withings has no
* mechanism for setting custom HTTP headers on outgoing notifications
* and never signs the body, so the callback URL is the only
* authenticity surface a subscriber controls — the path-segment form
* is the largest practical shift away from the loggable query string.
*
* When `WITHINGS_WEBHOOK_SECRET` is unset (dev / new install before
* provisioning) we fall back to the bare legacy URL so subscribe
* doesn't 500 — the route handler will then reject every inbound
* delivery with 401 anyway.
*/
export function getWithingsWebhookCallbackUrl(): string {
const baseUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/withings/webhook`;
const secret = process.env.WITHINGS_WEBHOOK_SECRET;
if (!secret) return baseUrl;
// Path-segment form. Withings preserves the full callback URL on
// every notification, so once a subscription is created with this
// URL every delivery carries the secret in the path rather than the
// query string. Encode for safety even though we expect a strong
// random secret.
return `${baseUrl}/${encodeURIComponent(secret)}`;
}
/**
* Get valid access token for a user, refreshing if expired.
*/
export async function getValidToken(userId: string): Promise<{
accessToken: string;
connection: { id: string; withingsUserId: string };
} | null> {
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
});
if (!connection) return null;
const accessToken = decrypt(connection.accessToken);
const refreshToken = decrypt(connection.refreshToken);
// Check if token is expired (with 5 min buffer)
if (connection.tokenExpiresAt.getTime() - 5 * 60 * 1000 < Date.now()) {
try {
const creds = await getUserWithingsCredentials(userId);
if (!creds) {
getEvent()?.addWarning(
`No credentials found for user ${userId} during token refresh`,
);
// Without credentials we cannot refresh OR re-authenticate — the
// user has to re-enter the client_id/secret. Mark as reauth so
// scheduled syncs back off.
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: "Withings credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
return null;
}
const newTokens = await refreshAccessToken(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
await prisma.withingsConnection.update({
where: { id: connection.id },
data: {
accessToken: encrypt(newTokens.access_token),
refreshToken: encrypt(newTokens.refresh_token),
tokenExpiresAt: expiresAt,
},
});
return {
accessToken: newTokens.access_token,
connection: {
id: connection.id,
withingsUserId: connection.withingsUserId,
},
};
} catch (err) {
getEvent()?.addWarning(`Token refresh failed for user ${userId}: ${err}`);
await recordWithingsSyncFailure(userId, err);
return null;
}
}
return {
accessToken,
connection: {
id: connection.id,
withingsUserId: connection.withingsUserId,
},
};
}
/**
* Sync measurements from Withings for a given user.
* Fetches data since last sync (or last 30 days if first sync).
*
* Status-tracking contract:
* - On any failure (refresh failure, fetch failure, downstream
* measurement-upsert that fails ALL items) we call
* `recordSyncFailure` so the IntegrationStatus row + audit log
* reflect the burst, and so the persistent-failure threshold can
* trip an admin alert.
* - On full success we call `recordSyncSuccess` to clear the streak.
* - If the connection is parked at `error_reauth` we short-circuit
* immediately — the user has to reconnect first.
*/
export async function syncUserMeasurements(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
// Park: if the last burst said reauth_required, do nothing until
// the OAuth callback flips state back to "connected". Returning 0
// matches the existing contract for "no-op sync".
if (await isReauthRequired(userId, "withings")) {
getEvent()?.addWarning(
`withings sync skipped for ${userId}: parked at error_reauth`,
);
return 0;
}
const tokenInfo = await getValidToken(userId);
// Note: getValidToken already calls recordSyncFailure on the refresh
// path, so we don't double-record here.
if (!tokenInfo) return 0;
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
});
if (!connection) return 0;
const startDate = opts.fullSync
? undefined
: connection.lastSyncedAt
? new Date(connection.lastSyncedAt.getTime() - 60 * 1000) // overlap 1 min to avoid gaps
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back
let measures: Awaited<ReturnType<typeof fetchMeasurements>>;
try {
measures = await fetchMeasurements(tokenInfo.accessToken, startDate);
} catch (err) {
await recordWithingsSyncFailure(userId, err);
throw err;
}
let imported = 0;
// v1.4.39.1 — track every (type, measuredAt) we touched so the
// persistent rollup tier can be re-folded at the end of the sync.
// Pre-fix, Withings-ingested BP / weight / pulse / body-fat rows
// skipped the rollup write hook entirely: subsequent dashboard chart
// fetches with `source=rollup` saw fewer DAY buckets than the live
// measurements table, painting the "Noch nicht genug Daten" empty
// state for the 30-day range on accounts whose recent data only came
// through Withings. We collect first, fold once at the end so a 100-
// row monthly catch-up costs at most ~N (type, day) recomputes rather
// than N per-row hooks. Best-effort: a populator hiccup never fails
// the user's sync.
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const m of measures) {
const measType = m.type as MeasurementType;
try {
// v1.4.25 W17b/c — Migration 0055 added `sleepStage` to the
// composite unique with `NULLS NOT DISTINCT`, so a non-sleep
// upsert keyed on (userId, type, measuredAt, source) with
// sleepStage IS NULL is still safe. But Prisma's typed compound
// input requires a non-null `sleepStage`, so we model the
// idempotent write as a `findFirst` + `create`/`update`. The
// unique index still serializes concurrent inserts, so the worst
// case is a Prisma P2002 we catch and ignore.
const existing = await prisma.measurement.findFirst({
where: {
userId,
type: measType,
measuredAt: m.measuredAt,
source: "WITHINGS",
sleepStage: null,
},
select: { id: true },
});
if (existing) {
await prisma.measurement.update({
where: { id: existing.id },
data: { value: m.value },
});
} else {
await prisma.measurement.create({
data: {
userId,
type: measType,
value: m.value,
unit: getUnitForType(m.type),
measuredAt: m.measuredAt,
source: "WITHINGS",
},
});
}
touched.push({ type: measType, measuredAt: m.measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(`Failed to upsert measure: ${err}`);
}
}
// v1.4.39.1 — refresh the persistent rollup table for every distinct
// (type, day) the sync touched. The chart-data + analytics read paths
// consume these buckets via `source=rollup`; without this hook,
// Withings-only metrics stayed off the rollup tier and the dashboard
// chart at the 30-day range under-counted recent days.
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
// v1.8.0 — a Withings sync that lands new weight / BP / pulse /
// body-comp rows dirties the matching per-metric assessment caches.
// Drop them so the next mount / nightly warm pass regenerates
// against the new data. Fire-and-forget: never fails the sync.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`withings: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings: rollup recompute failed for ${userId}: ${err}`,
);
}
// Update last synced timestamp
await prisma.withingsConnection.update({
where: { userId },
data: { lastSyncedAt: new Date() },
});
// Record success — even when measures.length === 0, completing the
// round-trip is the signal "the connection is healthy". A user who
// doesn't weigh themselves for a week shouldn't see "error" on
// their settings page.
await recordSyncSuccess(userId, "withings");
return imported;
}
/**
* Map a Withings response classification onto the `FailureKind` carried
* into `recordSyncFailure`. Pure function — no IO, no state.
*
* `success` → not a failure; callers must not invoke this.
* `transient` → `transient` (retry next sync)
* `reauth_required` → `reauth_required` (park at `error_reauth`)
* `persistent` → `persistent` (audited; next sync still runs)
*/
/**
* v1.4.42 — shared failure-recording shape for the two Withings sync
* catch-blocks. The typed `WithingsApiError` carries the classification
* verdict directly; `classifyError`'s regex fallback handles plain
* `Error` instances (e.g. a pg-boss job retry that lost the prototype
* during the JSON round-trip).
*/
export async function recordWithingsSyncFailure(
userId: string,
err: unknown,
): Promise<void> {
const message = err instanceof Error ? err.message : String(err);
await recordSyncFailure({
userId,
integration: "withings",
kind: classificationToFailureKind(classifyError(err)),
message,
errorCode:
err instanceof WithingsApiError
? err.withingsStatus?.toString()
: extractWithingsStatus(message),
});
}
export function classificationToFailureKind(
classification: WithingsClassification,
): FailureKind {
switch (classification) {
case "reauth_required":
return "reauth_required";
case "persistent":
return "persistent";
case "transient":
return "transient";
case "success":
// Defensive: a caller asking for the FailureKind of a success
// is a contract bug. Default to `transient` so the audit log
// still surfaces the bug rather than silently ignoring it.
return "transient";
}
}
/**
* Withings status codes that indicate a permanent-revoke condition
* (the refresh_token will not work again — the user has to redo OAuth).
*
* Documented values per
* https://developer.withings.com/api-reference#tag/oauth2/operation/oauth2-getaccesstoken :
*
* 100 → "Authentication failed"
* 101 → "Invalid token"
* 102 → "User does not exist"
* 200..299 → various OAuth/grant failures (treated as reauth-required
* by Withings' own libraries)
*
* The error message format from `refreshAccessToken()` is
* "Withings refresh error: <status> - <error>".
*
* v1.4.42 W6 — superseded for new code paths by
* `classifyError`/`classifyWithingsResponse` in `response-classifier.ts`.
* Kept for the W17b/c sync-activity + sync-sleep catch-blocks that
* haven't yet been migrated; both paths now also benefit from the
* typed-error throw upstream because `classifyError` falls back to the
* same regex.
*/
export function isWithingsRefreshReauthFailure(message: string): boolean {
const status = extractWithingsStatus(message);
if (!status) return false;
const n = Number.parseInt(status, 10);
if (!Number.isFinite(n)) return false;
if (n === 100 || n === 101 || n === 102) return true;
if (n >= 200 && n <= 299) return true;
return false;
}
export function extractWithingsStatus(message: string): string | undefined {
// Captures the digit run after "Withings <verb> error:" — same shape
// exchangeCode and refreshAccessToken use.
const m = /Withings\s+\w+\s+error:\s*(\d+)/.exec(message);
return m?.[1];
}
/**
* Withings notify appli categories HealthLog cares about today. Each
* category requires its own subscribe call.
*
* - 1 — weight + body composition (meastypes 1, 5, 6, 8, 88)
* - 2 — temperature (meastypes 12, 71, 73)
* - 4 — pressure family (BP dia 9, BP sys 10, pulse 11, SpO2 54)
* - 16 — activity (steps, distance, active energy, floors climbed);
* v1.4.25 W17b webhook-primary trigger for the new
* `syncUserActivity` routine.
* - 44 — sleep v2 (per-stage segments + nightly summary); v1.4.25 W17c
* webhook-primary trigger for the new `syncUserSleep` routine.
*
* Without 2 and 4, BP and temperature readings flow only through the
* hourly poll fallback. Adding them removes up to an hour of latency
* on a freshly-taken BP reading without changing the OAuth scope. The
* activity + sleep categories require the `user.activity` scope from
* W5d; legacy connections that never reconnected sit on `user.metrics`
* only and the subscribe call returns 503/293 — `setupWebhook` logs
* the failure and keeps the remaining appli subscriptions.
*/
export const WITHINGS_NOTIFY_APPLIS = [1, 2, 4, 16, 44] as const;
/**
* Subscribe to every Withings notify category HealthLog ingests. Each
* appli is its own subscribe call upstream; a failure on one category
* is logged and we continue with the rest, because losing one webhook
* is strictly better than rolling back all three.
*/
export async function setupWebhook(userId: string): Promise<void> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return;
const callbackUrl = getWithingsWebhookCallbackUrl();
for (const appli of WITHINGS_NOTIFY_APPLIS) {
try {
await subscribeWebhook(tokenInfo.accessToken, callbackUrl, appli);
getEvent()?.addMeta(`webhook_subscribed_${appli}`, userId);
} catch (err) {
getEvent()?.addWarning(
`Webhook subscribe (appli=${appli}) failed for user ${userId}: ${err}`,
);
}
}
}