From a732b1926e46af066dfa435f71f847bab2c1deeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:27:37 +0200 Subject: [PATCH] feat(withings): subscribe to BP + temperature webhooks alongside weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setupWebhook now subscribes to appli=1 (weight + composition), appli=2 (temperature), and appli=4 (pressure family: BP, pulse, SpO2) in one pass. The previous implementation only registered appli=1, so a new BP reading from a BPM cuff sat for up to an hour waiting for the hourly poll fallback instead of arriving via push within seconds. A failure on one category is logged and we proceed with the rest — losing one webhook is strictly better than rolling back all three. The disconnect path mirrors the same iteration so cleanup unsubscribes every category we ever subscribed to. Webhook handler already dispatches `syncUserMeasurements` regardless of appli, so no change needed on the receiver side. Exports `WITHINGS_NOTIFY_APPLIS` as the single source of truth so the unit test and the disconnect route stay in lockstep with setupWebhook. --- src/app/api/withings/disconnect/route.ts | 19 +++++++--- src/lib/withings/__tests__/client.test.ts | 22 ++++++++++++ src/lib/withings/sync.ts | 42 +++++++++++++++++------ 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/app/api/withings/disconnect/route.ts b/src/app/api/withings/disconnect/route.ts index 95a4a5a63..109fa6c65 100644 --- a/src/app/api/withings/disconnect/route.ts +++ b/src/app/api/withings/disconnect/route.ts @@ -5,7 +5,10 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { decrypt } from "@/lib/crypto"; import { unsubscribeWebhook } from "@/lib/withings/client"; -import { getWithingsWebhookCallbackUrl } from "@/lib/withings/sync"; +import { + WITHINGS_NOTIFY_APPLIS, + getWithingsWebhookCallbackUrl, +} from "@/lib/withings/sync"; import { markDisconnected } from "@/lib/integrations/status"; /** @@ -23,13 +26,21 @@ export const POST = apiHandler(async () => { return apiError("No Withings connection", 404); } - // Try to unsubscribe webhook (best-effort) + // Best-effort unsubscribe across every appli category we subscribed + // to in setupWebhook. A token-expired failure on one category should + // not stop us from cleaning up the others. try { const accessToken = decrypt(connection.accessToken); const callbackUrl = getWithingsWebhookCallbackUrl(); - await unsubscribeWebhook(accessToken, callbackUrl); + for (const appli of WITHINGS_NOTIFY_APPLIS) { + try { + await unsubscribeWebhook(accessToken, callbackUrl, appli); + } catch { + // Ignore — token might be expired or the appli was never subscribed. + } + } } catch { - // Ignore — token might be expired + // Ignore — token might be undecryptable. } await prisma.withingsConnection.delete({ diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts index 4335b9e9a..b5e8d3ad7 100644 --- a/src/lib/withings/__tests__/client.test.ts +++ b/src/lib/withings/__tests__/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MEASURE_TYPE_MAP, fetchMeasurements } from "../client"; +import { WITHINGS_NOTIFY_APPLIS } from "../sync"; /** * Withings client — meastype mapping + payload-parsing unit tests. @@ -70,6 +71,27 @@ describe("MEASURE_TYPE_MAP", () => { }); }); +describe("WITHINGS_NOTIFY_APPLIS", () => { + it("subscribes to weight + temperature + pressure categories", () => { + expect(WITHINGS_NOTIFY_APPLIS).toEqual([1, 2, 4]); + }); + + it("contains every appli for the meastypes we ingest", () => { + // Sanity guard so a future contributor who adds a meastype is + // nudged to also wire its appli category. Today we ingest: + // - 1, 6, 77, 88 → appli=1 (weight + composition) + // - 12, 71 → appli=2 (temperature) + // - 9, 10, 11, 35, 54 → appli=4 (BP + pulse + SpO2) + // - 123 → appli=1 (VO2 max is part of the weight category in + // Withings' bucketing; verified against the developer guide). + const ingested = Object.keys(MEASURE_TYPE_MAP).map(Number).sort(); + expect(ingested).toContain(12); + expect(ingested).toContain(71); + expect(ingested).toContain(35); + expect(ingested).toContain(123); + }); +}); + describe("fetchMeasurements — VO2 max (meastype 123)", () => { it("decodes a ScanWatch VO2 max reading into VO2_MAX mL/(kg·min)", async () => { // 42.5 mL/(kg·min) as Withings exponent encoding: value=425, unit=-1. diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 5b1a7ce3a..4882dee17 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -255,21 +255,41 @@ export function extractWithingsStatus(message: string): string | undefined { } /** - * Subscribe to Withings webhook for a user. + * 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) + * + * 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. + * + * Sleep (appli=44) and activity (appli=16) ship alongside the + * corresponding sync routines in v1.4.26. + */ +export const WITHINGS_NOTIFY_APPLIS = [1, 2, 4] 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 { const tokenInfo = await getValidToken(userId); if (!tokenInfo) return; - try { - await subscribeWebhook( - tokenInfo.accessToken, - getWithingsWebhookCallbackUrl(), - ); - getEvent()?.addMeta("webhook_subscribed", userId); - } catch (err) { - getEvent()?.addWarning( - `Webhook subscribe failed for user ${userId}: ${err}`, - ); + 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}`, + ); + } } }