From 31abd307fd65fc98c7a5ca8c3741b6fdd39e02e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 20:08:50 +0200 Subject: [PATCH] feat(integrations): add Polar AccessLink OAuth sync Connect a Polar account over AccessLink v3: an authorization-code OAuth flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF. The callback verifies the signature, expiry, provider binding, and the byte-exact cookie match, then encrypts the access token plus member id onto the user row. An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages, and daily activity to canonical measurements tagged source POLAR, idempotent on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE through the canonical ladder, which now ranks native readiness above the computed proxy. Settings gains a connect / disconnect card. Tokens are never logged or returned; every egress goes through safeFetch. --- .env.production.example | 12 + docker-compose.yml | 9 + messages/de.json | 22 +- messages/en.json | 22 +- messages/es.json | 22 +- messages/fr.json | 22 +- messages/it.json | 22 +- messages/pl.json | 22 +- scripts/env-manifest.json | 16 + src/app/api/polar/callback/route.ts | 118 +++++ src/app/api/polar/connect/route.ts | 67 +++ src/app/api/polar/disconnect/route.ts | 40 ++ .../api/polar/status/__tests__/route.test.ts | 83 ++++ src/app/api/polar/status/route.ts | 44 ++ .../__tests__/integrations-section.test.tsx | 7 +- .../settings/integrations-section.tsx | 54 ++ .../integrations/oauth-provider-card.tsx | 213 ++++++++ .../settings/integrations/polar-card.tsx | 18 + .../__tests__/recovery-resolve.test.ts | 32 ++ src/lib/insights/derived/recovery-resolve.ts | 14 +- src/lib/jobs/reminder-worker.ts | 21 + src/lib/jobs/reminder/polar-sync.ts | 66 +++ src/lib/oauth/__tests__/signed-state.test.ts | 93 ++++ src/lib/oauth/signed-state.ts | 146 ++++++ src/lib/polar/__tests__/client.test.ts | 213 ++++++++ src/lib/polar/__tests__/sync.test.ts | 140 ++++++ src/lib/polar/client.ts | 465 ++++++++++++++++++ src/lib/polar/credentials.ts | 40 ++ src/lib/polar/response-classifier.ts | 110 +++++ src/lib/polar/sync.ts | 195 ++++++++ src/lib/query-keys/integrations.ts | 6 + 31 files changed, 2341 insertions(+), 13 deletions(-) create mode 100644 src/app/api/polar/callback/route.ts create mode 100644 src/app/api/polar/connect/route.ts create mode 100644 src/app/api/polar/disconnect/route.ts create mode 100644 src/app/api/polar/status/__tests__/route.test.ts create mode 100644 src/app/api/polar/status/route.ts create mode 100644 src/components/settings/integrations/oauth-provider-card.tsx create mode 100644 src/components/settings/integrations/polar-card.tsx create mode 100644 src/lib/jobs/reminder/polar-sync.ts create mode 100644 src/lib/oauth/__tests__/signed-state.test.ts create mode 100644 src/lib/oauth/signed-state.ts create mode 100644 src/lib/polar/__tests__/client.test.ts create mode 100644 src/lib/polar/__tests__/sync.test.ts create mode 100644 src/lib/polar/client.ts create mode 100644 src/lib/polar/credentials.ts create mode 100644 src/lib/polar/response-classifier.ts create mode 100644 src/lib/polar/sync.ts diff --git a/.env.production.example b/.env.production.example index 28072371e..e7ae7cd05 100644 --- a/.env.production.example +++ b/.env.production.example @@ -50,6 +50,18 @@ API_TOKEN_HMAC_KEY="" # WHOOP_WEBHOOK_SECRET="" # WHOOP_REDIRECT_URI="" +# ----------------------------------------------------------------------------- +# Polar AccessLink -- optional integration (full OAuth, shared app from env) +# ----------------------------------------------------------------------------- +# Register an app at https://admin.polaraccesslink.com and set its redirect URI +# to `${NEXT_PUBLIC_APP_URL}/api/polar/callback`. Both vars together enable the +# Polar connect button; set neither to leave the integration disabled. Polar +# access tokens do not expire and carry no refresh token. POLAR_REDIRECT_URI +# overrides the derived callback when the public URL differs. +# POLAR_CLIENT_ID="" +# POLAR_CLIENT_SECRET="" +# POLAR_REDIRECT_URI="" + # ----------------------------------------------------------------------------- # APNs -- iOS push (optional, all-or-none) diff --git a/docker-compose.yml b/docker-compose.yml index 67bc2ddbb..19d20d0e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,15 @@ services: # container (the `environment:` block is a whitelist). WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}" WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}" + # v1.17.0 (F4) — Polar AccessLink OAuth (shared app from env). The pair + # together enables the connect button; an unset pair leaves the + # integration disabled. The optional POLAR_REDIRECT_URI overrides the + # derived `${NEXT_PUBLIC_APP_URL}/api/polar/callback`. Listed here so + # operator values in .env reach the container (the `environment:` block is + # a whitelist). + POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}" + POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}" + POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}" # Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates # POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the # deployment-dashboard URL (e.g. the Coolify UI) linked in diff --git a/messages/de.json b/messages/de.json index eedfbf516..d9b73d58e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4585,7 +4585,27 @@ "nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.", "nightscoutDisconnect": "Trennen", "nightscoutDisconnectTitle": "Nightscout trennen?", - "nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf." + "nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.", + "polar": "Polar", + "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.", + "polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", + "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.", + "polarConnect": "Polar verbinden", + "polarDisconnect": "Trennen", + "polarDisconnectTitle": "Polar trennen?", + "polarDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.", + "polarOauthConnected": "Polar verbunden — die erste Synchronisierung startet im Hintergrund.", + "polarOauthFailed": "Polar-Verbindung fehlgeschlagen", + "polarOauthError": { + "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", + "state": "Die Antwort von Polar passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.", + "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.", + "nocode": "Polar hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Polar-Dialog.", + "nocreds": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.", + "token": "Der Token-Austausch mit Polar ist fehlgeschlagen. Bitte starte die Verbindung erneut.", + "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + } }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index ffd41caf9..d568a1e36 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4585,7 +4585,27 @@ "nightscoutConnectFailed": "Could not connect to Nightscout.", "nightscoutDisconnect": "Disconnect", "nightscoutDisconnectTitle": "Disconnect Nightscout?", - "nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history." + "nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.", + "polar": "Polar", + "polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.", + "polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.", + "polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.", + "polarConnect": "Connect Polar", + "polarDisconnect": "Disconnect", + "polarDisconnectTitle": "Disconnect Polar?", + "polarDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.", + "polarOauthConnected": "Polar connected — the first sync starts in the background.", + "polarOauthFailed": "Polar connection failed", + "polarOauthError": { + "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", + "state": "The response from Polar did not match any open connection request. Please start the connection again.", + "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.", + "nocode": "Polar did not return an authorization code. Try again and allow access in the Polar dialog.", + "nocreds": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.", + "token": "The token exchange with Polar failed. Please start the connection again.", + "rate_limited": "Too many connection attempts. Wait a moment and try again.", + "generic": "Unknown error while connecting. Please start the connection again." + } }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index 7615b36a6..c485b3e0a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4585,7 +4585,27 @@ "nightscoutConnectFailed": "No se pudo conectar con Nightscout.", "nightscoutDisconnect": "Desconectar", "nightscoutDisconnectTitle": "¿Desconectar Nightscout?", - "nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial." + "nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.", + "polar": "Polar", + "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.", + "polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", + "polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.", + "polarConnect": "Conectar Polar", + "polarDisconnect": "Desconectar", + "polarDisconnectTitle": "¿Desconectar Polar?", + "polarDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.", + "polarOauthConnected": "Polar conectado: la primera sincronización empieza en segundo plano.", + "polarOauthFailed": "Error al conectar con Polar", + "polarOauthError": { + "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", + "state": "La respuesta de Polar no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", + "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.", + "nocode": "Polar no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Polar.", + "nocreds": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.", + "token": "El intercambio de token con Polar falló. Inicia la conexión de nuevo.", + "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + } }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index 22e9be210..c8ff71cb9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4585,7 +4585,27 @@ "nightscoutConnectFailed": "Impossible de se connecter à Nightscout.", "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." + "nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.", + "polar": "Polar", + "polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.", + "polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", + "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.", + "polarConnect": "Connecter Polar", + "polarDisconnect": "Déconnecter", + "polarDisconnectTitle": "Déconnecter Polar ?", + "polarDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.", + "polarOauthConnected": "Polar connecté — la première synchronisation démarre en arrière-plan.", + "polarOauthFailed": "Échec de la connexion à Polar", + "polarOauthError": { + "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", + "state": "La réponse de Polar ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.", + "cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.", + "nocode": "Polar n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Polar.", + "nocreds": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.", + "token": "L'échange de jeton avec Polar a échoué. Veuillez relancer la connexion.", + "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + } }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index 71f2f9cb5..89dfad67b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4585,7 +4585,27 @@ "nightscoutConnectFailed": "Impossibile connettersi a Nightscout.", "nightscoutDisconnect": "Disconnetti", "nightscoutDisconnectTitle": "Disconnettere Nightscout?", - "nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia." + "nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.", + "polar": "Polar", + "polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.", + "polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", + "polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.", + "polarConnect": "Collega Polar", + "polarDisconnect": "Disconnetti", + "polarDisconnectTitle": "Disconnettere Polar?", + "polarDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.", + "polarOauthConnected": "Polar collegato — la prima sincronizzazione parte in background.", + "polarOauthFailed": "Connessione a Polar non riuscita", + "polarOauthError": { + "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", + "state": "La risposta di Polar non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.", + "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.", + "nocode": "Polar non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Polar.", + "nocreds": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.", + "token": "Lo scambio del token con Polar è fallito. Riavvia la connessione.", + "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + } }, "admin": { "title": "Administration", diff --git a/messages/pl.json b/messages/pl.json index b0204d0ff..8889d3b71 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4585,7 +4585,27 @@ "nightscoutConnectFailed": "Nie można połączyć z Nightscout.", "nightscoutDisconnect": "Rozłącz", "nightscoutDisconnectTitle": "Rozłączyć Nightscout?", - "nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii." + "nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.", + "polar": "Polar", + "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.", + "polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", + "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.", + "polarConnect": "Połącz Polar", + "polarDisconnect": "Rozłącz", + "polarDisconnectTitle": "Rozłączyć Polar?", + "polarDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.", + "polarOauthConnected": "Polar połączony — pierwsza synchronizacja startuje w tle.", + "polarOauthFailed": "Połączenie z Polar nie powiodło się", + "polarOauthError": { + "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", + "state": "Odpowiedź z Polar nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", + "cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.", + "nocode": "Polar nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Polar.", + "nocreds": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.", + "token": "Wymiana tokena z Polar nie powiodła się. Rozpocznij połączenie ponownie.", + "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + } }, "admin": { "title": "Administracja", diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index ab266a882..10ba0c0cc 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -54,6 +54,22 @@ } ] }, + { + "name": "Polar OAuth", + "description": "Required to enable the Polar AccessLink integration. App boots without these but the Polar connect button stays disabled. Register an app at https://admin.polaraccesslink.com and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/polar/callback.", + "required": false, + "allOrNone": true, + "variables": [ + { + "name": "POLAR_CLIENT_ID", + "purpose": "Polar AccessLink OAuth client id." + }, + { + "name": "POLAR_CLIENT_SECRET", + "purpose": "Polar AccessLink OAuth client secret." + } + ] + }, { "name": "APNs (iOS push)", "description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.", diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts new file mode 100644 index 000000000..10cfd08fc --- /dev/null +++ b/src/app/api/polar/callback/route.ts @@ -0,0 +1,118 @@ +import { prisma } from "@/lib/db"; +import { apiHandler } from "@/lib/api-handler"; +import { getSession } from "@/lib/auth/session"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { encrypt } from "@/lib/crypto"; +import { + exchangeCode, + getPolarCredentials, + registerUser, +} from "@/lib/polar/client"; +import { + oauthStateCookieName, + stateMatchesCookie, + verifySignedState, +} from "@/lib/oauth/signed-state"; +import { markReconnected } from "@/lib/integrations/status"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * v1.17.0 (F4) — OAuth callback from Polar AccessLink. + * + * Identity + CSRF: the `state` URL param is a stateless HMAC-signed token. The + * callback enforces (1) the param equals the httpOnly cookie byte-for-byte + * (double-submit), and (2) the signature + expiry + `polar` provider binding + * are valid. The `userId` is read from INSIDE the verified token — tamper- + * evident — so identity never comes from the ambient session. When a web + * session cookie is also present we cross-check it matches, so a logged-in user + * can't complete another user's in-flight handshake. + * + * On success: exchange the code (Basic-auth), register the user with the Polar + * app, persist the encrypted access token + member id on `User`, clear any + * prior reauth state, and redirect back to Settings. + */ +const ERR = (reason: string) => + NextResponse.redirect( + new URL( + `/settings/integrations?polar=error&reason=${reason}`, + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + +export const GET = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "polar.callback" } }); + + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const cookieState = request.cookies.get(oauthStateCookieName("polar"))?.value; + + // CSRF double-submit: the URL state must equal the cookie (constant-time). + if (!stateMatchesCookie(state, cookieState)) { + annotate({ meta: { reason: "csrf1" } }); + return ERR("csrf1"); + } + + // Verify signature + expiry + provider binding; userId rides inside. + const verified = verifySignedState("polar", state); + if (!verified) { + annotate({ meta: { reason: "state" } }); + return ERR("state"); + } + const userId = verified.userId; + + const evt = getEvent(); + if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); + + // Optional session cross-check when a web session is present. + const sessionData = await getSession(); + if (sessionData && sessionData.user.id !== userId) { + annotate({ meta: { reason: "cross_user" } }); + return ERR("cross_user"); + } + + if (!code) return ERR("nocode"); + + try { + const creds = getPolarCredentials(); + if (!creds) return ERR("nocreds"); + + const tokens = await exchangeCode(code, creds); + // Guard against an off-spec token body without `x_user_id`: persisting the + // string "undefined" as the member id would break every later data read + // (`/v3/users/undefined/...`) with no clear cause. + if (typeof tokens.x_user_id !== "number" || !Number.isFinite(tokens.x_user_id)) { + return ERR("token"); + } + const polarUserId = String(tokens.x_user_id); + + // Register the user with the Polar app (idempotent; 409 = already linked). + await registerUser(tokens.access_token, polarUserId); + + await prisma.user.update({ + where: { id: userId }, + data: { + polarAccessTokenEncrypted: encrypt(tokens.access_token), + // Polar issues no refresh token; the column stays null. + polarRefreshTokenEncrypted: null, + polarUserIdEncrypted: encrypt(polarUserId), + }, + }); + + await auditLog("polar.connect", { userId }); + await markReconnected(userId, "polar"); + + const response = NextResponse.redirect( + new URL( + "/settings/integrations?polar=connected", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + response.cookies.delete(oauthStateCookieName("polar")); + return response; + } catch (err) { + getEvent()?.setError(err); + return ERR("token"); + } +}); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts new file mode 100644 index 000000000..5da066968 --- /dev/null +++ b/src/app/api/polar/connect/route.ts @@ -0,0 +1,67 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client"; +import { + OAUTH_STATE_TTL_MS, + mintSignedState, + oauthStateCookieName, +} from "@/lib/oauth/signed-state"; +import { NextResponse } from "next/server"; + +/** + * v1.17.0 (F4) — redirect the user to the Polar AccessLink consent screen. + * + * Full OAuth from env: the shared client id/secret come from `POLAR_CLIENT_ID` + * / `POLAR_CLIENT_SECRET` (not per-user BYO). The CSRF defence is a stateless + * signed state (`src/lib/oauth/signed-state.ts`): the same token is the `state` + * URL param AND an httpOnly cookie, and the callback enforces both signature + * validity and a byte-exact cookie match. + * + * Rate-limited per user so a session can't spam the consent redirect. + */ +const CONNECT_RATE_LIMIT = 10; +const CONNECT_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.connect" } }); + + const rl = await checkRateLimit( + `polar:connect:${user.id}`, + CONNECT_RATE_LIMIT, + CONNECT_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ action: { name: "polar.connect.rate_limited" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?polar=error&reason=rate_limited", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + + const creds = getPolarCredentials(); + if (!creds) { + return apiError( + "Polar integration is not configured on this server.", + 400, + ); + } + + const state = mintSignedState("polar", user.id); + const url = getAuthorizationUrl(state, creds); + + const response = NextResponse.redirect(url); + response.cookies.set(oauthStateCookieName("polar"), state, { + httpOnly: true, + secure: shouldEmitSecureCookie(), + sameSite: "lax", + maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000), + path: "/", + }); + return response; +}); diff --git a/src/app/api/polar/disconnect/route.ts b/src/app/api/polar/disconnect/route.ts new file mode 100644 index 000000000..f18e46635 --- /dev/null +++ b/src/app/api/polar/disconnect/route.ts @@ -0,0 +1,40 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { markDisconnected } from "@/lib/integrations/status"; + +/** + * v1.17.0 (F4) — disconnect Polar for the current user. + * + * Clears the stored OAuth token + member id on `User` and parks the integration + * status at `disconnected`. Imported `source = POLAR` measurements are left + * intact (they are the user's historical readings); a reconnect resumes sync. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.disconnect" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { polarAccessTokenEncrypted: true }, + }); + if (!dbUser?.polarAccessTokenEncrypted) { + return apiError("No Polar connection", 404); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + polarAccessTokenEncrypted: null, + polarRefreshTokenEncrypted: null, + polarUserIdEncrypted: null, + }, + }); + + await auditLog("polar.disconnect", { userId: user.id }); + await markDisconnected(user.id, "polar"); + + return apiSuccess({ disconnected: true }); +}); diff --git a/src/app/api/polar/status/__tests__/route.test.ts b/src/app/api/polar/status/__tests__/route.test.ts new file mode 100644 index 000000000..e7e94d27a --- /dev/null +++ b/src/app/api/polar/status/__tests__/route.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null }), +})); + +vi.mock("@/lib/polar/client", () => ({ + getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })), +})); + +vi.mock("@/lib/integrations/status", () => ({ + getIntegrationStatus: vi.fn(async () => ({ + integration: "polar", + state: "connected", + lastSuccessAt: "2026-06-10T00:00:00.000Z", + lastAttemptAt: "2026-06-10T00:00:00.000Z", + lastError: null, + consecutiveFailuresByKind: null, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getPolarCredentials } from "@/lib/polar/client"; + +const userFind = prisma.user.findUnique as ReturnType; +const credsMock = getPolarCredentials as ReturnType; + +type Body = { + connected: boolean; + configured: boolean; + available: boolean; + state?: string; +}; +const call = () => + (GET as unknown as () => Promise<{ data: Body }>)().then((r) => r.data); + +describe("GET /api/polar/status", () => { + beforeEach(() => { + vi.clearAllMocks(); + credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" }); + }); + + it("reports not-connected but available when env is set and no token stored", async () => { + userFind.mockResolvedValue({ polarAccessTokenEncrypted: null }); + const res = await call(); + expect(res.connected).toBe(false); + expect(res.available).toBe(true); + }); + + it("reports available=false when the server has no Polar app configured", async () => { + credsMock.mockReturnValue(null); + userFind.mockResolvedValue(null); + const res = await call(); + expect(res.available).toBe(false); + expect(res.connected).toBe(false); + }); + + it("reports connected + ledger state when a token is stored", async () => { + userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" }); + const res = await call(); + expect(res.connected).toBe(true); + expect(res.configured).toBe(true); + expect(res.state).toBe("connected"); + }); + + it("never returns the token", async () => { + userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" }); + const res = (await call()) as Record; + expect(JSON.stringify(res)).not.toContain("enc"); + }); +}); diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts new file mode 100644 index 000000000..cfa8ad71f --- /dev/null +++ b/src/app/api/polar/status/route.ts @@ -0,0 +1,44 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { getPolarCredentials } from "@/lib/polar/client"; +import { getIntegrationStatus } from "@/lib/integrations/status"; + +/** + * v1.17.0 (F4) — Polar connection status for the current user. + * + * `available` reports whether the server has the shared OAuth app configured + * (env client id/secret) so the card can grey out the connect button when the + * operator hasn't set it up. `connected` is whether a token is stored. The + * ledger snapshot (state / lastSuccessAt / lastError) comes off the shared + * `polar` IntegrationKey. The access token + member id are NEVER returned. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.status" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { polarAccessTokenEncrypted: true }, + }); + + const available = !!getPolarCredentials(); + const connected = !!dbUser?.polarAccessTokenEncrypted; + + if (!connected) { + return apiSuccess({ connected: false, configured: false, available }); + } + + const status = await getIntegrationStatus(user.id, "polar"); + + return apiSuccess({ + connected: true, + configured: true, + available, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + lastError: status.lastError, + }); +}); diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index 29b574db1..9d6344867 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -131,9 +131,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }); const html = render(); - // Exactly one pill per card → 4 pills total (Withings, WHOOP, - // Fitbit / Google Health, Nightscout). The moodLog integration was removed. - expect(count(html, 'data-testid="integration-status-pill"')).toBe(4); + // Exactly one pill per card → 5 pills total (Withings, WHOOP, + // Fitbit / Google Health, Polar, Nightscout). The moodLog integration was + // removed; Polar (F4) was added in v1.17.0. + expect(count(html, 'data-testid="integration-status-pill"')).toBe(5); // The redundant banner from v1.4.15 is gone. expect(html).not.toContain('data-testid="integration-status-banner"'); // Card-body "letzter Sync" repetition is gone — no diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 6e700ec0b..89fb1116e 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; +import { PolarCard } from "@/components/settings/integrations/polar-card"; import { pickStatus, useIntegrationStatuses, @@ -90,6 +91,58 @@ export function IntegrationsSection() { } }, [withingsOauthOutcome, router, queryClient, t]); + // v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers. + // The Polar callback redirects back with `?polar=connected` or + // `?polar=error&reason=`; surface the outcome as a toast and scrub the + // one-shot params so a reload doesn't replay it. + const [oauthOutcome] = useState< + { provider: "polar"; kind: "connected" } | { provider: "polar"; kind: "error"; reason: string } | null + >(() => { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + for (const provider of ["polar"] as const) { + const v = params.get(provider); + if (v === "connected") return { provider, kind: "connected" }; + if (v === "error") { + return { provider, kind: "error", reason: params.get("reason") ?? "unknown" }; + } + } + return null; + }); + + useEffect(() => { + if (!oauthOutcome) return; + const { provider } = oauthOutcome; + const url = new URL(window.location.href); + url.searchParams.delete(provider); + url.searchParams.delete("reason"); + router.replace(`${url.pathname}${url.search}`, { scroll: false }); + const key = queryKeys.polar(); + if (oauthOutcome.kind === "connected") { + toast.success(t(`settings.${provider}OauthConnected`)); + queryClient.invalidateQueries({ queryKey: key }); + } else { + // Known reason tags resolve to a specific message; anything else falls + // back to the generic copy (matching the Withings handler). + const knownReasons = new Set([ + "csrf1", + "state", + "cross_user", + "nocode", + "nocreds", + "token", + "rate_limited", + ]); + const reasonKey = knownReasons.has(oauthOutcome.reason) + ? `settings.${provider}OauthError.${oauthOutcome.reason}` + : `settings.${provider}OauthError.generic`; + toast.error(t(`settings.${provider}OauthFailed`), { + description: t(reasonKey), + duration: 10_000, + }); + } + }, [oauthOutcome, router, queryClient, t]); + const withingsViewModel = pickStatus(integrationStatus, "withings"); const whoopViewModel = pickStatus(integrationStatus, "whoop"); const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); @@ -125,6 +178,7 @@ export function IntegrationsSection() { + ); diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx new file mode 100644 index 000000000..42f68950a --- /dev/null +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -0,0 +1,213 @@ +"use client"; + +// v1.17.0 (F4) — shared OAuth integration card for env-based providers (Polar, +// Oura). Unlike WHOOP / Fitbit (per-user BYO client id/secret), these use a +// single shared OAuth app the operator configures via env, so the card has NO +// credential inputs — just a connect button that redirects to the provider, a +// status pill off the self-contained status read, and a disconnect action. +// Mirrors the Nightscout card's self-contained status pattern. + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Activity, Link2, Unlink } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { + IntegrationStatusPill, + type IntegrationPillState, +} from "@/components/settings/integration-status-pill"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; + +export interface OAuthProviderStatus { + connected: boolean; + configured: boolean; + available: boolean; + state?: + | "connected" + | "error_transient" + | "error_reauth" + | "disconnected" + | "parked"; + lastSuccessAt?: string | null; + lastAttemptAt?: string | null; + lastError?: string | null; +} + +function pillStateFor(status: OAuthProviderStatus | undefined): IntegrationPillState { + if (!status?.connected) return "disconnected"; + switch (status.state) { + case "parked": + return "parked"; + case "error_reauth": + return "error"; + case "error_transient": + return "warning"; + default: + return "connected"; + } +} + +export interface OAuthProviderCardProps { + /** Lower-case provider key, used for routes + query keys + testids. */ + provider: "polar" | "oura"; + /** The query-key array shared by the status read + the invalidations. */ + statusQueryKey: readonly unknown[]; + /** i18n key prefix (e.g. `settings.polar`). */ + i18nPrefix: string; + enabled?: boolean; +} + +export function OAuthProviderCard({ + provider, + statusQueryKey, + i18nPrefix, + enabled = true, +}: OAuthProviderCardProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [msg, setMsg] = useState(null); + + const { data: status } = useQuery({ + queryKey: statusQueryKey, + queryFn: async () => apiGet(`/api/${provider}/status`), + enabled, + refetchOnWindowFocus: true, + }); + + const disconnect = useMutation({ + mutationFn: async () => { + await apiPost(`/api/${provider}/disconnect`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + }, + }); + + const pillState = pillStateFor(status); + const errorMessage = + (pillState === "error" || pillState === "parked") && status?.lastError + ? status.lastError + : null; + const serverUnavailable = status && !status.available; + + function handleConnect() { + setMsg(null); + void invalidateKeys(queryClient, measurementDependentKeys); + window.location.href = `/api/${provider}/connect`; + } + + return ( +
+ {t(`${i18nPrefix}Description`)}

} + status={ + + } + /> + +
+ +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + +

