From 9bbbeb805cc6762aa6bcec516f62a994dd83733f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 04:42:52 +0200 Subject: [PATCH 1/4] fix(oura): derive the sync window from the newest imported reading The lookback was pinned at seven days, so a sync that stayed broken for longer than a week came back with a window too narrow to reach the gap. The quieter case did the same damage with every signal green: a ring left unsynced holds its nights in Oura's cloud until the app is opened, every hourly tick succeeds with empty arrays, and when the days finally arrive they sit outside the window. Anchor the window on the newest live Oura measurement instead, clamped between the existing seven-day floor and a new thirty-day ceiling, plus a day of overlap. Both failure modes leave the same fingerprint, so one anchor closes both, and the watermark cannot drift out of step with the data because it is the data. Tombstones are excluded: the sleep sweep soft-deletes rows a revised hypnogram orphaned, and one of those must not pass for a live reading. The ceiling is thirty rather than the ninety days Oura's guidance would allow, because the whole batch still rides one transaction. With data flowing normally the clamp floors at seven and the tick is unchanged. A connection with no rows at all takes the full month. --- src/lib/oura/__tests__/sync.test.ts | 172 +++++++++++++++++++++++++++- src/lib/oura/sync.ts | 71 +++++++++++- 2 files changed, 237 insertions(+), 6 deletions(-) diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 798947ed9..2e13277b9 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -17,6 +17,7 @@ const { createManyAndReturnMock, updateMock, findManyMock, + findFirstMock, updateManyMock, emitArrivalMock, recordSuccessMock, @@ -42,6 +43,7 @@ const { createManyAndReturnMock: vi.fn(), updateMock: vi.fn(), findManyMock: vi.fn(), + findFirstMock: vi.fn(), updateManyMock: vi.fn(), emitArrivalMock: vi.fn(), recordSuccessMock: vi.fn(), @@ -64,6 +66,7 @@ vi.mock("@/lib/db", () => ({ createManyAndReturn: createManyAndReturnMock, update: updateMock, findMany: findManyMock, + findFirst: findFirstMock, updateMany: updateManyMock, }, $transaction: transactionMock, @@ -112,10 +115,18 @@ vi.mock("../client", async (importOriginal) => { }; }); -import { syncUserOura, upsertOuraMeasurements } from "../sync"; +import { + OURA_SYNC_LOOKBACK_DAYS, + OURA_SYNC_MAX_LOOKBACK_DAYS, + resolveOuraLookbackDays, + syncUserOura, + upsertOuraMeasurements, +} from "../sync"; import { OuraApiError } from "../response-classifier"; import { isSyncFailureRecorded } from "@/lib/integrations/status"; +const DAY_MS = 24 * 60 * 60 * 1000; + const CONN = { accessToken: "acc", refreshToken: "ref", @@ -178,6 +189,11 @@ beforeEach(() => { ); updateMock.mockReset().mockResolvedValue({}); findManyMock.mockReset(); + // Watermark read: default to a fresh row (yesterday) so every pre-existing + // test keeps the shipped 7-day floor and asserts against an unchanged window. + findFirstMock + .mockReset() + .mockResolvedValue({ measuredAt: new Date(Date.now() - DAY_MS) }); updateManyMock.mockReset().mockResolvedValue({ count: 0 }); emitArrivalMock.mockReset().mockResolvedValue(undefined); recordSuccessMock.mockReset().mockResolvedValue(undefined); @@ -706,3 +722,157 @@ describe("upsertOuraMeasurements — exact insertion results", () => { expect(emitArrivalMock).toHaveBeenCalledWith("u1", [inserted], "oura"); }); }); + +/** + * v1.32.28 — the lookback window is derived from the newest Oura row we hold + * rather than pinned at seven days. + * + * Two ways days used to be lost. A sync that stayed broken for longer than a + * week came back with a window too narrow to reach the gap. And a ring left + * unsynced produced a run of EMPTY successes, every signal green, after which + * the late-arriving days fell outside the window. Both leave the same + * fingerprint: the newest row stops moving. So one anchor closes both. + */ +describe("syncUserOura — window resolution", () => { + /** Days between the fetch window's start and today, as the query sees it. */ + function windowDays(): number { + const query = fetchReadinessMock.mock.calls[0]![1] as { + startDate: string; + endDate: string; + }; + const start = Date.parse(`${query.startDate}T00:00:00Z`); + const end = Date.parse(`${query.endDate}T00:00:00Z`); + return Math.round((end - start) / DAY_MS); + } + + it("widens the window to cover a ten-day gap", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 10 * DAY_MS), + }); + + await syncUserOura("u1"); + + // Ten days stale plus one day of overlap over the last seen day. + expect(windowDays()).toBe(11); + // Every collection shares the one widened window, not just readiness. + for (const fetchMock of [ + fetchSleepMock, + fetchActivityMock, + fetchDailySleepMock, + fetchSpo2Mock, + fetchVo2MaxMock, + fetchCardioAgeMock, + fetchResilienceMock, + ]) { + expect(fetchMock.mock.calls[0]![1]).toEqual( + fetchReadinessMock.mock.calls[0]![1], + ); + } + }); + + it("keeps the seven-day floor while data is flowing", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - DAY_MS), + }); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(7); + // One indexed read per tick, and tombstones are excluded from it — a + // soft-deleted row must not pass for a live one. + expect(findFirstMock).toHaveBeenCalledTimes(1); + expect(findFirstMock).toHaveBeenCalledWith({ + where: { userId: "u1", source: "OURA", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + }); + + it("ignores a swept row: a tombstone cannot hold the watermark fresh", async () => { + getConnMock.mockResolvedValue(CONN); + // The sleep sweep soft-deletes rows a revised hypnogram orphaned. The + // resolver's `deletedAt: null` filter means such a row is simply not + // returned, so the newest LIVE row is what answers — here, twelve days old. + findFirstMock.mockImplementation( + async (args: { where: { deletedAt: null } }) => { + expect(args.where.deletedAt).toBeNull(); + return { measuredAt: new Date(Date.now() - 12 * DAY_MS) }; + }, + ); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(13); + }); + + it("takes the full month on a fresh connection with no rows at all", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue(null); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(OURA_SYNC_MAX_LOOKBACK_DAYS); + }); + + it("clamps a ninety-day gap to the month ceiling", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 90 * DAY_MS), + }); + + await syncUserOura("u1"); + + expect(windowDays()).toBe(OURA_SYNC_MAX_LOOKBACK_DAYS); + }); + + it("lets an explicit lookback win without consulting the watermark", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 10 * DAY_MS), + }); + + await syncUserOura("u1", { lookbackDays: 3 }); + + expect(windowDays()).toBe(3); + expect(findFirstMock).not.toHaveBeenCalled(); + }); + + it("passes the resolved window to the cycle-phases leg too", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 10 * DAY_MS), + }); + + await syncUserOura("u1"); + + expect(fetchCyclePhasesMock).toHaveBeenCalled(); + const query = fetchCyclePhasesMock.mock.calls[0]![1] as { + startDate: string; + endDate: string; + }; + expect(query).toEqual(fetchReadinessMock.mock.calls[0]![1]); + }); +}); + +describe("resolveOuraLookbackDays", () => { + it("floors at the shipped week and ceilings at the month", async () => { + findFirstMock.mockResolvedValue({ measuredAt: new Date() }); + expect(await resolveOuraLookbackDays("u1")).toBe(OURA_SYNC_LOOKBACK_DAYS); + + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 400 * DAY_MS), + }); + expect(await resolveOuraLookbackDays("u1")).toBe( + OURA_SYNC_MAX_LOOKBACK_DAYS, + ); + }); + + it("returns the gap plus one day of overlap in between", async () => { + findFirstMock.mockResolvedValue({ + measuredAt: new Date(Date.now() - 20 * DAY_MS), + }); + expect(await resolveOuraLookbackDays("u1")).toBe(21); + }); +}); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 9ed4bc39a..fd6c1e2ff 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -89,11 +89,67 @@ import { import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; -/** Default lookback window (days) for an incremental sync. Oura finalises a - * night's scores hours after wake; 7 days re-fetches a handful of records (the - * upserts are idempotent) and closes any catch-up gap. */ +/** Floor of the lookback window (days) for an incremental sync. Oura finalises + * a night's scores hours after wake; 7 days re-fetches a handful of records + * (the upserts are idempotent) and covers the re-score tail. */ export const OURA_SYNC_LOOKBACK_DAYS = 7; +/** + * Ceiling of the lookback window (days). A catch-up tick may widen the window + * up to this many days; beyond it the missing span is history, not an outage, + * and belongs to a backfill rather than an hourly poll. + * + * 30 rather than the ~90 days Oura's own guidance would allow: the whole batch + * still rides ONE `$transaction` with a 60 s timeout (see + * `upsertOuraMeasurements` below), and a 30-day catch-up is roughly four times + * the row count of a normal tick — comfortably inside what that transaction + * handles. Raise it only after the batch write is chunked. + */ +export const OURA_SYNC_MAX_LOOKBACK_DAYS = 30; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Resolve how far back this tick should reach, anchored on the newest Oura + * measurement we actually hold. + * + * The fixed 7-day window lost days in two ways. A sync that failed for longer + * than a week resumed with a window too narrow to reach the gap, so the missing + * days stayed missing. And a ring left unsynced (Oura's cloud has nothing until + * the app is opened) produced a run of EMPTY successes — every signal green — + * after which the late-arriving days fell outside the window and were never + * imported. + * + * Anchoring on the newest imported row covers both: rows stop arriving whether + * the sync fails or succeeds empty, so the same watermark goes stale in either + * case. It is derived, never stored, so it cannot drift out of step with the + * data the way a persisted cursor can — the watermark IS the data. + * + * `deletedAt: null` matters: the sleep sweep soft-deletes rows a revised + * hypnogram orphaned, and a tombstone must not hold the watermark artificially + * fresh. The `+ 1` day is the overlap buffer over the last seen day. With data + * flowing normally the watermark is under two days old and the clamp floors at + * `OURA_SYNC_LOOKBACK_DAYS`, so the steady-state window and request count are + * exactly what they were before. No watermark at all is a fresh connection: + * take the full ceiling. + */ +export async function resolveOuraLookbackDays(userId: string): Promise { + const newest = await prisma.measurement.findFirst({ + where: { userId, source: "OURA", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + if (!newest) return OURA_SYNC_MAX_LOOKBACK_DAYS; + + const daysSince = Math.floor( + (Date.now() - newest.measuredAt.getTime()) / DAY_MS, + ); + return Math.min( + OURA_SYNC_MAX_LOOKBACK_DAYS, + Math.max(OURA_SYNC_LOOKBACK_DAYS, daysSince + 1), + ); +} + export function classifyOuraFailure(err: unknown): FailureKind { return toFailureKind(classifyOuraError(err)); } @@ -366,6 +422,11 @@ export async function syncUserOura( const conn = await getOuraConnection(userId); if (!conn) return 0; + // An explicit window from the caller wins; otherwise derive it from what we + // already hold so a gap widens the window instead of being skipped over. + const lookbackDays = + opts.lookbackDays ?? (await resolveOuraLookbackDays(userId)); + let result: OuraFetchResult; try { result = await fetchAll( @@ -373,7 +434,7 @@ export async function syncUserOura( conn.accessToken, conn.refreshToken, conn.refreshTokenCiphertext, - opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS, + lookbackDays, ); } catch (err) { // Only a whole-connection failure (a failed token refresh → reauth) reaches @@ -415,7 +476,7 @@ export async function syncUserOura( await syncUserOuraCyclePhases( userId, freshConn.accessToken, - opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS, + lookbackDays, ); } } catch (err) { From a9f1629f8265d59657a02642d4d9dde1dfad3cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 04:43:06 +0200 Subject: [PATCH 2/4] feat(integrations): add manual sync for Oura, Polar, Strava and Nightscout These four were the only connected providers with no way to ask for a sync; they ran on their scheduled pass and nothing else. Each gets a POST sync route on the hardened Fitbit and Google Health template: session auth, a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no full-sync flag, because none of the four entry points has a full-history arm to call. Polar goes through the shared legs entry point so both legs run with the attribution rules built for them. The Nightscout error arm is the one that matters: a base URL carries the API token as a query parameter, so the caught message can hold the user's secret and never reaches the response body. The classified failure is already on the ledger, recorded with the provider's own redaction. Connecting also starts the first sync now. The poll handler has accepted a single-user payload since it was written and never had a producer, so a fresh connection sat empty for up to an hour waiting for the schedule. The Oura and Polar callbacks and the Nightscout connect route enqueue one targeted job each, best effort, with the hourly tick still the safety net. Strava needs none: its callback already enqueues the history backfill, which covers the recent window. The four poll-queue names move into their own module so a request-path caller can name a queue without dragging the whole worker handler graph into the request bundle, following the backfill-queue precedent. Creation, scheduling and handler binding stay in the registrar, and the dead-queue guard follows the declarations. --- .../connect/__tests__/route.test.ts | 64 ++++++++- src/app/api/nightscout/connect/route.ts | 19 ++- .../nightscout/sync/__tests__/route.test.ts | 116 +++++++++++++++ src/app/api/nightscout/sync/route.ts | 42 ++++++ .../api/oura/callback/__tests__/route.test.ts | 66 +++++++-- src/app/api/oura/callback/route.ts | 17 +++ src/app/api/oura/sync/__tests__/route.test.ts | 104 ++++++++++++++ src/app/api/oura/sync/route.ts | 42 ++++++ .../polar/callback/__tests__/route.test.ts | 45 +++++- src/app/api/polar/callback/route.ts | 15 ++ .../api/polar/sync/__tests__/route.test.ts | 132 ++++++++++++++++++ src/app/api/polar/sync/route.ts | 41 ++++++ .../api/strava/sync/__tests__/route.test.ts | 104 ++++++++++++++ src/app/api/strava/sync/route.ts | 37 +++++ .../jobs/__tests__/nightscout-queues.test.ts | 8 ++ src/lib/jobs/integration-poll-queues.ts | 27 ++++ .../reminder/register-integration-sync.ts | 16 ++- 17 files changed, 874 insertions(+), 21 deletions(-) create mode 100644 src/app/api/nightscout/sync/__tests__/route.test.ts create mode 100644 src/app/api/nightscout/sync/route.ts create mode 100644 src/app/api/oura/sync/__tests__/route.test.ts create mode 100644 src/app/api/oura/sync/route.ts create mode 100644 src/app/api/polar/sync/__tests__/route.test.ts create mode 100644 src/app/api/polar/sync/route.ts create mode 100644 src/app/api/strava/sync/__tests__/route.test.ts create mode 100644 src/app/api/strava/sync/route.ts create mode 100644 src/lib/jobs/integration-poll-queues.ts diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index 115bfb8a3..da7d9c03c 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -4,10 +4,14 @@ import { NextRequest } from "next/server"; import { SafeFetchError } from "@/lib/safe-fetch"; import { NightscoutApiError } from "@/lib/nightscout/client"; -const { fetchSgvEntriesMock, rateLimitMock } = vi.hoisted(() => ({ - fetchSgvEntriesMock: vi.fn(), - rateLimitMock: vi.fn(), -})); +const { fetchSgvEntriesMock, rateLimitMock, bossSend, bossMock } = vi.hoisted( + () => ({ + fetchSgvEntriesMock: vi.fn(), + rateLimitMock: vi.fn(), + bossSend: vi.fn(), + bossMock: vi.fn(), + }), +); vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, @@ -18,10 +22,14 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } }, })); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ addWarning: vi.fn() }), +})); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() })); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); vi.mock("@/lib/nightscout/client", async (importOriginal) => { const actual = await importOriginal(); @@ -66,6 +74,8 @@ beforeEach(() => { vi.clearAllMocks(); rateLimitMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }); fetchSgvEntriesMock.mockResolvedValue([{ id: "x", sgv: 100, date: 1 }]); + bossSend.mockResolvedValue("job-1"); + bossMock.mockReturnValue({ send: bossSend }); }); describe("POST /api/nightscout/connect", () => { @@ -208,3 +218,47 @@ describe("POST /api/nightscout/connect", () => { expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); }); }); + +/** + * v1.32.28 — a Nightscout connection pulls its first glucose window straight + * away instead of leaving the card empty until the next hourly tick. + */ +describe("POST /api/nightscout/connect — connect-time first sync", () => { + it("enqueues exactly one targeted nightscout-sync job for the connecting user", async () => { + const res = await post( + req({ url: "https://ns.example.com", token: "tok" }), + ); + + expect(res.status).toBe(200); + expect(bossSend).toHaveBeenCalledTimes(1); + expect(bossSend).toHaveBeenCalledWith("nightscout-sync", { userId: "u1" }); + }); + + it("does not enqueue when the connection was rejected", async () => { + const res = await post(req({ url: "not a url" })); + + expect(res.status).toBe(422); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("still connects when no boss instance is available", async () => { + bossMock.mockReturnValue(null); + + const res = await post( + req({ url: "https://ns.example.com", token: "tok" }), + ); + + expect(res.status).toBe(200); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("still connects when the enqueue itself rejects", async () => { + bossSend.mockRejectedValue(new Error("queue unreachable")); + + const res = await post( + req({ url: "https://ns.example.com", token: "tok" }), + ); + + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 50c7ad821..6108e05cc 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -4,11 +4,13 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markReconnected } from "@/lib/integrations/status"; +import { NIGHTSCOUT_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { SafeFetchError } from "@/lib/safe-fetch"; import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client"; import { nightscoutConnectSchema } from "@/lib/validations/nightscout"; @@ -116,5 +118,20 @@ export const POST = apiHandler(async (request: NextRequest) => { await markReconnected(user.id, "nightscout"); await auditLog("nightscout.connect", { userId: user.id }); + // Pull the first glucose window now instead of leaving the user staring at an + // empty card until the next hourly tick. Best-effort by design: the hourly + // cron remains the safety net, so a missing boss instance must never fail the + // connect response the user just earned. + const boss = getGlobalBoss(); + if (boss) { + await boss + .send(NIGHTSCOUT_SYNC_QUEUE, { userId: user.id }) + .catch((err) => + getEvent()?.addWarning( + `nightscout-sync connect enqueue failed: ${err}`, + ), + ); + } + return apiSuccess({ connected: true }); }); diff --git a/src/app/api/nightscout/sync/__tests__/route.test.ts b/src/app/api/nightscout/sync/__tests__/route.test.ts new file mode 100644 index 000000000..a133cb8c5 --- /dev/null +++ b/src/app/api/nightscout/sync/__tests__/route.test.ts @@ -0,0 +1,116 @@ +/** + * v1.32.28 — manual Nightscout sync. + * + * The error arm carries the load here: a Nightscout base URL carries the API + * token as a query parameter, so an upstream message can contain the user's + * secret. The route answers with a fixed string and never the caught message. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual("@/lib/api-handler"); + return { + ...actual, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), + }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/nightscout/sync", () => ({ syncUserNightscout: vi.fn() })); + +import { POST } from "../route"; +import { HttpError, requireAuth } from "@/lib/api-handler"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { syncUserNightscout } from "@/lib/nightscout/sync"; + +function request(): NextRequest { + return new NextRequest("http://localhost/api/nightscout/sync", { + method: "POST", + }); +} + +// The route takes no arguments (no body, no flags); the alias keeps the +// direct invoke typed while still handing apiHandler a real request. +const post = POST as unknown as (r: NextRequest) => Promise; + +async function envelope(response: Response) { + return (await response.json()) as { + data: { imported?: number } | null; + error: string | null; + meta?: { errorCode?: string }; + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never); +}); + +describe("POST /api/nightscout/sync", () => { + it("syncs the session user and answers with the freshly inserted count", async () => { + vi.mocked(syncUserNightscout).mockResolvedValue(36); + + const response = await post(request()); + + expect(response.status).toBe(200); + expect(await envelope(response)).toEqual({ + data: { imported: 36 }, + error: null, + }); + expect(syncUserNightscout).toHaveBeenCalledWith("u1"); + }); + + it("refuses an unauthenticated caller before syncing anything", async () => { + vi.mocked(requireAuth).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + + const response = await post(request()); + + expect(response.status).toBe(401); + expect(syncUserNightscout).not.toHaveBeenCalled(); + }); + + it("refuses past the per-user rate limit without touching the sync", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + + const response = await post(request()); + const body = await envelope(response); + + expect(response.status).toBe(429); + expect(body.meta?.errorCode).toBe("rate_limited_self"); + expect(syncUserNightscout).not.toHaveBeenCalled(); + expect(checkRateLimit).toHaveBeenCalledWith( + "nightscout-sync:u1", + 5, + 60_000, + ); + }); + + it("never echoes the token-bearing instance URL into the response body", async () => { + vi.mocked(syncUserNightscout).mockRejectedValue( + new Error( + "GET https://cgm.example.org/api/v1/entries.json?token=hunter2secret failed with 401", + ), + ); + + const response = await post(request()); + const body = await envelope(response); + const serialised = JSON.stringify(body); + + expect(response.status).toBe(502); + expect(body.error).toBe("Nightscout sync failed"); + expect(serialised).not.toContain("hunter2secret"); + expect(serialised).not.toContain("cgm.example.org"); + expect(serialised).not.toContain("token="); + }); +}); diff --git a/src/app/api/nightscout/sync/route.ts b/src/app/api/nightscout/sync/route.ts new file mode 100644 index 000000000..1c379ce79 --- /dev/null +++ b/src/app/api/nightscout/sync/route.ts @@ -0,0 +1,42 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { syncUserNightscout } from "@/lib/nightscout/sync"; + +/** + * Manually trigger a Nightscout sync for the current user (v1.32.28). + * + * Pulls the recent SGV window from the configured instance; `imported` counts + * the readings that were genuinely new this run, not the ones re-confirmed. + * + * No body and no `fullSync` flag: there is no full-history arm to call. + * + * The error arm answers with a fixed message and NEVER the caught error's own. + * A Nightscout base URL carries the API token as a query parameter, so an + * upstream message can contain the user's secret; echoing it here would put it + * in a response body, a browser devtools panel, and any proxy log in between. + * The classified failure is already on the `nightscout` ledger, recorded by the + * provider with its redaction rules applied. + * + * Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health + * routes; every run is an outbound call to a user-supplied host. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "nightscout.sync" } }); + + const rl = await checkRateLimit(`nightscout-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + + try { + const imported = await syncUserNightscout(user.id); + return apiSuccess({ imported }); + } catch { + return apiError("Nightscout sync failed", 502); + } +}); diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index acd65851c..07cb60d32 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -12,21 +12,34 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), - getEvent: () => ({ setError: vi.fn(), setAuth: vi.fn() }), + getEvent: () => ({ + setError: vi.fn(), + setAuth: vi.fn(), + addWarning: vi.fn(), + }), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() })); -const { exchangeMock, getCredsMock, matchMock, verifyMock } = vi.hoisted( - () => ({ - exchangeMock: vi.fn(), - getCredsMock: vi.fn(), - matchMock: vi.fn(), - verifyMock: vi.fn(), - }), -); +const { + exchangeMock, + getCredsMock, + matchMock, + verifyMock, + bossSend, + bossMock, +} = vi.hoisted(() => ({ + exchangeMock: vi.fn(), + getCredsMock: vi.fn(), + matchMock: vi.fn(), + verifyMock: vi.fn(), + bossSend: vi.fn(), + bossMock: vi.fn(), +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock })); vi.mock("@/lib/oura/credentials", () => ({ @@ -75,6 +88,8 @@ beforeEach(() => { exchangeMock.mockResolvedValue({ access_token: "at", refresh_token: "rt" }); userUpdate.mockResolvedValue({}); (getSession as ReturnType).mockResolvedValue(null); + bossSend.mockResolvedValue("job-1"); + bossMock.mockReturnValue({ send: bossSend }); }); describe("GET /api/oura/callback", () => { @@ -123,3 +138,36 @@ describe("GET /api/oura/callback", () => { expect(res.headers.get("location")).toContain("oura=connected"); }); }); + +/** + * v1.32.28 — connecting starts a sync straight away. The poll handler has + * accepted a single-user payload since it was written; this callback is the + * producer that was missing, and without it a fresh connection showed an empty + * card for up to an hour. + */ +describe("GET /api/oura/callback — connect-time first sync", () => { + it("enqueues exactly one targeted oura-sync job for the connecting user", async () => { + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" })); + + expect(res.headers.get("location")).toContain("oura=connected"); + expect(bossSend).toHaveBeenCalledTimes(1); + expect(bossSend).toHaveBeenCalledWith("oura-sync", { userId: "u1" }); + }); + + it("still connects when no boss instance is available", async () => { + bossMock.mockReturnValue(null); + + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" })); + + expect(res.headers.get("location")).toContain("oura=connected"); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("still connects when the enqueue itself rejects", async () => { + bossSend.mockRejectedValue(new Error("queue unreachable")); + + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" })); + + expect(res.headers.get("location")).toContain("oura=connected"); + }); +}); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 9dd9a0fad..553dd9c64 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -12,6 +12,8 @@ import { verifySignedState, } from "@/lib/oauth/signed-state"; import { markReconnected } from "@/lib/integrations/status"; +import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { NextRequest, NextResponse } from "next/server"; /** @@ -82,6 +84,21 @@ export const GET = apiHandler(async (request: NextRequest) => { await auditLog("oura.connect", { userId }); await markReconnected(userId, "oura"); + // Start the first sync now instead of leaving the user staring at an empty + // card until the next hourly tick. The poll handler has accepted a + // single-user payload since it was written; this is the producer that was + // missing. Reconnects ride the same path. Best-effort by design: the hourly + // cron remains the safety net, so a missing boss instance must never strand + // the redirect. + const boss = getGlobalBoss(); + if (boss) { + await boss + .send(OURA_SYNC_QUEUE, { userId }) + .catch((err) => + getEvent()?.addWarning(`oura-sync connect enqueue failed: ${err}`), + ); + } + const response = NextResponse.redirect( new URL( "/settings/integrations?oura=connected", diff --git a/src/app/api/oura/sync/__tests__/route.test.ts b/src/app/api/oura/sync/__tests__/route.test.ts new file mode 100644 index 000000000..8dd1fd51a --- /dev/null +++ b/src/app/api/oura/sync/__tests__/route.test.ts @@ -0,0 +1,104 @@ +/** + * v1.32.28 — manual Oura sync. Until now Oura only ran on its hourly tick. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual("@/lib/api-handler"); + return { + ...actual, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), + }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/oura/sync", () => ({ syncUserOura: vi.fn() })); + +import { POST } from "../route"; +import { HttpError, requireAuth } from "@/lib/api-handler"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { syncUserOura } from "@/lib/oura/sync"; + +function request(): NextRequest { + return new NextRequest("http://localhost/api/oura/sync", { method: "POST" }); +} + +// The route takes no arguments (no body, no flags); the alias keeps the +// direct invoke typed while still handing apiHandler a real request. +const post = POST as unknown as (r: NextRequest) => Promise; + +async function envelope(response: Response) { + return (await response.json()) as { + data: { imported?: number } | null; + error: string | null; + meta?: { errorCode?: string }; + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(requireAuth).mockResolvedValue({ + user: { id: "u1" }, + } as never); +}); + +describe("POST /api/oura/sync", () => { + it("syncs the session user and answers with the imported count", async () => { + vi.mocked(syncUserOura).mockResolvedValue(12); + + const response = await post(request()); + + expect(response.status).toBe(200); + expect(await envelope(response)).toEqual({ + data: { imported: 12 }, + error: null, + }); + // The id comes from the session, never from the request. + expect(syncUserOura).toHaveBeenCalledWith("u1"); + }); + + it("refuses an unauthenticated caller before syncing anything", async () => { + vi.mocked(requireAuth).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + + const response = await post(request()); + + expect(response.status).toBe(401); + expect(syncUserOura).not.toHaveBeenCalled(); + }); + + it("refuses past the per-user rate limit without touching the sync", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + + const response = await post(request()); + const body = await envelope(response); + + expect(response.status).toBe(429); + expect(body.meta?.errorCode).toBe("rate_limited_self"); + expect(syncUserOura).not.toHaveBeenCalled(); + expect(checkRateLimit).toHaveBeenCalledWith("oura-sync:u1", 5, 60_000); + }); + + it("answers 502 generically when the sync throws", async () => { + vi.mocked(syncUserOura).mockRejectedValue( + new Error("oura said: token abc123 is invalid"), + ); + + const response = await post(request()); + const body = await envelope(response); + + expect(response.status).toBe(502); + expect(body.error).toBe("Oura sync failed"); + expect(JSON.stringify(body)).not.toContain("abc123"); + }); +}); diff --git a/src/app/api/oura/sync/route.ts b/src/app/api/oura/sync/route.ts new file mode 100644 index 000000000..98602ed80 --- /dev/null +++ b/src/app/api/oura/sync/route.ts @@ -0,0 +1,42 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { syncUserOura } from "@/lib/oura/sync"; + +/** + * Manually trigger an Oura sync for the current user (v1.32.28). + * + * Until now Oura only ever synced on its hourly cron tick, so a user who could + * see something was behind had no way to ask for a fresh pull. + * + * No body and no `fullSync` flag: `syncUserOura` has no full-history arm, and + * the window is not the caller's to choose — the sync derives it from the + * newest row it already holds, which is what makes a catch-up honest. Accepting + * a `lookbackDays` here would hand an unvalidated cost knob to the client. + * + * Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health + * routes; a sync is a fan-out of upstream requests plus a batched write, so a + * tight retry loop must not be able to pin Prisma or burn the Oura budget. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "oura.sync" } }); + + const rl = await checkRateLimit(`oura-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + + try { + const imported = await syncUserOura(user.id); + return apiSuccess({ imported }); + } catch { + // The failure is already on the `oura` ledger, classified by the provider + // that owns the error shape. Answer generically — an upstream message can + // carry detail that has no business in a response body. + return apiError("Oura sync failed", 502); + } +}); diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index c290363ad..ac69bba3e 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -8,6 +8,8 @@ const { registerUserMock, getCredsMock, markReconnectedMock, + bossSend, + bossMock, } = vi.hoisted(() => ({ stateMatchesCookieMock: vi.fn(), verifySignedStateMock: vi.fn(), @@ -16,6 +18,8 @@ const { registerUserMock: vi.fn(), getCredsMock: vi.fn(), markReconnectedMock: vi.fn(), + bossSend: vi.fn(), + bossMock: vi.fn(), })); vi.mock("@/lib/api-handler", () => ({ @@ -24,7 +28,11 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), - getEvent: () => ({ setAuth: vi.fn(), setError: vi.fn() }), + getEvent: () => ({ + setAuth: vi.fn(), + setError: vi.fn(), + addWarning: vi.fn(), + }), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); @@ -44,6 +52,7 @@ vi.mock("@/lib/oauth/signed-state", () => ({ vi.mock("@/lib/integrations/status", () => ({ markReconnected: markReconnectedMock, })); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); import { GET } from "../route"; @@ -68,6 +77,8 @@ beforeEach(() => { getSessionMock.mockResolvedValue(null); getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); exchangeCodeMock.mockResolvedValue({ access_token: "tok", x_user_id: 42 }); + bossSend.mockResolvedValue("job-1"); + bossMock.mockReturnValue({ send: bossSend }); registerUserMock.mockResolvedValue(undefined); }); @@ -100,3 +111,35 @@ describe("GET /api/polar/callback", () => { expect(registerUserMock).not.toHaveBeenCalled(); }); }); + +/** + * v1.32.28 — connecting starts a sync straight away instead of leaving the card + * empty until the next hourly tick. The poll handler has accepted a single-user + * payload since it was written; this callback is the producer that was missing. + */ +describe("GET /api/polar/callback — connect-time first sync", () => { + it("enqueues exactly one targeted polar-sync job for the connecting user", async () => { + const res = await run(makeReq()); + + expect(res.headers.get("location")).toContain("polar=connected"); + expect(bossSend).toHaveBeenCalledTimes(1); + expect(bossSend).toHaveBeenCalledWith("polar-sync", { userId: "u1" }); + }); + + it("still connects when no boss instance is available", async () => { + bossMock.mockReturnValue(null); + + const res = await run(makeReq()); + + expect(res.headers.get("location")).toContain("polar=connected"); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("still connects when the enqueue itself rejects", async () => { + bossSend.mockRejectedValue(new Error("queue unreachable")); + + const res = await run(makeReq()); + + expect(res.headers.get("location")).toContain("polar=connected"); + }); +}); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index e8a8a09f7..8e89f51b1 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -12,6 +12,8 @@ import { verifySignedState, } from "@/lib/oauth/signed-state"; import { markReconnected } from "@/lib/integrations/status"; +import { POLAR_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { NextRequest, NextResponse } from "next/server"; /** @@ -102,6 +104,19 @@ export const GET = apiHandler(async (request: NextRequest) => { await auditLog("polar.connect", { userId }); await markReconnected(userId, "polar"); + // Start the first sync now instead of leaving the user staring at an empty + // card until the next hourly tick. Best-effort by design: the hourly cron + // remains the safety net, so a missing boss instance must never strand the + // redirect. + const boss = getGlobalBoss(); + if (boss) { + await boss + .send(POLAR_SYNC_QUEUE, { userId }) + .catch((err) => + getEvent()?.addWarning(`polar-sync connect enqueue failed: ${err}`), + ); + } + const response = NextResponse.redirect( new URL( "/settings/integrations?polar=connected", diff --git a/src/app/api/polar/sync/__tests__/route.test.ts b/src/app/api/polar/sync/__tests__/route.test.ts new file mode 100644 index 000000000..f18d71c4f --- /dev/null +++ b/src/app/api/polar/sync/__tests__/route.test.ts @@ -0,0 +1,132 @@ +/** + * v1.32.28 — manual Polar sync. + * + * The entry point matters as much as the behaviour: the route must call + * `syncUserPolarLegs`, the same seam the hourly poll uses, so both legs run + * with the attribution rules built for them. Calling the vitals leg alone would + * quietly import half of Polar and stamp the ledger wrong, and a naive test + * that only asserted a row count would not notice. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +const { syncUserPolarMock, syncUserPolarWorkoutsMock } = vi.hoisted(() => ({ + syncUserPolarMock: vi.fn(), + syncUserPolarWorkoutsMock: vi.fn(), +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual("@/lib/api-handler"); + return { + ...actual, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), + }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +// Mock the two LEGS rather than the wrapper, so the real `syncUserPolarLegs` +// runs and the test can see whether both of them were driven. +vi.mock("@/lib/polar/sync", () => ({ + syncUserPolar: syncUserPolarMock, + recordPolarSyncFailure: vi.fn(), +})); +vi.mock("@/lib/polar/sync-workouts", () => ({ + syncUserPolarWorkouts: syncUserPolarWorkoutsMock, +})); + +import { POST } from "../route"; +import { HttpError, requireAuth } from "@/lib/api-handler"; +import { checkRateLimit } from "@/lib/rate-limit"; + +function request(): NextRequest { + return new NextRequest("http://localhost/api/polar/sync", { method: "POST" }); +} + +// The route takes no arguments (no body, no flags); the alias keeps the +// direct invoke typed while still handing apiHandler a real request. +const post = POST as unknown as (r: NextRequest) => Promise; + +async function envelope(response: Response) { + return (await response.json()) as { + data: { imported?: number } | null; + error: string | null; + meta?: { errorCode?: string }; + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never); + syncUserPolarMock.mockResolvedValue(0); + syncUserPolarWorkoutsMock.mockResolvedValue(0); +}); + +describe("POST /api/polar/sync", () => { + it("runs both Polar legs and totals what they imported", async () => { + syncUserPolarMock.mockResolvedValue(5); + syncUserPolarWorkoutsMock.mockResolvedValue(2); + + const response = await post(request()); + + expect(response.status).toBe(200); + expect(await envelope(response)).toEqual({ + data: { imported: 7 }, + error: null, + }); + expect(syncUserPolarMock).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1"); + }); + + it("goes through the shared legs entry point, not the provider internals", () => { + const source = readFileSync(join(__dirname, "..", "route.ts"), "utf8"); + expect(source).toContain("syncUserPolarLegs"); + expect(source).not.toMatch(/from "@\/lib\/polar\//); + }); + + it("refuses an unauthenticated caller before syncing anything", async () => { + vi.mocked(requireAuth).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + + const response = await post(request()); + + expect(response.status).toBe(401); + expect(syncUserPolarMock).not.toHaveBeenCalled(); + expect(syncUserPolarWorkoutsMock).not.toHaveBeenCalled(); + }); + + it("refuses past the per-user rate limit without touching the sync", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + + const response = await post(request()); + const body = await envelope(response); + + expect(response.status).toBe(429); + expect(body.meta?.errorCode).toBe("rate_limited_self"); + expect(syncUserPolarMock).not.toHaveBeenCalled(); + expect(checkRateLimit).toHaveBeenCalledWith("polar-sync:u1", 5, 60_000); + }); + + it("answers 502 generically when a leg throws", async () => { + syncUserPolarMock.mockRejectedValue( + new Error("polar said: token abc123 is invalid"), + ); + + const response = await post(request()); + const body = await envelope(response); + + expect(response.status).toBe(502); + expect(body.error).toBe("Polar sync failed"); + expect(JSON.stringify(body)).not.toContain("abc123"); + }); +}); diff --git a/src/app/api/polar/sync/route.ts b/src/app/api/polar/sync/route.ts new file mode 100644 index 000000000..01b528dcd --- /dev/null +++ b/src/app/api/polar/sync/route.ts @@ -0,0 +1,41 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { syncUserPolarLegs } from "@/lib/jobs/reminder/polar-sync"; + +/** + * Manually trigger a Polar sync for the current user (v1.32.28). + * + * Calls `syncUserPolarLegs` — the same entry point the hourly poll uses — so + * both Polar legs run with the attribution rules that were built for them + * (vitals then workouts, independent failure recording, one success stamp). + * Reaching past it into `@/lib/polar/*` would run half the provider and get the + * ledger wrong. The module graph behind this import bottoms out in the shared + * Prisma client and constants, so it is safe on the request path. + * + * No body and no `fullSync` flag: there is no full-history arm to call. + * + * Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health + * routes. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.sync" } }); + + const rl = await checkRateLimit(`polar-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + + try { + const imported = await syncUserPolarLegs(user.id); + return apiSuccess({ imported }); + } catch { + // Both legs record their own classified failure on the `polar` ledger. + // Answer generically so no upstream detail rides the response body. + return apiError("Polar sync failed", 502); + } +}); diff --git a/src/app/api/strava/sync/__tests__/route.test.ts b/src/app/api/strava/sync/__tests__/route.test.ts new file mode 100644 index 000000000..45fcd1bcf --- /dev/null +++ b/src/app/api/strava/sync/__tests__/route.test.ts @@ -0,0 +1,104 @@ +/** + * v1.32.28 — manual Strava sync. Incremental by nature (Strava carries a real + * cursor); no full-history arm is exposed here, that stays with the backfill. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual("@/lib/api-handler"); + return { + ...actual, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), + }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/strava/sync", () => ({ syncUserStrava: vi.fn() })); + +import { POST } from "../route"; +import { HttpError, requireAuth } from "@/lib/api-handler"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { syncUserStrava } from "@/lib/strava/sync"; + +function request(): NextRequest { + return new NextRequest("http://localhost/api/strava/sync", { + method: "POST", + }); +} + +// The route takes no arguments (no body, no flags); the alias keeps the +// direct invoke typed while still handing apiHandler a real request. +const post = POST as unknown as (r: NextRequest) => Promise; + +async function envelope(response: Response) { + return (await response.json()) as { + data: { imported?: number } | null; + error: string | null; + meta?: { errorCode?: string }; + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never); +}); + +describe("POST /api/strava/sync", () => { + it("syncs the session user and answers with the imported count", async () => { + vi.mocked(syncUserStrava).mockResolvedValue(4); + + const response = await post(request()); + + expect(response.status).toBe(200); + expect(await envelope(response)).toEqual({ + data: { imported: 4 }, + error: null, + }); + expect(syncUserStrava).toHaveBeenCalledWith("u1"); + }); + + it("refuses an unauthenticated caller before syncing anything", async () => { + vi.mocked(requireAuth).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + + const response = await post(request()); + + expect(response.status).toBe(401); + expect(syncUserStrava).not.toHaveBeenCalled(); + }); + + it("refuses past the per-user rate limit without touching the sync", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + + const response = await post(request()); + const body = await envelope(response); + + expect(response.status).toBe(429); + expect(body.meta?.errorCode).toBe("rate_limited_self"); + expect(syncUserStrava).not.toHaveBeenCalled(); + expect(checkRateLimit).toHaveBeenCalledWith("strava-sync:u1", 5, 60_000); + }); + + it("answers 502 generically when the sync throws", async () => { + vi.mocked(syncUserStrava).mockRejectedValue( + new Error("strava said: token abc123 is invalid"), + ); + + const response = await post(request()); + const body = await envelope(response); + + expect(response.status).toBe(502); + expect(body.error).toBe("Strava sync failed"); + expect(JSON.stringify(body)).not.toContain("abc123"); + }); +}); diff --git a/src/app/api/strava/sync/route.ts b/src/app/api/strava/sync/route.ts new file mode 100644 index 000000000..0c36326cd --- /dev/null +++ b/src/app/api/strava/sync/route.ts @@ -0,0 +1,37 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { syncUserStrava } from "@/lib/strava/sync"; + +/** + * Manually trigger a Strava sync for the current user (v1.32.28). + * + * Incremental by nature: Strava already carries a real cursor + * (`stravaLastActivityAt` minus an overlap), so a manual run picks up wherever + * the last one stopped. No `fullSync` flag — deep history belongs to the + * connect-time backfill queue, which is self-converging and already gated. + * + * Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health + * routes; Strava's 200-req / 15-min cap leaves no room for a retry loop. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "strava.sync" } }); + + const rl = await checkRateLimit(`strava-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + + try { + const imported = await syncUserStrava(user.id); + return apiSuccess({ imported }); + } catch { + // Already recorded on the `strava` ledger with its classification. Answer + // generically so no upstream detail rides the response body. + return apiError("Strava sync failed", 502); + } +}); diff --git a/src/lib/jobs/__tests__/nightscout-queues.test.ts b/src/lib/jobs/__tests__/nightscout-queues.test.ts index 373f019df..5873b8382 100644 --- a/src/lib/jobs/__tests__/nightscout-queues.test.ts +++ b/src/lib/jobs/__tests__/nightscout-queues.test.ts @@ -18,7 +18,15 @@ import { describe, expect, it } from "vitest"; // file into domain registrars; the Nightscout / Polar / Oura queues live in the // integration-sync registrar. The dead-queue guard follows the wiring there // (the handler module is still concatenated for the handler-symbol assertions). +// +// v1.32.28 — the four poll-queue NAMES moved again, into +// `integration-poll-queues.ts`, so an OAuth callback can enqueue a connect-time +// first sync without importing the registrar's handler graph. The guard follows +// them: the name declarations are asserted against that module, everything else +// (allQueues membership, cron schedule, handler binding) still against the +// registrar. const source = + readFileSync(join(__dirname, "..", "integration-poll-queues.ts"), "utf8") + readFileSync( join(__dirname, "..", "reminder", "register-integration-sync.ts"), "utf8", diff --git a/src/lib/jobs/integration-poll-queues.ts b/src/lib/jobs/integration-poll-queues.ts new file mode 100644 index 000000000..addd5e94d --- /dev/null +++ b/src/lib/jobs/integration-poll-queues.ts @@ -0,0 +1,27 @@ +/** + * Queue names for the poll-only integration syncs. + * + * Split out of `reminder/register-integration-sync.ts` so a request-path caller + * (an OAuth callback enqueueing the first sync at connect time) can name the + * queue without importing the registrar — which would drag every worker handler + * and its transitive graph into the request bundle. Same shape as + * `STRAVA_BACKFILL_QUEUE`, which the Strava callback already imports from its + * own module. + * + * The registrar remains the single place that creates, schedules, and binds a + * handler to each of these; the values here are its source. A name declared + * here but missing from `allQueues` there is the v1.4.37 dead-queue class, and + * the queue-wiring guards read both files for exactly that reason. + */ + +/** Hourly Nightscout CGM poll (:11). */ +export const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync"; + +/** Hourly Polar AccessLink poll (:13). */ +export const POLAR_SYNC_QUEUE = "polar-sync"; + +/** Hourly Oura Cloud poll (:15). */ +export const OURA_SYNC_QUEUE = "oura-sync"; + +/** Hourly Strava poll (:17). */ +export const STRAVA_SYNC_QUEUE = "strava-sync"; diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index eaaf55544..7abc44a0e 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -77,6 +77,12 @@ import { type ScheduleEntry, } from "./registrar-shared"; import { WITHINGS_ECG_SYNC_QUEUE } from "@/lib/jobs/withings-ecg-queue"; +import { + NIGHTSCOUT_SYNC_QUEUE, + OURA_SYNC_QUEUE, + POLAR_SYNC_QUEUE, + STRAVA_SYNC_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; import { WithingsSyncPayload, WithingsActivitySyncPayload, @@ -223,8 +229,11 @@ const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON = "26 3 * * *"; // pulls the recent SGV window per configured instance. :11 staggers off the // WHOOP (:05), Fitbit (:08), and Withings sync ticks so the hourly polls don't // pile up on one boss poll. -const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync"; - +// +// The four poll-queue NAMES live in `@/lib/jobs/integration-poll-queues` (see +// the import above) so an OAuth callback can enqueue a connect-time first sync +// without importing this registrar and its whole handler graph. Creation, +// scheduling, and handler binding stay here. const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11 // v1.17.0 (F4) — Polar + Oura OAuth poll sync. Poll-only (one hourly tick per // provider re-walks every connected user). :13 / :15 stagger off the other @@ -232,16 +241,13 @@ const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11 // pile up on one boss poll. The queues MUST be registered in `allQueues` below // or pg-boss never provisions them and the schedule silently no-ops (the // v1.4.37 dead-queue class). -const POLAR_SYNC_QUEUE = "polar-sync"; const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13 -const OURA_SYNC_QUEUE = "oura-sync"; const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15 // v1.28.x — Strava OAuth poll sync. Poll-only (webhook deferred): one hourly // tick re-walks every connected user. :17 staggers off the Polar (:13) / Oura // (:15) ticks so the polls don't pile up on one boss poll. The queue MUST be // registered in `allQueues` below or pg-boss never provisions it and the // schedule silently no-ops (the v1.4.37 dead-queue class). -const STRAVA_SYNC_QUEUE = "strava-sync"; const STRAVA_SYNC_CRON = "17 * * * *"; // every hour at :17 // v1.30.x — daily sweep for the native OIDC handoff ledger. Handoff codes are // single-use + 90s-lived and expiry is enforced at read, so this is pure From 0bcbb88db11ae32eba002a841603d8c40cb8d347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 04:43:12 +0200 Subject: [PATCH 3/4] feat(settings): put a sync-now button on the four cards that lacked one A route with no caller is an API detail, not a feature. The Oura, Polar, Strava and Nightscout cards get the same action WHOOP already had, in the same row as Test connection, rendered only once connected: post the sync, show the count or the failure, invalidate the reads that depend on measurements. No full-history dialog, since these providers have no such arm. Keys added across the six locales. --- messages/de.json | 12 ++++ messages/en.json | 12 ++++ messages/es.json | 12 ++++ messages/fr.json | 12 ++++ messages/it.json | 12 ++++ messages/pl.json | 12 ++++ .../__tests__/nightscout-card.test.tsx | 23 +++++++ .../__tests__/oauth-provider-card.test.tsx | 31 +++++++++ .../settings/integrations/nightscout-card.tsx | 64 +++++++++++++++++++ .../integrations/oauth-provider-card.tsx | 62 ++++++++++++++++++ 10 files changed, 252 insertions(+) diff --git a/messages/de.json b/messages/de.json index 3f965de1d..2a1b33f10 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5543,6 +5543,9 @@ "nightscoutUpdate": "Änderungen speichern", "nightscoutConnected": "Nightscout verbunden.", "nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.", + "nightscoutSync": "Jetzt synchronisieren", + "nightscoutSyncResult": "{count} Messwerte synchronisiert", + "nightscoutSyncFailed": "Synchronisierung fehlgeschlagen", "nightscoutDisconnect": "Trennen", "nightscoutDisconnectTitle": "Nightscout trennen?", "nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.", @@ -5558,6 +5561,9 @@ "polarSaveCredentials": "Speichern", "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "polarConnect": "Polar verbinden", + "polarSync": "Jetzt synchronisieren", + "polarSyncResult": "{count} Messwerte synchronisiert", + "polarSyncFailed": "Synchronisierung fehlgeschlagen", "polarDisconnect": "Trennen", "polarDisconnectTitle": "Polar trennen?", "polarDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.", @@ -5585,6 +5591,9 @@ "ouraSaveCredentials": "Speichern", "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "ouraConnect": "Oura verbinden", + "ouraSync": "Jetzt synchronisieren", + "ouraSyncResult": "{count} Messwerte synchronisiert", + "ouraSyncFailed": "Synchronisierung fehlgeschlagen", "ouraDisconnect": "Trennen", "ouraDisconnectTitle": "Oura trennen?", "ouraDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.", @@ -5813,6 +5822,9 @@ "stravaSaveCredentials": "Speichern", "stravaUnavailable": "Strava ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Strava-API-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "stravaConnect": "Strava verbinden", + "stravaSync": "Jetzt synchronisieren", + "stravaSyncResult": "{count} Messwerte synchronisiert", + "stravaSyncFailed": "Synchronisierung fehlgeschlagen", "stravaDisconnect": "Trennen", "stravaDisconnectTitle": "Strava trennen?", "stravaDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Aktivitäten bleiben in deinem Verlauf.", diff --git a/messages/en.json b/messages/en.json index ef2007aae..de6e07aa0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5533,6 +5533,9 @@ "nightscoutUpdate": "Save changes", "nightscoutConnected": "Nightscout connected.", "nightscoutConnectFailed": "Could not connect to Nightscout.", + "nightscoutSync": "Sync now", + "nightscoutSyncResult": "{count} measurements synchronized", + "nightscoutSyncFailed": "Sync failed", "nightscoutDisconnect": "Disconnect", "nightscoutDisconnectTitle": "Disconnect Nightscout?", "nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.", @@ -5548,6 +5551,9 @@ "polarSaveCredentials": "Save", "polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.", "polarConnect": "Connect Polar", + "polarSync": "Sync now", + "polarSyncResult": "{count} measurements synchronized", + "polarSyncFailed": "Sync failed", "polarDisconnect": "Disconnect", "polarDisconnectTitle": "Disconnect Polar?", "polarDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.", @@ -5575,6 +5581,9 @@ "ouraSaveCredentials": "Save", "ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.", "ouraConnect": "Connect Oura", + "ouraSync": "Sync now", + "ouraSyncResult": "{count} measurements synchronized", + "ouraSyncFailed": "Sync failed", "ouraDisconnect": "Disconnect", "ouraDisconnectTitle": "Disconnect Oura?", "ouraDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.", @@ -5803,6 +5812,9 @@ "stravaSaveCredentials": "Save", "stravaUnavailable": "Strava is not configured on this server. Add your own Strava API credentials above, or ask your administrator to configure the shared app.", "stravaConnect": "Connect Strava", + "stravaSync": "Sync now", + "stravaSyncResult": "{count} measurements synchronized", + "stravaSyncFailed": "Sync failed", "stravaDisconnect": "Disconnect", "stravaDisconnectTitle": "Disconnect Strava?", "stravaDisconnectDescription": "Future syncs stop. Activities already imported stay in your history.", diff --git a/messages/es.json b/messages/es.json index c6c9f21a2..8d6bbbc5c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5545,6 +5545,9 @@ "nightscoutUpdate": "Guardar cambios", "nightscoutConnected": "Nightscout conectado.", "nightscoutConnectFailed": "No se pudo conectar con Nightscout.", + "nightscoutSync": "Sincronizar ahora", + "nightscoutSyncResult": "{count} mediciones sincronizadas", + "nightscoutSyncFailed": "Error de sincronización", "nightscoutDisconnect": "Desconectar", "nightscoutDisconnectTitle": "¿Desconectar Nightscout?", "nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.", @@ -5560,6 +5563,9 @@ "polarSaveCredentials": "Guardar", "polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.", "polarConnect": "Conectar Polar", + "polarSync": "Sincronizar ahora", + "polarSyncResult": "{count} mediciones sincronizadas", + "polarSyncFailed": "Error de sincronización", "polarDisconnect": "Desconectar", "polarDisconnectTitle": "¿Desconectar Polar?", "polarDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.", @@ -5587,6 +5593,9 @@ "ouraSaveCredentials": "Guardar", "ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.", "ouraConnect": "Conectar Oura", + "ouraSync": "Sincronizar ahora", + "ouraSyncResult": "{count} mediciones sincronizadas", + "ouraSyncFailed": "Error de sincronización", "ouraDisconnect": "Desconectar", "ouraDisconnectTitle": "¿Desconectar Oura?", "ouraDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.", @@ -5815,6 +5824,9 @@ "stravaSaveCredentials": "Guardar", "stravaUnavailable": "Strava no está configurado en este servidor. Añade arriba tus propias credenciales de API de Strava o pide a tu administrador que configure la app compartida.", "stravaConnect": "Conectar Strava", + "stravaSync": "Sincronizar ahora", + "stravaSyncResult": "{count} mediciones sincronizadas", + "stravaSyncFailed": "Error de sincronización", "stravaDisconnect": "Desconectar", "stravaDisconnectTitle": "¿Desconectar Strava?", "stravaDisconnectDescription": "Las sincronizaciones futuras se detienen. Las actividades ya importadas permanecen en tu historial.", diff --git a/messages/fr.json b/messages/fr.json index 870ed5dfd..afa537f60 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5547,6 +5547,9 @@ "nightscoutUpdate": "Enregistrer les modifications", "nightscoutConnected": "Nightscout connecté.", "nightscoutConnectFailed": "Impossible de se connecter à Nightscout.", + "nightscoutSync": "Synchroniser", + "nightscoutSyncResult": "{count} mesures synchronisées", + "nightscoutSyncFailed": "Échec de la synchronisation", "nightscoutDisconnect": "Déconnecter", "nightscoutDisconnectTitle": "Déconnecter Nightscout ?", "nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.", @@ -5562,6 +5565,9 @@ "polarSaveCredentials": "Enregistrer", "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.", "polarConnect": "Connecter Polar", + "polarSync": "Synchroniser", + "polarSyncResult": "{count} mesures synchronisées", + "polarSyncFailed": "Échec de la synchronisation", "polarDisconnect": "Déconnecter", "polarDisconnectTitle": "Déconnecter Polar ?", "polarDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.", @@ -5589,6 +5595,9 @@ "ouraSaveCredentials": "Enregistrer", "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.", "ouraConnect": "Connecter Oura", + "ouraSync": "Synchroniser", + "ouraSyncResult": "{count} mesures synchronisées", + "ouraSyncFailed": "Échec de la synchronisation", "ouraDisconnect": "Déconnecter", "ouraDisconnectTitle": "Déconnecter Oura ?", "ouraDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.", @@ -5817,6 +5826,9 @@ "stravaSaveCredentials": "Enregistrer", "stravaUnavailable": "Strava n'est pas configuré sur ce serveur. Ajoute tes propres identifiants API Strava ci-dessus ou demande à ton administrateur de configurer l'application partagée.", "stravaConnect": "Connecter Strava", + "stravaSync": "Synchroniser", + "stravaSyncResult": "{count} mesures synchronisées", + "stravaSyncFailed": "Échec de la synchronisation", "stravaDisconnect": "Déconnecter", "stravaDisconnectTitle": "Déconnecter Strava ?", "stravaDisconnectDescription": "Les synchronisations futures s'arrêtent. Les activités déjà importées restent dans ton historique.", diff --git a/messages/it.json b/messages/it.json index c8eae2287..d29e1ff10 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5537,6 +5537,9 @@ "nightscoutUpdate": "Salva modifiche", "nightscoutConnected": "Nightscout connesso.", "nightscoutConnectFailed": "Impossibile connettersi a Nightscout.", + "nightscoutSync": "Sincronizza ora", + "nightscoutSyncResult": "{count} misurazioni sincronizzate", + "nightscoutSyncFailed": "Sincronizzazione non riuscita", "nightscoutDisconnect": "Disconnetti", "nightscoutDisconnectTitle": "Disconnettere Nightscout?", "nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.", @@ -5552,6 +5555,9 @@ "polarSaveCredentials": "Salva", "polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.", "polarConnect": "Collega Polar", + "polarSync": "Sincronizza ora", + "polarSyncResult": "{count} misurazioni sincronizzate", + "polarSyncFailed": "Sincronizzazione non riuscita", "polarDisconnect": "Disconnetti", "polarDisconnectTitle": "Disconnettere Polar?", "polarDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.", @@ -5579,6 +5585,9 @@ "ouraSaveCredentials": "Salva", "ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.", "ouraConnect": "Collega Oura", + "ouraSync": "Sincronizza ora", + "ouraSyncResult": "{count} misurazioni sincronizzate", + "ouraSyncFailed": "Sincronizzazione non riuscita", "ouraDisconnect": "Disconnetti", "ouraDisconnectTitle": "Disconnettere Oura?", "ouraDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.", @@ -5807,6 +5816,9 @@ "stravaSaveCredentials": "Salva", "stravaUnavailable": "Strava non è configurato su questo server. Aggiungi sopra le tue credenziali API di Strava o chiedi al tuo amministratore di configurare l'app condivisa.", "stravaConnect": "Collega Strava", + "stravaSync": "Sincronizza ora", + "stravaSyncResult": "{count} misurazioni sincronizzate", + "stravaSyncFailed": "Sincronizzazione non riuscita", "stravaDisconnect": "Disconnetti", "stravaDisconnectTitle": "Disconnettere Strava?", "stravaDisconnectDescription": "Le sincronizzazioni future si interrompono. Le attività già importate restano nella cronologia.", diff --git a/messages/pl.json b/messages/pl.json index 0ea309978..390c23307 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5531,6 +5531,9 @@ "nightscoutUpdate": "Zapisz zmiany", "nightscoutConnected": "Nightscout połączony.", "nightscoutConnectFailed": "Nie można połączyć z Nightscout.", + "nightscoutSync": "Synchronizuj teraz", + "nightscoutSyncResult": "Zsynchronizowano {count} pomiarów", + "nightscoutSyncFailed": "Synchronizacja nie powiodła się", "nightscoutDisconnect": "Rozłącz", "nightscoutDisconnectTitle": "Rozłączyć Nightscout?", "nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.", @@ -5546,6 +5549,9 @@ "polarSaveCredentials": "Zapisz", "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.", "polarConnect": "Połącz Polar", + "polarSync": "Synchronizuj teraz", + "polarSyncResult": "Zsynchronizowano {count} pomiarów", + "polarSyncFailed": "Synchronizacja nie powiodła się", "polarDisconnect": "Rozłącz", "polarDisconnectTitle": "Rozłączyć Polar?", "polarDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.", @@ -5573,6 +5579,9 @@ "ouraSaveCredentials": "Zapisz", "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.", "ouraConnect": "Połącz Oura", + "ouraSync": "Synchronizuj teraz", + "ouraSyncResult": "Zsynchronizowano {count} pomiarów", + "ouraSyncFailed": "Synchronizacja nie powiodła się", "ouraDisconnect": "Rozłącz", "ouraDisconnectTitle": "Rozłączyć Oura?", "ouraDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.", @@ -5801,6 +5810,9 @@ "stravaSaveCredentials": "Zapisz", "stravaUnavailable": "Strava nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane API Strava lub poproś administratora o skonfigurowanie wspólnej aplikacji.", "stravaConnect": "Połącz Strava", + "stravaSync": "Synchronizuj teraz", + "stravaSyncResult": "Zsynchronizowano {count} pomiarów", + "stravaSyncFailed": "Synchronizacja nie powiodła się", "stravaDisconnect": "Rozłącz", "stravaDisconnectTitle": "Rozłączyć Strava?", "stravaDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane aktywności pozostaną w historii.", diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx index 6b5a6b0ac..4259839dc 100644 --- a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -83,3 +83,26 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => { expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus()); }); }); + +/** + * v1.32.28 — sync now, matching the treatment the OAuth cards get. + */ +describe("NightscoutCard — sync-now action", () => { + it("offers the sync action on a connected instance", () => { + statusPayload = { + connected: true, + configured: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + }; + const html = render(); + expect(html).toContain('data-testid="nightscout-sync"'); + expect(html).toContain("Sync now"); + }); + + it("does not offer it before an instance is configured", () => { + statusPayload = { connected: false, configured: false }; + expect(render()).not.toContain('data-testid="nightscout-sync"'); + }); +}); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 9a0f3739a..33ee40a18 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -178,3 +178,34 @@ describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", expect(html).not.toContain('data-testid="polar-redirect-guide"'); }); }); + +/** + * v1.32.28 — sync now. The route without the button would be an endpoint with + * no caller; the button is what makes the manual sync a feature rather than an + * API detail. Rendered only once the provider is connected, in the same action + * row as Test connection. + */ +describe("OAuthProviderCard — sync-now action", () => { + it("offers the sync action on a connected provider", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + }; + const html = render(); + expect(html).toContain('data-testid="polar-sync"'); + expect(html).toContain("Sync now"); + }); + + it("does not offer it before the provider is connected", () => { + statusPayload = { + connected: false, + configured: true, + available: true, + }; + expect(render()).not.toContain('data-testid="polar-sync"'); + }); +}); diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index 9392777c1..43ad583b5 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -15,6 +15,7 @@ import { Droplet, Link2, Loader2, + RefreshCw, Save, Unlink, } from "lucide-react"; @@ -96,6 +97,11 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( + null, + ); const { data: status } = useQuery({ queryKey: queryKeys.nightscoutStatus(), @@ -170,6 +176,39 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { setSaving(false); } + // Sync now. Same shape as the WHOOP card's incremental arm, minus the + // full-history dialog — Nightscout has no full-sync arm to call. + async function handleSync() { + setSyncing(true); + setSyncMsg(null); + setSyncMsgType(null); + try { + const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" }); + const json = await res.json(); + if (res.ok) { + setSyncMsg( + t("settings.nightscoutSyncResult", { count: json.data.imported }), + ); + setSyncMsgType("success"); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ + queryKey: queryKeys.nightscoutStatus(), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncMsg(json.error || t("settings.nightscoutSyncFailed")); + setSyncMsgType("error"); + } + } catch { + setSyncMsg(t("settings.nightscoutSyncFailed")); + setSyncMsgType("error"); + } finally { + setSyncing(false); + } + } + const pillState = pillStateFor(status); const errorMessage = (pillState === "error" || pillState === "parked") && status?.lastError @@ -337,6 +376,22 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { {status?.connected && ( <>
+
+ {syncMsg && ( +

+ {syncMsg} +

+ )} {/* connect→data loop: a discreet link to where the glucose readings now surface — doubles as the "your data is richer" cue. */} diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index fe4a05d0d..59c4a9c26 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -15,6 +15,7 @@ import { ArrowRight, Link2, Loader2, + RefreshCw, Save, Unlink, type LucideIcon, @@ -125,6 +126,11 @@ export function OAuthProviderCard({ const { t } = useTranslations(); const queryClient = useQueryClient(); const [msg, setMsg] = useState(null); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( + null, + ); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); @@ -145,6 +151,37 @@ export function OAuthProviderCard({ }); const status = viewModel ?? fetchedStatus; + // Sync now. Same shape as the WHOOP card's incremental arm, minus the + // full-history dialog — none of these providers has a full-sync arm to call. + async function handleSync() { + setSyncing(true); + setSyncMsg(null); + setSyncMsgType(null); + try { + const res = await apiFetchRaw(`/api/${provider}/sync`, { + method: "POST", + }); + const json = await res.json(); + if (res.ok) { + setSyncMsg(t(`${i18nPrefix}SyncResult`, { count: json.data.imported })); + setSyncMsgType("success"); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncMsg(json.error || t(`${i18nPrefix}SyncFailed`)); + setSyncMsgType("error"); + } + } catch { + setSyncMsg(t(`${i18nPrefix}SyncFailed`)); + setSyncMsgType("error"); + } finally { + setSyncing(false); + } + } + async function handleSaveCredentials(e: React.FormEvent) { e.preventDefault(); setCredsSaving(true); @@ -374,6 +411,22 @@ export function OAuthProviderCard({ {status?.connected ? ( <>
+
+ {syncMsg && ( +

+ {syncMsg} +

+ )} {/* connect→data loop: a discreet link to where this provider's readings now surface — doubles as the "your data is richer" cue. */} From 40a746b2b76fefe77cb78f390bc12b0038d60460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 04:43:18 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.32.28=20=E2=80=94=20Ou?= =?UTF-8?q?ra=20catches=20up=20after=20a=20gap;=20manual=20sync=20for=20th?= =?UTF-8?q?e=20last=20four=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 24 ++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e8b3a4f..8bfa5c7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +## [1.32.28] — 2026-07-25 + +Oura used to look back exactly one week and no further. If the sync had been +broken for longer than that, or if the ring sat unused and the older nights +only reached Oura's cloud later, those days fell outside the window and were +never imported. Nothing said so, because a sync that finds nothing still counts +as a sync that worked. + +- **Oura catches up after a gap.** The window is now measured from the newest + Oura reading actually on file rather than from a fixed week. A ten day gap + pulls eleven days on the next run and closes itself; a month is the ceiling. + When data is arriving normally the window stays at a week and nothing about + the hourly run changes. +- **A fresh Oura connection imports a month** instead of a week. Deeper history + is a separate question and is not part of this release. +- **Sync now for Oura, Polar, Strava and Nightscout.** These four had no way to + ask for a sync; they only ever ran on their scheduled pass. Each connected + card gets the same button WHOOP and Fitbit already had, limited to a few runs + per minute. +- **Connecting starts the first sync straight away.** Oura, Polar and + Nightscout used to sit empty for up to an hour after connecting, waiting for + the next scheduled pass. The first pull now runs on connect, and the schedule + stays as the fallback. + ## [1.32.27] — 2026-07-25 Target and threshold panels now follow the metric and imperial preference. The diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 68f1a5e20..ddff6a25d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.27 + version: 1.32.28 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 3acabb0c1..e19716cb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.27", + "version": "1.32.28", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index f7112ce6e..207db6e90 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.27"; + /* @sw-version-fallback */ "v1.32.28"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`