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}`, + ); + } } }