+ {t(`${i18nPrefix}Help`)} +

+ + {serverUnavailable && ( +

+ {t(`${i18nPrefix}Unavailable`)} +

+ )} + + {status?.connected ? ( +
+ + + + + + + + {t(`${i18nPrefix}DisconnectTitle`)} + + + {t(`${i18nPrefix}DisconnectDescription`)} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t(`${i18nPrefix}Disconnect`)} + + + + +
+ ) : ( + + )} + + {msg && ( +

+ {msg} +

+ )} +
+
+ ); +} diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx new file mode 100644 index 000000000..949734fa3 --- /dev/null +++ b/src/components/settings/integrations/polar-card.tsx @@ -0,0 +1,18 @@ +"use client"; + +// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the +// shared env-based OAuth card. + +import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card"; +import { queryKeys } from "@/lib/query-keys"; + +export function PolarCard({ enabled = true }: { enabled?: boolean }) { + return ( + + ); +} diff --git a/src/lib/insights/derived/__tests__/recovery-resolve.test.ts b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts index 5fac7090f..b393ee458 100644 --- a/src/lib/insights/derived/__tests__/recovery-resolve.test.ts +++ b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts @@ -81,6 +81,38 @@ describe("resolveCanonicalRecovery", () => { expect(resolved[0].value).toBe(82); }); + it("ranks native OURA + POLAR readiness above the COMPUTED proxy", () => { + // Same night (wake Jun 02): an Oura readiness and a Polar Nightly Recharge + // row both stamped on the wake day, plus the COMPUTED proxy filed under the + // day-that-ended (Jun 01). Oura must win over Polar, and both must win over + // COMPUTED — the v1.17.0 (F4) ladder WHOOP > OURA > POLAR > COMPUTED. + const oura = [ + row("2026-06-02T00:00:00Z", 84, "OURA"), + row("2026-06-01T12:00:00Z", 50, "COMPUTED"), + ]; + const resolvedOura = resolveCanonicalRecovery(oura); + expect(resolvedOura).toHaveLength(1); + expect(resolvedOura[0].source).toBe("OURA"); + expect(resolvedOura[0].value).toBe(84); + + const ouraVsPolar = [ + row("2026-06-02T00:00:00Z", 84, "OURA"), + row("2026-06-02T00:00:00Z", 60, "POLAR"), + row("2026-06-01T12:00:00Z", 50, "COMPUTED"), + ]; + const resolvedBoth = resolveCanonicalRecovery(ouraVsPolar); + expect(resolvedBoth).toHaveLength(1); + expect(resolvedBoth[0].source).toBe("OURA"); + + const polarOnly = [ + row("2026-06-02T00:00:00Z", 60, "POLAR"), + row("2026-06-01T12:00:00Z", 50, "COMPUTED"), + ]; + const resolvedPolar = resolveCanonicalRecovery(polarOnly); + expect(resolvedPolar).toHaveLength(1); + expect(resolvedPolar[0].source).toBe("POLAR"); + }); + it("returns an empty list for no rows", () => { expect(resolveCanonicalRecovery([])).toEqual([]); }); diff --git a/src/lib/insights/derived/recovery-resolve.ts b/src/lib/insights/derived/recovery-resolve.ts index 85e432e30..4f8c8cfed 100644 --- a/src/lib/insights/derived/recovery-resolve.ts +++ b/src/lib/insights/derived/recovery-resolve.ts @@ -52,13 +52,19 @@ export interface RecoveryRow { } /** - * The per-source preference for `RECOVERY_SCORE`: a native WHOOP row outranks - * the COMPUTED proxy for the same day. Lower number = higher authority. A - * source not listed (there is none today) falls back below both. + * The per-source preference for `RECOVERY_SCORE`: a native device row outranks + * the COMPUTED proxy for the same day. Lower number = higher authority. The + * ladder mirrors `DEFAULT_SOURCE_PRIORITY.recovery` + * (`WHOOP > OURA > POLAR > COMPUTED`): WHOOP's all-night strap leads, then the + * Oura ring readiness and the Polar Nightly Recharge band, then the computed + * proxy as the fallback for users without a wearable. A source not listed falls + * below all of them. */ const RECOVERY_SOURCE_RANK: Partial> = { WHOOP: 0, - COMPUTED: 1, + OURA: 1, + POLAR: 2, + COMPUTED: 3, }; function rankOf(source: MeasurementSource): number { diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 9ec660a47..ecf91a553 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -263,6 +263,7 @@ import { NightscoutSyncPayload, handleNightscoutSync, } from "./reminder/nightscout-sync"; +import { PolarSyncPayload, handlePolarSync } from "./reminder/polar-sync"; import { handleMedicationInventoryExpire, handleIntakeAutoSkip, @@ -401,6 +402,13 @@ const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *"; const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync"; const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11 +// v1.17.0 (F4) — Polar OAuth poll sync. Poll-only (one hourly tick re-walks +// every connected user). :13 staggers off the other hourly sync ticks (WHOOP +// :05, Fitbit :08, Nightscout :11) 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 POLAR_SYNC_QUEUE = "polar-sync"; +const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13 // v1.15.19 — daily duplicate dose-slot dedup discovery tick. The boot-time // pass only ran on worker restart, so a cross-source duplicate slot created // between deploys (a pending REMINDER row plus a standalone API/WEB row on @@ -622,6 +630,9 @@ export async function startReminderWorker() { // MUST be registered here or pg-boss never provisions it and the schedule // below silently no-ops (the v1.4.37 dead-queue class). NIGHTSCOUT_SYNC_QUEUE, + // v1.17.0 (F4) — Polar OAuth poll sync. Registered here or pg-boss never + // provisions it and the hourly schedule below silently no-ops. + POLAR_SYNC_QUEUE, OFFHOST_BACKUP_QUEUE, RESTORE_DRILL_QUEUE, HOST_METRIC_QUEUE, @@ -845,6 +856,8 @@ export async function startReminderWorker() { // v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync // ticks). [NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON], + // v1.17.0 (F4) — hourly Polar (:13) OAuth poll. + [POLAR_SYNC_QUEUE, POLAR_SYNC_CRON], [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON], [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON], [HOST_METRIC_QUEUE, HOST_METRIC_CRON], @@ -1045,6 +1058,14 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleNightscoutSync, ); + // v1.17.0 (F4) — Polar OAuth poll-cohort sync. The hourly cron tick (no + // `userId`) re-walks every connected user; one user's revoked grant is + // warned, not fatal. + await boss.work( + POLAR_SYNC_QUEUE, + { localConcurrency: 1 }, + handlePolarSync, + ); await boss.work( GENERAL_STATUS_QUEUE, { localConcurrency: 1 }, diff --git a/src/lib/jobs/reminder/polar-sync.ts b/src/lib/jobs/reminder/polar-sync.ts new file mode 100644 index 000000000..332bce2c8 --- /dev/null +++ b/src/lib/jobs/reminder/polar-sync.ts @@ -0,0 +1,66 @@ +/** + * v1.17.0 (F4) — Polar poll-cohort sync handler. + * + * Poll-only: Polar AccessLink has a webhook, but HealthLog runs the simpler + * poll model (matching Nightscout). One hourly cron tick carries no `userId` + * and the handler iterates every user with a stored Polar token, re-syncing + * each via `syncUserPolar`. One user's revoked grant / outage is warned and the + * pass continues. + * + * The queue name, cron schedule, and boss.work registration live in + * reminder-worker.ts. + */ +import { type Job } from "pg-boss"; + +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { syncUserPolar } from "@/lib/polar/sync"; +import { getWorkerPrisma } from "./shared"; + +export interface PolarSyncPayload { + userId?: string; +} + +export async function handlePolarSync(jobs: Job[]) { + await withBackgroundEvent("job.polar_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const targets: Array<{ userId: string }> = []; + for (const job of jobs) { + if (job.data?.userId) targets.push({ userId: job.data.userId }); + } + if (targets.length === 0) { + const users = await prisma.user.findMany({ + where: { polarAccessTokenEncrypted: { not: null } }, + select: { id: true }, + }); + targets.push(...users.map((u) => ({ userId: u.id }))); + } + if (targets.length === 0) return; + + let usersSynced = 0; + let measurementsImported = 0; + for (const { userId } of targets) { + try { + measurementsImported += await syncUserPolar(userId); + usersSynced++; + } catch (err) { + evt.addWarning(`job.polar_sync failed for user ${userId}: ${err}`); + } + } + + evt.setBackground({ + task_name: "job.polar_sync", + result: { + users_synced: usersSynced, + total: targets.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/oauth/__tests__/signed-state.test.ts b/src/lib/oauth/__tests__/signed-state.test.ts new file mode 100644 index 000000000..9ff65e656 --- /dev/null +++ b/src/lib/oauth/__tests__/signed-state.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + OAUTH_STATE_TTL_MS, + mintSignedState, + oauthStateCookieName, + stateMatchesCookie, + verifySignedState, +} from "../signed-state"; + +const KEY = "x".repeat(48); + +describe("OAuth signed state (Polar / Oura)", () => { + let prevKey: string | undefined; + + beforeEach(() => { + prevKey = process.env.API_TOKEN_HMAC_KEY; + process.env.API_TOKEN_HMAC_KEY = KEY; + }); + afterEach(() => { + if (prevKey === undefined) delete process.env.API_TOKEN_HMAC_KEY; + else process.env.API_TOKEN_HMAC_KEY = prevKey; + }); + + it("pins the cookie name per provider", () => { + expect(oauthStateCookieName("polar")).toBe("polar_oauth_state"); + expect(oauthStateCookieName("oura")).toBe("oura_oauth_state"); + }); + + it("round-trips a minted token back to the userId + provider", () => { + const now = 1_000_000; + const token = mintSignedState("polar", "user-abc", now); + const verified = verifySignedState("polar", token, now + 1000); + expect(verified).toEqual({ + provider: "polar", + userId: "user-abc", + expiry: now + OAUTH_STATE_TTL_MS, + }); + }); + + it("mints unique tokens for the same user (nonce entropy)", () => { + const a = mintSignedState("oura", "u1"); + const b = mintSignedState("oura", "u1"); + expect(a).not.toBe(b); + }); + + it("rejects a token whose provider does not match", () => { + const token = mintSignedState("polar", "u1"); + expect(verifySignedState("oura", token)).toBeNull(); + }); + + it("rejects a tampered userId (signature mismatch)", () => { + const now = 5_000; + const token = mintSignedState("polar", "victim", now); + const parts = token.split("."); + parts[1] = "attacker"; + const forged = parts.join("."); + expect(verifySignedState("polar", forged, now + 1)).toBeNull(); + }); + + it("rejects a tampered signature tag", () => { + const token = mintSignedState("oura", "u1"); + const forged = token.slice(0, -3) + "AAA"; + expect(verifySignedState("oura", forged)).toBeNull(); + }); + + it("rejects an expired token even with a valid signature", () => { + const now = 10_000; + const token = mintSignedState("polar", "u1", now); + expect(verifySignedState("polar", token, now + OAUTH_STATE_TTL_MS + 1)).toBeNull(); + }); + + it("rejects malformed shapes and empty input", () => { + expect(verifySignedState("polar", null)).toBeNull(); + expect(verifySignedState("polar", "")).toBeNull(); + expect(verifySignedState("polar", "a.b.c")).toBeNull(); + expect(verifySignedState("polar", "polar.u1.notanumber.nonce.tag")).toBeNull(); + }); + + it("a token minted under a different key fails verification", () => { + const token = mintSignedState("polar", "u1"); + process.env.API_TOKEN_HMAC_KEY = "y".repeat(48); + expect(verifySignedState("polar", token)).toBeNull(); + }); + + it("double-submit compare is byte-exact", () => { + const token = mintSignedState("oura", "u1"); + expect(stateMatchesCookie(token, token)).toBe(true); + expect(stateMatchesCookie(token, token + "x")).toBe(false); + expect(stateMatchesCookie(token, null)).toBe(false); + expect(stateMatchesCookie(null, token)).toBe(false); + }); +}); diff --git a/src/lib/oauth/signed-state.ts b/src/lib/oauth/signed-state.ts new file mode 100644 index 000000000..2992afc7a --- /dev/null +++ b/src/lib/oauth/signed-state.ts @@ -0,0 +1,146 @@ +/** + * v1.17.0 (F4) — stateless, HMAC-signed OAuth `state` for the Polar + Oura + * authorization-code flows. + * + * Unlike WHOOP / Withings (which carry the in-flight `(nonce → userId)` mapping + * in a short-lived DB ledger row), the F4 providers store their tokens directly + * on `User` and the merged schema ships NO `*OAuthState` table. The CSRF + * defence therefore lives in the signed state itself: the `state` param is + * `...` plus an HMAC-SHA256 tag over that + * payload, keyed by `API_TOKEN_HMAC_KEY`. The same opaque token is also set as + * an httpOnly cookie at connect time, so the callback enforces BOTH: + * + * 1. signature + expiry + provider binding are valid (the token wasn't forged + * or replayed past its TTL), AND + * 2. the `state` URL param equals the cookie value byte-for-byte (double- + * submit — an attacker who can plant a `state` query param cannot also set + * our httpOnly cookie). + * + * The `userId` rides INSIDE the signed payload (tamper-evident), so the + * callback resolves identity from the verified token rather than the ambient + * session — the same property the WHOOP ledger row gives, without a table. + * + * Security notes: + * - The payload is signed, not encrypted. A `userId` (an opaque cuid) is not + * a secret, and it never leaves the user's own browser round-trip. The + * signature is what matters: a third party cannot mint a state for another + * user without the server HMAC key. + * - `verifySignedState` is fully constant-time on the tag compare and rejects + * a provider / expiry / shape mismatch BEFORE the (already constant-time) + * compare, so a malformed probe can't leak timing about the key. + */ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** Providers that share this signed-state machinery. */ +export type OAuthStateProvider = "polar" | "oura"; + +/** 10-minute TTL — long enough to approve a consent screen, short enough that + * an abandoned handshake's token is useless within the hour. Mirrors the + * WHOOP / Withings ledger-row TTL. */ +export const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; + +/** httpOnly cookie name carrying the in-flight signed state, per provider. */ +export function oauthStateCookieName(provider: OAuthStateProvider): string { + return `${provider}_oauth_state`; +} + +function getKey(): string { + const key = process.env.API_TOKEN_HMAC_KEY; + if (!key || key.length < 32) { + throw new Error( + "API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign OAuth state", + ); + } + return key; +} + +/** Field separator — `.` is base64url-safe-adjacent and absent from every + * field (provider is a fixed literal, userId is a cuid, the rest are digits / + * base64url), so the split is unambiguous. */ +const SEP = "."; + +function sign(payload: string): string { + return createHmac("sha256", getKey()).update(payload).digest("base64url"); +} + +/** + * Mint a signed state token for a user starting `provider`'s OAuth flow. The + * returned string is used BOTH as the OAuth `state` URL param and as the + * httpOnly cookie value. + */ +export function mintSignedState( + provider: OAuthStateProvider, + userId: string, + now: number = Date.now(), +): string { + const expiry = now + OAUTH_STATE_TTL_MS; + const nonce = randomBytes(16).toString("base64url"); + const payload = [provider, userId, String(expiry), nonce].join(SEP); + const tag = sign(payload); + return `${payload}${SEP}${tag}`; +} + +export interface VerifiedState { + provider: OAuthStateProvider; + userId: string; + expiry: number; +} + +/** + * Verify a signed state token. Returns the decoded `{ provider, userId, expiry }` + * only when the signature is valid, the provider matches, and the TTL has not + * elapsed. Returns null on any failure — a forged tag, a wrong provider, an + * expired token, or a malformed shape. Never throws on bad input (only on a + * missing server key, which is an operator misconfiguration). + */ +export function verifySignedState( + provider: OAuthStateProvider, + token: string | null | undefined, + now: number = Date.now(), +): VerifiedState | null { + if (!token || token.length > 512) return null; + const parts = token.split(SEP); + if (parts.length !== 5) return null; + const [gotProvider, userId, expiryStr, nonce, tag] = parts as [ + string, + string, + string, + string, + string, + ]; + if (gotProvider !== provider) return null; + if (!userId || !nonce || !tag) return null; + + const expiry = Number.parseInt(expiryStr, 10); + if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) return null; + + const payload = [gotProvider, userId, expiryStr, nonce].join(SEP); + const expected = sign(payload); + // Constant-time compare; length guard first so timingSafeEqual never throws + // on mismatched buffer sizes. + if ( + tag.length !== expected.length || + !timingSafeEqual(Buffer.from(tag), Buffer.from(expected)) + ) { + return null; + } + + // Signature is valid; now enforce the TTL. + if (expiry <= now) return null; + + return { provider, userId, expiry }; +} + +/** + * Constant-time double-submit check: the `state` URL param must equal the + * cookie value byte-for-byte. Both are the same minted token, so an attacker + * who can inject a `state` query param cannot also set our httpOnly cookie. + */ +export function stateMatchesCookie( + urlState: string | null | undefined, + cookieState: string | null | undefined, +): boolean { + if (!urlState || !cookieState) return false; + if (urlState.length !== cookieState.length) return false; + return timingSafeEqual(Buffer.from(urlState), Buffer.from(cookieState)); +} diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts new file mode 100644 index 000000000..78dde0ff3 --- /dev/null +++ b/src/lib/polar/__tests__/client.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + POLAR_OAUTH_SCOPE, + exchangeCode, + fetchNightlyRecharges, + getAuthorizationUrl, + getPolarCredentials, + getPolarRedirectUri, + mapActivity, + mapNightlyRecharge, + mapSleep, + registerUser, + type PolarActivity, + type PolarNightlyRecharge, + type PolarSleep, +} from "../client"; +import { PolarApiError } from "../response-classifier"; + +const CREDS = { clientId: "cid", clientSecret: "csecret" }; + +function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { + let i = 0; + const fetchMock = vi.fn(async (...args: [string, RequestInit?]) => { + void args; + const page = pages[Math.min(i, pages.length - 1)]!; + i += 1; + return { + status: page.status, + json: async () => page.body ?? null, + }; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +beforeEach(() => { + process.env.POLAR_CLIENT_ID = "env-cid"; + process.env.POLAR_CLIENT_SECRET = "env-secret"; + process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + delete process.env.POLAR_REDIRECT_URI; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("getPolarCredentials", () => { + it("reads from env", () => { + expect(getPolarCredentials()).toEqual({ + clientId: "env-cid", + clientSecret: "env-secret", + }); + }); + it("returns null when unconfigured", () => { + delete process.env.POLAR_CLIENT_ID; + expect(getPolarCredentials()).toBeNull(); + }); +}); + +describe("getAuthorizationUrl", () => { + it("builds the Polar authorize URL with scope + state", () => { + const url = getAuthorizationUrl("signed-state", CREDS); + expect(url).toContain("flow.polar.com/oauth2/authorization"); + expect(url).toContain("response_type=code"); + expect(url).toContain("scope=accesslink.read_all"); + expect(url).toContain("state=signed-state"); + expect(url).toContain("client_id=cid"); + }); +}); + +describe("getPolarRedirectUri", () => { + it("derives from app URL when override absent", () => { + expect(getPolarRedirectUri()).toBe( + "https://app.example/api/polar/callback", + ); + }); + it("honours the explicit override", () => { + process.env.POLAR_REDIRECT_URI = "https://custom/cb"; + expect(getPolarRedirectUri()).toBe("https://custom/cb"); + }); +}); + +describe("exchangeCode", () => { + it("posts Basic-auth + returns the token and x_user_id", async () => { + const fetchMock = installFetchMock([ + { + status: 200, + body: { access_token: "tok", token_type: "bearer", x_user_id: 42 }, + }, + ]); + const tok = await exchangeCode("code123", CREDS); + expect(tok.access_token).toBe("tok"); + expect(tok.x_user_id).toBe(42); + const [, init] = fetchMock.mock.calls[0]!; + const headers = init!.headers as Record; + expect(headers.Authorization).toBe( + `Basic ${Buffer.from("cid:csecret").toString("base64")}`, + ); + expect(init!.body).toContain("grant_type=authorization_code"); + }); + + it("throws a classified PolarApiError on a 401", async () => { + installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]); + await expect(exchangeCode("bad", CREDS)).rejects.toBeInstanceOf( + PolarApiError, + ); + }); +}); + +describe("registerUser", () => { + it("treats a 409 (already registered) as success", async () => { + installFetchMock([{ status: 409, body: {} }]); + await expect(registerUser("tok", "42")).resolves.toBeUndefined(); + }); + it("succeeds on a 200", async () => { + installFetchMock([{ status: 200, body: {} }]); + await expect(registerUser("tok", "42")).resolves.toBeUndefined(); + }); + it("throws on a 500", async () => { + installFetchMock([{ status: 500 }]); + await expect(registerUser("tok", "42")).rejects.toBeInstanceOf( + PolarApiError, + ); + }); +}); + +describe("fetchNightlyRecharges", () => { + it("returns [] on a 204 No Content", async () => { + installFetchMock([{ status: 204 }]); + expect(await fetchNightlyRecharges("tok", "42")).toEqual([]); + }); + it("unwraps the recharges array", async () => { + installFetchMock([ + { status: 200, body: { recharges: [{ date: "2026-06-10" }] } }, + ]); + const r = await fetchNightlyRecharges("tok", "42"); + expect(r).toHaveLength(1); + expect(r[0]!.date).toBe("2026-06-10"); + }); +}); + +describe("mapNightlyRecharge", () => { + it("maps the recovery band to a 0-100 RECOVERY_SCORE", () => { + const rec: PolarNightlyRecharge = { + date: "2026-06-10", + nightly_recharge_status: 6, + hrv_avg: 55, + heart_rate_avg: 52.4, + breathing_rate_avg: 14.2, + }; + const mapped = mapNightlyRecharge(rec); + const recovery = mapped.find((m) => m.type === "RECOVERY_SCORE"); + expect(recovery?.value).toBe(100); + expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(55); + expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(52); + expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.2); + }); + + it("rescales status 1 -> 0", () => { + const mapped = mapNightlyRecharge({ + date: "2026-06-10", + nightly_recharge_status: 1, + }); + expect(mapped.find((m) => m.type === "RECOVERY_SCORE")?.value).toBe(0); + }); + + it("skips a bad date", () => { + expect(mapNightlyRecharge({ date: "not-a-date" })).toEqual([]); + }); +}); + +describe("mapSleep", () => { + it("maps per-stage durations s->min and the sleep score", () => { + const s: PolarSleep = { + date: "2026-06-10", + light_sleep: 3600, + deep_sleep: 1800, + rem_sleep: 5400, + sleep_score: 82, + }; + const mapped = mapSleep(s); + const core = mapped.find((m) => m.sleepStage === "CORE"); + expect(core?.value).toBe(60); + expect(mapped.find((m) => m.sleepStage === "DEEP")?.value).toBe(30); + expect(mapped.find((m) => m.sleepStage === "REM")?.value).toBe(90); + expect(mapped.find((m) => m.type === "SLEEP_PERFORMANCE")?.value).toBe(82); + }); +}); + +describe("mapActivity", () => { + it("maps steps and active energy to canonical MeasurementType enum values", () => { + const a: PolarActivity = { + date: "2026-06-10", + "active-steps": 8123, + "active-calories": 540.6, + calories: 2400, + }; + const mapped = mapActivity(a); + expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(8123); + // ACTIVE portion only — NOT the total `calories` (2400, incl. BMR). + expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe( + 540.6, + ); + }); +}); + +describe("POLAR_OAUTH_SCOPE", () => { + it("is the read-all scope", () => { + expect(POLAR_OAUTH_SCOPE).toBe("accesslink.read_all"); + }); +}); diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts new file mode 100644 index 000000000..c2bebb1a1 --- /dev/null +++ b/src/lib/polar/__tests__/sync.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getConnMock, + fetchRechargesMock, + fetchSleepsMock, + fetchActivitiesMock, + upsertMock, + recordSuccessMock, + recordFailureMock, + recomputeMock, + invalidateMock, +} = vi.hoisted(() => ({ + getConnMock: vi.fn(), + fetchRechargesMock: vi.fn(), + fetchSleepsMock: vi.fn(), + fetchActivitiesMock: vi.fn(), + upsertMock: vi.fn(), + recordSuccessMock: vi.fn(), + recordFailureMock: vi.fn(), + recomputeMock: vi.fn(), + invalidateMock: vi.fn(), +})); + +vi.mock("../credentials", () => ({ + getPolarConnection: getConnMock, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { upsert: upsertMock } }, +})); + +vi.mock("@/lib/integrations/status", () => ({ + recordSyncSuccess: recordSuccessMock, + recordSyncFailure: recordFailureMock, +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) => + rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), + recomputeBucketsForMeasurement: recomputeMock, +})); + +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: invalidateMock, +})); + +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchNightlyRecharges: fetchRechargesMock, + fetchSleeps: fetchSleepsMock, + fetchActivities: fetchActivitiesMock, + }; +}); + +import { syncUserPolar } from "../sync"; +import { PolarApiError } from "../response-classifier"; + +const CONN = { accessToken: "tok", polarUserId: "42" }; + +beforeEach(() => { + getConnMock.mockReset(); + fetchRechargesMock.mockReset(); + fetchSleepsMock.mockReset(); + fetchActivitiesMock.mockReset(); + upsertMock.mockReset().mockResolvedValue({}); + recordSuccessMock.mockReset().mockResolvedValue(undefined); + recordFailureMock.mockReset().mockResolvedValue(undefined); + recomputeMock.mockReset().mockResolvedValue(undefined); + invalidateMock.mockReset().mockResolvedValue(undefined); + fetchRechargesMock.mockResolvedValue([]); + fetchSleepsMock.mockResolvedValue([]); + fetchActivitiesMock.mockResolvedValue([]); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("syncUserPolar", () => { + it("no-ops cleanly for an unconnected user", async () => { + getConnMock.mockResolvedValue(null); + expect(await syncUserPolar("u1")).toBe(0); + expect(upsertMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("maps a recharge RECOVERY_SCORE row with source POLAR + stable externalId", async () => { + getConnMock.mockResolvedValue(CONN); + fetchRechargesMock.mockResolvedValue([ + { date: "2026-06-10", nightly_recharge_status: 6 }, + ]); + const imported = await syncUserPolar("u1"); + expect(imported).toBe(1); + const arg = upsertMock.mock.calls[0]![0]; + expect(arg.where.userId_type_source_externalId).toMatchObject({ + userId: "u1", + type: "RECOVERY_SCORE", + source: "POLAR", + externalId: "recharge:2026-06-10:recovery", + }); + expect(arg.create.source).toBe("POLAR"); + expect(arg.create.value).toBe(100); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar"); + }); + + it("is idempotent — a re-sync upserts on the same externalId", async () => { + getConnMock.mockResolvedValue(CONN); + fetchRechargesMock.mockResolvedValue([ + { date: "2026-06-10", nightly_recharge_status: 3 }, + ]); + await syncUserPolar("u1"); + await syncUserPolar("u1"); + expect(upsertMock.mock.calls[0]![0].where).toEqual( + upsertMock.mock.calls[1]![0].where, + ); + }); + + it("records a reauth_required failure on a 401 and rethrows", async () => { + getConnMock.mockResolvedValue(CONN); + fetchRechargesMock.mockRejectedValue( + new PolarApiError({ + verb: "fetchNightlyRecharges", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }), + ); + await expect(syncUserPolar("u1")).rejects.toBeInstanceOf(PolarApiError); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "u1", + integration: "polar", + kind: "reauth_required", + errorCode: "401", + }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts new file mode 100644 index 000000000..11e246cdb --- /dev/null +++ b/src/lib/polar/client.ts @@ -0,0 +1,465 @@ +/** + * v1.17.0 (F4) — Polar AccessLink v3 client for OAuth + data fetching. + * Docs: https://www.polar.com/accesslink-api/ (re-verify at build). + * + * Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled + * fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection + * fetchers, and a single source-of-truth field→Measurement mapping + * (`POLAR_FIELD_MAP`). + * + * Polar specifics that differ from WHOOP: + * - The token endpoint uses HTTP Basic auth (`base64(client_id:client_secret)`) + * rather than client_id/secret in the body. + * - Polar access tokens DO NOT expire and there is NO refresh token. The + * `User.polarRefreshTokenEncrypted` column stays null; `getValidToken` + * never refreshes. A revoked grant surfaces as a 401 on the next data read. + * - The token response carries `x_user_id` — Polar's numeric member id — which + * every data path needs (`/v3/users/{userId}/...`). It is persisted as + * `User.polarUserIdEncrypted` at connect time. + * - The client must register the user once (`POST /v3/users`) before data + * reads work; a 409 from registration means "already registered" and is + * treated as success. + */ +import { getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; +import { PolarApiError, classifyPolarResponse } from "./response-classifier"; + +const POLAR_API_BASE = "https://www.polaraccesslink.com"; +const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization"; +const POLAR_OAUTH_TOKEN_URL = "https://polarremote.com/v2/oauth2/token"; + +export interface PolarCredentials { + clientId: string; + clientSecret: string; +} + +/** Resolve the env-configured shared OAuth credentials (F4 = full OAuth from + * env, not per-user BYO keys). Returns null when unconfigured so the connect + * route can surface a clean "integration disabled" message. */ +export function getPolarCredentials(): PolarCredentials | null { + const clientId = process.env.POLAR_CLIENT_ID; + const clientSecret = process.env.POLAR_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret }; +} + +export function getPolarRedirectUri(): string { + // `||`, not `??`: the compose whitelist materialises an unset var as an empty + // string, which must still fall through to the derived URI. + return ( + process.env.POLAR_REDIRECT_URI || + `${process.env.NEXT_PUBLIC_APP_URL}/api/polar/callback` + ); +} + +/** + * Polar OAuth scope. `accesslink.read_all` grants every read collection so the + * user consents once. Space-separated on the wire. + */ +export const POLAR_OAUTH_SCOPE = "accesslink.read_all" as const; + +/** Build the browser-redirect authorization URL. `state` is the signed nonce. */ +export function getAuthorizationUrl( + state: string, + creds: PolarCredentials, +): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: creds.clientId, + redirect_uri: getPolarRedirectUri(), + scope: POLAR_OAUTH_SCOPE, + state, + }); + return `${POLAR_OAUTH_AUTH_URL}?${params}`; +} + +export interface PolarTokenResponse { + access_token: string; + token_type: string; + expires_in?: number; + x_user_id: number; +} + +function basicAuthHeader(creds: PolarCredentials): string { + const raw = `${creds.clientId}:${creds.clientSecret}`; + return `Basic ${Buffer.from(raw).toString("base64")}`; +} + +/** Exchange an authorization code for the access token + `x_user_id`. */ +export async function exchangeCode( + code: string, + creds: PolarCredentials, +): Promise { + const start = performance.now(); + const res = await safeFetch(POLAR_OAUTH_TOKEN_URL, { + method: "POST", + headers: { + Authorization: basicAuthHeader(creds), + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: getPolarRedirectUri(), + }).toString(), + }); + const json = await res.json().catch(() => null); + const verdict = classifyPolarResponse(res.status); + getEvent()?.addExternalCall({ + service: "polar", + method: "exchangeCode", + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new PolarApiError({ + verb: "exchangeCode", + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: + typeof json?.error === "string" ? json.error : undefined, + }); + } + return json as PolarTokenResponse; +} + +/** + * Register the user with the Polar app (`POST /v3/users`). Required once before + * any data read works. A 409 means the member is already registered for this + * app — treated as success. Other non-2xx throw a classified error. + */ +export async function registerUser( + accessToken: string, + memberId: string, +): Promise { + const start = performance.now(); + const res = await safeFetch(`${POLAR_API_BASE}/v3/users`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ "member-id": memberId }), + }); + const verdict = classifyPolarResponse(res.status); + getEvent()?.addExternalCall({ + service: "polar", + method: "registerUser", + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: + res.status === 409 || verdict.classification === "success" + ? undefined + : verdict.reason, + }); + // 409 Conflict — member already registered for this app. Idempotent success. + if (res.status === 409) return; + if (verdict.classification !== "success") { + throw new PolarApiError({ + verb: "registerUser", + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } +} + +// ─── Collection reads ────────────────────────────────────────── + +/** One Polar Nightly Recharge record. */ +export interface PolarNightlyRecharge { + date: string; + nightly_recharge_status?: number | null; + ans_charge?: number | null; + ans_charge_status?: number | null; + heart_rate_avg?: number | null; + hrv_avg?: number | null; + breathing_rate_avg?: number | null; +} + +/** One Polar Sleep record. Durations are seconds. */ +export interface PolarSleep { + date: string; + sleep_start_time?: string; + sleep_end_time?: string; + light_sleep?: number | null; + deep_sleep?: number | null; + rem_sleep?: number | null; + total_interruption_duration?: number | null; + sleep_charge?: number | null; + sleep_score?: number | null; + heart_rate_samples?: Record; +} + +/** One Polar daily-activity summary. */ +export interface PolarActivity { + date: string; + "active-calories"?: number | null; + "active-steps"?: number | null; + calories?: number | null; +} + +interface CollectionResult { + records: T[]; +} + +async function fetchCollection( + path: string, + accessToken: string, + verb: string, + recordsKey: string, +): Promise { + const start = performance.now(); + const res = await safeFetch(`${POLAR_API_BASE}${path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + const verdict = classifyPolarResponse(res.status); + getEvent()?.addExternalCall({ + service: "polar", + method: verb, + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new PolarApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } + // 204 No Content — a window with no records. Polar returns an empty body. + if (res.status === 204) return []; + const json = (await res.json().catch(() => null)) as + | CollectionResult + | Record + | null; + if (!json) return []; + const list = (json as Record)[recordsKey]; + return Array.isArray(list) ? (list as T[]) : []; +} + +export function fetchNightlyRecharges( + accessToken: string, + userId: string, +): Promise { + return fetchCollection( + `/v3/users/${encodeURIComponent(userId)}/nightly-recharge`, + accessToken, + "fetchNightlyRecharges", + "recharges", + ); +} + +export function fetchSleeps( + accessToken: string, + userId: string, +): Promise { + return fetchCollection( + `/v3/users/${encodeURIComponent(userId)}/sleep`, + accessToken, + "fetchSleeps", + "nights", + ); +} + +export function fetchActivities( + accessToken: string, + userId: string, +): Promise { + return fetchCollection( + `/v3/users/${encodeURIComponent(userId)}/activity-transactions`, + accessToken, + "fetchActivities", + "activity-log", + ); +} + +// ─── Field → Measurement mapping ─────────────────────────────── + +const SEC_TO_MIN = 1 / 60; + +function round2(n: number): number { + return parseFloat(n.toFixed(2)); +} + +/** A single mapped reading destined for one `Measurement` row. The sync layer + * stamps `source = POLAR` + `externalId = :`. */ +export interface MappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + fieldTag: string; + sleepStage?: "CORE" | "DEEP" | "REM"; +} + +/** A `nightly_recharge_status` is Polar's 1–6 recovery band; map it to a + * 0–100 RECOVERY_SCORE so it feeds the canonical recovery ladder below WHOOP. + * The status is the most stable recovery signal Polar exposes; we linearly + * rescale 1–6 → 0–100 (1 = worst, 6 = best). */ +function rechargeStatusToScore(status: number): number { + const clamped = Math.max(1, Math.min(6, status)); + return round2(((clamped - 1) / 5) * 100); +} + +/** + * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` + * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the + * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. + * `measuredAt` is the record's date at local midnight UTC anchor (the wake + * morning the night belongs to). + */ +export function mapNightlyRecharge(r: PolarNightlyRecharge): MappedMeasurement[] { + const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + if (Number.isNaN(measuredAt.getTime())) return []; + const out: MappedMeasurement[] = []; + + if (typeof r.nightly_recharge_status === "number") { + out.push({ + type: "RECOVERY_SCORE", + value: rechargeStatusToScore(r.nightly_recharge_status), + unit: "score", + measuredAt, + fieldTag: "recovery", + }); + } + if (typeof r.hrv_avg === "number" && r.hrv_avg > 0) { + out.push({ + type: "HRV_RMSSD", + value: round2(r.hrv_avg), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + }); + } + if (typeof r.heart_rate_avg === "number" && r.heart_rate_avg > 0) { + out.push({ + type: "RESTING_HEART_RATE", + value: Math.round(r.heart_rate_avg), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + }); + } + if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) { + out.push({ + type: "RESPIRATORY_RATE", + value: round2(r.breathing_rate_avg), + unit: "breaths/min", + measuredAt, + fieldTag: "resp_rate", + }); + } + return out; +} + +/** Map one Polar Sleep record: per-stage `SLEEP_DURATION` (s→min) + the + * `SLEEP_SCORE` percentage where present. */ +export function mapSleep(s: PolarSleep): MappedMeasurement[] { + const measuredAt = new Date(`${s.date}T00:00:00.000Z`); + if (Number.isNaN(measuredAt.getTime())) return []; + const out: MappedMeasurement[] = []; + + const stages: Array<[number | null | undefined, MappedMeasurement["sleepStage"], string]> = [ + [s.light_sleep, "CORE", "sleep_core"], + [s.deep_sleep, "DEEP", "sleep_deep"], + [s.rem_sleep, "REM", "sleep_rem"], + ]; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec === "number" && sec >= 0) { + out.push({ + type: "SLEEP_DURATION", + value: round2(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag, + sleepStage: stage, + }); + } + } + if (typeof s.sleep_score === "number") { + out.push({ + type: "SLEEP_PERFORMANCE", + value: round2(s.sleep_score), + unit: "%", + measuredAt, + fieldTag: "sleep_score", + }); + } + return out; +} + +/** Map one Polar daily-activity record: `ACTIVITY_STEPS` + + * `ACTIVE_ENERGY_BURNED` where Polar reports them. Steps live under + * `active-steps`; the ACTIVE energy portion under `active-calories` (NOT the + * total `calories`, which includes BMR — matching the Fitbit active-only + * convention). */ +export function mapActivity(a: PolarActivity): MappedMeasurement[] { + const measuredAt = new Date(`${a.date}T00:00:00.000Z`); + if (Number.isNaN(measuredAt.getTime())) return []; + const out: MappedMeasurement[] = []; + const steps = a["active-steps"]; + if (typeof steps === "number" && steps >= 0) { + out.push({ + type: "ACTIVITY_STEPS", + value: Math.round(steps), + unit: "steps", + measuredAt, + fieldTag: "steps", + }); + } + const activeCalories = a["active-calories"]; + if (typeof activeCalories === "number" && activeCalories >= 0) { + out.push({ + type: "ACTIVE_ENERGY_BURNED", + value: round2(activeCalories), + unit: "kcal", + measuredAt, + fieldTag: "active_energy", + }); + } + return out; +} + +/** Field→Measurement reference table (mirror of the mappers above). */ +export const POLAR_FIELD_MAP: Record< + string, + { type: string; unit: string; note?: string } +> = { + "nightly_recharge.nightly_recharge_status": { + type: "RECOVERY_SCORE", + unit: "score", + note: "1-6 band rescaled to 0-100; feeds recovery ladder below WHOOP", + }, + "nightly_recharge.hrv_avg": { type: "HRV_RMSSD", unit: "ms" }, + "nightly_recharge.heart_rate_avg": { + type: "RESTING_HEART_RATE", + unit: "bpm", + }, + "nightly_recharge.breathing_rate_avg": { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + }, + "sleep.{light,deep,rem}_sleep": { + type: "SLEEP_DURATION", + unit: "minutes", + note: "s->min, one row per stage (light->CORE, deep->DEEP, rem->REM)", + }, + "sleep.sleep_score": { type: "SLEEP_PERFORMANCE", unit: "%" }, + "activity.active-steps": { type: "ACTIVITY_STEPS", unit: "steps" }, + "activity.active-calories": { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + note: "active portion only (NOT total calories incl. BMR)", + }, +}; diff --git a/src/lib/polar/credentials.ts b/src/lib/polar/credentials.ts new file mode 100644 index 000000000..ab1cd9fdb --- /dev/null +++ b/src/lib/polar/credentials.ts @@ -0,0 +1,40 @@ +/** + * v1.17.0 (F4) — per-user Polar token accessors. + * + * Unlike WHOOP (per-user BYO client id/secret), Polar uses a single shared + * OAuth app whose client id/secret come from env (`getPolarCredentials`). What + * is per-user is the granted token + Polar member id, stored encrypted on + * `User`. Polar access tokens do not expire and carry no refresh token, so + * there is no refresh path — a revoked grant surfaces as a 401 on the next read. + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; + +export interface PolarConnection { + accessToken: string; + /** Polar numeric member id (`x_user_id`), needed for every data path. */ + polarUserId: string; +} + +/** + * Fetch + decrypt the user's stored Polar token. Returns null when the user has + * not connected Polar (no access token) or the member id is missing. + */ +export async function getPolarConnection( + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + polarAccessTokenEncrypted: true, + polarUserIdEncrypted: true, + }, + }); + if (!user?.polarAccessTokenEncrypted || !user?.polarUserIdEncrypted) { + return null; + } + return { + accessToken: decrypt(user.polarAccessTokenEncrypted), + polarUserId: decrypt(user.polarUserIdEncrypted), + }; +} diff --git a/src/lib/polar/response-classifier.ts b/src/lib/polar/response-classifier.ts new file mode 100644 index 000000000..7ec824425 --- /dev/null +++ b/src/lib/polar/response-classifier.ts @@ -0,0 +1,110 @@ +/** + * v1.17.0 (F4) — off-response classifier for Polar AccessLink replies. + * + * Mirrors the WHOOP classifier (`src/lib/whoop/response-classifier.ts`): Polar + * signals failure through the HTTP status code, so the classifier is + * status-driven. + * + * - `success` → HTTP 2xx (incl. 204 No Content — Polar returns 204 + * when a collection window holds no records). + * - `transient` → retryable: 429 (rate-limited), 5xx upstream, 3xx + * CDN glitch, and an unknown status. + * - `reauth_required` → 401 / 403 — the access token is rejected or the user + * revoked the grant; the user must reconnect. + * - `persistent` → any other 4xx — a malformed request / contract + * mismatch that a retry won't fix. + */ + +export type PolarClassification = + | "success" + | "transient" + | "reauth_required" + | "persistent"; + +export interface ClassifiedPolarResponse { + classification: PolarClassification; + httpStatus: number | undefined; + reason: string; +} + +export function classifyPolarResponse( + httpStatus: number, +): ClassifiedPolarResponse { + if (httpStatus >= 200 && httpStatus < 300) { + return { classification: "success", httpStatus, reason: "ok" }; + } + if (httpStatus === 429) { + return { classification: "transient", httpStatus, reason: "http_429" }; + } + if (httpStatus === 401 || httpStatus === 403) { + return { + classification: "reauth_required", + httpStatus, + reason: `http_${httpStatus}`, + }; + } + if (httpStatus >= 400 && httpStatus < 500) { + return { + classification: "persistent", + httpStatus, + reason: `http_${httpStatus}`, + }; + } + if (httpStatus >= 500 && httpStatus < 600) { + return { + classification: "transient", + httpStatus, + reason: `http_${httpStatus}`, + }; + } + return { + classification: "transient", + httpStatus, + reason: `http_${httpStatus}_unknown`, + }; +} + +/** Typed error carrying the classification verdict. Mirrors `WhoopApiError`. */ +export class PolarApiError extends Error { + readonly classification: PolarClassification; + readonly httpStatus: number | undefined; + readonly reason: string; + readonly verb: string; + readonly upstreamError: string | undefined; + + constructor(opts: { + verb: string; + classification: PolarClassification; + httpStatus: number | undefined; + reason: string; + upstreamError?: string; + }) { + const statusLabel = + typeof opts.httpStatus === "number" ? opts.httpStatus : "?"; + const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : ""; + const raw = `Polar ${opts.verb} error: ${statusLabel}${errSegment}`; + super(raw.slice(0, 1024)); + this.name = "PolarApiError"; + this.verb = opts.verb; + this.classification = opts.classification; + this.httpStatus = opts.httpStatus; + this.reason = opts.reason; + this.upstreamError = opts.upstreamError; + } +} + +/** + * Read the classification from any caught error. A non-`PolarApiError` defaults + * to `transient` so a plain `Error` retries rather than disabling the + * integration. Falls back to parsing the status out of the message shape for a + * caller that lost the prototype across a pg-boss retry. + */ +export function classifyPolarError(err: unknown): PolarClassification { + if (err instanceof PolarApiError) return err.classification; + const msg = err instanceof Error ? err.message : String(err); + const m = /Polar\s+\w+\s+error:\s*(\d+)/.exec(msg); + if (!m) return "transient"; + const status = Number.parseInt(m[1]!, 10); + if (!Number.isFinite(status)) return "transient"; + return classifyPolarResponse(status).classification; +} diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts new file mode 100644 index 000000000..329b02a27 --- /dev/null +++ b/src/lib/polar/sync.ts @@ -0,0 +1,195 @@ +/** + * v1.17.0 (F4) — Polar AccessLink sync. + * + * Pulls Nightly Recharge (→ RECOVERY_SCORE / HRV / RHR / respiratory rate), + * sleep (per-stage durations + sleep score), and daily activity (steps + + * active energy) for one connected user, mapping each into `Measurement` rows + * tagged `source = POLAR`. + * + * Token model: Polar tokens do not expire and have no refresh path, so there is + * no `getValidToken` refresh dance (the WHOOP shape). A revoked grant surfaces + * as a 401 → `reauth_required` on the shared integration ledger (`polar` key), + * exactly like Nightscout's token-rejected path. + * + * Idempotency: each row's `externalId` is `:` and the write is + * an upsert keyed on `(userId, type, source = POLAR, externalId)`. Polar + * re-scores a night for a short window after the fact, so the `update` branch + * overwrites in place (WHOOP-style re-score), not first-write-wins. + * + * The measurement-write tail (per-row upsert → rollup fold → status-insight + * invalidate) mirrors the shared WHOOP / Nightscout sync tail; it is NOT a new + * write path. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { getEvent } from "@/lib/logging/context"; +import { + recordSyncFailure, + recordSyncSuccess, + type FailureKind, +} from "@/lib/integrations/status"; +import { + collapseToTypeDayKeys, + recomputeBucketsForMeasurement, +} from "@/lib/rollups/measurement-rollups"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { + fetchActivities, + fetchNightlyRecharges, + fetchSleeps, + mapActivity, + mapNightlyRecharge, + mapSleep, + type MappedMeasurement, +} from "./client"; +import { getPolarConnection } from "./credentials"; +import { PolarApiError, classifyPolarError } from "./response-classifier"; + +/** Map a Polar error onto the shared integration-ledger failure kind. */ +export function classifyPolarFailure(err: unknown): FailureKind { + const c = classifyPolarError(err); + if (c === "reauth_required") return "reauth_required"; + if (c === "persistent") return "persistent"; + return "transient"; +} + +/** One mapped reading with its `externalId` resolved. */ +export interface PolarMeasurementUpsert { + type: string; + value: number; + unit: string; + measuredAt: Date; + externalId: string; + sleepStage?: "CORE" | "DEEP" | "REM" | null; +} + +function toUpsert( + mapped: MappedMeasurement[], + resourcePrefix: string, +): PolarMeasurementUpsert[] { + return mapped.map((m) => ({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + // `::` — stable across re-syncs of the same day. + externalId: `${resourcePrefix}:${m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`, + sleepStage: m.sleepStage ?? null, + })); +} + +/** + * Sync one user's Polar data. Returns the count of measurement rows written. + * A user with no Polar connection is a clean no-op (returns 0, touches no + * status row). + */ +export async function syncUserPolar(userId: string): Promise { + const conn = await getPolarConnection(userId); + if (!conn) return 0; + + const readings: PolarMeasurementUpsert[] = []; + try { + const [recharges, sleeps, activities] = await Promise.all([ + fetchNightlyRecharges(conn.accessToken, conn.polarUserId), + fetchSleeps(conn.accessToken, conn.polarUserId), + fetchActivities(conn.accessToken, conn.polarUserId), + ]); + for (const r of recharges) { + readings.push(...toUpsert(mapNightlyRecharge(r), "recharge")); + } + for (const s of sleeps) { + readings.push(...toUpsert(mapSleep(s), "sleep")); + } + for (const a of activities) { + readings.push(...toUpsert(mapActivity(a), "activity")); + } + } catch (err) { + await recordSyncFailure({ + userId, + integration: "polar", + kind: classifyPolarFailure(err), + message: err instanceof Error ? err.message : String(err), + errorCode: + err instanceof PolarApiError && err.httpStatus != null + ? String(err.httpStatus) + : undefined, + }); + throw err; + } + + const imported = await upsertPolarMeasurements(userId, readings); + await recordSyncSuccess(userId, "polar"); + return imported; +} + +/** + * Upsert a batch of mapped Polar readings, then fold the rollup tier + + * invalidate status-insight caches once at the end (mirrors the WHOOP / + * Nightscout sync tail). Idempotent: the `(userId, type, source, externalId)` + * unique key makes a re-post overwrite in place. Best-effort on the rollup fold. + */ +export async function upsertPolarMeasurements( + userId: string, + readings: PolarMeasurementUpsert[], +): Promise { + if (readings.length === 0) return 0; + + let imported = 0; + const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; + + for (const r of readings) { + const type = r.type as MeasurementType; + try { + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type, + source: "POLAR", + externalId: r.externalId, + }, + }, + create: { + userId, + type, + source: "POLAR", + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }, + update: { + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + sleepStage: r.sleepStage ?? null, + syncVersion: { increment: 1 }, + }, + }); + touched.push({ type, measuredAt: r.measuredAt }); + imported++; + } catch (err) { + getEvent()?.addWarning(`polar: failed to upsert measurement: ${err}`); + } + } + + try { + const keys = collapseToTypeDayKeys(touched); + for (const k of keys) { + await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); + } + invalidateStatusInsightsForTypes( + userId, + keys.map((k) => k.type), + ).catch((err) => { + getEvent()?.addWarning( + `polar: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); + } catch (err) { + getEvent()?.addWarning(`polar: rollup recompute failed for ${userId}: ${err}`); + } + + return imported; +} diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index adcbf3c6c..97d90909d 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -34,6 +34,12 @@ export const integrationKeys = { nightscout: () => ["nightscout"] as const, nightscoutStatus: () => ["nightscout", "status"] as const, + // v1.17.0 (F4) — Polar OAuth integration card. Self-contained status read + // (not the consolidated /api/integrations/status envelope), so the + // connect/disconnect mutations invalidate these single keys. + polar: () => ["polar"] as const, + polarStatus: () => ["polar", "status"] as const, + moodlogStatus: () => ["moodlog-status"] as const, integrationsStatus: () => ["integrations", "status"] as const,