diff --git a/src/lib/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts new file mode 100644 index 000000000..d100da50b --- /dev/null +++ b/src/lib/whoop/__tests__/client.test.ts @@ -0,0 +1,370 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + KJ_TO_KCAL, + WHOOP_FIELD_MAP, + WHOOP_OAUTH_SCOPE, + WHOOP_PAGE_LIMIT, + exchangeCode, + fetchBodyMeasurement, + fetchCycles, + fetchProfile, + fetchRecoveries, + fetchSleeps, + fetchWorkouts, + getAuthorizationUrl, + mapCycle, + mapRecovery, + mapSleep, + refreshAccessToken, + type WhoopCycle, + type WhoopRecovery, + type WhoopSleep, +} from "../client"; +import { WhoopApiError } from "../response-classifier"; + +const CREDS = { clientId: "cid", clientSecret: "csecret" }; + +/** Stub global fetch with a queue of `{ status, body }` responses. */ +function installFetchMock(pages: Array<{ status: number; body: unknown }>) { + let i = 0; + const fetchMock = vi.fn(async () => { + const page = pages[Math.min(i, pages.length - 1)]!; + i += 1; + return { + status: page.status, + json: async () => page.body, + }; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("getAuthorizationUrl", () => { + it("builds the v2 authorize URL with the offline scope and state", () => { + const url = getAuthorizationUrl("nonce123", CREDS); + expect(url).toContain("api.prod.whoop.com/oauth/oauth2/auth"); + expect(url).toContain("response_type=code"); + expect(url).toContain("client_id=cid"); + expect(url).toContain("state=nonce123"); + // URLSearchParams encodes the space-separated scope with `+`; compare the + // parsed `scope` param back to the canonical constant. + const scope = new URL(url).searchParams.get("scope"); + expect(scope).toBe(WHOOP_OAUTH_SCOPE); + expect(WHOOP_OAUTH_SCOPE).toContain("offline"); + }); +}); + +describe("token exchange + refresh", () => { + it("exchanges an authorization code for a token pair", async () => { + installFetchMock([ + { + status: 200, + body: { + access_token: "at", + refresh_token: "rt", + expires_in: 3600, + scope: "offline read:recovery", + }, + }, + ]); + const tok = await exchangeCode("code", CREDS); + expect(tok.access_token).toBe("at"); + expect(tok.refresh_token).toBe("rt"); + expect(tok.expires_in).toBe(3600); + }); + + it("re-requests the offline scope on refresh so a new refresh token rotates in", async () => { + const fetchMock = installFetchMock([ + { + status: 200, + body: { access_token: "at2", refresh_token: "rt2", expires_in: 3600 }, + }, + ]); + const tok = await refreshAccessToken("rt1", CREDS); + expect(tok.refresh_token).toBe("rt2"); + const [, init] = fetchMock.mock.calls[0] as unknown as [ + string, + { body: string }, + ]; + expect(init.body).toContain("grant_type=refresh_token"); + expect(init.body).toContain("scope=offline"); + }); + + it("throws a classified WhoopApiError on a 401 token response", async () => { + installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]); + await expect(exchangeCode("bad", CREDS)).rejects.toMatchObject({ + name: "WhoopApiError", + classification: "reauth_required", + }); + }); +}); + +describe("fetchCollection pagination", () => { + it("walks next_token across pages and concatenates records", async () => { + const fetchMock = installFetchMock([ + { status: 200, body: { records: [{ id: 1 }, { id: 2 }], next_token: "t1" } }, + { status: 200, body: { records: [{ id: 3 }], next_token: null } }, + ]); + const recs = await fetchRecoveries("at"); + expect(recs.map((r) => (r as unknown as { id: number }).id)).toEqual([ + 1, 2, 3, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + // Page 1 carries no nextToken; page 2 forwards the cursor. + const [url1] = fetchMock.mock.calls[0] as unknown as [string]; + const [url2] = fetchMock.mock.calls[1] as unknown as [string]; + expect(url1).toContain(`limit=${WHOOP_PAGE_LIMIT}`); + expect(url1).not.toContain("nextToken"); + expect(url2).toContain("nextToken=t1"); + }); + + it("stops on a single page when next_token is absent", async () => { + const fetchMock = installFetchMock([ + { status: 200, body: { records: [{ id: 1 }] } }, + ]); + await fetchRecoveries("at"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws WhoopApiError when a page returns 500", async () => { + installFetchMock([{ status: 500, body: { error: "boom" } }]); + await expect(fetchRecoveries("at")).rejects.toBeInstanceOf(WhoopApiError); + }); + + it("forwards start/end window params on collection reads", async () => { + const fetchMock = installFetchMock([ + { status: 200, body: { records: [] } }, + ]); + await fetchSleeps("at", { + start: new Date("2026-06-01T00:00:00.000Z"), + end: new Date("2026-06-02T00:00:00.000Z"), + }); + const [url] = fetchMock.mock.calls[0] as unknown as [string]; + expect(url).toContain("/v2/activity/sleep"); + expect(url).toContain("start=2026-06-01T00%3A00%3A00.000Z"); + expect(url).toContain("end=2026-06-02T00%3A00%3A00.000Z"); + }); +}); + +describe("collection endpoint paths", () => { + it("hits the documented v2 path for each collection", async () => { + const cases: Array<[() => Promise, string]> = [ + [() => fetchCycles("at"), "/v2/cycle"], + [() => fetchWorkouts("at"), "/v2/activity/workout"], + ]; + for (const [call, path] of cases) { + const fetchMock = installFetchMock([ + { status: 200, body: { records: [] } }, + ]); + await call(); + const [url] = fetchMock.mock.calls[0] as unknown as [string]; + expect(url).toContain(path); + vi.unstubAllGlobals(); + } + }); +}); + +describe("single-object endpoints", () => { + it("fetches the body measurement (max_heart_rate is a profile constant)", async () => { + installFetchMock([ + { + status: 200, + body: { height_meter: 1.8, weight_kilogram: 80, max_heart_rate: 190 }, + }, + ]); + const body = await fetchBodyMeasurement("at"); + expect(body.weight_kilogram).toBe(80); + expect(body.max_heart_rate).toBe(190); + }); + + it("fetches the basic profile", async () => { + installFetchMock([ + { status: 200, body: { user_id: 42, first_name: "A" } }, + ]); + const profile = await fetchProfile("at"); + expect(profile.user_id).toBe(42); + }); + + it("throws a classified error on a 401 single-object read", async () => { + installFetchMock([{ status: 401, body: null }]); + await expect(fetchProfile("bad")).rejects.toMatchObject({ + classification: "reauth_required", + }); + }); +}); + +describe("mapRecovery", () => { + const base: WhoopRecovery = { + cycle_id: 1, + sleep_id: "sleep-uuid", + user_id: 42, + created_at: "2026-06-01T06:00:00.000Z", + updated_at: "2026-06-01T07:00:00.000Z", + score_state: "SCORED", + score: { + user_calibrating: false, + recovery_score: 66, + resting_heart_rate: 52, + hrv_rmssd_milli: 48.7, + spo2_percentage: 97, + skin_temp_celsius: 33.4, + }, + }; + + it("maps recovery score, RMSSD (not SDNN), RHR, spo2, skin-temp", () => { + const rows = mapRecovery(base); + const byType = Object.fromEntries(rows.map((r) => [r.type, r])); + expect(byType.RECOVERY_SCORE?.value).toBe(66); + expect(byType.HRV_RMSSD?.value).toBe(48.7); + expect(byType.HRV_RMSSD?.unit).toBe("ms"); + // RMSSD must never relabel as the SDNN HEART_RATE_VARIABILITY. + expect(byType.HEART_RATE_VARIABILITY).toBeUndefined(); + expect(byType.RESTING_HEART_RATE?.value).toBe(52); + expect(byType.OXYGEN_SATURATION?.value).toBe(97); + expect(byType.SKIN_TEMPERATURE?.value).toBe(33.4); + // measuredAt tracks updated_at (the re-score timestamp). + expect(byType.RECOVERY_SCORE?.measuredAt.toISOString()).toBe( + "2026-06-01T07:00:00.000Z", + ); + // Every row carries a distinct field-tag for the externalId. + expect(new Set(rows.map((r) => r.fieldTag)).size).toBe(rows.length); + }); + + it("emits nothing for an unscored recovery (score null)", () => { + expect(mapRecovery({ ...base, score: null })).toEqual([]); + }); + + it("omits optional spo2 / skin-temp when absent", () => { + const rows = mapRecovery({ + ...base, + score: { ...base.score!, spo2_percentage: undefined, skin_temp_celsius: undefined }, + }); + expect(rows.some((r) => r.type === "OXYGEN_SATURATION")).toBe(false); + expect(rows.some((r) => r.type === "SKIN_TEMPERATURE")).toBe(false); + }); +}); + +describe("mapSleep", () => { + const base: WhoopSleep = { + id: "sleep-uuid", + user_id: 42, + created_at: "2026-06-01T05:00:00.000Z", + updated_at: "2026-06-01T07:00:00.000Z", + start: "2026-05-31T23:00:00.000Z", + end: "2026-06-01T07:00:00.000Z", + nap: false, + score_state: "SCORED", + score: { + stage_summary: { + total_in_bed_time_milli: 28_800_000, // 480 min + total_awake_time_milli: 1_800_000, // 30 min + total_light_sleep_time_milli: 14_400_000, // 240 min + total_slow_wave_sleep_time_milli: 5_400_000, // 90 min + total_rem_sleep_time_milli: 7_200_000, // 120 min + }, + sleep_needed: { + baseline_milli: 27_000_000, // 450 min + need_from_sleep_debt_milli: 1_800_000, // 30 min + need_from_recent_strain_milli: 600_000, // 10 min + need_from_recent_nap_milli: 0, + }, + respiratory_rate: 15.2, + sleep_performance_percentage: 88, + sleep_efficiency_percentage: 93.5, + sleep_consistency_percentage: 71, + }, + }; + + it("maps per-stage SLEEP_DURATION rows (ms→min) with sleepStage", () => { + const rows = mapSleep(base); + const dur = rows.filter((r) => r.type === "SLEEP_DURATION"); + const byStage = Object.fromEntries(dur.map((r) => [r.sleepStage, r.value])); + expect(byStage.CORE).toBe(240); // light → CORE + expect(byStage.DEEP).toBe(90); // slow-wave → DEEP + expect(byStage.REM).toBe(120); + expect(byStage.AWAKE).toBe(30); + expect(byStage.IN_BED).toBe(480); + expect(dur.every((r) => r.unit === "minutes")).toBe(true); + }); + + it("sums SLEEP_NEED components ms→min", () => { + const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED"); + expect(need?.value).toBe(490); // 450 + 30 + 10 + 0 + expect(need?.unit).toBe("minutes"); + }); + + it("maps the SLEEP_* percentages and respiratory rate", () => { + const byType = Object.fromEntries(mapSleep(base).map((r) => [r.type, r])); + expect(byType.SLEEP_PERFORMANCE?.value).toBe(88); + expect(byType.SLEEP_EFFICIENCY?.value).toBe(93.5); + expect(byType.SLEEP_CONSISTENCY?.value).toBe(71); + expect(byType.RESPIRATORY_RATE?.value).toBe(15.2); + expect(byType.RESPIRATORY_RATE?.unit).toBe("breaths/min"); + }); + + it("uses sleep.end as measuredAt", () => { + const row = mapSleep(base)[0]!; + expect(row.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z"); + }); + + it("emits nothing for an unscored sleep", () => { + expect(mapSleep({ ...base, score: null })).toEqual([]); + }); +}); + +describe("mapCycle", () => { + const base: WhoopCycle = { + id: 1234567890, + user_id: 42, + created_at: "2026-06-01T00:00:00.000Z", + updated_at: "2026-06-01T23:59:00.000Z", + start: "2026-06-01T00:00:00.000Z", + end: "2026-06-01T23:59:00.000Z", + score_state: "SCORED", + score: { + strain: 12.34, + kilojoule: 8765.4, + average_heart_rate: 70, + max_heart_rate: 180, + }, + }; + + it("maps DAY_STRAIN (not STRAIN_SCORE) and ENERGY_EXPENDITURE_KJ in native kJ", () => { + const byType = Object.fromEntries(mapCycle(base).map((r) => [r.type, r])); + expect(byType.DAY_STRAIN?.value).toBe(12.34); + expect(byType.DAY_STRAIN?.unit).toBe("score"); + // WHOOP day-strain must never collide with the COMPUTED STRAIN_SCORE. + expect(byType.STRAIN_SCORE).toBeUndefined(); + expect(byType.ENERGY_EXPENDITURE_KJ?.value).toBe(8765.4); + expect(byType.ENERGY_EXPENDITURE_KJ?.unit).toBe("kJ"); + // measuredAt uses cycle.start. + expect(byType.DAY_STRAIN?.measuredAt.toISOString()).toBe( + "2026-06-01T00:00:00.000Z", + ); + }); + + it("emits nothing for an unscored cycle", () => { + expect(mapCycle({ ...base, score: null })).toEqual([]); + }); +}); + +describe("WHOOP_FIELD_MAP", () => { + it("documents the kJ→kcal factor for the workout energy path", () => { + expect(WHOOP_FIELD_MAP["workout.score.kilojoule"]?.factor).toBeCloseTo( + KJ_TO_KCAL, + ); + expect(KJ_TO_KCAL).toBeCloseTo(1 / 4.184); + }); + + it("keeps day-strain and workout-strain off the COMPUTED STRAIN_SCORE type", () => { + expect(WHOOP_FIELD_MAP["cycle.score.strain"]?.type).toBe("DAY_STRAIN"); + expect(WHOOP_FIELD_MAP["workout.score.strain"]?.type).toBe( + "WORKOUT_STRAIN", + ); + }); +}); diff --git a/src/lib/whoop/__tests__/credentials.test.ts b/src/lib/whoop/__tests__/credentials.test.ts new file mode 100644 index 000000000..5810493aa --- /dev/null +++ b/src/lib/whoop/__tests__/credentials.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findUnique = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } }, +})); + +// decrypt() is the fail-closed AES-256-GCM reader; the resolver only needs it +// to round-trip the stored ciphertext, so a `dec()` shim is sufficient here. +vi.mock("@/lib/crypto", () => ({ + decrypt: (cipher: string) => `dec(${cipher})`, +})); + +import { getUserWhoopCredentials } from "../credentials"; + +describe("getUserWhoopCredentials", () => { + beforeEach(() => { + findUnique.mockReset(); + }); + + it("decrypts and returns the per-user BYO client id/secret", async () => { + findUnique.mockResolvedValue({ + whoopClientIdEncrypted: "enc-id", + whoopClientSecretEncrypted: "enc-secret", + }); + const creds = await getUserWhoopCredentials("user-1"); + expect(creds).toEqual({ + clientId: "dec(enc-id)", + clientSecret: "dec(enc-secret)", + }); + // Resolution is user-scoped and selects only the two encrypted columns. + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user-1" }, + select: { + whoopClientIdEncrypted: true, + whoopClientSecretEncrypted: true, + }, + }); + }); + + it("returns null when the user has no WHOOP credentials configured", async () => { + findUnique.mockResolvedValue({ + whoopClientIdEncrypted: null, + whoopClientSecretEncrypted: null, + }); + expect(await getUserWhoopCredentials("user-1")).toBeNull(); + }); + + it("returns null when only one half of the pair is present", async () => { + findUnique.mockResolvedValue({ + whoopClientIdEncrypted: "enc-id", + whoopClientSecretEncrypted: null, + }); + expect(await getUserWhoopCredentials("user-1")).toBeNull(); + }); + + it("returns null when the user row is missing", async () => { + findUnique.mockResolvedValue(null); + expect(await getUserWhoopCredentials("ghost")).toBeNull(); + }); +}); diff --git a/src/lib/whoop/__tests__/oauth-state.test.ts b/src/lib/whoop/__tests__/oauth-state.test.ts new file mode 100644 index 000000000..508a50be6 --- /dev/null +++ b/src/lib/whoop/__tests__/oauth-state.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + WHOOP_OAUTH_STATE_COOKIE, + WHOOP_OAUTH_STATE_TTL_MS, + mintWhoopOAuthStateNonce, +} from "../oauth-state"; + +describe("WHOOP OAuth state", () => { + it("pins the cookie name and 10-minute TTL", () => { + expect(WHOOP_OAUTH_STATE_COOKIE).toBe("whoop_state"); + expect(WHOOP_OAUTH_STATE_TTL_MS).toBe(10 * 60 * 1000); + }); + + it("mints a 22-char base64url nonce (128 bits, no padding)", () => { + const nonce = mintWhoopOAuthStateNonce(); + expect(nonce).toHaveLength(22); + expect(nonce).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it("mints unique nonces", () => { + const set = new Set( + Array.from({ length: 100 }, () => mintWhoopOAuthStateNonce()), + ); + expect(set.size).toBe(100); + }); +}); diff --git a/src/lib/whoop/__tests__/response-classifier.test.ts b/src/lib/whoop/__tests__/response-classifier.test.ts new file mode 100644 index 000000000..e24ba2466 --- /dev/null +++ b/src/lib/whoop/__tests__/response-classifier.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + WhoopApiError, + classifyWhoopError, + classifyWhoopResponse, +} from "../response-classifier"; + +describe("classifyWhoopResponse", () => { + it("classifies 2xx as success", () => { + for (const status of [200, 201, 204, 299]) { + const v = classifyWhoopResponse(status); + expect(v.classification).toBe("success"); + expect(v.httpStatus).toBe(status); + } + }); + + it("classifies 429 as transient (rate-limit)", () => { + const v = classifyWhoopResponse(429); + expect(v.classification).toBe("transient"); + expect(v.reason).toBe("http_429"); + }); + + it("classifies 401 and 403 as reauth_required", () => { + expect(classifyWhoopResponse(401).classification).toBe("reauth_required"); + expect(classifyWhoopResponse(403).classification).toBe("reauth_required"); + }); + + it("classifies other 4xx as persistent", () => { + for (const status of [400, 404, 422]) { + expect(classifyWhoopResponse(status).classification).toBe("persistent"); + } + }); + + it("classifies 5xx as transient", () => { + for (const status of [500, 502, 503, 504]) { + expect(classifyWhoopResponse(status).classification).toBe("transient"); + } + }); + + it("defaults unknown / 3xx statuses to transient", () => { + const v = classifyWhoopResponse(302); + expect(v.classification).toBe("transient"); + expect(v.reason).toBe("http_302_unknown"); + }); +}); + +describe("WhoopApiError", () => { + it("carries the classification verdict and a bounded message", () => { + const err = new WhoopApiError({ + verb: "fetchRecoveries", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }); + expect(err.classification).toBe("reauth_required"); + expect(err.httpStatus).toBe(401); + expect(err.name).toBe("WhoopApiError"); + expect(err.message).toContain("WHOOP fetchRecoveries error: 401"); + }); + + it("caps a runaway upstream error body at 1024 chars", () => { + const err = new WhoopApiError({ + verb: "token", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "x".repeat(5000), + }); + expect(err.message.length).toBeLessThanOrEqual(1024); + }); +}); + +describe("classifyWhoopError", () => { + it("reads the verdict from a WhoopApiError directly", () => { + const err = new WhoopApiError({ + verb: "fetchSleeps", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }); + expect(classifyWhoopError(err)).toBe("transient"); + }); + + it("parses the status out of an unwrapped message", () => { + const err = new Error("WHOOP fetchCycles error: 403 - forbidden"); + expect(classifyWhoopError(err)).toBe("reauth_required"); + }); + + it("falls back to transient for an unrecognised error", () => { + expect(classifyWhoopError(new Error("network down"))).toBe("transient"); + expect(classifyWhoopError("boom")).toBe("transient"); + }); +}); diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts new file mode 100644 index 000000000..23519bde4 --- /dev/null +++ b/src/lib/whoop/client.ts @@ -0,0 +1,688 @@ +/** + * WHOOP API v2 client for OAuth and data fetching. + * Docs: https://developer.whoop.com (re-verify at build — the space moves). + * + * Mirrors the Withings client structure (`src/lib/withings/client.ts`): + * hand-rolled fetch over `safeFetch` (no SDK), an OAuth handshake + * (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), typed + * collection fetchers with `next_token` pagination, and a single + * source-of-truth field→Measurement mapping (`WHOOP_FIELD_MAP`, kept in sync + * with `src/lib/whoop/mapping.md`). + * + * v2 only — v1 was removed 2025-10-01. Sleep / workout / recovery ids are + * UUID strings; cycle id is int64. Token TTL 3600 s; refresh tokens ROTATE + * (each refresh invalidates the prior access AND refresh token — the sync + * layer persists BOTH rotated tokens). The `offline` scope is required to + * receive a refresh token at all. + */ +import { getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; +import { WhoopApiError, classifyWhoopResponse } from "./response-classifier"; + +const WHOOP_API_BASE = "https://api.prod.whoop.com/developer"; +const WHOOP_OAUTH_AUTH_URL = + "https://api.prod.whoop.com/oauth/oauth2/auth"; +const WHOOP_OAUTH_TOKEN_URL = + "https://api.prod.whoop.com/oauth/oauth2/token"; + +/** WHOOP collection reads cap `limit` at 25. */ +export const WHOOP_PAGE_LIMIT = 25; + +export interface WhoopCredentials { + clientId: string; + clientSecret: string; +} + +function getRedirectUri(): string { + return ( + process.env.WHOOP_REDIRECT_URI ?? + `${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback` + ); +} + +/** + * OAuth scopes HealthLog requests (space-separated on the wire). `offline` is + * mandatory to receive a refresh token; the rest are read-only collection + * scopes. Request all so the user grants once and every sync resource is + * covered. + */ +export const WHOOP_OAUTH_SCOPE = + "offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement" as const; + +/** + * Generate the WHOOP OAuth authorization URL (a browser redirect, not a + * fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`. + */ +export function getAuthorizationUrl( + state: string, + creds: WhoopCredentials, +): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: creds.clientId, + redirect_uri: getRedirectUri(), + scope: WHOOP_OAUTH_SCOPE, + state, + }); + return `${WHOOP_OAUTH_AUTH_URL}?${params}`; +} + +export interface WhoopTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + token_type?: string; +} + +async function postToken( + params: URLSearchParams, + verb: string, +): Promise { + const start = performance.now(); + const res = await safeFetch(WHOOP_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + const json = await res.json().catch(() => null); + const verdict = classifyWhoopResponse(res.status); + getEvent()?.addExternalCall({ + service: "whoop", + method: verb, + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new WhoopApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: + typeof json?.error === "string" ? json.error : undefined, + }); + } + return json as WhoopTokenResponse; +} + +/** Exchange an authorization code for the initial token pair. */ +export async function exchangeCode( + code: string, + creds: WhoopCredentials, +): Promise { + return postToken( + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: creds.clientId, + client_secret: creds.clientSecret, + redirect_uri: getRedirectUri(), + }), + "exchangeCode", + ); +} + +/** + * Refresh an expired access token. WHOOP rotates the refresh token on every + * use — the caller MUST persist the returned `refresh_token`, not just the + * access token. `scope` must include `offline` (re-sent here per the WHOOP + * spec) for the rotation to return a fresh refresh token. + */ +export async function refreshAccessToken( + refreshToken: string, + creds: WhoopCredentials, +): Promise { + return postToken( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: creds.clientId, + client_secret: creds.clientSecret, + scope: "offline", + }), + "refreshAccessToken", + ); +} + +// ─── Collection envelopes (v2) ───────────────────────────────── + +/** WHOOP paginated collection envelope: `{ records, next_token }`. */ +export interface WhoopCollection { + records: T[]; + next_token?: string | null; +} + +/** Recovery record. `score` is null until WHOOP finishes scoring. */ +export interface WhoopRecovery { + cycle_id: number; + sleep_id: string; + user_id: number; + created_at: string; + updated_at: string; + score_state: string; + score: { + user_calibrating: boolean; + recovery_score: number; + resting_heart_rate: number; + hrv_rmssd_milli: number; + spo2_percentage?: number; + skin_temp_celsius?: number; + } | null; +} + +/** Sleep activity record. */ +export interface WhoopSleep { + id: string; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + nap: boolean; + score_state: string; + score: { + stage_summary: { + total_in_bed_time_milli: number; + total_awake_time_milli: number; + total_light_sleep_time_milli: number; + total_slow_wave_sleep_time_milli: number; + total_rem_sleep_time_milli: number; + }; + sleep_needed: { + baseline_milli: number; + need_from_sleep_debt_milli: number; + need_from_recent_strain_milli: number; + need_from_recent_nap_milli: number; + }; + respiratory_rate?: number; + sleep_performance_percentage?: number; + sleep_consistency_percentage?: number; + sleep_efficiency_percentage?: number; + } | null; +} + +/** Physiological cycle (day) record. `id` is an int64. */ +export interface WhoopCycle { + id: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end?: string | null; + score_state: string; + score: { + strain: number; + kilojoule: number; + average_heart_rate: number; + max_heart_rate: number; + } | null; +} + +/** Workout activity record. */ +export interface WhoopWorkout { + id: string; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + sport_id?: number; + sport_name?: string; + score_state: string; + score: { + strain: number; + average_heart_rate: number; + max_heart_rate: number; + kilojoule: number; + percent_recorded: number; + distance_meter?: number; + altitude_gain_meter?: number; + altitude_change_meter?: number; + zone_durations?: Record; + } | null; +} + +/** Body-measurement object (single, not paginated). */ +export interface WhoopBodyMeasurement { + height_meter?: number; + weight_kilogram?: number; + max_heart_rate?: number; +} + +/** Basic profile object (single, not paginated). */ +export interface WhoopProfile { + user_id: number; + email?: string; + first_name?: string; + last_name?: string; +} + +interface CollectionQuery { + start?: Date; + end?: Date; + /** Hard ceiling on pages walked (defence against a runaway cursor). */ + maxPages?: number; +} + +async function fetchCollection( + path: string, + accessToken: string, + verb: string, + query: CollectionQuery = {}, +): Promise { + const records: T[] = []; + let nextToken: string | null | undefined; + let pageCount = 0; + const maxPages = query.maxPages ?? 1000; + + do { + const params = new URLSearchParams({ limit: String(WHOOP_PAGE_LIMIT) }); + if (query.start) params.set("start", query.start.toISOString()); + if (query.end) params.set("end", query.end.toISOString()); + if (nextToken) params.set("nextToken", nextToken); + + const pageStart = performance.now(); + const res = await safeFetch(`${WHOOP_API_BASE}${path}?${params}`, { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + const json = (await res.json().catch(() => null)) as + | WhoopCollection + | null; + const verdict = classifyWhoopResponse(res.status); + getEvent()?.addExternalCall({ + service: "whoop", + method: `${verb}(page=${pageCount})`, + duration_ms: Math.round(performance.now() - pageStart), + status: res.status, + error: + verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new WhoopApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } + + for (const r of json?.records ?? []) records.push(r); + nextToken = json?.next_token ?? null; + pageCount += 1; + } while (nextToken && pageCount < maxPages); + + return records; +} + +async function fetchSingle( + path: string, + accessToken: string, + verb: string, +): Promise { + const start = performance.now(); + const res = await safeFetch(`${WHOOP_API_BASE}${path}`, { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const json = (await res.json().catch(() => null)) as T | null; + const verdict = classifyWhoopResponse(res.status); + getEvent()?.addExternalCall({ + service: "whoop", + method: verb, + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new WhoopApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } + return json as T; +} + +export function fetchRecoveries( + accessToken: string, + query?: CollectionQuery, +): Promise { + return fetchCollection( + "/v2/recovery", + accessToken, + "fetchRecoveries", + query, + ); +} + +export function fetchSleeps( + accessToken: string, + query?: CollectionQuery, +): Promise { + return fetchCollection( + "/v2/activity/sleep", + accessToken, + "fetchSleeps", + query, + ); +} + +export function fetchCycles( + accessToken: string, + query?: CollectionQuery, +): Promise { + return fetchCollection( + "/v2/cycle", + accessToken, + "fetchCycles", + query, + ); +} + +export function fetchWorkouts( + accessToken: string, + query?: CollectionQuery, +): Promise { + return fetchCollection( + "/v2/activity/workout", + accessToken, + "fetchWorkouts", + query, + ); +} + +export function fetchBodyMeasurement( + accessToken: string, +): Promise { + return fetchSingle( + "/v2/user/measurement/body", + accessToken, + "fetchBodyMeasurement", + ); +} + +export function fetchProfile(accessToken: string): Promise { + return fetchSingle( + "/v2/user/profile/basic", + accessToken, + "fetchProfile", + ); +} + +// ─── Field → Measurement mapping ─────────────────────────────── +// The single source of truth is `src/lib/whoop/mapping.md` — keep both in +// sync when adding entries. + +/** Milliseconds → minutes. */ +const MS_TO_MIN = 1 / 60_000; +/** Kilojoules → kilocalories (workout energy). */ +export const KJ_TO_KCAL = 1 / 4.184; + +/** + * A single mapped reading destined for one `Measurement` row. The `source` + * (`WHOOP`) and `externalId` (`:`) are stamped by the + * sync layer (W3); the mapper only emits the type/value/unit/measuredAt and + * the field-tag that disambiguates the several rows derived from one resource. + */ +export interface MappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + /** Disambiguator appended to the resource uuid to form the externalId. */ + fieldTag: string; + /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; +} + +/** WHOOP sleep stage → HealthLog SleepStage (light→CORE, slow-wave→DEEP). */ +const SLEEP_STAGE_MAP: Record< + string, + { stage: MappedMeasurement["sleepStage"]; fieldTag: string } +> = { + total_light_sleep_time_milli: { stage: "CORE", fieldTag: "sleep_core" }, + total_slow_wave_sleep_time_milli: { stage: "DEEP", fieldTag: "sleep_deep" }, + total_rem_sleep_time_milli: { stage: "REM", fieldTag: "sleep_rem" }, + total_awake_time_milli: { stage: "AWAKE", fieldTag: "sleep_awake" }, + total_in_bed_time_milli: { stage: "IN_BED", fieldTag: "sleep_in_bed" }, +}; + +function round2(n: number): number { + return parseFloat(n.toFixed(2)); +} + +/** + * Map one WHOOP recovery record into Measurement readings. An unscored record + * (`score === null`) yields nothing. `recovery.score.recovery_score` → + * `RECOVERY_SCORE` (source WHOOP, distinct from the COMPUTED proxy); + * `hrv_rmssd_milli` → `HRV_RMSSD` (distinct from the SDNN + * `HEART_RATE_VARIABILITY`); RHR / SpO2 / skin-temp resolve through the + * cross-source picker. + */ +export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] { + if (!r.score) return []; + const measuredAt = new Date(r.updated_at); + const out: MappedMeasurement[] = [ + { + type: "RECOVERY_SCORE", + value: round2(r.score.recovery_score), + unit: "score", + measuredAt, + fieldTag: "recovery", + }, + { + type: "HRV_RMSSD", + value: round2(r.score.hrv_rmssd_milli), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + }, + { + type: "RESTING_HEART_RATE", + value: round2(r.score.resting_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + }, + ]; + if (typeof r.score.spo2_percentage === "number") { + out.push({ + type: "OXYGEN_SATURATION", + value: round2(r.score.spo2_percentage), + unit: "%", + measuredAt, + fieldTag: "spo2", + }); + } + if (typeof r.score.skin_temp_celsius === "number") { + out.push({ + type: "SKIN_TEMPERATURE", + value: round2(r.score.skin_temp_celsius), + unit: "celsius", + measuredAt, + fieldTag: "skin_temp", + }); + } + return out; +} + +/** + * Map one WHOOP sleep record into Measurement readings: per-stage + * `SLEEP_DURATION` rows (ms→min, one per stage), `SLEEP_NEED` (ms→min, summed + * components), the `SLEEP_*` percentage scores, and `RESPIRATORY_RATE`. + */ +export function mapSleep(s: WhoopSleep): MappedMeasurement[] { + if (!s.score) return []; + const measuredAt = new Date(s.end); + const out: MappedMeasurement[] = []; + + const stages = s.score.stage_summary; + for (const [key, mapping] of Object.entries(SLEEP_STAGE_MAP)) { + const ms = stages[key as keyof typeof stages]; + if (typeof ms !== "number") continue; + out.push({ + type: "SLEEP_DURATION", + value: round2(ms * MS_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag: mapping.fieldTag, + sleepStage: mapping.stage, + }); + } + + const need = s.score.sleep_needed; + const totalNeedMilli = + need.baseline_milli + + need.need_from_sleep_debt_milli + + need.need_from_recent_strain_milli + + need.need_from_recent_nap_milli; + out.push({ + type: "SLEEP_NEED", + value: round2(totalNeedMilli * MS_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag: "sleep_need", + }); + + const pct: Array<[string, number | undefined, string]> = [ + ["SLEEP_PERFORMANCE", s.score.sleep_performance_percentage, "sleep_perf"], + ["SLEEP_EFFICIENCY", s.score.sleep_efficiency_percentage, "sleep_eff"], + [ + "SLEEP_CONSISTENCY", + s.score.sleep_consistency_percentage, + "sleep_consistency", + ], + ]; + for (const [type, value, fieldTag] of pct) { + if (typeof value === "number") { + out.push({ + type, + value: round2(value), + unit: "%", + measuredAt, + fieldTag, + }); + } + } + + if (typeof s.score.respiratory_rate === "number") { + out.push({ + type: "RESPIRATORY_RATE", + value: round2(s.score.respiratory_rate), + unit: "breaths/min", + measuredAt, + fieldTag: "resp_rate", + }); + } + + return out; +} + +/** + * Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED + * `STRAIN_SCORE`) and `ENERGY_EXPENDITURE_KJ` (kept in native kJ). Energy is + * NOT converted to kcal here — that conversion is for the workout path only. + */ +export function mapCycle(c: WhoopCycle): MappedMeasurement[] { + if (!c.score) return []; + const measuredAt = new Date(c.start); + return [ + { + type: "DAY_STRAIN", + value: round2(c.score.strain), + unit: "score", + measuredAt, + fieldTag: "day_strain", + }, + { + type: "ENERGY_EXPENDITURE_KJ", + value: round2(c.score.kilojoule), + unit: "kJ", + measuredAt, + fieldTag: "energy_kj", + }, + ]; +} + +/** + * Field→Measurement mapping table (mirror of `mapping.md`). Documents which + * WHOOP source field becomes which MeasurementType + unit. Used as the + * single-glance reference and by the mapper tests; the mappers above are the + * executable form. + */ +export const WHOOP_FIELD_MAP: Record< + string, + { type: string; unit: string; factor?: number; note?: string } +> = { + "recovery.score.recovery_score": { type: "RECOVERY_SCORE", unit: "score" }, + "recovery.score.hrv_rmssd_milli": { type: "HRV_RMSSD", unit: "ms" }, + "recovery.score.resting_heart_rate": { + type: "RESTING_HEART_RATE", + unit: "bpm", + }, + "recovery.score.spo2_percentage": { + type: "OXYGEN_SATURATION", + unit: "%", + }, + "recovery.score.skin_temp_celsius": { + type: "SKIN_TEMPERATURE", + unit: "celsius", + }, + "sleep.score.stage_summary.*_time_milli": { + type: "SLEEP_DURATION", + unit: "minutes", + factor: MS_TO_MIN, + note: "one row per stage (light→CORE, slow-wave→DEEP, rem→REM, awake→AWAKE, in-bed→IN_BED)", + }, + "sleep.score.sleep_needed.*_milli": { + type: "SLEEP_NEED", + unit: "minutes", + factor: MS_TO_MIN, + note: "baseline + debt + strain + nap components summed", + }, + "sleep.score.sleep_performance_percentage": { + type: "SLEEP_PERFORMANCE", + unit: "%", + }, + "sleep.score.sleep_efficiency_percentage": { + type: "SLEEP_EFFICIENCY", + unit: "%", + }, + "sleep.score.sleep_consistency_percentage": { + type: "SLEEP_CONSISTENCY", + unit: "%", + }, + "sleep.score.respiratory_rate": { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + }, + "cycle.score.strain": { type: "DAY_STRAIN", unit: "score" }, + "cycle.score.kilojoule": { type: "ENERGY_EXPENDITURE_KJ", unit: "kJ" }, + "workout.score.strain": { + type: "WORKOUT_STRAIN", + unit: "score", + note: "preferentially stored in Workout.metadata, not a free-floating Measurement", + }, + "workout.score.kilojoule": { + type: "Workout.totalEnergyKcal", + unit: "kcal", + factor: KJ_TO_KCAL, + note: "kJ→kcal for the workout row", + }, + "body.weight_kilogram": { + type: "WEIGHT", + unit: "kg", + note: "picker ranks a real scale above WHOOP", + }, + "body.max_heart_rate": { + type: "WhoopConnection.maxHeartRate", + unit: "bpm", + note: "profile constant — stored on the connection, not a Measurement", + }, +}; diff --git a/src/lib/whoop/credentials.ts b/src/lib/whoop/credentials.ts new file mode 100644 index 000000000..cdb5f547d --- /dev/null +++ b/src/lib/whoop/credentials.ts @@ -0,0 +1,37 @@ +/** + * Helper to retrieve per-user WHOOP OAuth credentials from the database. + * Mirrors `src/lib/withings/credentials.ts`. + * + * WHOOP ships per-user BYO-keys: each self-hoster registers their own WHOOP + * dev app and pastes the client id/secret into Settings, stored encrypted on + * `User` (the per-app authorized-user cap makes a single shared app + * unworkable for a multi-operator product). + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import type { WhoopCredentials } from "./client"; + +/** + * Fetch and decrypt the user's WHOOP API credentials. + * Returns null if the user has not configured credentials. + */ +export async function getUserWhoopCredentials( + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + whoopClientIdEncrypted: true, + whoopClientSecretEncrypted: true, + }, + }); + + if (!user?.whoopClientIdEncrypted || !user?.whoopClientSecretEncrypted) { + return null; + } + + return { + clientId: decrypt(user.whoopClientIdEncrypted), + clientSecret: decrypt(user.whoopClientSecretEncrypted), + }; +} diff --git a/src/lib/whoop/mapping.md b/src/lib/whoop/mapping.md new file mode 100644 index 000000000..2660b2881 --- /dev/null +++ b/src/lib/whoop/mapping.md @@ -0,0 +1,65 @@ +# WHOOP field → Measurement mapping + +Single source of truth for the WHOOP v2 field → HealthLog `Measurement` +mapping. Keep this in sync with `WHOOP_FIELD_MAP` and the `mapRecovery` / +`mapSleep` / `mapCycle` mappers in `client.ts`. Every WHOOP row ingests +server-side with `source = WHOOP` and `externalId = :` +(the field-tag disambiguates the several measurements derived from one WHOOP +resource). WHOOP rows tag `deviceType = band`. + +Stance reference: `.planning/v1.11-build/epic-A-whoop-buildspec.md` §3.2 + §5. + +## Recovery (`/v2/recovery`) — record `updated_at` is `measuredAt` + +| Source field | MeasurementType | Unit | fieldTag | Note | +|---|---|---|---|---| +| `recovery.score.recovery_score` | `RECOVERY_SCORE` | score | `recovery` | Native WHOOP recovery. Same type as the COMPUTED proxy, distinguished by `source = WHOOP`. | +| `recovery.score.hrv_rmssd_milli` | `HRV_RMSSD` | ms | `hrv_rmssd` | RMSSD — kept distinct from the SDNN `HEART_RATE_VARIABILITY`. | +| `recovery.score.resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | Cross-source picker resolves WHOOP vs Apple vs Withings. | +| `recovery.score.spo2_percentage` | `OXYGEN_SATURATION` | % | `spo2` | Optional; already 0–100. | +| `recovery.score.skin_temp_celsius` | `SKIN_TEMPERATURE` | celsius | `skin_temp` | Optional; distinct from `BODY_TEMPERATURE`. | + +A recovery record with `score === null` (WHOOP still scoring) maps to nothing. + +## Sleep (`/v2/activity/sleep`) — record `end` is `measuredAt` + +| Source field | MeasurementType | Unit | fieldTag | sleepStage | +|---|---|---|---|---| +| `stage_summary.total_light_sleep_time_milli` | `SLEEP_DURATION` | minutes | `sleep_core` | `CORE` | +| `stage_summary.total_slow_wave_sleep_time_milli` | `SLEEP_DURATION` | minutes | `sleep_deep` | `DEEP` | +| `stage_summary.total_rem_sleep_time_milli` | `SLEEP_DURATION` | minutes | `sleep_rem` | `REM` | +| `stage_summary.total_awake_time_milli` | `SLEEP_DURATION` | minutes | `sleep_awake` | `AWAKE` | +| `stage_summary.total_in_bed_time_milli` | `SLEEP_DURATION` | minutes | `sleep_in_bed` | `IN_BED` | +| `sleep_needed.{baseline,debt,strain,nap}_milli` (summed) | `SLEEP_NEED` | minutes | `sleep_need` | — | +| `sleep_performance_percentage` | `SLEEP_PERFORMANCE` | % | `sleep_perf` | — | +| `sleep_efficiency_percentage` | `SLEEP_EFFICIENCY` | % | `sleep_eff` | — | +| `sleep_consistency_percentage` | `SLEEP_CONSISTENCY` | % | `sleep_consistency` | — | +| `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | — | + +Stage durations are ms→minutes (`÷ 60000`). One row per stage per night +(same pattern as Apple Health). Percentage / respiratory fields are optional. + +## Cycle (`/v2/cycle`) — record `start` is `measuredAt` + +| Source field | MeasurementType | Unit | fieldTag | Note | +|---|---|---|---|---| +| `cycle.score.strain` | `DAY_STRAIN` | score | `day_strain` | 0–21 WHOOP scale. Distinct from the COMPUTED `STRAIN_SCORE` (0–100 TRIMP proxy). | +| `cycle.score.kilojoule` | `ENERGY_EXPENDITURE_KJ` | kJ | `energy_kj` | Native kJ — NOT converted to kcal (the workout path converts). | + +## Workout (`/v2/activity/workout`) — into `Workout`, not `Measurement` + +| Source field | Destination | Unit | Note | +|---|---|---|---| +| `workout.score.strain` | `Workout.metadata` (`WORKOUT_STRAIN` type exists for the rare detached case) | score | Tied to the workout row so a phantom strain row never survives workout dedup. | +| `workout.score.kilojoule` | `Workout.totalEnergyKcal` | kcal | kJ→kcal (`÷ 4.184`). | +| `workout.score.{zone_durations,percent_recorded,distance_meter,altitude_*}` | `Workout.metadata` | — | HR-zone durations + recording quality + altitude. | + +Workout ingest + dedup lands in W3/W6 (the E-slice); this module only owns the +fetch + the score→energy conversion factor (`KJ_TO_KCAL`). + +## Body / profile (single objects, no pagination) + +| Source field | Destination | Unit | Note | +|---|---|---|---| +| `body.weight_kilogram` | `WEIGHT` | kg | Picker ranks a real scale above WHOOP. | +| `body.max_heart_rate` | `WhoopConnection.maxHeartRate` | bpm | Profile constant — stored on the connection, not a `Measurement`. | diff --git a/src/lib/whoop/oauth-state.ts b/src/lib/whoop/oauth-state.ts new file mode 100644 index 000000000..50ed51ef0 --- /dev/null +++ b/src/lib/whoop/oauth-state.ts @@ -0,0 +1,34 @@ +/** + * v1.11.0 — shared constants + mint helper for the WHOOP OAuth state nonce + * ledger. Mirrors `src/lib/withings/oauth-state.ts`. + * + * The nonce is random + opaque; a short-lived `WhoopOAuthState` row carries + * the `(nonce → userId)` mapping so the user id never leaks into request logs + * or network captures via the OAuth `state` param. The connect route, the + * callback route, and the cleanup cron all read these constants so the + * contract has one source of truth and the unit tests can pin it without + * importing either route file. + */ +import { randomBytes } from "node:crypto"; + +/** + * Cookie name carrying the in-flight state nonce. httpOnly + Secure + + * sameSite:lax at the connect route (mirrors the Withings cookie shape). + */ +export const WHOOP_OAUTH_STATE_COOKIE = "whoop_state" as const; + +/** + * 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to + * cover a user approving the WHOOP consent prompt; short enough that an + * abandoned handshake doesn't strand a row for hours. + */ +export const WHOOP_OAUTH_STATE_TTL_MS = 10 * 60 * 1000; + +/** + * Mint a fresh state nonce. 16 random bytes → 22 base64url chars without + * padding — 128 bits of entropy, aligned with the OAuth 2.0 §10.10 + * "at least 128 bits" recommendation. + */ +export function mintWhoopOAuthStateNonce(): string { + return randomBytes(16).toString("base64url"); +} diff --git a/src/lib/whoop/response-classifier.ts b/src/lib/whoop/response-classifier.ts new file mode 100644 index 000000000..793ed3510 --- /dev/null +++ b/src/lib/whoop/response-classifier.ts @@ -0,0 +1,143 @@ +/** + * Off-response classifier for WHOOP API replies. + * + * Mirrors the Withings classifier (`src/lib/withings/response-classifier.ts`) + * but speaks the WHOOP wire instead. WHOOP, unlike Withings, signals failure + * through the HTTP status code itself (no `status` field embedded in a 200 + * body), so this classifier is HTTP-status-driven. + * + * Classification buckets: + * + * - `success` → HTTP 2xx + * - `transient` → retryable: HTTP 429 (rate-limited — WHOOP caps at + * 100 req/min + 10 000 req/day per app), 5xx upstream + * outages, 3xx CDN/network glitches, and an empty / + * off-spec body where we never reached the envelope. + * - `reauth_required` → HTTP 401 / 403 — the access token is rejected or the + * grant was revoked; the user must redo OAuth. + * - `persistent` → any other 4xx (400, 404, 422, …) — a malformed + * request or a contract mismatch that retrying without + * operator intervention will not fix. + * + * The classifier is conservative: anything it doesn't recognise defaults to + * `transient` so a single unknown response doesn't hard-disable the + * integration. The 3-strike admin-alert ladder in `recordSyncFailure` + * already catches the "keeps happening" case for recurring transients. + */ + +/** Outcome buckets for a WHOOP response. Same shape as Withings. */ +export type WhoopClassification = + | "success" + | "transient" + | "reauth_required" + | "persistent"; + +export interface ClassifiedWhoopResponse { + classification: WhoopClassification; + /** The HTTP status code that drove the verdict (undefined if none seen). */ + httpStatus: number | undefined; + /** Short human-readable label for logs / audit details. */ + reason: string; +} + +/** + * Classify a single WHOOP API response by its HTTP status code. + * + * @param httpStatus The HTTP status from fetch. + */ +export function classifyWhoopResponse( + httpStatus: number, +): ClassifiedWhoopResponse { + if (httpStatus >= 200 && httpStatus < 300) { + return { classification: "success", httpStatus, reason: "ok" }; + } + // 429 is rate-limit — honour `X-RateLimit-*` for backoff at the call site, + // but the verdict is a plain transient so the next sync retries. + if (httpStatus === 429) { + return { classification: "transient", httpStatus, reason: "http_429" }; + } + // 401/403 — the bearer token is rejected or the grant revoked. The user has + // to reconnect; do not retry silently. + if (httpStatus === 401 || httpStatus === 403) { + return { + classification: "reauth_required", + httpStatus, + reason: `http_${httpStatus}`, + }; + } + // Any other 4xx is a malformed request / contract mismatch — persistent. + if (httpStatus >= 400 && httpStatus < 500) { + return { + classification: "persistent", + httpStatus, + reason: `http_${httpStatus}`, + }; + } + // 5xx upstream outage — retryable. + if (httpStatus >= 500 && httpStatus < 600) { + return { + classification: "transient", + httpStatus, + reason: `http_${httpStatus}`, + }; + } + // 3xx (a CDN / redirect glitch — safeFetch uses redirect:"manual", so a + // surfaced 3xx is unexpected) and everything else defaults to transient. + return { + classification: "transient", + httpStatus, + reason: `http_${httpStatus}_unknown`, + }; +} + +/** + * Typed Error subclass carrying the classification verdict so downstream + * `try/catch` blocks can branch without re-parsing the message. Mirrors + * `WithingsApiError`. + */ +export class WhoopApiError extends Error { + readonly classification: WhoopClassification; + readonly httpStatus: number | undefined; + readonly reason: string; + readonly verb: string; + + constructor(opts: { + verb: string; + classification: WhoopClassification; + httpStatus: number | undefined; + reason: string; + upstreamError?: string; + }) { + const statusLabel = + typeof opts.httpStatus === "number" ? opts.httpStatus : "?"; + const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : ""; + // Cap the message at 1024 chars in the constructor (same bound as + // WithingsApiError) so a misbehaving upstream returning a multi-MB error + // body can't bloat an AuditLog row downstream. + const raw = `WHOOP ${opts.verb} error: ${statusLabel}${errSegment}`; + super(raw.slice(0, 1024)); + this.name = "WhoopApiError"; + this.verb = opts.verb; + this.classification = opts.classification; + this.httpStatus = opts.httpStatus; + this.reason = opts.reason; + } +} + +/** + * Read the classification from any caught error. Returns `transient` for a + * non-`WhoopApiError` input so a call site surfacing a plain `Error` retries + * rather than permanently disabling the integration. Falls back to parsing the + * HTTP status out of the legacy `"WHOOP error: "` message shape + * for callers that lose the original prototype across a pg-boss retry. + */ +export function classifyWhoopError(err: unknown): WhoopClassification { + if (err instanceof WhoopApiError) return err.classification; + + const msg = err instanceof Error ? err.message : String(err); + const m = /WHOOP\s+\w+\s+error:\s*(\d+)/.exec(msg); + if (!m) return "transient"; + const status = Number.parseInt(m[1]!, 10); + if (!Number.isFinite(status)) return "transient"; + return classifyWhoopResponse(status).classification; +}