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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 14:13:58 +02:00
1 parent 6f04123982
commit 5a9612822e
9 files changed
+199 -5

No files matched your search

+10 -2
View File
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode } from "@/lib/withings/client";
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
@@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
// Upsert connection (user may be reconnecting)
// v1.4.25 W5d — persist the OAuth scope we requested. The token
// response doesn't echo the granted scopes back as a top-level
// field, but Withings honours the scope param verbatim — there's
// no scope downgrade unless the user explicitly revokes one in
// their Health Mate account, which would fail the token exchange
// entirely. Storing the requested set is therefore a safe
// representation of what the connection holds.
await prisma.withingsConnection.upsert({
where: { userId: user.id },
update: {
@@ -80,6 +86,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
},
create: {
userId: user.id,
@@ -87,6 +94,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
},
});
+9 -1
View File
@@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt, encrypt } from "@/lib/crypto";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { refreshAccessToken } from "@/lib/withings/client";
import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client";
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";
import { markReauthRequired } from "@/lib/integrations/status";
@@ -37,6 +37,7 @@ export const GET = apiHandler(async () => {
lastSyncedAt: true,
tokenExpiresAt: true,
createdAt: true,
scope: true,
},
});
@@ -95,5 +96,12 @@ export const GET = apiHandler(async () => {
tokenExpired,
tokenRefreshFailed,
tokenExpiresAt,
// v1.4.25 W5d — `scope` is the comma-separated OAuth scope string
// returned during the auth flow; `hasActivityScope` is the
// pre-computed convenience flag the Settings reconnect banner
// reads. Null `scope` means a legacy v1.4.24 connection that's
// never re-authed since v1.4.25 — it counts as "missing activity".
scope: connection.scope,
hasActivityScope: hasActivityScope(connection.scope),
});
});