fix(whoop): advance sync-status ledger on per-resource webhook syncs

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).
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 08:44:58 +02:00
1 parent 0c5049cd69
commit e8f4bb3dba
3 files changed
+80 -5

No files matched your search

+7 -4
View File
@@ -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
@@ -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();
});
});
+31
View File
@@ -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<number>,
): Promise<number> {
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.