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/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/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/*` 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/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. */} 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 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) {