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 } },