diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index a3376b757..4613b0cd4 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -23,6 +23,11 @@ const { whoopConnection: { update: vi.fn<(...a: unknown[]) => Promise>(async () => ({})), }, + measurement: { + deleteMany: vi.fn<(...a: unknown[]) => Promise>(async () => ({ + count: 0, + })), + }, user: { findUnique: vi.fn(), update: vi.fn<(...a: unknown[]) => Promise>(async () => ({})), @@ -111,40 +116,32 @@ describe("mapBody", () => { }); }); -describe("syncUserBody — weight overwrite", () => { - it("upserts weight against the stable externalId (no duplicate per sync)", async () => { +describe("syncUserBody — profile weight is not ingested (v1.16.11)", () => { + it("never upserts a weight Measurement, even when WHOOP sends one", async () => { fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 }); prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); const imported = await syncUserBody("user1"); - expect(imported).toBe(1); - expect(upsertWhoopMeasurementsMock).toHaveBeenCalledTimes(1); - const readings = upsertWhoopMeasurementsMock.mock - .calls[0]![1] as WhoopMeasurementUpsert[]; - expect(readings).toHaveLength(1); - expect(readings[0]).toMatchObject({ - type: "WEIGHT", - value: 80, - unit: "kg", - externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, - }); - // A second sync re-uses the SAME externalId — the upsert collapses it. - await syncUserBody("user1"); - const readings2 = upsertWhoopMeasurementsMock.mock - .calls[1]![1] as WhoopMeasurementUpsert[]; - expect(readings2[0]!.externalId).toBe(readings[0]!.externalId); - }); - - it("does not upsert a weight row when WHOOP omits weight", async () => { - fetchBodyMeasurementMock.mockResolvedValue({ max_heart_rate: 185 }); - prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); - - const imported = await syncUserBody("user1"); - expect(imported).toBe(0); expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled(); }); + + it("clears the legacy overwrite row on every pass (idempotent no-op after the first)", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); + + await syncUserBody("user1"); + + expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user1", + type: "WEIGHT", + source: "WHOOP", + externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, + }, + }); + }); }); describe("syncUserBody — max heart rate", () => { diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index ece003b60..9bc1af7d9 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -1,21 +1,21 @@ /** * WHOOP body-measurement sync. The body endpoint is a single object (not a * paginated collection): one self-reported profile snapshot carrying weight, - * max heart rate, and height. It fans out to three destinations: + * max heart rate, and height. Two destinations remain: * - * - `weight_kilogram` → a `WEIGHT` `Measurement` (source = WHOOP) keyed on a - * STABLE externalId (`whoop:body:weight`). Because the profile weight is a - * single value rather than a time series, the externalId never carries the - * fetch time — a re-sync overwrites the same row in place rather than - * accumulating a duplicate per poll. `measuredAt` is the fetch time so the - * read-time source-priority picker (a real scale outranks WHOOP) and the - * trend view treat it as "as of now". * - `max_heart_rate` → `WhoopConnection.maxHeartRate` (a profile constant, * not a time series — lives on the connection row, not a `Measurement`). * - `height_meter` → `User.heightCm`, converted m→cm, written ONLY when the * user has no height yet. A user-set height is never overwritten, and * height is never minted as a `Measurement`. * + * `weight_kilogram` is deliberately NOT ingested (v1.16.11). It is a + * self-reported profile field, not a reading — the previous overwrite-in- + * place row took a fresh `measuredAt` on every sync, so a stale manual + * entry kept resurfacing as the newest weight "measurement" no matter + * what the scale said. The sync now also removes the legacy + * `whoop:body:weight` row once, so existing accounts heal themselves. + * * Every write is field-by-field and idempotent across reruns. A per-resource * 403 soft-skips this data class (returns 0, leaves the connection connected) * rather than parking the whole integration — see `isCollectionForbidden`. @@ -25,13 +25,14 @@ import { getValidToken, handleCollectionFetchError, markSynced, - upsertWhoopMeasurements, - type WhoopMeasurementUpsert, } from "./sync"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; -/** Stable externalId for the single WHOOP profile weight row (overwrite). */ +/** + * Stable externalId the retired profile-weight ingestion wrote under — + * kept only so the sync can clean the legacy row off existing accounts. + */ export const WHOOP_BODY_WEIGHT_EXTERNAL_ID = "whoop:body:weight"; /** @@ -57,19 +58,23 @@ export async function syncUserBody( } const mapped = mapBody(body); - const measuredAt = new Date(); - // Weight → a single overwrite-in-place WEIGHT Measurement. - let imported = 0; - if (mapped.weightKg !== null) { - const reading: WhoopMeasurementUpsert = { - type: "WEIGHT", - value: mapped.weightKg, - unit: "kg", - measuredAt, - externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, - }; - imported += await upsertWhoopMeasurements(userId, [reading]); + // Profile weight is NOT ingested (see the module header). Remove the + // legacy overwrite row once; deleteMany is a no-op after the first + // pass, keeping the sync idempotent. + try { + await prisma.measurement.deleteMany({ + where: { + userId, + type: "WEIGHT", + source: "WHOOP", + externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, + }, + }); + } catch (err) { + getEvent()?.addWarning( + `whoop: failed to clear the legacy profile-weight row for ${userId}: ${err}`, + ); } // Max heart rate → WhoopConnection.maxHeartRate (profile constant). @@ -108,5 +113,6 @@ export async function syncUserBody( } await markSynced(userId); - return imported; + // No Measurement rows are minted here any more. + return 0; }