Files
HealthLog/src/lib/withings/client.ts
T
Marc-André Bombeck 5a9612822e feat(withings): OAuth scope upgrade + reconnect banner
v1.4.25 W5d. The Withings Activity / Sleep endpoints sit behind the
`user.activity` OAuth scope which v1.4.24-and-earlier connections
never requested. This commit ships the scope plumbing + reconnect
surface so existing users can opt in to step / active energy /
distance / floors-climbed ingest with a single click.

  - Schema: `WithingsConnection.scope` nullable column. NULL =
    legacy connection that's never re-authed since v1.4.25.
  - Migration 0050 — additive column add.
  - Client: `WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity"`
    + `parseWithingsScope` / `hasActivityScope` helpers.
  - Callback: persists `scope: WITHINGS_OAUTH_SCOPE` on every
    successful exchange, so reconnecting upgrades the row.
  - Status route: exposes `scope` + `hasActivityScope` so the UI
    can render the reconnect banner conditionally.
  - Settings → Integrations: paint a reconnect banner when the
    user has an active connection AND `hasActivityScope === false`.
    Click → /api/withings/connect → standard OAuth flow.
  - i18n: EN + DE strings for the banner +
    `measurements.types.{fatFreeMass,fatMass,muscleMass,
    skinTemperature,pulseWaveVelocity,vascularAge,visceralFat}`.
  - 5 new unit tests covering the scope helpers.

Note: the Activity sync routine itself
(`POST /v2/measure?action=getactivity`) lands in v1.4.26. Shipping the
scope upgrade now means users only reconnect once instead of twice.

Cross-source dedup protection is already in place via the W5e
foundation (`pickCanonicalSourceRows`): once Activity sync lands AND
a user also has Apple Health passthrough (v1.5), the per-day picker
prevents double-counting cumulative metrics from two sources.
2026-05-14 14:13:58 +02:00

