feat(withings): subscribe to BP + temperature webhooks alongside weight

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:27:37 +02:00
1 parent 5cb4a1dde1
commit a732b1926e
3 files changed
+68 -15

No files matched your search

+15 -4
View File
@@ -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({
+22
View File
@@ -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.
+31 -11
View File
@@ -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<void> {
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}`,
);
}
}
}