mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
e561f8d893
commit
31abd307fd
31 files changed
+2341
-13
No files matched your search
@@ -50,6 +50,18 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
|
||||
# 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-1
@@ -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",
|
||||
|
||||
+21
-1
@@ -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",
|
||||
|
||||
+21
-1
@@ -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",
|
||||
|
||||
+21
-1
@@ -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",
|
||||
|
||||
+21
-1
@@ -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",
|
||||
|
||||
+21
-1
@@ -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",
|
||||
|
||||
@@ -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 <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
|
||||
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<string, unknown>;
|
||||
expect(JSON.stringify(res)).not.toContain("enc");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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=<tag>`; 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() {
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: statusQueryKey,
|
||||
queryFn: async () => apiGet<OAuthProviderStatus>(`/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 (
|
||||
<div
|
||||
data-testid={`${provider}-card`}
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
title={t(`${i18nPrefix}`)}
|
||||
description={<p>{t(`${i18nPrefix}Description`)}</p>}
|
||||
status={
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={status?.lastSuccessAt ?? null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="border-border/60 mt-4" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid={`${provider}-error`}
|
||||
className="text-destructive text-sm break-words"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(`${i18nPrefix}Help`)}
|
||||
</p>
|
||||
|
||||
{serverUnavailable && (
|
||||
<p
|
||||
className="text-muted-foreground/80 text-xs"
|
||||
data-testid={`${provider}-unavailable`}
|
||||
>
|
||||
{t(`${i18nPrefix}Unavailable`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.connected ? (
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
data-testid={`${provider}-disconnect`}
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Disconnect`)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(`${i18nPrefix}DisconnectTitle`)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(`${i18nPrefix}DisconnectDescription`)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t(`${i18nPrefix}Disconnect`)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={serverUnavailable}
|
||||
onClick={handleConnect}
|
||||
data-testid={`${provider}-connect`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Connect`)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<OAuthProviderCard
|
||||
provider="polar"
|
||||
statusQueryKey={queryKeys.polar()}
|
||||
i18nPrefix="settings.polar"
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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<Record<MeasurementSource, number>> = {
|
||||
WHOOP: 0,
|
||||
COMPUTED: 1,
|
||||
OURA: 1,
|
||||
POLAR: 2,
|
||||
COMPUTED: 3,
|
||||
};
|
||||
|
||||
function rankOf(source: MeasurementSource): number {
|
||||
|
||||
@@ -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<PolarSyncPayload>(
|
||||
POLAR_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handlePolarSync,
|
||||
);
|
||||
await boss.work<GeneralStatusPayload>(
|
||||
GENERAL_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
|
||||
@@ -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<PolarSyncPayload>[]) {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* `<provider>.<userId>.<expiryMs>.<nonce>` 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));
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("../client")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<PolarTokenResponse> {
|
||||
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<void> {
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
/** One Polar daily-activity summary. */
|
||||
export interface PolarActivity {
|
||||
date: string;
|
||||
"active-calories"?: number | null;
|
||||
"active-steps"?: number | null;
|
||||
calories?: number | null;
|
||||
}
|
||||
|
||||
interface CollectionResult<T> {
|
||||
records: T[];
|
||||
}
|
||||
|
||||
async function fetchCollection<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
verb: string,
|
||||
recordsKey: string,
|
||||
): Promise<T[]> {
|
||||
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<T>
|
||||
| Record<string, T[]>
|
||||
| null;
|
||||
if (!json) return [];
|
||||
const list = (json as Record<string, unknown>)[recordsKey];
|
||||
return Array.isArray(list) ? (list as T[]) : [];
|
||||
}
|
||||
|
||||
export function fetchNightlyRecharges(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarNightlyRecharge[]> {
|
||||
return fetchCollection<PolarNightlyRecharge>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/nightly-recharge`,
|
||||
accessToken,
|
||||
"fetchNightlyRecharges",
|
||||
"recharges",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchSleeps(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarSleep[]> {
|
||||
return fetchCollection<PolarSleep>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/sleep`,
|
||||
accessToken,
|
||||
"fetchSleeps",
|
||||
"nights",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchActivities(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarActivity[]> {
|
||||
return fetchCollection<PolarActivity>(
|
||||
`/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 = <date>:<fieldTag>`. */
|
||||
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)",
|
||||
},
|
||||
};
|
||||
@@ -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<PolarConnection | null> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 `<date>:<fieldTag>` 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,
|
||||
// `<resource>:<date>:<fieldTag>` — 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<number> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in new issue
Block a user