diff --git a/src/app/api/polar/sync/__tests__/route.test.ts b/src/app/api/polar/sync/__tests__/route.test.ts index a3f870aed..654fa82f6 100644 --- a/src/app/api/polar/sync/__tests__/route.test.ts +++ b/src/app/api/polar/sync/__tests__/route.test.ts @@ -67,13 +67,21 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never); - syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false }); + syncUserPolarMock.mockResolvedValue({ + imported: 0, + failed: false, + tokenProven: true, + }); syncUserPolarWorkoutsMock.mockResolvedValue(0); }); describe("POST /api/polar/sync", () => { it("runs both Polar legs and totals what they imported", async () => { - syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false }); + syncUserPolarMock.mockResolvedValue({ + imported: 5, + failed: false, + tokenProven: true, + }); syncUserPolarWorkoutsMock.mockResolvedValue(2); const response = await post(request()); @@ -84,7 +92,10 @@ describe("POST /api/polar/sync", () => { error: null, }); expect(syncUserPolarMock).toHaveBeenCalledWith("u1"); - expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1"); + // The vitals leg tells the workouts leg the token just worked. + expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1", { + tokenProven: true, + }); }); it("does not call a run whose vitals collections all failed a success", async () => { diff --git a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts index 9ea8874ce..4752702bf 100644 --- a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts +++ b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts @@ -17,20 +17,30 @@ import { syncUserPolarLegs } from "../polar-sync"; beforeEach(() => { vi.clearAllMocks(); - syncUserPolar.mockResolvedValue({ imported: 0, failed: false }); + syncUserPolar.mockResolvedValue({ + imported: 0, + failed: false, + tokenProven: true, + }); syncUserPolarWorkouts.mockResolvedValue(0); }); describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => { it("runs both legs and returns the summed count when both pass", async () => { - syncUserPolar.mockResolvedValue({ imported: 5, failed: false }); + syncUserPolar.mockResolvedValue({ + imported: 5, + failed: false, + tokenProven: true, + }); syncUserPolarWorkouts.mockResolvedValue(2); await expect(syncUserPolarLegs("u1")).resolves.toEqual({ imported: 7, failed: false, }); expect(syncUserPolar).toHaveBeenCalledWith("u1"); - expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", { + tokenProven: true, + }); }); it("sums both counts without throwing when the vitals leg returns a partial-failure count", async () => { @@ -38,7 +48,11 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge // its healthy-collections import count rather than throwing. The wrapper // must sum it with the workout leg and complete cleanly — a partial vitals // failure never starves the user's success accounting at the cohort level. - syncUserPolar.mockResolvedValue({ imported: 4, failed: true }); + syncUserPolar.mockResolvedValue({ + imported: 4, + failed: true, + tokenProven: true, + }); syncUserPolarWorkouts.mockResolvedValue(2); // The partial is carried, not swallowed: the wrapper completes with the // summed count AND says a leg did not settle, so the card cannot paint a @@ -48,7 +62,9 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge failed: true, }); expect(syncUserPolar).toHaveBeenCalledWith("u1"); - expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", { + tokenProven: true, + }); }); it("still attempts the workout leg when the vitals leg fails (vice-versa isolation)", async () => { @@ -57,11 +73,17 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge // The vitals error is rethrown (attributable), but only AFTER the workout // leg was attempted — a vitals failure never starves the workout leg. await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals down"); - expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", { + tokenProven: false, + }); }); it("leaves the vitals leg's result intact when the workout leg fails", async () => { - syncUserPolar.mockResolvedValue({ imported: 4, failed: false }); + syncUserPolar.mockResolvedValue({ + imported: 4, + failed: false, + tokenProven: true, + }); syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down")); // The vitals leg ran to completion (its own success already recorded on // the shared ledger); the workout error is surfaced without undoing it. @@ -73,6 +95,8 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge syncUserPolar.mockRejectedValue(new Error("vitals first")); syncUserPolarWorkouts.mockRejectedValue(new Error("workouts second")); await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals first"); - expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", { + tokenProven: false, + }); }); }); diff --git a/src/lib/jobs/reminder/polar-sync.ts b/src/lib/jobs/reminder/polar-sync.ts index a89cb7a6c..b8c34c2f5 100644 --- a/src/lib/jobs/reminder/polar-sync.ts +++ b/src/lib/jobs/reminder/polar-sync.ts @@ -23,8 +23,9 @@ export type PolarSyncPayload = PollCohortPayload; /** * Run both Polar legs for one user with INDEPENDENT attribution on the shared * `polar` ledger. Sequentially awaited (not `Promise.all`) so a workouts error - * stays attributable. Each leg records its own failure; the vitals leg owns the - * single success write. A failure in either leg is isolated — both legs are + * stays attributable, and so the vitals leg can tell the workouts leg whether + * the token just worked (a 403 on exercises is then a per-type refusal). Each + * leg records its own failure and its own success. A failure in either leg is isolated — both legs are * always attempted, so a workouts-leg failure can never mask a vitals success * and vice versa — then the FIRST error is rethrown so the cohort handler warns * and skips this user's success accounting without losing the error. @@ -45,18 +46,20 @@ export async function syncUserPolarLegs( // it has to be carried explicitly — otherwise a run in which four of five // collections failed reads exactly like a clean one. let partial = false; + let tokenProven = false; try { const vitals = await syncUserPolar(userId); total += vitals.imported; partial = vitals.failed; + tokenProven = vitals.tokenProven; } catch (err) { firstError = err; hadError = true; } try { - total += await syncUserPolarWorkouts(userId); + total += await syncUserPolarWorkouts(userId, { tokenProven }); } catch (err) { if (!hadError) { firstError = err; diff --git a/src/lib/polar/__tests__/sync-workouts.test.ts b/src/lib/polar/__tests__/sync-workouts.test.ts index 12b6d3fd1..893a4a509 100644 --- a/src/lib/polar/__tests__/sync-workouts.test.ts +++ b/src/lib/polar/__tests__/sync-workouts.test.ts @@ -9,6 +9,7 @@ const { getPolarConnection, fetchExercises, recordSyncFailure, + recordSyncSuccess, } = vi.hoisted(() => ({ createManyAndReturn: vi.fn(), update: vi.fn(), @@ -16,6 +17,7 @@ const { getPolarConnection: vi.fn(), fetchExercises: vi.fn(), recordSyncFailure: vi.fn(async () => {}), + recordSyncSuccess: vi.fn(async () => {}), })); vi.mock("@/lib/db", () => ({ @@ -32,6 +34,7 @@ vi.mock("../credentials", () => ({ getPolarConnection })); vi.mock("@/lib/integrations/status", async (importOriginal) => ({ ...(await importOriginal()), recordSyncFailure, + recordSyncSuccess, })); vi.mock("../client", async (importOriginal) => { const actual = await importOriginal(); @@ -181,9 +184,9 @@ describe("syncUserPolarWorkouts", () => { ); }); - // Assumption #4: a 403 scope gap is a soft/recoverable ledger error, not a - // hard crash — recorded as reauth_required and rethrown for cohort isolation. - it("records a 403 scope error as a soft reauth_required failure", async () => { + // Without a read that proved the token, a 403 may be the account-wide + // consent gap: recorded as reauth_required and rethrown for cohort isolation. + it("records a 403 as a reauth_required failure when nothing proved the token", async () => { fetchExercises.mockRejectedValue( new PolarApiError({ verb: "fetchExercises", @@ -204,11 +207,54 @@ describe("syncUserPolarWorkouts", () => { ); }); - it("never records success on the shared ledger (vitals leg owns the single success write)", async () => { - const status = await import("@/lib/integrations/status"); - const spy = vi.spyOn(status, "recordSyncSuccess"); + it("reads a 403 as a refused data type once the vitals leg proved the token", async () => { + fetchExercises.mockRejectedValue( + new PolarApiError({ + verb: "fetchExercises", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + await expect( + syncUserPolarWorkouts("u1", { tokenProven: true }), + ).resolves.toBe(0); + expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(recordSyncSuccess).toHaveBeenCalledWith("u1", "polar", { + leg: "workouts", + }); + }); + + it("keeps a 401 a failure even after the vitals leg proved the token", async () => { + fetchExercises.mockRejectedValue( + new PolarApiError({ + verb: "fetchExercises", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }), + ); + await expect( + syncUserPolarWorkouts("u1", { tokenProven: true }), + ).rejects.toBeInstanceOf(PolarApiError); + expect(recordSyncFailure).toHaveBeenCalledTimes(1); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + }); + + // A leg-scoped success clears only its own leg, so this leg has to write + // its own: left to the vitals leg, one workouts failure stayed on the row + // for good. + it("records its own leg's success after a clean read", async () => { fetchExercises.mockResolvedValue([]); await syncUserPolarWorkouts("u1"); - expect(spy).not.toHaveBeenCalled(); + expect(recordSyncSuccess).toHaveBeenCalledWith("u1", "polar", { + leg: "workouts", + }); + }); + + it("records no success when the read failed", async () => { + fetchExercises.mockRejectedValue(new Error("boom")); + await expect(syncUserPolarWorkouts("u1")).rejects.toThrow("boom"); + expect(recordSyncSuccess).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index 3a1351d1d..b4d36ef63 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -164,7 +164,11 @@ function createdRows() { describe("syncUserPolar", () => { it("no-ops cleanly for an unconnected user", async () => { getConnMock.mockResolvedValue(null); - expect(await syncUserPolar("u1")).toEqual({ imported: 0, failed: false }); + expect(await syncUserPolar("u1")).toEqual({ + imported: 0, + failed: false, + tokenProven: false, + }); expect(createManyAndReturnMock).not.toHaveBeenCalled(); expect(recordSuccessMock).not.toHaveBeenCalled(); }); @@ -370,13 +374,13 @@ describe("syncUserPolar", () => { it("isolates one dead collection — imports the healthy four and records one named failure", async () => { getConnMock.mockResolvedValue(CONN); - // SpO2 403s (no sensor / consent), the other four return one row each. + // SpO2 fails upstream, the other four return one row each. fetchSpo2Mock.mockRejectedValue( new PolarApiError({ verb: "fetchSpo2", - classification: "reauth_required", - httpStatus: 403, - reason: "http_403", + classification: "transient", + httpStatus: 503, + reason: "http_503", }), ); fetchRechargesMock.mockResolvedValue([ @@ -402,6 +406,7 @@ describe("syncUserPolar", () => { // The partial rides back with the count rather than being narrowed away. expect(result.failed).toBe(true); + expect(result.tokenProven).toBe(true); // The healthy four still wrote their rows — the source is not blanked. const written = createdRows().map((row) => row.type); expect(written).toContain("RECOVERY_SCORE"); @@ -410,19 +415,74 @@ describe("syncUserPolar", () => { expect(written).toContain("CARDIO_LOAD"); expect(written).not.toContain("OXYGEN_SATURATION"); expect(imported).toBeGreaterThan(0); - // Exactly one failure, classified off the 403, naming spo2. No success. + // Exactly one failure, naming spo2. No success. expect(recordFailureMock).toHaveBeenCalledTimes(1); expect(recordFailureMock).toHaveBeenCalledWith( expect.objectContaining({ integration: "polar", - kind: "reauth_required", - errorCode: "403", + kind: "transient", + errorCode: "503", message: expect.stringContaining("spo2"), }), ); expect(recordSuccessMock).not.toHaveBeenCalled(); }); + it("reads a 403 beside a healthy sibling as a refused data type, not a dead token", async () => { + getConnMock.mockResolvedValue(CONN); + // An account without the SpO2 sensor or the consent for it: the same token + // reads the other collections fine. + fetchSpo2Mock.mockRejectedValue( + new PolarApiError({ + verb: "fetchSpo2", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + fetchCardioLoadsMock.mockResolvedValue([ + { date: "2026-06-10", cardio_load: 55 }, + ]); + + const result = await syncUserPolar("u1"); + + expect(result).toMatchObject({ failed: false, tokenProven: true }); + expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD"); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar", { + leg: "vitals", + }); + }); + + it("keeps a 403 on every collection a reauth_required failure", async () => { + getConnMock.mockResolvedValue(CONN); + for (const mock of [ + fetchRechargesMock, + fetchSleepsMock, + fetchActivitiesMock, + fetchCardioLoadsMock, + fetchSpo2Mock, + ]) { + mock.mockRejectedValue( + new PolarApiError({ + verb: "fetch", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + } + + const result = await syncUserPolar("u1"); + + expect(result).toMatchObject({ failed: true, tokenProven: false }); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: "reauth_required", errorCode: "403" }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + it("isolates a throwing mapper — one malformed record does not blank siblings", async () => { getConnMock.mockResolvedValue(CONN); // A malformed recharge record whose mapper throws on first field access @@ -534,9 +594,9 @@ describe("syncUserPolar", () => { mockByName[name]!.mockRejectedValue( new PolarApiError({ verb: name, - classification: "reauth_required", - httpStatus: 403, - reason: "http_403", + classification: "persistent", + httpStatus: 404, + reason: "http_404", }), ); diff --git a/src/lib/polar/response-classifier.ts b/src/lib/polar/response-classifier.ts index 44b2cdc22..45ca52b1f 100644 --- a/src/lib/polar/response-classifier.ts +++ b/src/lib/polar/response-classifier.ts @@ -60,6 +60,17 @@ export function isInvalidGrant(err: unknown): boolean { return isOAuthInvalidGrant(err, "Polar"); } +/** + * True for a collection read Polar refused with 403. AccessLink documents its + * 403 as "User has not accepted all mandatory consents": the token is good, + * the account does not share this one data type (or has nothing behind it). + * Only meaningful next to a read that succeeded with the same token — a 403 on + * every collection is the account-wide consent gap, and stays a failure. + */ +export function isPolarCollectionRefusal(err: unknown): boolean { + return err instanceof PolarApiError && err.httpStatus === 403; +} + /** * Read the classification from any caught error. A non-`PolarApiError` defaults * to `transient` so a plain `Error` retries rather than disabling the diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts index 5b2df23bc..59e15cfec 100644 --- a/src/lib/polar/sync-workouts.ts +++ b/src/lib/polar/sync-workouts.ts @@ -21,12 +21,13 @@ * only write and it is idempotent — no cursor, no backfill queue (the API caps * history at 30 days, so first-connect backfill IS the first regular tick). * - * Ledger: workouts and vitals share the single `polar` integration ledger. This - * leg records only its OWN FAILURE (and rethrows so the cohort handler can - * attribute it); the single success write stays the vitals leg's job so a fully - * successful pass records success exactly once. A workouts-leg failure records a - * transient/reauth failure on the shared ledger but never touches the vitals - * success timestamp (`recordSyncFailure` leaves `lastSuccessAt` intact). + * Ledger: workouts and vitals share the single `polar` integration ledger, each + * under its own leg. This leg records its own failure (and rethrows so the + * cohort handler can attribute it) and its own success. The success used to be + * left to the vitals leg, but a leg-scoped success clears only its own leg, so + * a workouts failure stayed on the row for good once recorded: every later + * vitals success saw the workouts leg still failing and kept the connection in + * error, however healthy the exercises read had become. */ import { prisma } from "@/lib/db"; import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; @@ -35,11 +36,16 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { markSyncFailureRecorded, recordSyncFailure, + recordSyncSuccess, toFailureKind, } from "@/lib/integrations/status"; import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client"; import { getPolarConnection } from "./credentials"; -import { PolarApiError, classifyPolarError } from "./response-classifier"; +import { + PolarApiError, + classifyPolarError, + isPolarCollectionRefusal, +} from "./response-classifier"; const POLAR_LEG_WORKOUTS = "workouts"; @@ -70,13 +76,16 @@ async function recordPolarWorkoutFailure( * written. A user with no Polar connection is a clean no-op (returns 0, touches * no status row). * - * A 403 on the exercises read (a scope gap under `accesslink.read_all`) is - * classified through the shared response classifier as `reauth_required` — a - * SOFT/recoverable integration error surfaced on the status ledger, NOT a hard - * crash: it is recorded and rethrown so the composite cohort pass isolates this - * user and the vitals leg's own result is untouched. + * A 403 on the exercises read is a refusal when the vitals leg proved the token + * in the same run (`tokenProven`): the account does not share exercises, which + * no reconnect changes, so the leg reads nothing and stays healthy. Without + * that proof the 403 is classified `reauth_required` as before, recorded, and + * rethrown so the composite cohort pass isolates this user. */ -export async function syncUserPolarWorkouts(userId: string): Promise { +export async function syncUserPolarWorkouts( + userId: string, + opts: { tokenProven?: boolean } = {}, +): Promise { const conn = await getPolarConnection(userId); if (!conn) return 0; @@ -91,6 +100,11 @@ export async function syncUserPolarWorkouts(userId: string): Promise { if (row) rows.push(row); } } catch (err) { + if (opts.tokenProven && isPolarCollectionRefusal(err)) { + annotate({ meta: { polar_collections_refused: [POLAR_LEG_WORKOUTS] } }); + await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_WORKOUTS }); + return 0; + } await recordPolarWorkoutFailure(userId, err); throw markSyncFailureRecorded(err); } @@ -110,6 +124,7 @@ export async function syncUserPolarWorkouts(userId: string): Promise { invalidateUserMeasurements(userId); } + await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_WORKOUTS }); return imported; } diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index fede4d187..080108b79 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -13,12 +13,19 @@ * exactly like Nightscout's token-rejected path. * * Isolation: the five vitals collections are settled independently - * (`Promise.allSettled`), so a single dead collection — e.g. a 403 on SpO2 or - * cardio-load on an account without the sensor or the higher tier — imports the - * healthy four and records ONE partial failure rather than blanking every - * collection. Mirrors Oura's shipped per-collection isolation, minus the - * token-refresh leg Polar structurally lacks (a 401 is just a per-collection - * failure the classifier maps to `reauth_required`). + * (`Promise.allSettled`), so a single dead collection imports the healthy four + * and records ONE partial failure rather than blanking every collection. + * Mirrors Oura's shipped per-collection isolation, minus the token-refresh leg + * Polar structurally lacks (a 401 is just a per-collection failure the + * classifier maps to `reauth_required`). + * + * A 403 on one collection while a sibling succeeded is a refusal, not a + * failure (`isPolarCollectionRefusal`): the same token just worked, so the + * account simply does not share that data type (SpO2 without the sensor, + * cardio load without the tier). Counting it as `reauth_required` put a + * working connection behind a "reconnect" prompt that no reconnect clears. + * The refused names ride the wide event; the leg records success. Mirrors + * Oura's handling of a scope the grant does not cover. * * Window contract: every hourly tick re-reads the full server-fixed window for * each collection — 28 days for recharge / sleep / activity / cardio-load / @@ -39,7 +46,7 @@ */ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { recordSyncFailure, recordSyncSuccess, @@ -80,7 +87,11 @@ import { type SleepSegmentSweep, } from "@/lib/sleep/sweep-stale-segments"; import { getPolarConnection } from "./credentials"; -import { PolarApiError, classifyPolarError } from "./response-classifier"; +import { + PolarApiError, + classifyPolarError, + isPolarCollectionRefusal, +} from "./response-classifier"; import { resolveUserTimezone } from "@/lib/tz/resolver"; /** Map a Polar error onto the shared integration-ledger failure kind. */ @@ -164,6 +175,15 @@ export interface PolarCollectionFailure { err: unknown; } +/** The vitals leg's outcome, plus whether any collection proved the token. */ +export interface PolarVitalsResult extends SyncWriteResult { + /** + * True when at least one collection read succeeded with the token, so a 403 + * elsewhere in this run (the workouts leg included) is a per-type refusal. + */ + tokenProven: boolean; +} + /** * Sync one user's Polar data. Returns the rows written AND whether a * collection did not settle — the bare count used to narrow the partial away, @@ -177,9 +197,11 @@ export interface PolarCollectionFailure { * escapes now is a write-path/unexpected error out of `upsertPolarMeasurements` * — the poll-cohort boundary records that unmarked escape once. */ -export async function syncUserPolar(userId: string): Promise { +export async function syncUserPolar( + userId: string, +): Promise { const conn = await getPolarConnection(userId); - if (!conn) return { imported: 0, failed: false }; + if (!conn) return { imported: 0, failed: false, tokenProven: false }; // Date-keyed rows are anchored at local noon of their date in this zone. const tz = await resolveUserTimezone(userId); @@ -250,15 +272,23 @@ export async function syncUserPolar(userId: string): Promise { collections.map((spec) => spec.collect(conn.accessToken)), ); const readings: PolarMeasurementUpsert[] = []; - const failures: PolarCollectionFailure[] = []; + const settledFailures: PolarCollectionFailure[] = []; settled.forEach((res, i) => { const spec = collections[i]!; if (res.status === "fulfilled") { readings.push(...res.value); } else { - failures.push({ name: spec.name, err: res.reason }); + settledFailures.push({ name: spec.name, err: res.reason }); } }); + const tokenProven = settled.some((res) => res.status === "fulfilled"); + const { failures, refused } = splitPolarRefusals( + settledFailures, + tokenProven, + ); + if (refused.length > 0) { + annotate({ meta: { polar_collections_refused: refused } }); + } // Clear whatever an earlier scoring left under the re-fetched nights before // the fresh set upserts (mirrors Google Health's replace-by-window order). @@ -317,11 +347,29 @@ export async function syncUserPolar(userId: string): Promise { ? String(firstErr.httpStatus) : undefined, }); - return { imported, failed: true }; + return { imported, failed: true, tokenProven }; } await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS }); - return { imported, failed: false }; + return { imported, failed: false, tokenProven }; +} + +/** + * Split a run's collection failures into the ones that count and the per-type + * refusals (a 403 while another read proved the token). Without a proof every + * failure counts: a 403 on every collection is the account-wide consent gap. + */ +export function splitPolarRefusals( + failures: PolarCollectionFailure[], + tokenProven: boolean, +): { failures: PolarCollectionFailure[]; refused: string[] } { + if (!tokenProven) return { failures, refused: [] }; + return { + failures: failures.filter((f) => !isPolarCollectionRefusal(f.err)), + refused: failures + .filter((f) => isPolarCollectionRefusal(f.err)) + .map((f) => f.name), + }; } /**