mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(whoop): WHOOP v2 API client, response classifier, credential resolution
Add the WHOOP integration data layer on copy-of-Withings rails: - client.ts: hand-rolled fetch over safeFetch (no SDK) — OAuth authorize URL / code exchange / rotating-refresh, the four v2 collection fetchers (recovery, sleep, cycle, workout) with next_token pagination, single body-measurement + profile reads, and the field->Measurement mappers (RECOVERY_SCORE/HRV_RMSSD/RESTING_HEART_RATE/OXYGEN_SATURATION/ SKIN_TEMPERATURE, per-stage SLEEP_DURATION ms->min, SLEEP_NEED/SLEEP_*, DAY_STRAIN/ENERGY_EXPENDITURE_KJ, source=WHOOP). Day strain maps to DAY_STRAIN and RMSSD to HRV_RMSSD so the device-native values never share a bucket with the COMPUTED STRAIN_SCORE / SDNN proxies. - response-classifier.ts: HTTP-status-driven success/transient/ reauth_required/persistent verdicts + WhoopApiError, mirroring the Withings reason-tag classifier. - credentials.ts: per-user BYO-key resolution, decrypting the client id/secret from the User columns. - oauth-state.ts: opaque-nonce mint helper + cookie/TTL constants. - mapping.md: field->Measurement reference, kept in sync with the mappers. Unit tests cover the classifier, pagination, every fetcher, the mappers (ms->min, kJ->kcal factor, stage mapping, unscored-record handling), and credential resolution against mocked WHOOP JSON. No routes / OAuth flow / sync workers / migration yet.
This commit is contained in:
1 parent
999f2b6b08
commit
2258cbb489
9 files changed
+1518
No files matched your search
@@ -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<unknown>, 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(<x>)` 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<WhoopTokenResponse> {
|
||||
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<WhoopTokenResponse> {
|
||||
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<WhoopTokenResponse> {
|
||||
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<T> {
|
||||
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<string, number>;
|
||||
} | 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<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
verb: string,
|
||||
query: CollectionQuery = {},
|
||||
): Promise<T[]> {
|
||||
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<T>
|
||||
| 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<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
verb: string,
|
||||
): Promise<T> {
|
||||
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<WhoopRecovery[]> {
|
||||
return fetchCollection<WhoopRecovery>(
|
||||
"/v2/recovery",
|
||||
accessToken,
|
||||
"fetchRecoveries",
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchSleeps(
|
||||
accessToken: string,
|
||||
query?: CollectionQuery,
|
||||
): Promise<WhoopSleep[]> {
|
||||
return fetchCollection<WhoopSleep>(
|
||||
"/v2/activity/sleep",
|
||||
accessToken,
|
||||
"fetchSleeps",
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCycles(
|
||||
accessToken: string,
|
||||
query?: CollectionQuery,
|
||||
): Promise<WhoopCycle[]> {
|
||||
return fetchCollection<WhoopCycle>(
|
||||
"/v2/cycle",
|
||||
accessToken,
|
||||
"fetchCycles",
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchWorkouts(
|
||||
accessToken: string,
|
||||
query?: CollectionQuery,
|
||||
): Promise<WhoopWorkout[]> {
|
||||
return fetchCollection<WhoopWorkout>(
|
||||
"/v2/activity/workout",
|
||||
accessToken,
|
||||
"fetchWorkouts",
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchBodyMeasurement(
|
||||
accessToken: string,
|
||||
): Promise<WhoopBodyMeasurement> {
|
||||
return fetchSingle<WhoopBodyMeasurement>(
|
||||
"/v2/user/measurement/body",
|
||||
accessToken,
|
||||
"fetchBodyMeasurement",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchProfile(accessToken: string): Promise<WhoopProfile> {
|
||||
return fetchSingle<WhoopProfile>(
|
||||
"/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` (`<resource-uuid>:<fieldTag>`) 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",
|
||||
},
|
||||
};
|
||||
@@ -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<WhoopCredentials | null> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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 = <resource-uuid>:<fieldTag>`
|
||||
(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`. |
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 <verb> error: <status>"` 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;
|
||||
}
|
||||
Reference in new issue
Block a user