383 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Withings API client for OAuth and data fetching.
* Docs: https://developer.withings.com/api-reference
*/
import { getEvent } from "@/lib/logging/context";
const WITHINGS_OAUTH_URL =
"https://account.withings.com/oauth2_user/authorize2";
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure";
const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify";
export interface WithingsCredentials {
clientId: string;
clientSecret: string;
}
function getRedirectUri(): string {
return (
process.env.WITHINGS_REDIRECT_URI ??
`${process.env.NEXT_PUBLIC_APP_URL}/api/withings/callback`
);
}
/**
* v1.4.25 W5d — Withings OAuth scope set HealthLog requests.
*
* - `user.metrics` : every meastype the Measure / Heart-list /
* Sleep endpoints expose (weight, BP, pulse,
* SpO2, body comp, temperature, VO2 max, …)
* - `user.activity` : steps / active energy / distance / floors —
* served by `POST /v2/measure?action=getactivity`.
* The sync routine itself lands in v1.4.26; the
* scope is requested now so existing users
* reconnect once instead of twice.
*
* v1.4.24-and-earlier connections requested `user.metrics` only — the
* Settings → Integrations card surfaces a reconnect banner for those
* users (see `WithingsConnection.scope IS NULL` or scope without
* `user.activity`).
*/
export const WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity" as const;
/**
* Parse the persisted scope string into a Set for membership checks.
* Legacy NULL → empty set (treated as "no scopes yet") so the
* reconnect-banner conditional reads as truthy.
*/
export function parseWithingsScope(scope: string | null): Set<string> {
if (!scope) return new Set();
return new Set(
scope
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
/**
* Returns true when the connection holds `user.activity` — the
* minimum scope required by the Activity / Sleep endpoints.
*/
export function hasActivityScope(scope: string | null): boolean {
return parseWithingsScope(scope).has("user.activity");
}
/**
* Generate Withings OAuth authorization URL.
*/
export function getAuthorizationUrl(
state: string,
creds: WithingsCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getRedirectUri(),
scope: WITHINGS_OAUTH_SCOPE,
state,
});
return `${WITHINGS_OAUTH_URL}?${params}`;
}
export interface WithingsTokenResponse {
userid: string;
access_token: string;
refresh_token: string;
expires_in: number;
}
/**
* Exchange authorization code for tokens.
*/
export async function exchangeCode(
code: string,
creds: WithingsCredentials,
): Promise<WithingsTokenResponse> {
const params = new URLSearchParams({
action: "requesttoken",
grant_type: "authorization_code",
client_id: creds.clientId,
client_secret: creds.clientSecret,
redirect_uri: getRedirectUri(),
code,
});
const start = performance.now();
const res = await fetch(WITHINGS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json();
getEvent()?.addExternalCall({
service: "withings",
method: "exchangeCode",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: json.status !== 0 ? `status=${json.status}` : undefined,
});
if (json.status !== 0) {
throw new Error(`Withings token error: ${json.status} - ${json.error}`);
}
return json.body as WithingsTokenResponse;
}
/**
* Refresh an expired access token.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: WithingsCredentials,
): Promise<WithingsTokenResponse> {
const params = new URLSearchParams({
action: "requesttoken",
grant_type: "refresh_token",
client_id: creds.clientId,
client_secret: creds.clientSecret,
refresh_token: refreshToken,
});
const start = performance.now();
const res = await fetch(WITHINGS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json();
getEvent()?.addExternalCall({
service: "withings",
method: "refreshAccessToken",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: json.status !== 0 ? `status=${json.status}` : undefined,
});
if (json.status !== 0) {
throw new Error(`Withings refresh error: ${json.status} - ${json.error}`);
}
return json.body as WithingsTokenResponse;
}
// Withings measure type mapping. The single source of truth is
// `src/lib/withings/mapping.md` — keep both in sync when adding entries.
// https://developer.withings.com/api-reference#tag/measure/operation/measure-getmeas
export const MEASURE_TYPE_MAP: Record<number, { type: string; factor?: number }> = {
1: { type: "WEIGHT" }, // Weight (kg)
9: { type: "BLOOD_PRESSURE_DIA" }, // Diastolic BP
10: { type: "BLOOD_PRESSURE_SYS" }, // Systolic BP
11: { type: "PULSE" }, // Heart rate
6: { type: "BODY_FAT" }, // Body fat %
// v1.4.25 — first-gen Thermo (WBT01) reports temperature as meastype 12;
// current-gen Thermo ships meastype 71. Both canonical °C, both into
// the BODY_TEMPERATURE bucket. Skin temperature (73, ScanWatch) stays
// out until v1.4.26 ships a SKIN_TEMPERATURE enum — surface temps
// (~32 °C) and core temps (~37 °C) must not share a rollup.
12: { type: "BODY_TEMPERATURE" },
71: { type: "BODY_TEMPERATURE" },
77: { type: "TOTAL_BODY_WATER" }, // Hydration / water mass (kg)
88: { type: "BONE_MASS" }, // Bone mass (kg)
54: { type: "OXYGEN_SATURATION" }, // SpO2 (% — only ScanWatch / pulse-ox products)
// v1.4.25 — older Withings firmware (and some SDK examples) report
// SpO2 under meastype 35 instead of 54. Both target OXYGEN_SATURATION
// in percent; the exponent decode handles 0.97 vs 97 transparently.
35: { type: "OXYGEN_SATURATION" },
// v1.4.25 — VO2 max from the ScanWatch family. Withings reports
// mL/(kg·min) directly, matching the canonical DB unit.
123: { type: "VO2_MAX" },
// ── v1.4.25 W5d — Withings full coverage ──
// Body composition expansion (Body+, Body Cardio, Body Comp, Body
// Scan ship these values on every measurement). All canonical kg.
5: { type: "FAT_FREE_MASS" }, // Fat-free mass
8: { type: "FAT_MASS" }, // Fat mass (kg form of BODY_FAT)
76: { type: "MUSCLE_MASS" }, // Muscle mass
// Skin temperature (ScanWatch dermal sensor). Distinct from
// BODY_TEMPERATURE — surface temps ~32 °C, core ~37 °C. Same °C
// unit; the SKIN_TEMPERATURE enum value keeps the rollup honest.
73: { type: "SKIN_TEMPERATURE" },
// Pulse-wave velocity m/s — Body Cardio / Body Scan exclusive.
91: { type: "PULSE_WAVE_VELOCITY" },
// Vascular age in years — Body Scan composite of PWV + age.
155: { type: "VASCULAR_AGE" },
// Visceral fat rating (Withings 1–12 scale). Stored under the
// canonical VISCERAL_FAT enum with `rating` as the unit string.
170: { type: "VISCERAL_FAT" },
};
export interface WithingsMeasure {
type: string;
value: number;
measuredAt: Date;
}
export interface WithingsMeasureGroup {
grpid: number;
date: number; // unix timestamp
measures: Array<{ type: number; value: number; unit: number }>;
}
/**
* Fetch measurements from Withings API.
*/
export async function fetchMeasurements(
accessToken: string,
startDate?: Date,
endDate?: Date,
): Promise<WithingsMeasure[]> {
const baseParams: Record<string, string> = {
action: "getmeas",
meastypes: Object.keys(MEASURE_TYPE_MAP).join(","),
};
if (startDate) {
baseParams.startdate = String(Math.floor(startDate.getTime() / 1000));
}
if (endDate) {
baseParams.enddate = String(Math.floor(endDate.getTime() / 1000));
}
const results: WithingsMeasure[] = [];
let offset = 0;
let pageCount = 0;
while (true) {
const params = new URLSearchParams({
...baseParams,
offset: String(offset),
});
const pageStart = performance.now();
const res = await fetch(WITHINGS_MEASURE_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
getEvent()?.addExternalCall({
service: "withings",
method: `fetchMeasurements(page=${pageCount})`,
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error: json.status !== 0 ? `status=${json.status}` : undefined,
});
if (json.status !== 0) {
throw new Error(`Withings measure error: ${json.status}`);
}
const body = json.body ?? {};
const groups: WithingsMeasureGroup[] = body.measuregrps ?? [];
for (const group of groups) {
const measuredAt = new Date(group.date * 1000);
for (const m of group.measures) {
const mapping = MEASURE_TYPE_MAP[m.type];
if (!mapping) continue;
// Withings stores value * 10^unit (unit is usually negative)
const value = m.value * Math.pow(10, m.unit);
results.push({
type: mapping.type,
value: parseFloat(value.toFixed(2)),
measuredAt,
});
}
}
const hasMore = body.more === true || body.more === 1;
if (!hasMore) break;
const nextOffset = Number(body.offset);
if (!Number.isFinite(nextOffset) || nextOffset <= offset) break;
offset = nextOffset;
pageCount += 1;
if (pageCount > 1000) break;
}
return results;
}
/**
* Subscribe to Withings webhook notifications.
*/
export async function subscribeWebhook(
accessToken: string,
callbackUrl: string,
appli: number = 1, // 1 = user measures
): Promise<void> {
const params = new URLSearchParams({
action: "subscribe",
callbackurl: callbackUrl,
appli: String(appli),
});
const start = performance.now();
const res = await fetch(WITHINGS_NOTIFY_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
getEvent()?.addExternalCall({
service: "withings",
method: "subscribeWebhook",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error:
json.status !== 0 && json.status !== 294
? `status=${json.status}`
: undefined,
});
// Status 0 = success, 294 = already subscribed (both OK)
if (json.status !== 0 && json.status !== 294) {
throw new Error(`Withings subscribe error: ${json.status}`);
}
}
/**
* Unsubscribe from Withings webhook notifications.
*/
export async function unsubscribeWebhook(
accessToken: string,
callbackUrl: string,
appli: number = 1,
): Promise<void> {
const params = new URLSearchParams({
action: "revoke",
callbackurl: callbackUrl,
appli: String(appli),
});
const start = performance.now();
const res = await fetch(WITHINGS_NOTIFY_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
getEvent()?.addExternalCall({
service: "withings",
method: "unsubscribeWebhook",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: json.status !== 0 ? `status=${json.status}` : undefined,
});
if (json.status !== 0) {
getEvent()?.addWarning(`Withings unsubscribe warning: ${json.status}`);
}
}