feat(whoop): pg-boss sync workers + self-converging backfill

Build the WHOOP sync layer on the W2 client, mirroring the Withings
queues. A per-resource sync (recovery/sleep/cycle/workout) pulls since
the last cursor with a 24h overlap on recovery/sleep to absorb WHOOP's
after-the-fact re-scoring, maps via the W2 mappers, and upserts
Measurements with source=WHOOP keyed on (userId,type,source,externalId)
so a re-post overwrites in place. Workouts upsert into Workout with
per-workout strain + HR zones in metadata and kJ->kcal energy.

Token refresh persists BOTH rotated tokens (WHOOP invalidates the prior
access and refresh token on every refresh). A self-converging boot
backfill enqueues one full-history sync per un-backfilled connection and
stamps backfillCompletedAt; the discovery query drops completed accounts,
idempotent across reboots.

Every new queue is registered in the worker's allQueues + cron schedule
(recovery/sleep/workout poll catch-all, cycle poll-only, backfill,
oauth-state sweep). IntegrationStatus gains the whoop key + label.

Tests: rotating-refresh persistence, idempotent measurement upsert,
incremental-window overlap, backfill self-convergence, and a
queue-registration inventory guard.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 01:21:42 +02:00
1 parent 6aa3c75d27
commit 337f024a87
12 files changed
+1485 -2

No files matched your search

