fix(whoop): keep the night's sleep when its recovery is deleted

The v2 recovery webhook carries the id of the associated sleep, and a
recovery's rows are keyed off that same sleep id. The delete branch
matched on the `<id>:` prefix, so a `recovery.deleted` event also
soft-deleted every sleep row of that night, and a `sleep.deleted`
event took the recovery with it. Recovery now names its exact ids
(pinned against the mapper's field tags); sleep keeps the prefix minus
those ids.

The targeted refresh for `recovery.updated` passed the same sleep UUID
into `/v2/cycle/{cycleId}/recovery`, which answered 404 and booked a
sync failure on every recovery webhook. It now reads the sleep for its
`cycle_id` first.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 11:42:53 +02:00
1 parent 421aacf457
commit d21d29572d
6 files changed
+295 -23

No files matched your search

@@ -191,14 +191,16 @@ describe("POST /api/whoop/webhook/[token]", () => {
expect(getGlobalBoss).not.toHaveBeenCalled();
});
it("soft-deletes matching rows on a `*.deleted` event", async () => {
it("soft-deletes the recovery's own rows on a `recovery.deleted` event", async () => {
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue({
userId: "user-1",
} as never);
// The v2 recovery webhook id is the associated sleep's UUID, which also
// prefixes the night's sleep rows — the match names the recovery's ids.
const req = signedRequest({
user_id: 42,
id: "rec-uuid",
id: "sleep-uuid",
type: "recovery.deleted",
});
const res = await POST(req, ctx(SECRET));
@@ -208,7 +210,15 @@ describe("POST /api/whoop/webhook/[token]", () => {
where: {
userId: "user-1",
source: "WHOOP",
externalId: { startsWith: "rec-uuid:" },
externalId: {
in: [
"sleep-uuid:recovery",
"sleep-uuid:hrv_rmssd",
"sleep-uuid:rhr",
"sleep-uuid:spo2",
"sleep-uuid:skin_temp",
],
},
deletedAt: null,
},
data: { deletedAt: expect.any(Date), syncVersion: { increment: 1 } },
@@ -0,0 +1,85 @@
/**
* The v2 recovery webhook carries the id of the associated SLEEP, and WHOOP
* reads a recovery only through its cycle. The targeted refresh must resolve
* sleep -> cycle -> recovery; passing the sleep UUID straight into the cycle
* route answered 404 on every recovery webhook and booked a sync failure.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchSleepById, fetchRecoveryByCycleId, upsertMock, fetchErrorMock } =
vi.hoisted(() => ({
fetchSleepById: vi.fn(),
fetchRecoveryByCycleId: vi.fn(),
upsertMock: vi.fn(async (_u: string, rows: unknown[]) => rows.length),
fetchErrorMock: vi.fn(async () => 0),
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("../sync-core", async (importOriginal) => ({
...(await importOriginal<typeof import("../sync-core")>()),
getValidToken: vi.fn(async () => ({ accessToken: "tok" })),
upsertWhoopMeasurements: upsertMock,
handleCollectionFetchError: fetchErrorMock,
}));
vi.mock("../client", async (importOriginal) => ({
...(await importOriginal<typeof import("../client")>()),
fetchSleepById,
fetchRecoveryByCycleId,
}));
import { syncWhoopRecoveryById } from "../sync-recovery";
beforeEach(() => {
vi.clearAllMocks();
});
describe("syncWhoopRecoveryById", () => {
it("reads the sleep for its cycle id, then the recovery through the cycle", async () => {
fetchSleepById.mockResolvedValue({ id: "sleep-uuid", cycle_id: 991 });
fetchRecoveryByCycleId.mockResolvedValue({
cycle_id: 991,
sleep_id: "sleep-uuid",
user_id: 42,
created_at: "2026-06-01T07:00:00.000Z",
updated_at: "2026-06-01T07:05:00.000Z",
score_state: "SCORED",
score: {
user_calibrating: false,
recovery_score: 70,
resting_heart_rate: 52,
hrv_rmssd_milli: 61,
},
});
const imported = await syncWhoopRecoveryById("user-1", "sleep-uuid");
expect(fetchSleepById).toHaveBeenCalledWith("tok", "sleep-uuid");
expect(fetchRecoveryByCycleId).toHaveBeenCalledWith("tok", "991");
expect(fetchErrorMock).not.toHaveBeenCalled();
expect(imported).toBe(3);
const rows = upsertMock.mock.calls[0][1] as Array<{ externalId: string }>;
expect(rows.map((r) => r.externalId)).toEqual([
"sleep-uuid:recovery",
"sleep-uuid:hrv_rmssd",
"sleep-uuid:rhr",
]);
});
it("imports nothing when the sleep carries no cycle id", async () => {
fetchSleepById.mockResolvedValue({ id: "sleep-uuid" });
expect(await syncWhoopRecoveryById("user-1", "sleep-uuid")).toBe(0);
expect(fetchRecoveryByCycleId).not.toHaveBeenCalled();
expect(upsertMock).not.toHaveBeenCalled();
});
it("routes a failed sleep read through the shared fetch-error path", async () => {
const err = new Error("boom");
fetchSleepById.mockRejectedValue(err);
await syncWhoopRecoveryById("user-1", "sleep-uuid");
expect(fetchErrorMock).toHaveBeenCalledWith("recovery", "user-1", err);
});
});
@@ -54,7 +54,18 @@ vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ setAuth: vi.fn(), addWarning: vi.fn() }),
}));
import { processWhoopNotification } from "../webhook-handler";
import {
deletedResourceExternalIdFilter,
processWhoopNotification,
} from "../webhook-handler";
import {
mapRecovery,
mapSleep,
WHOOP_RECOVERY_FIELD_TAGS,
type WhoopRecovery,
type WhoopSleep,
} from "../client";
import type { Prisma } from "@/generated/prisma/client";
beforeEach(() => {
vi.clearAllMocks();
@@ -79,7 +90,7 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => {
});
});
it("carries a numeric resource id (cycle-based recovery) as a string", async () => {
it("carries a numeric resource id as a string", async () => {
await processWhoopNotification({
user_id: 42,
id: 9981,
@@ -148,3 +159,107 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => {
);
});
});
// One night as WHOOP v2 models it: a sleep and the recovery scored from it.
// Both key their rows off the sleep UUID, which is also the id the recovery
// webhook carries.
const SLEEP: WhoopSleep = {
id: "sleep-uuid",
cycle_id: 991,
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: 8 * 3_600_000,
total_awake_time_milli: 30 * 60_000,
total_light_sleep_time_milli: 4 * 3_600_000,
total_slow_wave_sleep_time_milli: 90 * 60_000,
total_rem_sleep_time_milli: 2 * 3_600_000,
disturbance_count: 4,
},
sleep_needed: {
baseline_milli: 8 * 3_600_000,
need_from_sleep_debt_milli: 0,
need_from_recent_strain_milli: 0,
need_from_recent_nap_milli: 0,
},
respiratory_rate: 15,
sleep_performance_percentage: 90,
sleep_consistency_percentage: 80,
sleep_efficiency_percentage: 92,
},
};
const RECOVERY: WhoopRecovery = {
cycle_id: 991,
sleep_id: "sleep-uuid",
user_id: 42,
created_at: "2026-06-01T07:00:00.000Z",
updated_at: "2026-06-01T07:05:00.000Z",
score_state: "SCORED",
score: {
user_calibrating: false,
recovery_score: 70,
resting_heart_rate: 52,
hrv_rmssd_milli: 61,
spo2_percentage: 96,
skin_temp_celsius: 33.4,
},
};
const SLEEP_IDS = mapSleep(SLEEP).map(
(m) => m.externalId ?? `${SLEEP.id}:${m.fieldTag}`,
);
const RECOVERY_IDS = mapRecovery(RECOVERY).map(
(m) => `${RECOVERY.sleep_id}:${m.fieldTag}`,
);
/** The subset of Prisma's StringFilter semantics the filter uses. */
function matches(filter: Prisma.StringFilter, id: string): boolean {
if (filter.in && !(filter.in as string[]).includes(id)) return false;
if (filter.notIn && (filter.notIn as string[]).includes(id)) return false;
if (filter.startsWith && !id.startsWith(filter.startsWith as string)) {
return false;
}
return true;
}
describe("deletedResourceExternalIdFilter — one night, two resources", () => {
it("pins the recovery field-tag list to what the mapper emits", () => {
expect(new Set(mapRecovery(RECOVERY).map((m) => m.fieldTag))).toEqual(
new Set(WHOOP_RECOVERY_FIELD_TAGS),
);
});
it("a recovery deletion removes the recovery and leaves the night's sleep", () => {
const filter = deletedResourceExternalIdFilter("recovery", "sleep-uuid");
expect(SLEEP_IDS.length).toBeGreaterThan(5);
expect(RECOVERY_IDS.filter((id) => matches(filter, id))).toEqual(
RECOVERY_IDS,
);
expect(SLEEP_IDS.filter((id) => matches(filter, id))).toEqual([]);
});
it("a sleep deletion removes the sleep and leaves its recovery", () => {
const filter = deletedResourceExternalIdFilter("sleep", "sleep-uuid");
expect(SLEEP_IDS.filter((id) => matches(filter, id))).toEqual(SLEEP_IDS);
expect(RECOVERY_IDS.filter((id) => matches(filter, id))).toEqual([]);
});
it("a recovery.deleted webhook passes the exact-id match to both reads", async () => {
updateMany.mockResolvedValue({ count: 5 });
await processWhoopNotification({
user_id: 42,
id: "sleep-uuid",
type: "recovery.deleted",
});
const expected = {
in: WHOOP_RECOVERY_FIELD_TAGS.map((t) => `sleep-uuid:${t}`),
};
expect(findMany.mock.calls[0][0].where.externalId).toEqual(expected);
expect(updateMany.mock.calls[0][0].where.externalId).toEqual(expected);
});
});
+25 -2
View File
@@ -180,6 +180,12 @@ export interface WhoopRecovery {
/** Sleep activity record. */
export interface WhoopSleep {
id: string;
/**
* The cycle this sleep belongs to. A v2 recovery is read through its cycle,
* and the recovery webhook carries the SLEEP id, so the targeted recovery
* refresh resolves sleep -> cycle -> recovery through this field.
*/
cycle_id?: number;
user_id: number;
created_at: string;
updated_at: string;
@@ -449,8 +455,10 @@ export function fetchCycleById(
/**
* WHOOP v2 has no recovery-by-recovery-id route — a recovery is read through
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The recovery webhook carries
* the cycle id, so the targeted refresh resolves the recovery from there.
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The v2 recovery webhook
* carries the id of the associated SLEEP, not the cycle, so the targeted
* refresh first reads the sleep for its `cycle_id` (see
* `syncWhoopRecoveryById`).
*/
export function fetchRecoveryByCycleId(
accessToken: string,
@@ -523,6 +531,21 @@ export interface MappedMeasurement {
reconstructed?: boolean;
}
/**
* Every field-tag `mapRecovery` can emit. A recovery's rows are keyed
* `<sleep_id>:<fieldTag>` and share the `<sleep_id>:` prefix with the rows of
* the sleep it belongs to, so a recovery deletion must name these exact ids
* rather than match on the prefix (which would take the night's sleep rows
* with it). Pinned against the mapper by a test.
*/
export const WHOOP_RECOVERY_FIELD_TAGS = [
"recovery",
"hrv_rmssd",
"rhr",
"spo2",
"skin_temp",
] as const;
/**
* Map one WHOOP recovery record into Measurement readings. An unscored record
* (`score === null`) yields nothing. `recovery.score.recovery_score` →
+23 -10
View File
@@ -9,7 +9,12 @@
* sleep UUID (v2 recovery carries `sleep_id`, not a stable recovery id) so the
* externalId is stable across re-scores.
*/
import { fetchRecoveries, fetchRecoveryByCycleId, mapRecovery } from "./client";
import {
fetchRecoveries,
fetchRecoveryByCycleId,
fetchSleepById,
mapRecovery,
} from "./client";
import {
getValidToken,
incrementalStart,
@@ -71,24 +76,32 @@ export async function syncUserRecovery(
}
/**
* Webhook-driven targeted refresh: resolve ONE recovery by its cycle id and
* upsert its readings, instead of re-walking the whole collection. WHOOP v2
* reads a recovery through its cycle (`/v2/cycle/{cycleId}/recovery`), and the
* recovery webhook carries the cycle id. An unscored / since-deleted record
* yields nothing. Does NOT advance the resource cursor — a single-id refresh
* proves nothing about records between the cursor and now, so the cron/overlap
* path stays responsible for moving the cursor.
* Webhook-driven targeted refresh: resolve ONE recovery and upsert its
* readings, instead of re-walking the whole collection. The v2 recovery
* webhook carries the id of the associated SLEEP (a UUID), but WHOOP reads a
* recovery only through its cycle (`/v2/cycle/{cycleId}/recovery`), so the
* sleep is read first for its `cycle_id`. Passing the sleep UUID as a cycle id
* answered 404 on every recovery webhook and booked each one as a sync
* failure. An unscored / since-deleted record yields nothing. Does NOT advance
* the resource cursor — a single-id refresh proves nothing about records
* between the cursor and now, so the cron/overlap path stays responsible for
* moving the cursor.
*/
export async function syncWhoopRecoveryById(
userId: string,
cycleId: string,
sleepId: string,
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
let record: Awaited<ReturnType<typeof fetchRecoveryByCycleId>>;
try {
record = await fetchRecoveryByCycleId(tokenInfo.accessToken, cycleId);
const sleep = await fetchSleepById(tokenInfo.accessToken, sleepId);
if (sleep.cycle_id == null) return 0;
record = await fetchRecoveryByCycleId(
tokenInfo.accessToken,
String(sleep.cycle_id),
);
} catch (err) {
return handleCollectionFetchError("recovery", userId, err);
}
+32 -6
View File
@@ -32,7 +32,8 @@ import { getClientIp } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { MeasurementType } from "@/generated/prisma/client";
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import { WHOOP_RECOVERY_FIELD_TAGS } from "./client";
/**
* Maximum age of a webhook timestamp before it is treated as a replay. Five
@@ -119,6 +120,31 @@ export function verifyWhoopSignature(args: {
return timingSafeEqual(a, b);
}
/**
* The `externalId` match for the rows a `<resource>.deleted` event removes.
*
* Rows are keyed `<resource-id>:<field-tag>`, but the v2 recovery webhook
* carries the id of the associated SLEEP, and a recovery's rows are keyed off
* that same sleep id (`<sleep_id>:hrv_rmssd`, ...). The sleep's own rows share
* the prefix (`<sleep_id>:sleep_eff`, `<sleep_id>:seg:REM`, ...). A prefix
* match on a recovery deletion therefore took the whole night's sleep with it,
* and a sleep deletion took the recovery. Recovery names its exact ids; sleep
* keeps the prefix minus those ids; every other resource keeps the prefix.
*/
export function deletedResourceExternalIdFilter(
resource: string | undefined,
resourceId: string,
): Prisma.StringFilter {
const recoveryIds = WHOOP_RECOVERY_FIELD_TAGS.map(
(tag) => `${resourceId}:${tag}`,
);
if (resource === "recovery") return { in: recoveryIds };
if (resource === "sleep") {
return { startsWith: `${resourceId}:`, notIn: recoveryIds };
}
return { startsWith: `${resourceId}:` };
}
interface WhoopWebhookBody {
user_id?: number | string;
id?: number | string;
@@ -160,11 +186,11 @@ export async function processWhoopNotification(
const queue = WHOOP_RESOURCE_QUEUE[resource ?? ""];
if (verb === "deleted") {
// Soft-delete every matching row for this user + WHOOP resource id.
// `externalId` is `<whoop-resource-uuid>:<field-tag>` so a `startsWith`
// match catches every measurement derived from the deleted resource.
// Soft-delete every row derived from the deleted resource for this user.
// See `deletedResourceExternalIdFilter` for why the match is per resource.
if (resourceId) {
const now = new Date();
const externalId = deletedResourceExternalIdFilter(resource, resourceId);
// Collect the affected `(type, measuredAt)` pairs BEFORE the
// soft-delete — the rollup tail below needs to know which
// `(type, day)` buckets lose rows, and after the updateMany the
@@ -173,7 +199,7 @@ export async function processWhoopNotification(
where: {
userId: connection.userId,
source: "WHOOP",
externalId: { startsWith: `${resourceId}:` },
externalId,
deletedAt: null,
},
select: { type: true, measuredAt: true },
@@ -182,7 +208,7 @@ export async function processWhoopNotification(
where: {
userId: connection.userId,
source: "WHOOP",
externalId: { startsWith: `${resourceId}:` },
externalId,
deletedAt: null,
},
data: { deletedAt: now, syncVersion: { increment: 1 } },