From e8f4bb3dbad3e385ddff42220a4ba6bee3537ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 08:44:58 +0200 Subject: [PATCH] fix(whoop): advance sync-status ledger on per-resource webhook syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook-driven per-resource sync advanced the resource cursor but never touched IntegrationStatus, so lastSuccessAt stayed frozen at the last full poll — the Settings 'last synced' pill and the integration-status read reported stale for up to an hour after a webhook had already landed last night's data. Stamp recordSyncSuccess per resource, reusing the full sync's exact success guard (a genuine import clears the error state; a 403 soft-skip leaves the looks-healthy window closed). --- src/lib/jobs/reminder/whoop-sync.ts | 11 +++-- .../whoop/__tests__/sync-user-whoop.test.ts | 43 ++++++++++++++++++- src/lib/whoop/sync.ts | 31 +++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts index 130740e65..b614c8110 100644 --- a/src/lib/jobs/reminder/whoop-sync.ts +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -21,6 +21,7 @@ import { syncUserWorkout, syncWhoopWorkoutById, } from "@/lib/whoop/sync-workout"; +import { syncWhoopResourceWithStatus } from "@/lib/whoop/sync"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; @@ -93,10 +94,12 @@ export async function runWhoopResourceSync( let measurementsImported = 0; for (const { userId, resourceId } of targets) { try { - const imported = - resourceId && byIdFn - ? await byIdFn(userId, resourceId) - : await syncFn(userId); + // Stamp `IntegrationStatus.lastSuccessAt` on a genuine per-resource + // import — a webhook-driven refresh advanced the cursor but used to + // leave the sync-status ledger frozen until the next full poll. + const imported = await syncWhoopResourceWithStatus(userId, () => + resourceId && byIdFn ? byIdFn(userId, resourceId) : syncFn(userId), + ); measurementsImported += imported; usersSynced++; // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge diff --git a/src/lib/whoop/__tests__/sync-user-whoop.test.ts b/src/lib/whoop/__tests__/sync-user-whoop.test.ts index 270922897..843a54ec7 100644 --- a/src/lib/whoop/__tests__/sync-user-whoop.test.ts +++ b/src/lib/whoop/__tests__/sync-user-whoop.test.ts @@ -71,7 +71,11 @@ vi.mock("../sync-body", () => ({ syncUserBody: (...a: unknown[]) => syncUserBody(...a), })); -import { handleCollectionFetchError, syncUserWhoop } from "../sync"; +import { + handleCollectionFetchError, + syncUserWhoop, + syncWhoopResourceWithStatus, +} from "../sync"; import { WhoopApiError } from "../response-classifier"; /** A resource sync that 403s its collection and soft-skips, like the real one. */ @@ -168,3 +172,40 @@ describe("syncUserWhoop — all-403 looks-healthy guard", () => { expect(syncUserRecovery).not.toHaveBeenCalled(); }); }); + +describe("syncWhoopResourceWithStatus — per-resource lastSuccessAt stamp", () => { + it("stamps success when the resource imported rows", async () => { + const imported = await syncWhoopResourceWithStatus("user1", async () => 5); + + expect(imported).toBe(5); + expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop"); + }); + + it("stamps success on a clean fetch that imported nothing new", async () => { + // No 403 soft-skip → a genuine "nothing changed" webhook/cron tick still + // proves the connection is alive, so `lastSuccessAt` advances. + const imported = await syncWhoopResourceWithStatus("user1", async () => 0); + + expect(imported).toBe(0); + expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop"); + }); + + it("does NOT stamp success when the resource soft-skipped a 403 and imported nothing", async () => { + const imported = await syncWhoopResourceWithStatus("user1", () => + softSkip403("user1"), + ); + + expect(imported).toBe(0); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + expect(recordSyncFailure).not.toHaveBeenCalled(); + }); + + it("propagates a hard failure without stamping success", async () => { + await expect( + syncWhoopResourceWithStatus("user1", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index 348cd2fe3..f47480115 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -524,6 +524,37 @@ export async function syncUserWhoop( return total; } +/** + * Run a SINGLE WHOOP resource sync (webhook-driven refresh or the per-resource + * cron walk) under the soft-skip tracker and stamp `recordSyncSuccess` iff the + * resource actually reached WHOOP — it did not throw and was not a pure + * soft-skip (an all-403 grant-revoke that imported nothing). + * + * The per-resource path (`runWhoopResourceSync`) advances the resource cursor + * via `markResourceSynced` but historically never touched `IntegrationStatus`, + * so `lastSuccessAt` stayed frozen at the last full `syncUserWhoop` run — the + * Settings "last synced" pill and the MCP `get_integration_status` read lied + * for up to an hour after a webhook already landed last night's data. Stamp it + * here, reusing `syncUserWhoop`'s exact success guard scoped to one resource: + * a genuine import clears the error state; a 403 soft-skip leaves the + * "looks-healthy" window closed. + */ +export async function syncWhoopResourceWithStatus( + userId: string, + run: () => Promise, +): Promise { + const tracker: SoftSkipTracker = { count: 0 }; + let imported = 0; + await softSkipStorage.run(tracker, async () => { + imported = await run(); + }); + const softSkippedOnly = tracker.count > 0 && imported === 0; + if (!softSkippedOnly) { + await recordSyncSuccess(userId, "whoop"); + } + return imported; +} + /** * Map a WHOOP response classification onto a `FailureKind` and record it. * Shared by every per-resource catch-block and the token-refresh path.