Files
HealthLog/src/lib/withings/sync-sleep.ts
T
Marc-André Bombeck 7ad0b9c4f4 fix(sleep): stamp Withings sleep segments at the END, not the START
Withings sleep sync stamped each segment's measuredAt with its startdate,
but every reader treats measuredAt as the segment END and resolves the
span as start = end − duration. Each night was therefore rendered one
segment-length earlier than reality. Stamp the enddate so the span lands
at its true clock time.
2026-06-14 23:16:23 +02:00

346 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.
/**
* v1.4.25 W17c — Withings Sleep v2 sync.
*
* Pulls per-stage sleep segments from `POST /v2/sleep?action=get` and
* writes one Measurement row per segment, tagged with the appropriate
* `SleepStage` enum. The webhook trigger (appli=44) drives most calls
* in production; the hourly pg-boss cron (`withings-sleep-sync`) is
* the safety net for the 1 % of webhook deliveries Withings drops.
*
* State mapping (research §2 + withings-go reference):
*
* - state 0 (awake) → AWAKE
* - state 1 (light sleep) → CORE
* - state 2 (deep sleep) → DEEP
* - state 3 (REM) → REM
* - state 4 (manual) → ignored (rare; Withings synthetic marker)
*
* The legacy `ASLEEP` and `IN_BED` slots in the HealthLog enum are
* reserved for HealthKit ingest (pre-iOS-16 `asleepUnspecified` and
* iOS-16+ `inBed` respectively) and are NOT emitted from this path.
*
* Idempotency: every segment writes through the v1.4.25 W17b/c
* composite (Migration 0055 — `(userId, type, measuredAt, source,
* sleepStage)` with NULLS NOT DISTINCT). The five-tuple guarantees a
* re-sync upserts the same row rather than inserting a duplicate, even
* when Withings re-emits the night with adjusted segment boundaries.
*
* Date semantics: each segment's `measuredAt` is the segment's
* `enddate` (unix seconds → `Date`). Every reader treats `measuredAt`
* as the segment END and resolves the span as `start = end − duration`
* (`sleep-night.ts` `segmentOf`); stamping the START shifted the whole
* night one segment-length earlier. The duration in minutes lands in
* `value`. The HealthLog analytics aggregator groups stage rows under
* their parent night via the per-night `dayKey` helper, so the
* segment-level END timestamp is the canonical sort key.
*/
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { getUnitForType } from "@/lib/validations/measurement";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { hasActivityScope } from "./client";
import {
WithingsApiError,
classifyWithingsResponse,
} from "./response-classifier";
import { getValidToken, recordWithingsSyncFailure } from "./sync";
import {
isReauthRequired,
parkIntegrationAtReauth,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
const WITHINGS_SLEEP_URL = "https://wbsapi.withings.net/v2/sleep";
/**
* Default backfill window for the first sleep sync of a connection.
* Matches every other Withings ingest path so a reconnect lights up
* 30 days of stage data.
*/
const SLEEP_BACKFILL_DAYS = 30;
/**
* One stage segment from the `action=get` series. Withings emits
* `state` as an integer enum; `startdate` and `enddate` are unix
* seconds (NOT YYYY-MM-DD like the activity response).
*/
export interface WithingsSleepSegment {
startdate: number;
enddate: number;
state: number;
// The session id is unique per night; HealthLog uses it as part of
// the externalId so a future re-sync can reconcile against the
// original write.
id?: number;
}
/**
* Map Withings `state` to the HealthLog `SleepStage` enum. Returns
* `null` for states we deliberately ignore (state 4 = synthetic
* marker) so the caller can skip the segment without an error.
*/
export function mapWithingsSleepState(state: number): SleepStage | null {
switch (state) {
case 0:
return "AWAKE";
case 1:
return "CORE";
case 2:
return "DEEP";
case 3:
return "REM";
default:
return null;
}
}
const SLEEP_TYPE: MeasurementType = "SLEEP_DURATION";
/**
* Fetch the per-segment sleep series for the given user. Returns the
* raw segments (no DB write) so callers compose the write step.
*/
export async function fetchWithingsSleep(
accessToken: string,
startdate: number,
enddate: number,
): Promise<WithingsSleepSegment[]> {
const params = new URLSearchParams({
action: "get",
startdate: String(startdate),
enddate: String(enddate),
// hr / rr / snoring are noisy intraday streams; HealthLog only
// ingests stage-level data today, so request the minimum set.
});
const pageStart = performance.now();
const res = await safeFetch(WITHINGS_SLEEP_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: "fetchWithingsSleep",
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "sleep",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
const series: WithingsSleepSegment[] = json.body?.series ?? [];
return series;
}
/**
* Sync sleep data for a single user. Walks the trailing 30-day window
* (or whatever `SLEEP_BACKFILL_DAYS` is set to), writing one
* Measurement row per stage segment. Returns the number of upserted
* rows.
*
* Park behaviour mirrors `syncUserMeasurements` — a connection at
* `error_reauth` short-circuits before the upstream call.
*/
export async function syncUserSleep(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
if (await isReauthRequired(userId, "withings")) {
getEvent()?.addWarning(
`withings sleep sync skipped for ${userId}: parked at error_reauth`,
);
return 0;
}
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// v1.4.26 — scope-skip guard. Sleep v2 shares the `user.activity`
// scope gate with the activity endpoints; legacy v1.4.24- connections
// 403 on every call. Park at `error_reauth` rather than retrying so
// pg-boss stops queuing and the 3-strike alert doesn't fire for users
// who haven't reconnected since W5d. See `sync-activity.ts` for the
// full rationale.
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
select: { scope: true },
});
if (!hasActivityScope(connection?.scope ?? null)) {
getEvent()?.addWarning(
`withings sleep sync skipped for ${userId}: missing user.activity scope (legacy connection — reconnect required)`,
);
// v1.4.27 — silent park (BL-P3-2 parity with activity sync). The
// scope-skip is a deliberate no-op; the 3-strike admin alert must
// NOT fire from this branch. The defence-in-depth 403 catch-block
// below stays on `recordSyncFailure` so a genuinely unexpected 403
// after the scope-skip lands still pages admins.
await parkIntegrationAtReauth({
userId,
integration: "withings",
message:
"Withings connection is missing the user.activity scope. Reconnect Withings in Settings to enable sleep sync.",
errorCode: "scope_missing",
});
return 0;
}
void opts.fullSync;
const now = new Date();
const start = new Date(
now.getTime() - SLEEP_BACKFILL_DAYS * 24 * 60 * 60 * 1000,
);
const startUnix = Math.floor(start.getTime() / 1000);
const endUnix = Math.floor(now.getTime() / 1000);
let segments: WithingsSleepSegment[];
try {
segments = await fetchWithingsSleep(
tokenInfo.accessToken,
startUnix,
endUnix,
);
} catch (err) {
// v1.4.43 W7-B3 — typed-classification path (see sync-activity.ts
// for the full rationale). The thrown `WithingsApiError` carries
// the classification verdict; `recordWithingsSyncFailure` routes
// it through `classifyError`, which falls back to the legacy regex
// for un-prototyped errors (pg-boss JSON round-trip).
//
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
// this scope-gated endpoint always means scope-missing or
// token-revoked; force `reauth_required` so the 3-strike alert
// still pages on the unexpected case.
const withingsStatus =
err instanceof WithingsApiError ? err.withingsStatus : undefined;
if (withingsStatus === 403) {
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
});
throw err;
}
await recordWithingsSyncFailure(userId, err);
throw err;
}
let imported = 0;
let segmentIndex = 0;
// v1.4.39.1 — track every (type, measuredAt) we touched so the
// persistent rollup tier can be re-folded at the end. See sync.ts for
// the full rationale.
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const segment of segments) {
const stage = mapWithingsSleepState(segment.state);
if (!stage) {
// State 4 (manual / synthetic marker) is ignored — see file
// header.
segmentIndex++;
continue;
}
// `measuredAt` is the segment END (`enddate`). Every reader treats
// `measuredAt` as the END instant — `segmentOf` resolves the span as
// `start = measuredAt − value·60_000` (sleep-night.ts). Stamping the START
// here shifted the whole night one segment-length earlier; END is correct.
const measuredAt = new Date(segment.enddate * 1000);
const durationSec = Math.max(0, segment.enddate - segment.startdate);
// Canonical SLEEP_DURATION unit is minutes (Apple Health alignment,
// v1.4.23 schema note). Round so re-sync doesn't flip values on
// floating-point noise.
const minutes = Math.round(durationSec / 60);
const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segmentIndex}`;
try {
const existing = await prisma.measurement.findFirst({
where: {
userId,
type: SLEEP_TYPE,
measuredAt,
source: "WITHINGS",
sleepStage: stage,
},
select: { id: true },
});
if (existing) {
await prisma.measurement.update({
where: { id: existing.id },
data: { value: minutes },
});
} else {
await prisma.measurement.create({
data: {
userId,
type: SLEEP_TYPE,
value: minutes,
unit: getUnitForType(SLEEP_TYPE),
measuredAt,
source: "WITHINGS",
sleepStage: stage,
externalId,
},
});
}
touched.push({ type: SLEEP_TYPE, measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(
`Failed to upsert sleep segment (${stage}, ${measuredAt.toISOString()}): ${err}`,
);
}
segmentIndex++;
}
// v1.4.39.1 — refresh the persistent rollup table for every distinct
// (type, day) the sync touched. Sleep segments collapse heavily —
// ~10 stage rows per night land in one DAY recompute.
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
// v1.8.0 — drop the per-metric assessment caches the synced types
// dirty (sleep + resting-HR feed the pulse / general cards).
// Fire-and-forget.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`withings sleep: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings sleep: rollup recompute failed for ${userId}: ${err}`,
);
}
await recordSyncSuccess(userId, "withings");
return imported;
}