+6 -2
View File
@@ -39,7 +39,7 @@ import { auditLog } from "@/lib/auth/audit";
import { getEvent } from "@/lib/logging/context";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
export type IntegrationKey = "withings" | "moodlog";
export type IntegrationKey = "withings" | "whoop" | "moodlog";
/**
* Failure kinds carried into `recordSyncFailure`.
@@ -820,7 +820,11 @@ export function formatAdminAlertPayload(input: AlertInput): {
metadata: Record<string, unknown>;
} {
const integrationLabel =
input.integration === "withings" ? "Withings" : "moodLog";
input.integration === "withings"
? "Withings"
: input.integration === "whoop"
? "WHOOP"
: "moodLog";
const subjectLabel = input.subjectLabel ?? input.userId;
const { reason: reasonLabel, action: actionLabel } =
FAILURE_KIND_COPY[input.kind];
@@ -0,0 +1,101 @@
/**
* v1.11.0 — WHOOP backfill self-convergence tests (mocked).
* - discovery only matches un-backfilled connections;
* - a completed backfill stamps `backfillCompletedAt` so the next discovery
* pass drops the account (idempotent across reboots);
* - the discovery enqueue is singleton-keyed per user.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { prismaMock, bossSend, syncUserWhoop } = vi.hoisted(() => ({
prismaMock: {
whoopConnection: {
findMany: vi.fn(),
update: vi.fn(),
},
},
bossSend: vi.fn(),
syncUserWhoop: vi.fn(),
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => ({ send: bossSend }),
}));
vi.mock("@/lib/whoop/sync", () => ({
syncUserWhoop: (...a: unknown[]) => syncUserWhoop(...a),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: () => {},
getEvent: () => null,
}));
import {
enqueueBootTimeWhoopBackfill,
runWhoopBackfillForUser,
} from "../whoop-backfill";
beforeEach(() => {
vi.clearAllMocks();
});
describe("enqueueBootTimeWhoopBackfill — discovery", () => {
it("queries only un-backfilled connections", async () => {
prismaMock.whoopConnection.findMany.mockResolvedValue([
{ userId: "u1" },
{ userId: "u2" },
]);
bossSend.mockResolvedValue("job-id");
const result = await enqueueBootTimeWhoopBackfill();
expect(prismaMock.whoopConnection.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { backfillCompletedAt: null },
}),
);
expect(result.enqueued).toBe(2);
// Singleton-keyed per user so a fast restart never double-enqueues.
expect(bossSend).toHaveBeenCalledWith(
"whoop-backfill",
expect.objectContaining({ userId: "u1" }),
expect.objectContaining({ singletonKey: "whoop-backfill|u1" }),
);
});
it("self-converges: no un-backfilled connections → nothing enqueued", async () => {
prismaMock.whoopConnection.findMany.mockResolvedValue([]);
const result = await enqueueBootTimeWhoopBackfill();
expect(result.enqueued).toBe(0);
expect(bossSend).not.toHaveBeenCalled();
});
it("never throws — surfaces a discovery error through the result value", async () => {
prismaMock.whoopConnection.findMany.mockRejectedValue(new Error("db down"));
const result = await enqueueBootTimeWhoopBackfill();
expect(result.error).toBe("db down");
expect(result.enqueued).toBe(0);
});
});
describe("runWhoopBackfillForUser", () => {
it("runs a full sync and stamps backfillCompletedAt", async () => {
syncUserWhoop.mockResolvedValue(123);
prismaMock.whoopConnection.update.mockResolvedValue({});
const { imported } = await runWhoopBackfillForUser("u1");
expect(imported).toBe(123);
expect(syncUserWhoop).toHaveBeenCalledWith("u1", { fullSync: true });
const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0];
expect(updateArg.where).toEqual({ userId: "u1" });
expect(updateArg.data.backfillCompletedAt).toBeInstanceOf(Date);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* v1.11.0 — pg-boss queue registration for the WHOOP sync layer.
*
* The reminder-worker imports heavy infrastructure (pg-boss, Prisma adapter,
* notification dispatchers, …) so we don't boot the whole worker here. Instead
* we read the source as text and assert the queue constants + cron schedules +
* handler wiring + allQueues + boot-enqueue are present — the fast guard that
* catches the v1.4.37 dead-queue class (a queue declared but never registered
* in `allQueues`, which silently never drains).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
const WHOOP_QUEUE_CONSTS = [
"WHOOP_RECOVERY_SYNC_QUEUE",
"WHOOP_SLEEP_SYNC_QUEUE",
"WHOOP_WORKOUT_SYNC_QUEUE",
"WHOOP_CYCLE_SYNC_QUEUE",
"WHOOP_BACKFILL_QUEUE",
"WHOOP_OAUTH_STATE_CLEANUP_QUEUE",
] as const;
describe("reminder-worker — WHOOP sync queues", () => {
it("declares every WHOOP sync queue constant", () => {
expect(source).toMatch(
/WHOOP_RECOVERY_SYNC_QUEUE\s*=\s*["']whoop-recovery-sync["']/,
);
expect(source).toMatch(
/WHOOP_SLEEP_SYNC_QUEUE\s*=\s*["']whoop-sleep-sync["']/,
);
expect(source).toMatch(
/WHOOP_WORKOUT_SYNC_QUEUE\s*=\s*["']whoop-workout-sync["']/,
);
expect(source).toMatch(
/WHOOP_CYCLE_SYNC_QUEUE\s*=\s*["']whoop-cycle-sync["']/,
);
});
it("registers EVERY WHOOP queue in allQueues (v1.4.37 dead-queue guard)", () => {
// Isolate the allQueues array body so the assertion can't be satisfied by
// an incidental mention elsewhere in the file.
const block = /const allQueues = \[([\s\S]*?)\];/.exec(source);
expect(block).not.toBeNull();
const body = block![1]!;
for (const q of WHOOP_QUEUE_CONSTS) {
expect(body).toContain(q);
}
});
it("schedules the four poll crons + the daily oauth-state sweep", () => {
expect(source).toMatch(
/\[WHOOP_RECOVERY_SYNC_QUEUE,\s*WHOOP_RECOVERY_SYNC_CRON\]/,
);
expect(source).toMatch(
/\[WHOOP_SLEEP_SYNC_QUEUE,\s*WHOOP_SLEEP_SYNC_CRON\]/,
);
expect(source).toMatch(
/\[WHOOP_WORKOUT_SYNC_QUEUE,\s*WHOOP_WORKOUT_SYNC_CRON\]/,
);
expect(source).toMatch(
/\[WHOOP_CYCLE_SYNC_QUEUE,\s*WHOOP_CYCLE_SYNC_CRON\]/,
);
expect(source).toMatch(
/\[WHOOP_OAUTH_STATE_CLEANUP_QUEUE,\s*WHOOP_OAUTH_STATE_CLEANUP_CRON\]/,
);
});
it("registers a boss.work handler for every WHOOP sync queue", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,160}WHOOP_RECOVERY_SYNC_QUEUE[\s\S]{0,160}handleWhoopRecoverySync/,
);
expect(source).toMatch(
/boss\.work[\s\S]{0,160}WHOOP_SLEEP_SYNC_QUEUE[\s\S]{0,160}handleWhoopSleepSync/,
);
expect(source).toMatch(
/boss\.work[\s\S]{0,160}WHOOP_WORKOUT_SYNC_QUEUE[\s\S]{0,160}handleWhoopWorkoutSync/,
);
expect(source).toMatch(
/boss\.work[\s\S]{0,160}WHOOP_CYCLE_SYNC_QUEUE[\s\S]{0,160}handleWhoopCycleSync/,
);
expect(source).toMatch(
/boss\.work[\s\S]{0,200}WHOOP_BACKFILL_QUEUE[\s\S]{0,200}runWhoopBackfillForUser/,
);
});
it("wires the self-converging WHOOP backfill boot discovery", () => {
expect(source).toMatch(/enqueueBootTimeWhoopBackfill\(\)/);
});
it("imports the WHOOP per-resource sync routines", () => {
expect(source).toMatch(
/from\s*["']@\/lib\/whoop\/sync-recovery["']/,
);
expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-cycle["']/);
expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-workout["']/);
});
});
+225
View File
@@ -20,6 +20,18 @@ import {
import { syncUserMeasurements } from "@/lib/withings/sync";
import { syncUserActivity } from "@/lib/withings/sync-activity";
import { syncUserSleep } from "@/lib/withings/sync-sleep";
import { syncUserRecovery } from "@/lib/whoop/sync-recovery";
import { syncUserSleep as syncWhoopSleep } from "@/lib/whoop/sync-sleep";
import { syncUserCycle } from "@/lib/whoop/sync-cycle";
import { syncUserWorkout } from "@/lib/whoop/sync-workout";
import {
WHOOP_BACKFILL_QUEUE,
WHOOP_BACKFILL_CONCURRENCY,
runWhoopBackfillForUser,
enqueueBootTimeWhoopBackfill,
type WhoopBackfillPayload,
} from "@/lib/jobs/whoop-backfill";
import { cleanupExpiredWhoopOAuthStates } from "@/lib/jobs/whoop-oauth-state-cleanup";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
@@ -259,6 +271,24 @@ const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin)
// Withings approval tab without bouncing back to the callback URL.
const WITHINGS_OAUTH_STATE_CLEANUP_QUEUE = "withings-oauth-state-cleanup";
const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *";
// v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net, mirroring
// the Withings activity/sleep crons. Recovery / sleep / workout each have a
// WHOOP webhook (`*.updated`) that enqueues the matching per-resource job; the
// crons below are the catch-net for dropped deliveries. Cycle has NO webhook,
// so its cron is the only driver. Minutes are staggered off the Withings crons
// (:00/:15) to spread DB load.
const WHOOP_RECOVERY_SYNC_QUEUE = "whoop-recovery-sync";
const WHOOP_RECOVERY_SYNC_CRON = "5 * * * *"; // every hour at :05
const WHOOP_SLEEP_SYNC_QUEUE = "whoop-sleep-sync";
const WHOOP_SLEEP_SYNC_CRON = "20 * * * *"; // every hour at :20
const WHOOP_WORKOUT_SYNC_QUEUE = "whoop-workout-sync";
const WHOOP_WORKOUT_SYNC_CRON = "35 * * * *"; // every hour at :35
const WHOOP_CYCLE_SYNC_QUEUE = "whoop-cycle-sync";
const WHOOP_CYCLE_SYNC_CRON = "50 * * * *"; // every hour at :50 (poll-only)
// v1.11.0 — daily sweep for the WHOOP OAuth state ledger. Slots at 03:22,
// next to the Withings sweep (03:20), inside the maintenance window.
const WHOOP_OAUTH_STATE_CLEANUP_QUEUE = "whoop-oauth-state-cleanup";
const WHOOP_OAUTH_STATE_CLEANUP_CRON = "22 3 * * *";
const OFFHOST_BACKUP_QUEUE = "data-backup-offhost";
// 02:30 Europe/Berlin — runs after audit-log/idempotency cleanups so old
// rows are gone before they're snapshotted, but before the existing
@@ -1070,6 +1100,89 @@ async function handleWithingsSleepSync(jobs: Job<WithingsSleepSyncPayload>[]) {
});
}
/**
* v1.11.0 — WHOOP per-resource sync payload. Two enqueue paths feed each
* WHOOP sync queue:
*
* 1. Webhook (`recovery.updated` / `sleep.updated` / `workout.updated`) —
* payload carries `userId`, the handler syncs that one user.
* 2. Cron — payload has no `userId`; the handler iterates every WHOOP
* connection and re-syncs each, catching dropped webhook deliveries.
* Cycle has no webhook, so its cron is the sole driver.
*/
interface WhoopSyncPayload {
userId?: string;
}
/**
* Shared driver for the per-resource WHOOP sync handlers. Resolves the target
* set (per-user from the webhook payload, or every connection on the cron
* tick) and runs `syncFn` per user. One user's parked-at-reauth state never
* starves the rest of the cohort on the cron path.
*/
async function runWhoopResourceSync(
taskName: string,
jobs: Job<WhoopSyncPayload>[],
syncFn: (userId: string) => Promise<number>,
): Promise<void> {
await withBackgroundEvent(taskName, async (evt) => {
const prisma = getWorkerPrisma();
try {
const targets: Array<{ userId: string }> = [];
for (const job of jobs) {
if (job.data?.userId) targets.push({ userId: job.data.userId });
}
if (targets.length === 0) {
const connections = await prisma.whoopConnection.findMany({
select: { userId: true },
});
targets.push(...connections);
}
if (targets.length === 0) return;
let usersSynced = 0;
let measurementsImported = 0;
for (const { userId } of targets) {
try {
measurementsImported += await syncFn(userId);
usersSynced++;
} catch (err) {
evt.addWarning(`${taskName} failed for user ${userId}: ${err}`);
}
}
evt.setBackground({
task_name: taskName,
result: {
users_synced: usersSynced,
total: targets.length,
measurements_imported: measurementsImported,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
function handleWhoopRecoverySync(jobs: Job<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_recovery_sync", jobs, syncUserRecovery);
}
function handleWhoopSleepSync(jobs: Job<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_sleep_sync", jobs, syncWhoopSleep);
}
function handleWhoopWorkoutSync(jobs: Job<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_workout_sync", jobs, syncUserWorkout);
}
function handleWhoopCycleSync(jobs: Job<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle);
}
async function handleGeneralStatusGenerate(jobs: Job<GeneralStatusPayload>[]) {
void jobs;
await withBackgroundEvent("job.insights.general", async (evt) => {
@@ -1562,6 +1675,25 @@ async function handleWithingsOAuthStateCleanup(
});
}
interface WhoopOAuthStateCleanupPayload {
triggeredAt?: string;
}
async function handleWhoopOAuthStateCleanup(
jobs: Job<WhoopOAuthStateCleanupPayload>[],
) {
void jobs;
await withBackgroundEvent("job.whoop_oauth_state_cleanup", async (evt) => {
const p = getWorkerPrisma();
try {
const deleted = await cleanupExpiredWhoopOAuthStates(p);
evt.addMeta("whoop_oauth_state_cleanup_deleted", deleted);
} catch (err) {
evt.addWarning(`whoop-oauth-state-cleanup failed: ${err}`);
}
});
}
async function handleHostMetricSample(jobs: Job<HostMetricSamplePayload>[]) {
void jobs;
await withBackgroundEvent("job.host_metric_sample", async (evt) => {
@@ -2065,6 +2197,21 @@ export async function startReminderWorker() {
// crons; without this entry the schedule below silently no-ops
// and abandoned rows pile up.
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
// v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net for
// recovery / sleep / workout; cycle is poll-only (no WHOOP webhook).
// Every queue MUST be registered here or pg-boss never provisions it and
// both the webhook enqueue AND the cron schedule below silently no-op (the
// v1.4.37 dead-queue class).
WHOOP_RECOVERY_SYNC_QUEUE,
WHOOP_SLEEP_SYNC_QUEUE,
WHOOP_WORKOUT_SYNC_QUEUE,
WHOOP_CYCLE_SYNC_QUEUE,
// v1.11.0 — self-converging boot backfill for newly connected WHOOP
// accounts. Discovery enqueues one full-history sync per un-backfilled
// connection; idempotent across reboots.
WHOOP_BACKFILL_QUEUE,
// v1.11.0 — daily sweep for the WHOOP OAuth state ledger.
WHOOP_OAUTH_STATE_CLEANUP_QUEUE,
OFFHOST_BACKUP_QUEUE,
HOST_METRIC_QUEUE,
FEEDBACK_AGGREGATOR_QUEUE,
@@ -2219,6 +2366,15 @@ export async function startReminderWorker() {
[IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON],
[AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON],
[WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_CRON],
// v1.11.0 — WHOOP poll-fallback crons. Recovery/sleep/workout catch
// dropped webhooks; cycle is the sole driver (no webhook). Staggered off
// the Withings crons so the hourly ticks don't pile up on one boss poll.
[WHOOP_RECOVERY_SYNC_QUEUE, WHOOP_RECOVERY_SYNC_CRON],
[WHOOP_SLEEP_SYNC_QUEUE, WHOOP_SLEEP_SYNC_CRON],
[WHOOP_WORKOUT_SYNC_QUEUE, WHOOP_WORKOUT_SYNC_CRON],
[WHOOP_CYCLE_SYNC_QUEUE, WHOOP_CYCLE_SYNC_CRON],
// v1.11.0 — daily 03:22 Europe/Berlin prune for expired WHOOP OAuth states.
[WHOOP_OAUTH_STATE_CLEANUP_QUEUE, WHOOP_OAUTH_STATE_CLEANUP_CRON],
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
[HOST_METRIC_QUEUE, HOST_METRIC_CRON],
[FEEDBACK_AGGREGATOR_QUEUE, FEEDBACK_AGGREGATOR_CRON],
@@ -2300,6 +2456,51 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleWithingsSleepSync,
);
// v1.11.0 — WHOOP per-resource sync handlers. Webhook-driven per-user +
// cron full-iteration. Serial concurrency so a backfill-heavy tick never
// crowds the request pool and stays inside WHOOP's 100 req/min app cap.
await boss.work<WhoopSyncPayload>(
WHOOP_RECOVERY_SYNC_QUEUE,
{ localConcurrency: 1 },
handleWhoopRecoverySync,
);
await boss.work<WhoopSyncPayload>(
WHOOP_SLEEP_SYNC_QUEUE,
{ localConcurrency: 1 },
handleWhoopSleepSync,
);
await boss.work<WhoopSyncPayload>(
WHOOP_WORKOUT_SYNC_QUEUE,
{ localConcurrency: 1 },
handleWhoopWorkoutSync,
);
await boss.work<WhoopSyncPayload>(
WHOOP_CYCLE_SYNC_QUEUE,
{ localConcurrency: 1 },
handleWhoopCycleSync,
);
// v1.11.0 — self-converging WHOOP backfill. The boot enqueue below sends one
// full-history sync per un-backfilled connection; this handler runs it and
// stamps `backfillCompletedAt` so the discovery query drops the account.
await boss.work<WhoopBackfillPayload>(
WHOOP_BACKFILL_QUEUE,
{ localConcurrency: WHOOP_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
const { imported } = await runWhoopBackfillForUser(userId);
workerLog(
"info",
`[whoop-backfill] user=${userId} imported=${imported}`,
);
}
},
);
await boss.work<WhoopOAuthStateCleanupPayload>(
WHOOP_OAUTH_STATE_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleWhoopOAuthStateCleanup,
);
await boss.work<GeneralStatusPayload>(
GENERAL_STATUS_QUEUE,
{ localConcurrency: 1 },
@@ -2915,6 +3116,30 @@ export async function startReminderWorker() {
);
}
// v1.11.0 — fire-and-forget boot discovery for the WHOOP backfill. Finds
// every WHOOP connection not yet backfilled and enqueues one full-history
// sync per account. Idempotent across reboots: a completed backfill stamps
// `backfillCompletedAt`, dropping the connection from the discovery set.
// Errors come back through the helper's result value — the worker boot never
// fails because of a backfill miss.
try {
const { enqueued, skipped, error } = await enqueueBootTimeWhoopBackfill();
if (error) {
workerLog("error", `[whoop-backfill] boot discovery failed: ${error}`);
} else {
workerLog(
"info",
`[whoop-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[whoop-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.7.0 — fire-and-forget boot discovery for the daily-mean
// consolidation pass. Finds every user holding live per-sample
// high-frequency mean-type rows and enqueues one job per account.
+122
View File
@@ -0,0 +1,122 @@
/**
* v1.11.0 — pg-boss queue + boot-time self-converging backfill for newly
* connected WHOOP accounts. Modelled on the `rollup-full-backfill` /
* step-consolidation boot pattern: a discovery query enqueues one job per
* connection that has NOT yet been backfilled, the per-user handler runs a
* full-history sync, and the pass is idempotent across reboots (the discovery
* predicate `backfill_completed_at IS NULL` drops a connection once its
* backfill finishes).
*
* The full sync walks each collection from the far-past anchor to now via
* `next_token` (the client caps `limit` at 25 and stops on an empty cursor).
* Multi-year history is low-thousands of requests — well under WHOOP's
* 10 000 req/day app cap.
*
* The queue name MUST be registered in `allQueues` in
* `src/lib/jobs/reminder-worker.ts` or pg-boss never provisions it and the
* boot enqueue silently never drains (the v1.4.37 dead-queue class).
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { syncUserWhoop } from "@/lib/whoop/sync";
export const WHOOP_BACKFILL_QUEUE = "whoop-backfill";
/**
* Serial concurrency — a backfill walks years of history for one account and
* is rate-bounded by WHOOP's 100 req/min cap; concurrency-1 keeps it from
* crowding the request pool, matching `ROLLUP_FULL_BACKFILL_CONCURRENCY`.
*/
export const WHOOP_BACKFILL_CONCURRENCY = 1;
export interface WhoopBackfillPayload {
userId: string;
enqueuedAt: string;
}
/**
* Per-user backfill handler. Runs a full-history sync for one account and
* stamps `backfillCompletedAt` so the discovery query drops it. Idempotent:
* the per-resource upserts are key-stable, so a re-run (e.g. a reboot mid-walk)
* overwrites rather than duplicating.
*/
export async function runWhoopBackfillForUser(
userId: string,
): Promise<{ imported: number }> {
const imported = await syncUserWhoop(userId, { fullSync: true });
await prisma.whoopConnection.update({
where: { userId },
data: { backfillCompletedAt: new Date() },
});
annotate({
action: {
name: "whoop.backfill.complete",
details: { imported },
},
});
return { imported };
}
/**
* Boot-time discovery. Finds every WHOOP connection not yet backfilled
* (`backfill_completed_at IS NULL`) and enqueues one backfill job per account.
*
* Idempotent across reboots: once a connection's backfill completes,
* `backfillCompletedAt` is set and the predicate drops it from the discovery
* set. pg-boss `singletonKey` coalesces duplicate sends so a fast restart
* while a job is queued doesn't double up.
*
* Best-effort: errors are returned through the result value so the worker boot
* never fails because of a backfill miss.
*/
export async function enqueueBootTimeWhoopBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const connections = await prisma.whoopConnection.findMany({
where: { backfillCompletedAt: null },
select: { userId: true },
});
if (connections.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId } of connections) {
const payload: WhoopBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(WHOOP_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `whoop-backfill|${userId}`,
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* v1.11.0 — daily cleanup for the `whoop_oauth_states` table.
*
* Mirrors `withings-oauth-state-cleanup`. The OAuth state ledger is single-use
* — every happy-path + error-path branch in `whoop/callback` consumes its row,
* so an abandoned row only lingers when the user closed the WHOOP approval tab
* without bouncing back to the callback URL. The cookie + row TTL is 10
* minutes, so once the timestamp blows the row is dead weight.
*
* Idempotent: a re-run within the same window matches zero rows the second time
* because the first pass deleted everything older than `now()`.
*/
import type { PrismaClient } from "@/generated/prisma/client";
export async function cleanupExpiredWhoopOAuthStates(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<number> {
const { count } = await prisma.whoopOAuthState.deleteMany({
where: { expiresAt: { lt: now } },
});
return count;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* v1.11.0 — WHOOP sync layer tests (mocked). Covers:
* - the rotating refresh token persists BOTH new tokens;
* - an incremental recovery sync maps + upserts idempotently (a re-post with
* the same externalId routes through the upsert key, never a duplicate);
* - the incremental window helper picks the right overlap.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Module mocks ────────────────────────────────────────────────
const {
prismaMock,
refreshAccessTokenMock,
fetchRecoveriesMock,
recordSyncFailure,
recordSyncSuccess,
isReauthRequired,
} = vi.hoisted(() => ({
prismaMock: {
whoopConnection: {
findUnique: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
measurement: {
upsert: vi.fn(),
},
},
refreshAccessTokenMock: vi.fn(),
fetchRecoveriesMock: vi.fn(),
recordSyncFailure: vi.fn(async () => {}),
recordSyncSuccess: vi.fn(async () => {}),
isReauthRequired: vi.fn(async () => false),
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc(${s})`,
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
}));
vi.mock("../client", async (orig) => {
const actual = await orig<typeof import("../client")>();
return {
...actual,
refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a),
fetchRecoveries: (...a: unknown[]) => fetchRecoveriesMock(...a),
};
});
vi.mock("../credentials", () => ({
getUserWhoopCredentials: vi.fn(async () => ({
clientId: "cid",
clientSecret: "csecret",
})),
}));
vi.mock("@/lib/integrations/status", () => ({
recordSyncFailure: (...a: unknown[]) => recordSyncFailure(...a),
recordSyncSuccess: (...a: unknown[]) => recordSyncSuccess(...a),
isReauthRequired: (...a: unknown[]) => isReauthRequired(...a),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
collapseToTypeDayKeys: (
rows: Array<{ type: string; measuredAt: Date }>,
) => rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
recomputeBucketsForMeasurement: vi.fn(async () => {}),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => null,
annotate: () => {},
}));
import {
getValidToken,
incrementalStart,
upsertWhoopMeasurements,
WHOOP_DEFAULT_OVERLAP_MS,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
} from "../sync";
beforeEach(() => {
vi.clearAllMocks();
isReauthRequired.mockResolvedValue(false);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getValidToken — rotating refresh", () => {
it("persists BOTH the new access AND refresh token on refresh", async () => {
prismaMock.whoopConnection.findUnique.mockResolvedValue({
id: "conn1",
whoopUserId: "42",
accessToken: "enc(old-access)",
refreshToken: "enc(old-refresh)",
// Expired (past) so the refresh path fires.
tokenExpiresAt: new Date(Date.now() - 1000),
});
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
refresh_token: "new-refresh",
expires_in: 3600,
});
prismaMock.whoopConnection.update.mockResolvedValue({});
const result = await getValidToken("user1");
expect(result?.accessToken).toBe("new-access");
expect(refreshAccessTokenMock).toHaveBeenCalledWith("old-refresh", {
clientId: "cid",
clientSecret: "csecret",
});
const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0];
expect(updateArg.data.accessToken).toBe("enc(new-access)");
expect(updateArg.data.refreshToken).toBe("enc(new-refresh)");
});
it("returns the stored token without refresh when not near expiry", async () => {
prismaMock.whoopConnection.findUnique.mockResolvedValue({
id: "conn1",
whoopUserId: "42",
accessToken: "enc(live-access)",
refreshToken: "enc(live-refresh)",
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const result = await getValidToken("user1");
expect(result?.accessToken).toBe("live-access");
expect(refreshAccessTokenMock).not.toHaveBeenCalled();
expect(prismaMock.whoopConnection.update).not.toHaveBeenCalled();
});
it("records a reauth failure when credentials are missing on refresh", async () => {
const { getUserWhoopCredentials } = await import("../credentials");
(getUserWhoopCredentials as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
null,
);
prismaMock.whoopConnection.findUnique.mockResolvedValue({
id: "conn1",
whoopUserId: "42",
accessToken: "enc(a)",
refreshToken: "enc(r)",
tokenExpiresAt: new Date(Date.now() - 1000),
});
const result = await getValidToken("user1");
expect(result).toBeNull();
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({ integration: "whoop", kind: "reauth_required" }),
);
});
});
describe("incrementalStart", () => {
it("returns undefined for a full sync", () => {
expect(incrementalStart(new Date(), { fullSync: true })).toBeUndefined();
});
it("subtracts the overlap from lastSyncedAt", () => {
const last = new Date("2026-06-01T12:00:00Z");
const got = incrementalStart(last, {
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
});
expect(got!.getTime()).toBe(
last.getTime() - WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
);
});
it("defaults to the 1 h overlap when none supplied", () => {
const last = new Date("2026-06-01T12:00:00Z");
const got = incrementalStart(last);
expect(got!.getTime()).toBe(last.getTime() - WHOOP_DEFAULT_OVERLAP_MS);
});
});
describe("upsertWhoopMeasurements — idempotent upsert", () => {
it("routes each reading through the (userId,type,source,externalId) key", async () => {
prismaMock.measurement.upsert.mockResolvedValue({});
const n = await upsertWhoopMeasurements("user1", [
{
type: "RECOVERY_SCORE",
value: 71,
unit: "score",
measuredAt: new Date("2026-06-01T08:00:00Z"),
externalId: "sleep-uuid:recovery",
},
{
type: "HRV_RMSSD",
value: 64,
unit: "ms",
measuredAt: new Date("2026-06-01T08:00:00Z"),
externalId: "sleep-uuid:hrv_rmssd",
},
]);
expect(n).toBe(2);
const firstWhere =
prismaMock.measurement.upsert.mock.calls[0]![0].where
.userId_type_source_externalId;
expect(firstWhere).toEqual({
userId: "user1",
type: "RECOVERY_SCORE",
source: "WHOOP",
externalId: "sleep-uuid:recovery",
});
});
it("a re-post with the same externalId is one upsert, not two rows", async () => {
prismaMock.measurement.upsert.mockResolvedValue({});
const reading = {
type: "RECOVERY_SCORE",
value: 60,
unit: "score",
measuredAt: new Date("2026-06-01T08:00:00Z"),
externalId: "sleep-uuid:recovery",
};
await upsertWhoopMeasurements("user1", [reading]);
await upsertWhoopMeasurements("user1", [{ ...reading, value: 75 }]);
// Two upsert calls, both against the SAME key — the DB collapses them.
expect(prismaMock.measurement.upsert).toHaveBeenCalledTimes(2);
const a =
prismaMock.measurement.upsert.mock.calls[0]![0].where
.userId_type_source_externalId;
const b =
prismaMock.measurement.upsert.mock.calls[1]![0].where
.userId_type_source_externalId;
expect(a).toEqual(b);
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* WHOOP cycle (day) sync. There is NO webhook for cycles — this is poll-only,
* driven by the hourly fallback cron. Maps each scored cycle via `mapCycle`
* (DAY_STRAIN + ENERGY_EXPENDITURE_KJ) and upserts as `source = WHOOP`.
*
* Cycle id is an int64 (not a UUID); externalId = `cycle:<id>:<fieldTag>`.
*/
import { fetchCycles, mapCycle } from "./client";
import {
getValidToken,
incrementalStart,
markSynced,
recordWhoopSyncFailure,
upsertWhoopMeasurements,
type WhoopMeasurementUpsert,
} from "./sync";
import { prisma } from "@/lib/db";
export async function syncUserCycle(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
});
if (!connection) return 0;
const start = incrementalStart(connection.lastSyncedAt, {
fullSync: opts.fullSync,
});
let records: Awaited<ReturnType<typeof fetchCycles>>;
try {
records = await fetchCycles(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
}
const readings: WhoopMeasurementUpsert[] = [];
for (const c of records) {
for (const m of mapCycle(c)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `cycle:${c.id}:${m.fieldTag}`,
});
}
}
const imported = await upsertWhoopMeasurements(userId, readings);
await markSynced(userId);
return imported;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* WHOOP recovery sync. Fetches recovery records since the incremental cursor
* (24 h overlap to absorb WHOOP's after-the-fact re-scoring), maps each scored
* record via `mapRecovery`, and upserts the readings as `source = WHOOP`.
*
* Each recovery object yields several Measurement rows (recovery-score, RMSSD,
* RHR, SpO2, skin-temp), disambiguated by the field-tag in the externalId:
* `<sleep_id>:<fieldTag>`. The recovery record is keyed off its associated
* sleep UUID (v2 recovery carries `sleep_id`, not a stable recovery id) so the
* externalId is stable across re-scores.
*/
import { fetchRecoveries, mapRecovery } from "./client";
import {
getValidToken,
incrementalStart,
markSynced,
recordWhoopSyncFailure,
upsertWhoopMeasurements,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
type WhoopMeasurementUpsert,
} from "./sync";
import { prisma } from "@/lib/db";
export async function syncUserRecovery(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
});
if (!connection) return 0;
const start = incrementalStart(connection.lastSyncedAt, {
fullSync: opts.fullSync,
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
});
let records: Awaited<ReturnType<typeof fetchRecoveries>>;
try {
records = await fetchRecoveries(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
}
const readings: WhoopMeasurementUpsert[] = [];
for (const r of records) {
// `sleep_id` is the stable v2 anchor for the recovery record.
const anchor = r.sleep_id;
for (const m of mapRecovery(r)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${anchor}:${m.fieldTag}`,
});
}
}
const imported = await upsertWhoopMeasurements(userId, readings);
await markSynced(userId);
return imported;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* WHOOP sleep sync. Fetches sleep activity records since the incremental
* cursor (24 h overlap for the re-score lag), maps each scored record via
* `mapSleep` (per-stage SLEEP_DURATION rows, SLEEP_NEED, the SLEEP_*
* percentages, RESPIRATORY_RATE), and upserts as `source = WHOOP`.
*
* Per-stage rows carry the `sleepStage` axis so the five stage rows for one
* night stay distinct under the dedup key. externalId = `<sleep_id>:<fieldTag>`.
*/
import { fetchSleeps, mapSleep } from "./client";
import {
getValidToken,
incrementalStart,
markSynced,
recordWhoopSyncFailure,
upsertWhoopMeasurements,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
type WhoopMeasurementUpsert,
} from "./sync";
import { prisma } from "@/lib/db";
export async function syncUserSleep(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
});
if (!connection) return 0;
const start = incrementalStart(connection.lastSyncedAt, {
fullSync: opts.fullSync,
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
});
let records: Awaited<ReturnType<typeof fetchSleeps>>;
try {
records = await fetchSleeps(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
}
const readings: WhoopMeasurementUpsert[] = [];
for (const s of records) {
for (const m of mapSleep(s)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${s.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
}
const imported = await upsertWhoopMeasurements(userId, readings);
await markSynced(userId);
return imported;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* WHOOP workout sync. Fetches workout activities since the incremental cursor
* and upserts each into the `Workout` table as `source = WHOOP`, keyed on
* `(userId, source, externalId)` so a re-score overwrites in place.
*
* Per-workout strain (`WORKOUT_STRAIN`), HR-zone durations, `percent_recorded`,
* and altitude live in `Workout.metadata` (tied to the workout row) rather than
* as free-floating Measurements — a phantom strain Measurement would survive
* if the workout were later de-duped away by the read-time picker. Energy is
* converted kJ→kcal via `KJ_TO_KCAL` for `totalEnergyKcal`.
*
* A WHOOP run and the same run via Apple Health remain two distinct `Workout`
* rows (different `source`); the read-time `pickCanonicalWorkoutRows` picker
* (E-slice, W6) collapses the cross-source twin at read time.
*/
import { fetchWorkouts, KJ_TO_KCAL } from "./client";
import {
getValidToken,
incrementalStart,
markSynced,
recordWhoopSyncFailure,
} from "./sync";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { Prisma } from "@/generated/prisma/client";
/** WHOOP reports a numeric `sport_id`; fall back to a generic label. */
function sportLabel(sportId: number | undefined, sportName?: string): string {
if (sportName) return sportName;
if (typeof sportId === "number") return `whoop_sport_${sportId}`;
return "workout";
}
export async function syncUserWorkout(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
});
if (!connection) return 0;
const start = incrementalStart(connection.lastSyncedAt, {
fullSync: opts.fullSync,
});
let records: Awaited<ReturnType<typeof fetchWorkouts>>;
try {
records = await fetchWorkouts(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
}
let imported = 0;
for (const w of records) {
if (!w.score) continue; // unscored workout — nothing to store yet
const startedAt = new Date(w.start);
const endedAt = new Date(w.end);
const durationSec = Math.max(
0,
Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
);
const energyKcal =
typeof w.score.kilojoule === "number"
? Math.round(w.score.kilojoule * KJ_TO_KCAL)
: null;
const metadata: Prisma.InputJsonValue = {
whoopWorkoutStrain: w.score.strain,
percentRecorded: w.score.percent_recorded,
...(w.score.altitude_gain_meter != null
? { altitudeGainMeter: w.score.altitude_gain_meter }
: {}),
...(w.score.altitude_change_meter != null
? { altitudeChangeMeter: w.score.altitude_change_meter }
: {}),
...(w.score.zone_durations
? { zoneDurations: w.score.zone_durations }
: {}),
};
try {
await prisma.workout.upsert({
where: {
userId_source_externalId: {
userId,
source: "WHOOP",
externalId: w.id,
},
},
create: {
userId,
source: "WHOOP",
externalId: w.id,
sportType: sportLabel(w.sport_id, w.sport_name),
startedAt,
endedAt,
durationSec,
totalEnergyKcal: energyKcal,
totalDistanceM: w.score.distance_meter ?? null,
avgHeartRate: w.score.average_heart_rate ?? null,
maxHeartRate: w.score.max_heart_rate ?? null,
elevationM: w.score.altitude_gain_meter ?? null,
metadata,
},
update: {
sportType: sportLabel(w.sport_id, w.sport_name),
startedAt,
endedAt,
durationSec,
totalEnergyKcal: energyKcal,
totalDistanceM: w.score.distance_meter ?? null,
avgHeartRate: w.score.average_heart_rate ?? null,
maxHeartRate: w.score.max_heart_rate ?? null,
elevationM: w.score.altitude_gain_meter ?? null,
metadata,
},
});
imported++;
} catch (err) {
getEvent()?.addWarning(`WHOOP: failed to upsert workout: ${err}`);
}
}
await markSynced(userId);
return imported;
}
+338
View File
@@ -0,0 +1,338 @@
/**
* WHOOP sync service — token refresh (rotating refresh token), the shared
* Measurement upsert/rollup-fold tail, and the per-resource sync entry points.
*
* Mirrors `src/lib/withings/sync.ts`:
* - `getValidToken` decrypts the stored pair, refreshes at
* `tokenExpiresAt - 5 min`, and persists BOTH rotated tokens (WHOOP
* invalidates the prior access AND refresh token on every refresh — the
* same discipline Withings uses for its rotating refresh token).
* - Each per-resource sync (`sync-recovery` / `sync-sleep` / `sync-cycle` /
* `sync-workout`) upserts into `Measurement` / `Workout` keyed on
* `(userId, type, source = WHOOP, externalId)` so a re-post (WHOOP
* re-scores recovery/sleep after the fact) overwrites in place rather than
* minting a duplicate. After the upserts the rollup tier is re-folded
* (`recomputeBucketsForMeasurement`) and the status-insight caches are
* invalidated, identical to the Withings tail.
*
* The incremental window starts from `lastSyncedAt - overlap`. WHOOP re-scores
* recovery/sleep hours after the night, so the overlap must comfortably cover
* the re-score lag — `WHOOP_RECOVERY_SLEEP_OVERLAP_MS` is 24 h; workout/cycle
* use the smaller `WHOOP_DEFAULT_OVERLAP_MS`.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { encrypt, decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
type FailureKind,
} from "@/lib/integrations/status";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { refreshAccessToken } from "./client";
import { getUserWhoopCredentials } from "./credentials";
import {
WhoopApiError,
classifyWhoopError,
type WhoopClassification,
} from "./response-classifier";
/** Refresh the access token this many ms before `tokenExpiresAt`. */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
/**
* Overlap window for the incremental sync, in ms. WHOOP re-scores recovery
* and sleep after the fact (the final recovery/sleep score can land hours
* after the night), so the recovery/sleep overlap is a full 24 h to make sure
* the re-scored record is re-fetched on the next tick. Workout/cycle settle
* fast — a smaller overlap suffices and keeps the page count down.
*/
export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 24 * 60 * 60 * 1000;
export const WHOOP_DEFAULT_OVERLAP_MS = 60 * 60 * 1000; // 1 h
export interface WhoopTokenInfo {
accessToken: string;
connection: { id: string; whoopUserId: string };
}
/**
* Resolve a valid WHOOP access token for a user, refreshing if it is within
* the 5-minute expiry buffer. On refresh, persists BOTH rotated tokens.
* Returns null when there is no connection, no credentials, or the refresh
* fails (the failure is recorded so scheduled syncs back off).
*/
export async function getValidToken(
userId: string,
): Promise<WhoopTokenInfo | null> {
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
});
if (!connection) return null;
const accessToken = decrypt(connection.accessToken);
const refreshToken = decrypt(connection.refreshToken);
if (
connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS <
Date.now()
) {
try {
const creds = await getUserWhoopCredentials(userId);
if (!creds) {
getEvent()?.addWarning(
`No WHOOP credentials found for user ${userId} during token refresh`,
);
await recordSyncFailure({
userId,
integration: "whoop",
kind: "reauth_required",
message: "WHOOP credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
return null;
}
const newTokens = await refreshAccessToken(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
// WHOOP rotates the refresh token on every refresh — persist BOTH the
// new access token AND the new refresh token, or the next refresh
// reuses an invalidated token and the connection drops to reauth.
await prisma.whoopConnection.update({
where: { id: connection.id },
data: {
accessToken: encrypt(newTokens.access_token),
refreshToken: encrypt(newTokens.refresh_token),
tokenExpiresAt: expiresAt,
},
});
return {
accessToken: newTokens.access_token,
connection: {
id: connection.id,
whoopUserId: connection.whoopUserId,
},
};
} catch (err) {
getEvent()?.addWarning(
`WHOOP token refresh failed for user ${userId}: ${err}`,
);
await recordWhoopSyncFailure(userId, err);
return null;
}
}
return {
accessToken,
connection: {
id: connection.id,
whoopUserId: connection.whoopUserId,
},
};
}
/**
* Compute the incremental `start` for a resource sync. `fullSync` returns
* undefined (the backfill anchor handles the deep history). Otherwise start
* from `lastSyncedAt - overlap`, or 30 days back on the very first incremental
* tick.
*/
export function incrementalStart(
lastSyncedAt: Date | null,
opts: { fullSync?: boolean; overlapMs?: number } = {},
): Date | undefined {
if (opts.fullSync) return undefined;
const overlap = opts.overlapMs ?? WHOOP_DEFAULT_OVERLAP_MS;
if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap);
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
}
/**
* One mapped reading destined for a `Measurement` row, with the resource uuid
* already resolved into a full `externalId`. The per-resource syncs build these
* from the client mappers (`<resource-uuid>:<fieldTag>`).
*/
export interface WhoopMeasurementUpsert {
type: string;
value: number;
unit: string;
measuredAt: Date;
externalId: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED" | null;
}
/**
* Upsert a batch of mapped WHOOP readings for one user and fold the rollup
* tier + invalidate status-insight caches once at the end (mirrors the
* Withings sync tail). Idempotent: the `(userId, type, source, externalId)`
* unique key makes a re-post (WHOOP re-score) overwrite in place. Returns the
* count of rows written.
*
* Best-effort on the rollup fold + insight invalidate — a populator hiccup
* never fails the user's sync.
*/
export async function upsertWhoopMeasurements(
userId: string,
readings: WhoopMeasurementUpsert[],
): Promise<number> {
if (readings.length === 0) return 0;
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const r of readings) {
const type = r.type as MeasurementType;
try {
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type,
source: "WHOOP",
externalId: r.externalId,
},
},
create: {
userId,
type,
source: "WHOOP",
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
},
update: {
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
sleepStage: r.sleepStage ?? null,
// Surface the server-side mutation to the iOS LWW reconciler.
syncVersion: { increment: 1 },
},
});
touched.push({ type, measuredAt: r.measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(`WHOOP: failed to upsert measurement: ${err}`);
}
}
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`whoop: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`whoop: rollup recompute failed for ${userId}: ${err}`,
);
}
return imported;
}
/** Stamp `lastSyncedAt = now` after a successful resource sync. */
export async function markSynced(userId: string): Promise<void> {
await prisma.whoopConnection.update({
where: { userId },
data: { lastSyncedAt: new Date() },
});
}
/**
* Full per-user sync across every WHOOP resource. Webhook-driven syncs enqueue
* a single per-resource job; this drives the hourly poll catch-all and the
* manual `/api/whoop/sync` trigger.
*
* Parks immediately when the connection is at `error_reauth` (the user must
* reconnect first) — returns 0, matching the Withings no-op contract.
*/
export async function syncUserWhoop(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
if (await isReauthRequired(userId, "whoop")) {
getEvent()?.addWarning(
`whoop sync skipped for ${userId}: parked at error_reauth`,
);
return 0;
}
const { syncUserRecovery } = await import("./sync-recovery");
const { syncUserSleep } = await import("./sync-sleep");
const { syncUserCycle } = await import("./sync-cycle");
const { syncUserWorkout } = await import("./sync-workout");
let total = 0;
let anyFailed = false;
for (const fn of [
syncUserRecovery,
syncUserSleep,
syncUserCycle,
syncUserWorkout,
]) {
try {
total += await fn(userId, opts);
} catch (err) {
anyFailed = true;
getEvent()?.addWarning(`whoop ${fn.name} failed for ${userId}: ${err}`);
}
}
if (!anyFailed) {
await recordSyncSuccess(userId, "whoop");
}
return total;
}
/**
* Map a WHOOP response classification onto a `FailureKind` and record it.
* Shared by every per-resource catch-block and the token-refresh path.
*/
export async function recordWhoopSyncFailure(
userId: string,
err: unknown,
): Promise<void> {
const message = err instanceof Error ? err.message : String(err);
await recordSyncFailure({
userId,
integration: "whoop",
kind: classificationToFailureKind(classifyWhoopError(err)),
message,
errorCode:
err instanceof WhoopApiError ? err.httpStatus?.toString() : undefined,
});
}
export function classificationToFailureKind(
classification: WhoopClassification,
): FailureKind {
switch (classification) {
case "reauth_required":
return "reauth_required";
case "persistent":
return "persistent";
case "transient":
return "transient";
case "success":
// A caller asking for the FailureKind of a success is a contract bug;
// surface it as transient so the audit log still records the anomaly.
return "transient";
}
}