Merge Fitbit classic Web API transport into release/v1.20.0

Retarget the Fitbit integration from the Google Health API (which needs
annual CASA review — a wall for self-hosters) to the classic Fitbit Web
API with PKCE and rotating refresh tokens, so a self-hoster can connect
with an instant developer app (migration 0191 adds the PKCE verifier).
Transport-only fork; the ingest, dedup, source-priority, backfill and
poll paths are reused. Intraday HR and skin temperature are not mapped
(no honest slot); the integration stays experimental.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 07:22:31 +02:00
commit 9021b3e0ee
25 files changed
+1666 -1949

No files matched your search

+13
View File
@@ -50,6 +50,19 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
# WHOOP_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Fitbit -- optional integration (classic Web API, per-user BYO-keys)
# -----------------------------------------------------------------------------
# Each user registers their own app at https://dev.fitbit.com (self-serve, no
# brand verification) and pastes the OAuth Client ID + Client Secret into
# Settings (stored encrypted in the DB). There is therefore no
# FITBIT_CLIENT_ID/SECRET here. The OAuth flow uses Authorization Code + PKCE.
#
# FITBIT_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/fitbit/callback` when the public URL differs; it
# must exactly match the Callback URL registered in the dev.fitbit.com app.
# FITBIT_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Polar AccessLink -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
+14 -14
View File
@@ -4454,9 +4454,9 @@
"token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
"fitbitDescription": "Verbinde Fitbit, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
"fitbitExperimentalBadge": "Experimentell",
"fitbitCredentials": "API-Zugangsdaten",
"fitbitClientId": "Client-ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Jetzt synchronisieren",
"fitbitFullSync": "Alle Daten synchronisieren",
"fitbitFullSyncTitle": "Vollständige Synchronisierung?",
"fitbitFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.",
"fitbitFullSyncDescription": "Der gesamte verfügbare Fitbit-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.",
"fitbitSyncResult": "{count} Messungen synchronisiert",
"fitbitSyncFailed": "Synchronisierung fehlgeschlagen",
"fitbitSynchronize": "Synchronisieren",
"fitbitDisconnect": "Trennen",
"fitbitDisconnectTitle": "Google Health trennen?",
"fitbitDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
"fitbitConnect": "Mit Google Health verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen",
"fitbitDisconnectTitle": "Fitbit trennen?",
"fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
"fitbitConnect": "Mit Fitbit verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.",
"fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Fitbit verbunden — die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen",
"fitbitOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"state": "Die Antwort von Fitbit passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. 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": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
"nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"nocode": "Fitbit hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Fitbit-Dialog.",
"nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"integrations": {
+14 -14
View File
@@ -4454,9 +4454,9 @@
"token": "The token exchange with WHOOP failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
"fitbitDescription": "Connect Fitbit to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
"fitbitExperimentalBadge": "Experimental",
"fitbitCredentials": "API Credentials",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Sync now",
"fitbitFullSync": "Sync all data",
"fitbitFullSyncTitle": "Full synchronization?",
"fitbitFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.",
"fitbitFullSyncDescription": "All available Fitbit history will be fully synchronized. This may take some time depending on your history.",
"fitbitSyncResult": "{count} measurements synchronized",
"fitbitSyncFailed": "Sync failed",
"fitbitSynchronize": "Synchronize",
"fitbitDisconnect": "Disconnect",
"fitbitDisconnectTitle": "Disconnect Google Health?",
"fitbitDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.",
"fitbitConnect": "Connect with Google Health",
"fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.",
"fitbitBackfillInProgress": "Importing your Google Health history in the background…",
"fitbitOauthConnected": "Google Health connected — the first sync starts in the background.",
"fitbitOauthFailed": "Google Health connection failed",
"fitbitDisconnectTitle": "Disconnect Fitbit?",
"fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.",
"fitbitConnect": "Connect with Fitbit",
"fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.",
"fitbitBackfillInProgress": "Importing your Fitbit history in the background…",
"fitbitOauthConnected": "Fitbit connected — the first sync starts in the background.",
"fitbitOauthFailed": "Fitbit connection failed",
"fitbitOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from Google did not match any open connection request. Please start the connection again.",
"state": "The response from Fitbit did not match any open connection request. Please start the connection again.",
"expired": "The connection request expired before it completed. 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": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
"nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
"token": "The token exchange with Google failed. Please start the connection again.",
"nocode": "Fitbit did not return an authorization code. Try again and allow access in the Fitbit dialog.",
"nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.",
"token": "The token exchange with Fitbit failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"integrations": {
+14 -14
View File
@@ -4454,9 +4454,9 @@
"token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit y Pixel",
"fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
"fitbitDescription": "Conecta Fitbit para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
"fitbitExperimentalBadge": "Experimental",
"fitbitCredentials": "Credenciales de API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Sincronizar ahora",
"fitbitFullSync": "Sincronizar todos los datos",
"fitbitFullSyncTitle": "¿Sincronización completa?",
"fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Esto puede tardar un tiempo según tu historial.",
"fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Fitbit. Esto puede tardar un tiempo según tu historial.",
"fitbitSyncResult": "{count} mediciones sincronizadas",
"fitbitSyncFailed": "Error de sincronización",
"fitbitSynchronize": "Sincronizar",
"fitbitDisconnect": "Desconectar",
"fitbitDisconnectTitle": "¿Desconectar Google Health?",
"fitbitDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos sincronizados previamente se conservarán.",
"fitbitConnect": "Conectar con Google Health",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
"fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Google Health",
"fitbitDisconnectTitle": "¿Desconectar Fitbit?",
"fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.",
"fitbitConnect": "Conectar con Fitbit",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.",
"fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…",
"fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Fitbit",
"fitbitOauthError": {
"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 Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"state": "La respuesta de Fitbit no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"expired": "La solicitud de conexión caducó antes de completarse. 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": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.",
"nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.",
"nocode": "Fitbit no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Fitbit.",
"nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"integrations": {
+14 -14
View File
@@ -4454,9 +4454,9 @@
"token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit et Pixel",
"fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
"fitbitDescription": "Connecte Fitbit pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
"fitbitExperimentalBadge": "Expérimental",
"fitbitCredentials": "Identifiants d'API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Synchroniser maintenant",
"fitbitFullSync": "Tout synchroniser",
"fitbitFullSyncTitle": "Synchronisation complète ?",
"fitbitFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.",
"fitbitFullSyncDescription": "Tout l'historique Fitbit disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.",
"fitbitSyncResult": "{count} mesures synchronisées",
"fitbitSyncFailed": "Échec de la synchronisation",
"fitbitSynchronize": "Synchroniser",
"fitbitDisconnect": "Déconnecter",
"fitbitDisconnectTitle": "Déconnecter Google Health ?",
"fitbitDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.",
"fitbitConnect": "Se connecter à Google Health",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.",
"fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…",
"fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Google Health",
"fitbitDisconnectTitle": "Déconnecter Fitbit ?",
"fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.",
"fitbitConnect": "Se connecter à Fitbit",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.",
"fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…",
"fitbitOauthConnected": "Fitbit connecté — la première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Fitbit",
"fitbitOauthError": {
"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 Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"state": "La réponse de Fitbit ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"expired": "La demande de connexion a expiré avant d'aboutir. 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": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.",
"nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.",
"token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.",
"nocode": "Fitbit n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Fitbit.",
"nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.",
"token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"integrations": {
+14 -14
View File
@@ -4454,9 +4454,9 @@
"token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit e Pixel",
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
"fitbitDescription": "Collega Fitbit per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
"fitbitExperimentalBadge": "Sperimentale",
"fitbitCredentials": "Credenziali API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Sincronizza ora",
"fitbitFullSync": "Sincronizza tutti i dati",
"fitbitFullSyncTitle": "Sincronizzazione completa?",
"fitbitFullSyncDescription": "Tutto lo storico disponibile di Google Health verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.",
"fitbitFullSyncDescription": "Tutto lo storico disponibile di Fitbit verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.",
"fitbitSyncResult": "{count} misurazioni sincronizzate",
"fitbitSyncFailed": "Sincronizzazione non riuscita",
"fitbitSynchronize": "Sincronizza",
"fitbitDisconnect": "Disconnetti",
"fitbitDisconnectTitle": "Disconnettere Google Health?",
"fitbitDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.",
"fitbitConnect": "Connetti con Google Health",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"fitbitBackfillInProgress": "Importazione dello storico Google Health in background…",
"fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Google Health non riuscita",
"fitbitDisconnectTitle": "Disconnettere Fitbit?",
"fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.",
"fitbitConnect": "Connetti con Fitbit",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.",
"fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…",
"fitbitOauthConnected": "Fitbit collegato — la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Fitbit non riuscita",
"fitbitOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"state": "La risposta di Fitbit non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.",
"nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.",
"token": "Lo scambio del token con Google è fallito. Riavvia la connessione.",
"nocode": "Fitbit non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Fitbit.",
"nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.",
"token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"integrations": {
+14 -14
View File
@@ -4454,9 +4454,9 @@
"token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit i Pixel",
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
"fitbitDescription": "Połącz Fitbit, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
"fitbitExperimentalBadge": "Eksperymentalne",
"fitbitCredentials": "Dane uwierzytelniające API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Synchronizuj teraz",
"fitbitFullSync": "Synchronizuj wszystkie dane",
"fitbitFullSyncTitle": "Pełna synchronizacja?",
"fitbitFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.",
"fitbitFullSyncDescription": "Cała dostępna historia Fitbit zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.",
"fitbitSyncResult": "Zsynchronizowano {count} pomiarów",
"fitbitSyncFailed": "Synchronizacja nie powiodła się",
"fitbitSynchronize": "Synchronizuj",
"fitbitDisconnect": "Rozłącz",
"fitbitDisconnectTitle": "Rozłączyć Google Health?",
"fitbitDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"fitbitConnect": "Połącz z Google Health",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.",
"fitbitBackfillInProgress": "Importowanie historii Google Health w tle…",
"fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Google Health nie powiodło się",
"fitbitDisconnectTitle": "Rozłączyć Fitbit?",
"fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"fitbitConnect": "Połącz z Fitbit",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.",
"fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…",
"fitbitOauthConnected": "Fitbit połączony — pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się",
"fitbitOauthError": {
"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 Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"state": "Odpowiedź z Fitbit nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
"nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
"token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.",
"nocode": "Fitbit nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Fitbit.",
"nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.",
"token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"integrations": {
@@ -0,0 +1,8 @@
-- v1.20.0 — classic Fitbit Web API (api.fitbit.com) authorises with PKCE
-- (Authorization Code + S256). Carry the per-handshake `code_verifier` from the
-- connect route to the callback on the short-lived OAuth-state ledger row.
--
-- Additive + nullable: a legacy in-flight row (or any non-PKCE path) simply
-- carries no verifier, so this is reversible and rerun-safe with no data loss.
-- `IF NOT EXISTS` guards a prod rerun.
ALTER TABLE "fitbit_oauth_states" ADD COLUMN IF NOT EXISTS "code_verifier" TEXT;
+13 -4
View File
@@ -2658,11 +2658,20 @@ model FitbitConnection {
/// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth
/// state check, identical in shape + lifecycle to `WhoopOAuthState`.
///
/// v1.20.0 — the classic Fitbit Web API (`api.fitbit.com`) authorises with PKCE
/// (Authorization Code + S256), so the connect route mints a `code_verifier`
/// alongside the nonce and stashes it here; the callback reads it back to present
/// on the token exchange. Nullable + additive: a legacy row (or a non-PKCE path)
/// simply carries no verifier.
model FitbitOAuthState {
nonce String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
nonce String @id
userId String @map("user_id")
/// PKCE code_verifier carried from connect to callback (v1.20.0). Null when no
/// PKCE handshake is in flight (legacy rows).
codeVerifier String? @map("code_verifier")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+27 -13
View File
@@ -19,9 +19,9 @@ import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
/**
* OAuth callback from Google for Fitbit / Google Health (v1.12.0). Mirrors the
* WHOOP callback: the `state` param is a random base64url nonce keyed against
* the `FitbitOAuthState` ledger. The in-flight user is resolved via the row's
* OAuth callback from Fitbit (classic Web API, v1.20.0). Mirrors the WHOOP
* callback: the `state` param is a random base64url nonce keyed against the
* `FitbitOAuthState` ledger. The in-flight user is resolved via the row's
* `userId` (never parsed from the cookie value) and cross-checked against the
* session. The row is consumed (deleted) atomically on every exit branch so a
* replay of the same nonce fails the second time.
@@ -31,9 +31,12 @@ import { timingSafeEqual } from "node:crypto";
* nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user`
* (valid row, session userId mismatch).
*
* On success: exchange the code, fetch the Google Health profile for the
* external `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any
* prior reauth state, and enqueue the self-converging history backfill.
* v1.20.0 — the deleted ledger row carries the PKCE `code_verifier` minted at
* connect; it is presented on the token exchange.
*
* On success: exchange the code, fetch the Fitbit profile for the external
* `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior
* reauth state, and enqueue the self-converging history backfill.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
@@ -70,7 +73,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
// payload. P2025 means the nonce was already consumed (replay) or never
// existed. Atomic at the Postgres row level: two concurrent callbacks with the
// same nonce can't both pass before either deletes.
let stateRow: { userId: string; expiresAt: Date } | null = null;
let stateRow: {
userId: string;
expiresAt: Date;
codeVerifier: string | null;
} | null = null;
try {
stateRow = await prisma.fitbitOAuthState.delete({
where: { nonce: state },
@@ -102,19 +109,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
return ERR("nocode");
}
// The PKCE verifier minted at connect rides on the ledger row. Its absence
// means a malformed / legacy handshake — refuse rather than send a code
// exchange Fitbit will reject for a missing verifier.
if (!stateRow.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
try {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
const tokens = await exchangeCode(code, creds);
const tokens = await exchangeCode(code, stateRow.codeVerifier, creds);
// The initial code exchange MUST return a refresh token (the connect route
// forces `prompt=consent` + `access_type=offline`). If Google ever omits it,
// refuse to persist — a stored empty refresh token silently bricks every
// future token refresh (the refresh POST then 400s and the connection dies
// the moment the access token expires). Error out cleanly so the user
// The initial code exchange MUST return a refresh token. If Fitbit ever
// omits it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the connection
// dies the moment the access token expires). Error out cleanly so the user
// re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
+13 -5
View File
@@ -3,7 +3,7 @@ import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/fitbit/client";
import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import {
FITBIT_OAUTH_STATE_COOKIE,
@@ -14,8 +14,8 @@ import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirect the user to the Google OAuth consent page for Fitbit / Google Health
* (v1.12.0).
* Redirect the user to the Fitbit OAuth consent page (classic
* `www.fitbit.com/oauth2/authorize`, v1.20.0).
*
* Mirrors the WHOOP connect route: a fully-random base64url state nonce backed
* by a 10-minute `FitbitOAuthState` ledger row carries the `(nonce → userId)`
@@ -23,6 +23,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* land in request logs / network captures). The httpOnly + Secure cookie
* carries JUST the nonce; the callback resolves the user via the row's `userId`.
*
* v1.20.0 — the classic Fitbit Web API uses PKCE (S256). The route mints a
* `code_verifier`/`code_challenge` pair, stashes the verifier on the ledger row
* (server-side, never exposed to the browser), and sends the challenge to the
* authorize endpoint. The callback reads the verifier back to present on the
* token exchange.
*
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
* ledger rows for the full 10-min TTL window. Both the rate-limit and
* create-failure paths redirect (not JSON) because the entry point is a browser
@@ -53,17 +59,19 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
"Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.",
400,
);
}
const nonce = mintFitbitOAuthStateNonce();
const pkce = generatePkcePair();
try {
await prisma.fitbitOAuthState.create({
data: {
nonce,
userId: user.id,
codeVerifier: pkce.verifier,
expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS),
},
});
@@ -75,7 +83,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const url = getAuthorizationUrl(nonce, creds);
const url = getAuthorizationUrl(nonce, creds, pkce.challenge);
const response = NextResponse.redirect(url);
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
+7 -7
View File
@@ -6,17 +6,17 @@ import { syncUserFitbit } from "@/lib/fitbit/sync";
import { NextRequest } from "next/server";
/**
* Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0).
* Manually trigger a Fitbit sync for the current user.
*
* Mirrors the WHOOP manual-sync route: incremental by default, full history when
* `{ fullSync: true }` is posted.
*
* v1.12.1 — rate-limited (M-1). Every sibling Fitbit route (connect / test /
* resume) is limited; this one was the outlier, while `{ fullSync: true }`
* drives four paginated Google walkers (each capped at 1000 pages). A tight
* loop or a stolen native token could pin Prisma + the Google Health quota.
* A baseline 5/60s bucket (matching the test route) gates the route, and the
* expensive `fullSync` path carries a tighter 1/hour bucket of its own.
* Rate-limited: every sibling Fitbit route (connect / test / resume) is limited;
* this one was the outlier, while `{ fullSync: true }` drives four resource syncs
* over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a
* stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s
* bucket (matching the test route) gates the route, and the expensive `fullSync`
* path carries a tighter 1/hour bucket of its own.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -206,7 +206,7 @@ describe("POST /api/integrations/fitbit/test", () => {
new Response(
JSON.stringify({
error: "Authorization: Bearer sk-secret echoed",
url: "https://health.googleapis.com/v4/users/me/profile",
url: "https://api.fitbit.com/1/user/-/profile.json",
}),
{ status: 401 },
),
@@ -215,6 +215,5 @@ describe("POST /api/integrations/fitbit/test", () => {
const text = await response.text();
expect(response.status).toBe(502);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/health\.googleapis\.com/);
});
});
@@ -13,7 +13,7 @@ export const dynamic = "force-dynamic";
// Cheap authenticated probe — the single (non-paginated) profile endpoint the
// Fitbit client already calls via `fetchProfile`.
// Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`).
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`;
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/1/user/-/profile.json`;
const TIMEOUT_MS = 5_000;
type CategorisedError = { code: string; message: string };
@@ -22,24 +22,24 @@ function categoriseHttpStatus(status: number): CategorisedError {
if (status === 401 || status === 403) {
return {
code: "credentials_rejected",
message: "Google Health rejected the credentials",
message: "Fitbit rejected the credentials",
};
}
if (status === 429) {
return {
code: "rate_limited",
message: "Google Health rate-limited the request",
message: "Fitbit rate-limited the request",
};
}
if (status >= 500) {
return {
code: "upstream_error",
message: "Google Health returned a server error",
message: "Fitbit returned a server error",
};
}
return {
code: "connection_failed",
message: "Google Health connection failed",
message: "Fitbit connection failed",
};
}
@@ -57,7 +57,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Google Health not connected", 422, {
return apiError("Fitbit not connected", 422, {
errorCode: "not_configured",
});
}
@@ -115,9 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
return apiError(
isAbort
? "Google Health request timed out"
: "Google Health connection failed",
isAbort ? "Fitbit request timed out" : "Fitbit connection failed",
502,
{ errorCode: code },
);
+284 -408
View File
@@ -1,12 +1,14 @@
import { createHash } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FITBIT_API_BASE,
FITBIT_DATA_TYPES,
FITBIT_FIELD_MAP,
FITBIT_OAUTH_SCOPE,
exchangeCode,
fetchDataPoints,
fetchProfile,
fetchSleepRange,
fetchSpo2Range,
fetchWeightRange,
generatePkcePair,
getAuthorizationUrl,
mapActiveCalories,
mapBodyFat,
@@ -15,14 +17,17 @@ import {
mapFitbitSportType,
mapFloors,
mapHeartRateVariability,
mapHeightCm,
mapOxygenSaturation,
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepSession,
mapSteps,
mapVo2Max,
mapWeight,
mapWorkout,
parseVo2Max,
readActivityList,
readSleepSessions,
refreshAccessToken,
resolveFitbitUserId,
} from "../client";
@@ -44,8 +49,7 @@ function installFetchMock(pages: Array<{ status: number; body: unknown }>) {
return fetchMock;
}
// v1.12.1 — the redirect_uri allowlist (M-5) requires a configured, absolute
// https app origin. Pin one for the OAuth-handshake cases.
// The redirect_uri allowlist requires a configured, absolute https app origin.
const PRIOR_APP_URL = process.env.NEXT_PUBLIC_APP_URL;
beforeEach(() => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
@@ -58,48 +62,61 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("generatePkcePair", () => {
it("mints a high-entropy verifier and the matching S256 challenge", () => {
const { verifier, challenge } = generatePkcePair();
// 64 random bytes → 86 base64url chars; inside Fitbit's 43–128 range.
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier.length).toBeLessThanOrEqual(128);
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
// challenge = BASE64URL(SHA256(verifier)).
const expected = createHash("sha256").update(verifier).digest("base64url");
expect(challenge).toBe(expected);
});
it("mints a fresh pair each call", () => {
expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier);
});
});
describe("getAuthorizationUrl", () => {
it("builds the Google authorize URL with offline access + consent prompt", () => {
const url = getAuthorizationUrl("nonce123", CREDS);
expect(url).toContain("accounts.google.com/o/oauth2/v2/auth");
it("builds the classic Fitbit authorize URL with PKCE S256", () => {
const url = getAuthorizationUrl("nonce123", CREDS, "chal-xyz");
expect(url).toContain("www.fitbit.com/oauth2/authorize");
expect(url).toContain("response_type=code");
expect(url).toContain("client_id=cid");
expect(url).toContain("state=nonce123");
expect(url).toContain("access_type=offline");
expect(url).toContain("prompt=consent");
// URLSearchParams encodes the space-separated scope; compare the parsed
// `scope` param back to the canonical constant.
const scope = new URL(url).searchParams.get("scope");
expect(scope).toBe(FITBIT_OAUTH_SCOPE);
expect(FITBIT_OAUTH_SCOPE).toContain("googlehealth.profile.readonly");
const parsed = new URL(url);
expect(parsed.searchParams.get("code_challenge")).toBe("chal-xyz");
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
expect(parsed.searchParams.get("scope")).toBe(FITBIT_OAUTH_SCOPE);
});
it("requests exactly the four launch Restricted read bundles", () => {
it("requests the classic self-serve scopes and omits temperature", () => {
const scopes = FITBIT_OAUTH_SCOPE.split(" ");
expect(scopes).toHaveLength(4);
expect(scopes).toContain(
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
);
expect(scopes).toContain(
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
);
expect(scopes).toContain(
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
);
// ECG / nutrition / location are deliberately omitted from the launch set.
expect(FITBIT_OAUTH_SCOPE).not.toContain("ecg");
expect(scopes).toContain("activity");
expect(scopes).toContain("heartrate");
expect(scopes).toContain("sleep");
expect(scopes).toContain("weight");
expect(scopes).toContain("oxygen_saturation");
expect(scopes).toContain("respiratory_rate");
expect(scopes).toContain("cardio_fitness");
expect(scopes).toContain("profile");
// temperature deliberately omitted — the classic skin-temp reading is a
// baseline delta with no honest absolute slot.
expect(FITBIT_OAUTH_SCOPE).not.toContain("temperature");
expect(FITBIT_OAUTH_SCOPE).not.toContain("nutrition");
expect(FITBIT_OAUTH_SCOPE).not.toContain("location");
});
it("carries the configured app origin in the redirect_uri", () => {
const url = getAuthorizationUrl("nonce123", CREDS);
const url = getAuthorizationUrl("nonce123", CREDS, "c");
const redirect = new URL(url).searchParams.get("redirect_uri");
expect(redirect).toBe("https://app.example.test/api/fitbit/callback");
});
});
describe("redirect_uri allowlist (M-5)", () => {
describe("redirect_uri allowlist", () => {
const PRIOR_REDIRECT = process.env.FITBIT_REDIRECT_URI;
afterEach(() => {
if (PRIOR_REDIRECT === undefined) delete process.env.FITBIT_REDIRECT_URI;
@@ -108,12 +125,12 @@ describe("redirect_uri allowlist (M-5)", () => {
it("rejects a non-https app origin", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test";
expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/must be https/);
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(/must be https/);
});
it("allows http on localhost (dev)", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
const url = getAuthorizationUrl("n", CREDS);
const url = getAuthorizationUrl("n", CREDS, "c");
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
"http://localhost:3000/api/fitbit/callback",
);
@@ -121,7 +138,7 @@ describe("redirect_uri allowlist (M-5)", () => {
it("rejects an explicit redirect that targets the wrong path", () => {
process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil";
expect(() => getAuthorizationUrl("n", CREDS)).toThrow(
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
/must target \/api\/fitbit\/callback/,
);
});
@@ -130,80 +147,72 @@ describe("redirect_uri allowlist (M-5)", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
process.env.FITBIT_REDIRECT_URI =
"https://attacker.example.test/api/fitbit/callback";
expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/does not match/);
});
it("accepts an explicit same-origin redirect", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
process.env.FITBIT_REDIRECT_URI =
"https://app.example.test/api/fitbit/callback";
const url = getAuthorizationUrl("n", CREDS);
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
"https://app.example.test/api/fitbit/callback",
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
/does not match/,
);
});
});
describe("token exchange + refresh", () => {
it("exchanges an authorization code for a token pair via Basic auth", async () => {
it("exchanges an authorization code WITH the PKCE verifier via Basic auth", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
access_token: "at",
refresh_token: "rt",
expires_in: 3600,
scope: "https://www.googleapis.com/auth/googlehealth.sleep.readonly",
expires_in: 28800,
scope: "activity sleep weight",
user_id: "ABC123",
},
},
]);
const tok = await exchangeCode("code", CREDS);
const tok = await exchangeCode("code", "verifier-123", CREDS);
expect(tok.access_token).toBe("at");
expect(tok.refresh_token).toBe("rt");
expect(tok.expires_in).toBe(3600);
expect(tok.expires_in).toBe(28800);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string>; body: string },
];
expect(url).toContain("oauth2.googleapis.com/token");
expect(url).toContain("api.fitbit.com/oauth2/token");
expect(init.body).toContain("grant_type=authorization_code");
// Confidential client credentials ride in the Basic-auth header.
expect(init.body).toContain("code_verifier=verifier-123");
const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;
expect(init.headers.Authorization).toBe(expected);
});
it("refreshes WITHOUT re-sending scope (Google preserves the grant)", async () => {
it("refreshes and returns the ROTATED refresh token (classic rotates)", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: { access_token: "at2", expires_in: 3600 },
body: {
access_token: "at2",
refresh_token: "rt2",
expires_in: 28800,
},
},
]);
const tok = await refreshAccessToken("rt1", CREDS);
expect(tok.access_token).toBe("at2");
// Classic Fitbit rotates: a fresh refresh token is returned every time.
expect(tok.refresh_token).toBe("rt2");
const [, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ body: string },
];
expect(init.body).toContain("grant_type=refresh_token");
expect(init.body).toContain("refresh_token=rt1");
// Google preserves the original grant — no scope param is re-sent.
// The grant's scope is preserved — no scope param re-sent.
expect(init.body).not.toContain("scope=");
});
it("returns an absent refresh_token unchanged (Google does not rotate)", async () => {
installFetchMock([
{ status: 200, body: { access_token: "at3", expires_in: 3600 } },
]);
const tok = await refreshAccessToken("rt1", CREDS);
expect(tok.access_token).toBe("at3");
expect(tok.refresh_token).toBeUndefined();
});
it("throws a classified FitbitApiError on a 401 token response", async () => {
installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]);
await expect(exchangeCode("bad", CREDS)).rejects.toMatchObject({
installFetchMock([
{ status: 401, body: { errors: [{ errorType: "invalid_grant" }] } },
]);
await expect(exchangeCode("bad", "v", CREDS)).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
});
@@ -211,131 +220,51 @@ describe("token exchange + refresh", () => {
});
describe("fetchProfile + resolveFitbitUserId", () => {
it("fetches the profile from the Google Health base", async () => {
const fetchMock = installFetchMock([
{ status: 200, body: { name: "users/abc123" } },
]);
const profile = await fetchProfile("at");
expect(profile.name).toBe("users/abc123");
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(url).toBe(`${FITBIT_API_BASE}/users/me/profile`);
});
it("throws a classified error on a 403 profile read", async () => {
installFetchMock([{ status: 403, body: {} }]);
await expect(fetchProfile("at")).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
});
});
it("resolves the external user id from a users/{id} resource name", () => {
expect(resolveFitbitUserId({ name: "users/abc123" })).toBe("abc123");
});
it("falls back to a bare id, then to 'me'", () => {
expect(resolveFitbitUserId({ id: "xyz" })).toBe("xyz");
it("resolves the external user id from the profile encodedId", () => {
expect(resolveFitbitUserId({ user: { encodedId: "ABC123" } })).toBe(
"ABC123",
);
expect(resolveFitbitUserId({})).toBe("me");
expect(resolveFitbitUserId({ user: {} })).toBe("me");
});
});
describe("fetchDataPoints", () => {
it("encodes the data type kebab-case in the path and snake_case in the filter, walking nextPageToken", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
dataPoints: [{ a: 1 }],
nextPageToken: "tok2",
},
},
{ status: 200, body: { dataPoints: [{ a: 2 }] } },
]);
const points = await fetchDataPoints(
FITBIT_DATA_TYPES.bodyFat,
describe("date-range fetchers", () => {
it("encodes the weight endpoint path with the metric Accept-Language", async () => {
const fetchMock = installFetchMock([{ status: 200, body: { weight: [] } }]);
await fetchWeightRange(
"at",
"fetchBodyFat",
{ start: new Date("2026-01-01T00:00:00.000Z") },
new Date("2026-05-01T00:00:00Z"),
new Date("2026-05-30T00:00:00Z"),
);
expect(points).toHaveLength(2);
const [url1] = fetchMock.mock.calls[0] as unknown as [string];
// Path uses kebab-case; filter uses snake_case.
expect(url1).toContain(
`${FITBIT_API_BASE}/users/me/dataTypes/body-fat/dataPoints`,
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string> },
];
expect(url).toBe(
`${FITBIT_API_BASE}/1/user/-/body/log/weight/date/2026-05-01/2026-05-30.json`,
);
const parsed = new URL(url1);
expect(parsed.searchParams.get("filter")).toBe(
'body_fat.sample_time.physical_time >= "2026-01-01T00:00:00.000Z"',
);
// Second page carries the pageToken from the first response.
const [url2] = fetchMock.mock.calls[1] as unknown as [string];
expect(new URL(url2).searchParams.get("pageToken")).toBe("tok2");
expect(init.headers["Accept-Language"]).toBe("en_GB");
expect(init.headers.Authorization).toBe("Bearer at");
});
it("filters daily summaries on the civil date, not the sample time", async () => {
const fetchMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(
FITBIT_DATA_TYPES.restingHeartRate,
it("encodes the sleep endpoint on the 1.2 version path", async () => {
const fetchMock = installFetchMock([{ status: 200, body: { sleep: [] } }]);
await fetchSleepRange(
"at",
"fetchRhr",
{ start: new Date("2026-03-04T10:00:00.000Z") },
new Date("2026-05-01T00:00:00Z"),
new Date("2026-05-30T00:00:00Z"),
);
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(new URL(url).searchParams.get("filter")).toBe(
'daily_resting_heart_rate.date >= "2026-03-04"',
expect(url).toBe(
`${FITBIT_API_BASE}/1.2/user/-/sleep/date/2026-05-01/2026-05-30.json`,
);
});
it("filters INTERVAL types on interval.start_time, not a sample_time or a bare date", async () => {
// steps / distance / calories / floors, sleep and exercise are INTERVAL data
// types — Google anchors them on `interval.start_time`. A sample_time filter
// 400s/empties for these and stalls incremental sync.
const stepsMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(FITBIT_DATA_TYPES.steps, "at", "fetchSteps", {
start: new Date("2026-03-04T10:00:00.000Z"),
});
expect(
new URL(
(stepsMock.mock.calls[0] as unknown as [string])[0],
).searchParams.get("filter"),
).toBe('steps.interval.start_time >= "2026-03-04T10:00:00.000Z"');
const sleepMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(FITBIT_DATA_TYPES.sleep, "at", "fetchSleep", {
start: new Date("2026-03-04T10:00:00.000Z"),
});
expect(
new URL(
(sleepMock.mock.calls[0] as unknown as [string])[0],
).searchParams.get("filter"),
).toBe('sleep.interval.start_time >= "2026-03-04T10:00:00.000Z"');
const exMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(FITBIT_DATA_TYPES.exercise, "at", "fetchExercise", {
start: new Date("2026-03-04T10:00:00.000Z"),
});
expect(
new URL(
(exMock.mock.calls[0] as unknown as [string])[0],
).searchParams.get("filter"),
).toBe('exercise.interval.start_time >= "2026-03-04T10:00:00.000Z"');
});
it("throws a classified FitbitApiError on a non-2xx page", async () => {
it("throws a classified FitbitApiError on a 403 page", async () => {
installFetchMock([{ status: 403, body: {} }]);
await expect(
fetchDataPoints(FITBIT_DATA_TYPES.weight, "at", "fetchWeight"),
fetchSpo2Range("at", new Date(), new Date()),
).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
@@ -344,150 +273,145 @@ describe("fetchDataPoints", () => {
});
});
describe("metric mappers", () => {
it("maps weight in kg with a stable sample-time anchor + field-tag externalId", () => {
const point = {
weight: {
kilograms: 81.456,
sample_time: { physical_time: "2026-05-10T07:30:00.000Z" },
},
};
const out = mapWeight(point);
describe("metric mappers (classic shapes)", () => {
it("maps weight in kg, anchoring the externalId on the logId", () => {
const out = mapWeight({
weight: [
{
date: "2026-05-10",
time: "07:30:00",
weight: 81.46,
logId: 1551080804000,
source: "Aria",
},
],
});
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
type: "WEIGHT",
value: 81.46,
unit: "kg",
});
expect(out[0]).toMatchObject({ type: "WEIGHT", value: 81.46, unit: "kg" });
expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T07:30:00.000Z");
// externalId field-tag: <anchor>:<tag>; anchor is the sample time ISO string.
expect(out[0]!.fieldTag).toBe("2026-05-10T07:30:00.000Z:weight");
expect(out[0]!.fieldTag).toBe("1551080804000:weight");
});
it("maps body fat percentage", () => {
const out = mapBodyFat({ body_fat: { percentage: 18.2 } });
expect(out[0]).toMatchObject({ type: "BODY_FAT", value: 18.2, unit: "%" });
});
it("maps daily SpO2 with a civil-date anchor", () => {
const out = mapOxygenSaturation({
daily_oxygen_saturation: {
average_percentage: 97.4,
date: { year: 2026, month: 5, day: 10 },
},
it("maps body fat percentage from the fat array", () => {
const out = mapBodyFat({
fat: [{ date: "2026-05-10", time: "07:30:00", fat: 18.2, logId: 42 }],
});
expect(out[0]).toMatchObject({ type: "BODY_FAT", value: 18.2, unit: "%" });
expect(out[0]!.fieldTag).toBe("42:body_fat");
});
it("maps daily SpO2 from value.avg with a day-keyed externalId (bare array)", () => {
const out = mapOxygenSaturation([
{ dateTime: "2026-05-10", value: { avg: 97.4, min: 94, max: 100 } },
]);
expect(out[0]).toMatchObject({
type: "OXYGEN_SATURATION",
value: 97.4,
unit: "%",
});
// Daily anchor is the civil date (YYYY-MM-DD), so a re-fetch of the same day
// overwrites in place.
expect(out[0]!.fieldTag).toBe("2026-05-10:spo2");
});
it("maps Fitbit HRV into the SDNN HEART_RATE_VARIABILITY slot, NOT HRV_RMSSD", () => {
it("maps HRV dailyRmssd into the canonical HEART_RATE_VARIABILITY slot", () => {
const out = mapHeartRateVariability({
daily_heart_rate_variability: {
rmssd_milliseconds: 42.7,
date: { year: 2026, month: 5, day: 10 },
},
hrv: [
{ dateTime: "2026-05-10", value: { dailyRmssd: 42.7, deepRmssd: 50 } },
],
});
expect(out[0]!.type).toBe("HEART_RATE_VARIABILITY");
expect(out[0]!.type).not.toBe("HRV_RMSSD");
expect(out[0]).toMatchObject({ value: 42.7, unit: "ms" });
expect(out[0]!.fieldTag).toBe("2026-05-10:hrv");
});
it("maps daily resting heart rate", () => {
it("maps resting HR from the activities-heart value.restingHeartRate", () => {
const out = mapRestingHeartRate({
daily_resting_heart_rate: {
beats_per_minute: 54,
date: { year: 2026, month: 5, day: 10 },
},
"activities-heart": [
{ dateTime: "2026-05-10", value: { restingHeartRate: 54 } },
],
});
expect(out[0]).toMatchObject({ type: "RESTING_HEART_RATE", value: 54 });
});
it("drops a garbage / non-positive reading rather than minting a row", () => {
expect(mapWeight({ weight: { kilograms: 0 } })).toHaveLength(0);
expect(mapWeight({ weight: { kilograms: Number.NaN } })).toHaveLength(0);
expect(mapWeight({})).toHaveLength(0);
it("maps respiratory rate from value.breathingRate", () => {
const out = mapRespiratoryRate({
br: [{ dateTime: "2026-05-10", value: { breathingRate: 17.8 } }],
});
expect(out[0]).toMatchObject({
type: "RESPIRATORY_RATE",
value: 17.8,
unit: "breaths/min",
});
});
it("resolves height to cm from either centimetres or metres, never as a Measurement", () => {
expect(mapHeightCm({ height: { centimeters: 178 } })).toBe(178);
expect(mapHeightCm({ height: { meters: 1.78 } })).toBe(178);
expect(mapHeightCm({ height: {} })).toBeNull();
it("drops a garbage / non-positive / absent reading rather than minting a row", () => {
expect(
mapWeight({ weight: [{ date: "2026-05-10", weight: 0 }] }),
).toHaveLength(0);
expect(mapWeight({ weight: [] })).toHaveLength(0);
expect(mapWeight({})).toHaveLength(0);
expect(
mapOxygenSaturation([{ dateTime: "2026-05-10", value: {} }]),
).toHaveLength(0);
});
});
describe("FITBIT_FIELD_MAP ↔ FITBIT_DATA_TYPES casing parity", () => {
it("keeps the kebab-path / snake-filter pair consistent across both tables", () => {
for (const [, entry] of Object.entries(FITBIT_FIELD_MAP)) {
// Path is kebab-case, filter is snake_case — they must never carry the
// wrong separator (the design §A.1 casing gotcha).
expect(entry.path).not.toContain("_");
expect(entry.filter).not.toContain("-");
}
// Every FITBIT_DATA_TYPES entry has matching casing too.
for (const dt of Object.values(FITBIT_DATA_TYPES)) {
expect(dt.path).not.toContain("_");
expect(dt.filter).not.toContain("-");
}
describe("VO2 max", () => {
it("parses a single numeric string and a range midpoint", () => {
expect(parseVo2Max("45")).toBe(45);
expect(parseVo2Max("44-48")).toBe(46);
expect(parseVo2Max(50)).toBe(50);
expect(parseVo2Max("not-a-number")).toBeNull();
});
it("maps cardioScore value.vo2Max (range → midpoint) with a day-keyed stats externalId", () => {
const out = mapVo2Max({
cardioScore: [
{ dateTime: "2026-05-10", value: { vo2Max: "44-48" } },
{ dateTime: "2026-05-11", value: { vo2Max: "45" } },
],
});
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 46 });
expect(out[0]!.cumulativeDaily).toBe(true);
expect(out[0]!.fieldTag).toBe("vo2_max:2026-05-10");
expect(out[1]!.value).toBe(45);
});
});
describe("activity mappers (cumulative daily)", () => {
// INTERVAL data types: Google buckets the daily total into an `interval` with
// a `start_time` (physical instant) — NOT a bare civil `date`. The mapper
// anchors measuredAt on `interval.start_time` and day-keys the externalId.
const interval = { start_time: "2026-05-10T00:00:00.000Z" };
// A civil-only fallback shape (no physical start_time) still day-keys cleanly.
const civilInterval = {
civil_start_time: { year: 2026, month: 5, day: 10 },
};
it("maps steps with a stats:-shaped daily externalId and preserves a zero", () => {
const out = mapSteps({ steps: { count: 8421, interval } });
expect(out).toHaveLength(1);
it("maps steps from the string value, preserving a zero, with a day-keyed externalId", () => {
const out = mapSteps({
"activities-steps": [
{ dateTime: "2026-05-10", value: "8421" },
{ dateTime: "2026-05-11", value: "0" },
],
});
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
type: "ACTIVITY_STEPS",
value: 8421,
unit: "steps",
cumulativeDaily: true,
});
// fieldTag carries <tag>:<YYYY-MM-DD>; the sync layer prefixes `stats:`.
expect(out[0]!.fieldTag).toBe("steps:2026-05-10");
// measuredAt anchored on the interval start instant.
expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T00:00:00.000Z");
// The civil-only fallback day-keys the externalId at UTC midday.
const civil = mapSteps({ steps: { count: 100, interval: civilInterval } });
expect(civil[0]!.fieldTag).toBe("steps:2026-05-10");
// A rest day records 0 steps — that is real data, not a gap.
const rest = mapSteps({ steps: { count: 0, interval } });
expect(rest).toHaveLength(1);
expect(rest[0]!.value).toBe(0);
// A rest day of 0 steps is real data.
expect(out[1]!.value).toBe(0);
});
it("maps distance in metres, converting km → m when reported in km", () => {
expect(
mapDistance({ distance: { meters: 6200, interval } })[0],
).toMatchObject({
it("maps distance km → metres", () => {
const out = mapDistance({
"activities-distance": [{ dateTime: "2026-05-10", value: "6.2" }],
});
expect(out[0]).toMatchObject({
type: "WALKING_RUNNING_DISTANCE",
value: 6200,
unit: "m",
});
// km path multiplies by 1000.
expect(
mapDistance({ distance: { kilometers: 6.2, interval } })[0]!.value,
).toBe(6200);
});
it("maps the ACTIVE-calories portion into ACTIVE_ENERGY_BURNED", () => {
const out = mapActiveCalories({
active_calories: { active_kilocalories: 540, interval },
"activities-activityCalories": [{ dateTime: "2026-05-10", value: "540" }],
});
expect(out[0]).toMatchObject({
type: "ACTIVE_ENERGY_BURNED",
@@ -497,201 +421,141 @@ describe("activity mappers (cumulative daily)", () => {
});
it("maps floors and preserves a zero", () => {
expect(mapFloors({ floors: { count: 12, interval } })[0]).toMatchObject({
type: "FLIGHTS_CLIMBED",
value: 12,
unit: "flights",
const out = mapFloors({
"activities-floors": [
{ dateTime: "2026-05-10", value: "12" },
{ dateTime: "2026-05-11", value: "0" },
],
});
expect(mapFloors({ floors: { count: 0, interval } })[0]!.value).toBe(0);
});
it("maps VO2 max (strictly positive, daily latest-wins) and drops a zero", () => {
// VO2 max is a daily-summary metric (civil `date` anchor), not an interval.
const day = { year: 2026, month: 5, day: 10 };
const out = mapVo2Max({
vo2_max: { milliliters_per_kilogram_per_minute: 47.3, date: day },
});
expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 47.3 });
// VO2 max of 0 is garbage — dropped (unlike the running totals).
expect(
mapVo2Max({
vo2_max: { milliliters_per_kilogram_per_minute: 0, date: day },
}),
).toHaveLength(0);
expect(out[0]).toMatchObject({ type: "FLIGHTS_CLIMBED", value: 12 });
expect(out[1]!.value).toBe(0);
});
});
describe("sleep-stage mapping", () => {
it("harmonises Google stage labels onto the shared SleepStage enum", () => {
describe("sleep mapping (1.2 levels.data)", () => {
it("harmonises classic Fitbit level labels onto the shared SleepStage enum", () => {
expect(mapFitbitSleepStage("light")).toBe("CORE"); // Fitbit light ↔ Apple core
expect(mapFitbitSleepStage("deep")).toBe("DEEP");
expect(mapFitbitSleepStage("rem")).toBe("REM");
expect(mapFitbitSleepStage("awake")).toBe("AWAKE");
expect(mapFitbitSleepStage("wake")).toBe("AWAKE");
expect(mapFitbitSleepStage("awake")).toBe("AWAKE");
expect(mapFitbitSleepStage("restless")).toBe("AWAKE");
expect(mapFitbitSleepStage("in-bed")).toBe("IN_BED");
expect(mapFitbitSleepStage("asleep")).toBe("ASLEEP");
// Unknown / non-string → null (skipped, never mis-bucketed).
expect(mapFitbitSleepStage("snoring")).toBeNull();
expect(mapFitbitSleepStage(42)).toBeNull();
});
it("maps a session into per-SEGMENT SLEEP_DURATION rows with measuredAt = segment END (real timeline)", () => {
const session = {
sleep: {
startTime: "2026-05-10T22:00:00.000Z",
endTime: "2026-05-11T06:00:00.000Z",
segments: [
{
stage: "light",
startTime: "2026-05-10T22:00:00.000Z",
endTime: "2026-05-10T22:30:00.000Z",
it("maps a session into per-SEGMENT rows with measuredAt = segment END (real timeline)", () => {
const sessions = readSleepSessions({
sleep: [
{
logId: 999,
startTime: "2026-05-10T22:00:00.000",
endTime: "2026-05-11T06:00:00.000",
levels: {
data: [
{
dateTime: "2026-05-10T22:00:00.000",
level: "light",
seconds: 1800,
},
{
dateTime: "2026-05-10T22:30:00.000",
level: "deep",
seconds: 3600,
},
{
dateTime: "2026-05-10T23:30:00.000",
level: "rem",
seconds: 2700,
},
],
},
{
stage: "deep",
startTime: "2026-05-10T22:30:00.000Z",
endTime: "2026-05-10T23:30:00.000Z",
},
{
stage: "light",
startTime: "2026-05-10T23:30:00.000Z",
endTime: "2026-05-11T00:15:00.000Z",
},
{
stage: "rem",
startTime: "2026-05-11T00:15:00.000Z",
endTime: "2026-05-11T01:00:00.000Z",
},
],
},
};
const out = mapSleepSession(session);
},
],
});
expect(sessions).toHaveLength(1);
const out = mapSleepSession(sessions[0]!);
// One row PER segment (no collapse) — 4 segments → 4 rows.
expect(out).toHaveLength(4);
// One row per segment.
expect(out).toHaveLength(3);
expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true);
expect(out.every((m) => m.unit === "minutes")).toBe(true);
// Each row carries its OWN end instant + duration — a real clock-time
// timeline, not a stage collapse.
const core = out.filter((m) => m.sleepStage === "CORE");
expect(core.map((m) => m.value)).toEqual([30, 45]);
expect(core.map((m) => m.measuredAt.toISOString())).toEqual([
"2026-05-10T22:30:00.000Z",
"2026-05-11T00:15:00.000Z",
]);
const core = out.find((m) => m.sleepStage === "CORE")!;
expect(core.value).toBe(30); // 1800s → 30 min
// measuredAt = segment START + seconds = END (local instant).
expect(core.measuredAt.getTime()).toBe(
new Date("2026-05-10T22:30:00.000").getTime(),
);
const deep = out.find((m) => m.sleepStage === "DEEP")!;
expect(deep.value).toBe(60);
expect(deep.measuredAt.toISOString()).toBe("2026-05-10T23:30:00.000Z");
const rem = out.find((m) => m.sleepStage === "REM")!;
expect(rem.value).toBe(45);
// Every fieldTag is distinct (session anchor + stage + segment index) so
// the several segments of one stage never collide under the dedup key.
// Every fieldTag distinct (logId anchor + stage + segment index).
const tags = out.map((m) => m.fieldTag);
expect(new Set(tags).size).toBe(tags.length);
expect(tags).toContain("2026-05-11T06:00:00.000Z:sleep_deep:1");
});
it("anchors the session externalId on sleep.interval.end_time for an INTERVAL-shaped point", () => {
const out = mapSleepSession({
sleep: {
interval: {
start_time: "2026-05-10T22:00:00.000Z",
end_time: "2026-05-11T06:00:00.000Z",
},
segments: [
{
stage: "deep",
startTime: "2026-05-10T22:30:00.000Z",
endTime: "2026-05-10T23:30:00.000Z",
},
],
},
});
const deep = out.find((m) => m.sleepStage === "DEEP");
expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep:0");
expect(deep.fieldTag).toBe("999:sleep_deep:1");
});
it("yields nothing for a session with no parseable segments", () => {
expect(mapSleepSession({ sleep: {} })).toHaveLength(0);
expect(mapSleepSession({ levels: { data: [] } })).toHaveLength(0);
expect(mapSleepSession({})).toHaveLength(0);
});
});
describe("workout mapping", () => {
it("maps an exercise session into a Workout shape with a stable externalId", () => {
const w = mapWorkout({
exercise: {
session_id: "ex-123",
activity_type: "run",
startTime: "2026-05-10T07:00:00.000Z",
endTime: "2026-05-10T07:45:00.000Z",
active_kilocalories: 410,
distance: { meters: 7800 },
average_heart_rate: { beats_per_minute: 148 },
maximum_heart_rate: { beats_per_minute: 172 },
},
describe("workout mapping (activities list)", () => {
it("maps an activity-list entry into a Workout keyed on the logId", () => {
const entries = readActivityList({
activities: [
{
logId: 123456,
activityName: "Run",
startTime: "2026-05-10T07:00:00.000",
duration: 45 * 60 * 1000,
calories: 410,
distance: 7.8, // km
averageHeartRate: 148,
},
],
});
expect(entries).toHaveLength(1);
const w = mapWorkout(entries[0]!);
expect(w).not.toBeNull();
expect(w).toMatchObject({
externalId: "ex-123",
externalId: "123456",
sportType: "running",
durationSec: 45 * 60,
totalEnergyKcal: 410,
totalDistanceM: 7800,
totalDistanceM: 7800, // km → m
avgHeartRate: 148,
maxHeartRate: 172,
maxHeartRate: null,
minHeartRate: null,
});
expect(w!.startedAt.toISOString()).toBe("2026-05-10T07:00:00.000Z");
expect(w!.startedAt.getTime()).toBe(
new Date("2026-05-10T07:00:00.000").getTime(),
);
});
it("falls back to a start-anchored externalId + 'other' sport when fields are absent", () => {
const w = mapWorkout({
exercise: {
startTime: "2026-05-10T07:00:00.000Z",
endTime: "2026-05-10T07:30:00.000Z",
},
startTime: "2026-05-10T07:00:00.000",
duration: 30 * 60 * 1000,
});
expect(w!.externalId).toBe("exercise:2026-05-10T07:00:00.000Z");
expect(w!.externalId).toMatch(/^exercise:/);
expect(w!.sportType).toBe("other");
expect(w!.totalEnergyKcal).toBeNull();
expect(w!.avgHeartRate).toBeNull();
});
it("reads the start/end from exercise.interval for an INTERVAL-shaped point", () => {
const w = mapWorkout({
exercise: {
session_id: "ex-iv",
activity_type: "run",
interval: {
start_time: "2026-05-10T07:00:00.000Z",
end_time: "2026-05-10T07:45:00.000Z",
},
},
});
expect(w).not.toBeNull();
expect(w!.startedAt.toISOString()).toBe("2026-05-10T07:00:00.000Z");
expect(w!.endedAt.toISOString()).toBe("2026-05-10T07:45:00.000Z");
expect(w!.durationSec).toBe(45 * 60);
});
it("returns null for a session with no usable time span", () => {
expect(
mapWorkout({ exercise: { startTime: "2026-05-10T07:00:00.000Z" } }),
).toBeNull();
expect(
mapWorkout({
exercise: {
startTime: "2026-05-10T07:30:00.000Z",
endTime: "2026-05-10T07:00:00.000Z", // end before start
},
}),
).toBeNull();
it("returns null for an entry with no usable time span", () => {
expect(mapWorkout({ startTime: "2026-05-10T07:00:00.000" })).toBeNull();
expect(mapWorkout({ duration: 1000 })).toBeNull();
expect(mapWorkout({})).toBeNull();
});
it("resolves Google activity types to canonical sport labels", () => {
it("resolves activity names to canonical sport labels", () => {
expect(mapFitbitSportType("Walk")).toBe("walking");
expect(mapFitbitSportType("biking")).toBe("cycling");
expect(mapFitbitSportType("weights")).toBe("strength");
@@ -699,3 +563,15 @@ describe("workout mapping", () => {
expect(mapFitbitSportType("")).toBe("other");
});
});
describe("FITBIT_FIELD_MAP", () => {
it("documents a type + unit for every launch metric", () => {
for (const [, entry] of Object.entries(FITBIT_FIELD_MAP)) {
expect(typeof entry.type).toBe("string");
expect(entry.type.length).toBeGreaterThan(0);
expect(typeof entry.unit).toBe("string");
}
expect(FITBIT_FIELD_MAP.weight!.type).toBe("WEIGHT");
expect(FITBIT_FIELD_MAP.vo2Max!.type).toBe("VO2_MAX");
});
});
@@ -241,19 +241,26 @@ describe("syncUserFitbit — single cycle-wide watermark", () => {
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("passes fullSync + deferRollup through with an undefined start on a backfill cycle", async () => {
it("passes fullSync + deferRollup through with a bounded backfill start", async () => {
syncUserMetrics.mockResolvedValue(2);
await syncUserFitbit("user1", { fullSync: true });
const opts = syncUserMetrics.mock.calls[0]![1] as {
start?: Date;
end?: Date;
fullSync?: boolean;
deferRollup?: boolean;
};
expect(opts.fullSync).toBe(true);
// Full sync walks deep history with no lower bound.
expect(opts.start).toBeUndefined();
// The classic Web API range caps mean the backfill walks a bounded horizon
// (~1 year back), not an unbounded deep history.
expect(opts.start).toBeInstanceOf(Date);
expect(opts.end).toBeInstanceOf(Date);
const spanDays =
(opts.end!.getTime() - opts.start!.getTime()) / (24 * 60 * 60 * 1000);
expect(spanDays).toBeGreaterThan(360);
expect(spanDays).toBeLessThan(370);
// The backfill defers the per-write rollup hook to one end-of-cycle pass.
expect(opts.deferRollup).toBe(true);
});
+10 -8
View File
@@ -1,7 +1,8 @@
/**
* v1.12.0 — Fitbit token-management tests (mocked). Covers `getValidToken`:
* - the NO-ROTATION refresh branch (refresh token preserved when the response
* omits it; overwritten only when a fresh one is returned);
* Fitbit token-management tests (mocked). Covers `getValidToken`:
* - the ROTATING refresh branch (classic Fitbit returns a fresh refresh token
* on every refresh; persist it, replacing the stored one);
* - the defensive keep-existing guard when a malformed response omits it;
* - the stored-token fast path when not near expiry;
* - a reauth failure recorded when credentials are missing on refresh.
*/
@@ -86,8 +87,8 @@ afterEach(() => {
vi.clearAllMocks();
});
describe("getValidToken — non-rotating refresh", () => {
it("persists the new access token and KEEPS the stored refresh token when the response omits one", async () => {
describe("getValidToken — rotating refresh", () => {
it("defensively KEEPS the stored refresh token when a malformed response omits one", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue({
id: "conn1",
fitbitUserId: "abc123",
@@ -96,7 +97,8 @@ describe("getValidToken — non-rotating refresh", () => {
// Expired so the refresh path fires.
tokenExpiresAt: new Date(Date.now() - 1000),
});
// Google does NOT return a refresh_token on a routine refresh.
// Classic Fitbit always rotates; a missing refresh_token is a malformed
// reply. Guard it by keeping the existing token rather than writing a blank.
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
expires_in: 3600,
@@ -112,11 +114,11 @@ describe("getValidToken — non-rotating refresh", () => {
});
const updateArg = prismaMock.fitbitConnection.update.mock.calls[0]![0];
expect(updateArg.data.accessToken).toBe("enc(new-access)");
// The stored refresh token must NOT be touched when the response omits one.
// The stored refresh token must NOT be wiped when the response omits one.
expect(updateArg.data).not.toHaveProperty("refreshToken");
});
it("overwrites the stored refresh token only when the response carries a fresh one", async () => {
it("persists the ROTATED refresh token when the response carries a fresh one", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue({
id: "conn1",
fitbitUserId: "abc123",
+785 -1015
View File
File diff suppressed because it is too large. Load diff
+93 -87
View File
@@ -1,111 +1,117 @@
# Fitbit / Google Health field → Measurement mapping
# Fitbit Web API field → Measurement mapping
Single source of truth for the Fitbit (Google Health API v4) data-type →
Single source of truth for the classic Fitbit Web API (`api.fitbit.com`) →
HealthLog `Measurement` mapping. Keep this in sync with `FITBIT_FIELD_MAP` and
the per-type mappers in `client.ts`. Every Fitbit row ingests server-side with
`source = FITBIT` and `externalId = <anchor>:<fieldTag>` (the anchor is the data
point's sample time for spot readings, its civil date for daily summaries, or its
`interval.start_time` for INTERVAL types — steps/distance/calories/floors, sleep,
exercise; the field-tag disambiguates the metric).
`source = FITBIT`; the `externalId` is a stable per-reading key so a re-fetch of
the same window overwrites in place rather than minting a duplicate.
## Time anchor by grain (design §A.1)
Transport: v1.20.0 retargeted Fitbit from the Google Health API (Restricted
scopes behind brand-verification + CASA — a self-hoster adoption wall) onto the
classic Web API. A self-hoster registers an app at **dev.fitbit.com** in minutes
(no CASA). Auth: Authorization Code + **PKCE (S256)**;
`www.fitbit.com/oauth2/authorize` + `api.fitbit.com/oauth2/token`. Refresh tokens
**rotate** (one-time use) — the new token must be persisted every refresh.
Google Health exposes three time-anchor shapes. The incremental `filter` and the
read-time `measuredAt`/externalId resolution must target the right one or the
incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type):
Deprecation note: the classic Web API is announced for deprecation in September
2026, with migration directed back at the Google Health API. Until a self-serve
Google path exists, the classic API is the only viable transport for
self-hosters; the settings card keeps its experimental badge.
- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`.
- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, wrist temp,
VO2 max); anchor `{filter}.date` (a civil date / `{year,month,day}`).
- **interval** — INTERVAL type (steps/distance/calories/floors daily totals, plus
sleep + exercise sessions); anchor `{filter}.interval.start_time` (a physical
instant) with `{filter}.interval.civil_start_time` as the civil fallback.
All data reads send `Accept-Language: en_GB` so weight + distance arrive in
metric (kg / km); the US default is imperial.
Transport docs: `developers.google.com/health` (v4, post-Fitbit-Web-API). The
per-type value-field JSON is **NOT fully published** — the mappers read a small
set of candidate value paths defensively (`firstNumber` walks each shape and
takes the first finite-positive hit). **Re-verify against a live test account at
build** and tighten the extractors. Design: `.planning/v1.12.0-google-health-design.md`.
## Health-metrics endpoints
## Casing gotcha (design §A.1)
Scope: `weight`, `oxygen_saturation`, `heartrate`, `respiratory_rate`. Verified
against the dev.fitbit.com reference (response shapes quoted there).
The data-type id is **kebab-case in the request path** (`body-fat`) and
**snake_case in the `filter` predicate** (`body_fat`). `FITBIT_DATA_TYPES` pins
both forms per type so a fetcher can never encode the wrong one.
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
| ---------------------------------------------- | ------------------------ | ----------- | ------------------------------------------- | ----- | ----------------------------------------------------------- |
| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. |
| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. |
| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. |
| `/1/user/-/hrv/date/{s}/{e}.json` | `HEART_RATE_VARIABILITY` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator → canonical HRV slot (not `HRV_RMSSD`). |
| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. |
| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | |
## Health-metrics bundle (W3)
Each daily-summary value passes a finite + strictly-positive guard (a 0/NaN is a
garbage/empty reading and is dropped). Each endpoint caps its own date range
(30–31 days for these), so the sync chunks `[start, end]` into ≤30-day windows.
Scope: `googlehealth.health_metrics_and_measurements.readonly`.
## Activity + VO2 max endpoints
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note |
| ----------------------------------------------------------------------------- | ------------------------ | ----------- | ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Fitbit. |
| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | |
| `daily-oxygen-saturation` / `daily_oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. |
| `daily-heart-rate-variability` / `daily_heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Fitbit reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider `HRV_RMSSD` if warranted. |
| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
| `daily-respiratory-rate` / `daily_respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | |
| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. |
| `daily-sleep-temperature-derivations` / `daily_sleep_temperature_derivations` | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading. |
| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. |
Scope: `activity`, `cardio_fitness`. Per-day summaries; the externalId carries
the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a
re-fetched day **overwrites** in place (Apple-Health `stats:<HK>:<YYYY-MM-DD>`
contract). The running totals **preserve a 0** (a rest day is real data); VO2 max
stays strictly positive.
Every value passes a finite + strictly-positive guard (the launch metrics are
all positive — a zero/NaN is a garbage/empty reading and is dropped).
| Endpoint | MeasurementType | Unit | Source field | Note |
| --------------------------------------------------------- | -------------------------- | ----------- | ------------------------------------- | ------------------------------------------------------------ |
| `/1/user/-/activities/steps/date/{s}/{e}.json` | `ACTIVITY_STEPS` | steps | `activities-steps[].value` (string) | daily total; 0 valid |
| `/1/user/-/activities/distance/date/{s}/{e}.json` | `WALKING_RUNNING_DISTANCE` | m | `activities-distance[].value` | km → m (×1000) under the metric locale |
| `/1/user/-/activities/activityCalories/date/{s}/{e}.json` | `ACTIVE_ENERGY_BURNED` | kcal | `activities-activityCalories[].value` | ACTIVE portion only (excludes BMR) |
| `/1/user/-/activities/floors/date/{s}/{e}.json` | `FLIGHTS_CLIMBED` | flights | `activities-floors[].value` | daily total; 0 valid |
| `/1/user/-/cardioscore/date/{s}/{e}.json` | `VO2_MAX` | mL/(kg·min) | `cardioScore[].value.vo2Max` | **string** — a single number or a `"lo-hi"` range → midpoint |
## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set)
Activity time-series values arrive as numeric **strings** (`"2504"`). VO2 max
arrives as a string that may be a single value (`"45"`) or a range (`"44-48"`,
when no GPS run data exists) — the range resolves to its midpoint.
`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`,
`basal-body-temperature` → `BODY_TEMPERATURE`. These light up for free when
Google ships the data types.
## Sleep endpoint
## Activity bundle (W5) — daily cumulative
Scope: `sleep`. `/1.2/user/-/sleep/date/{s}/{e}.json` →
`sleep[].levels.data[]` (`{ dateTime, level, seconds }`). One `SLEEP_DURATION`
row **per segment**, `measuredAt = segment START + seconds = segment END`, value =
seconds → minutes. The 1.2 log carries a real per-segment series, so the rows lay
each block at its true clock time (a MEASURED timeline — rows are NOT flagged
reconstructed). externalId = `<logId>:sleep_<stage>:<i>` (indexed so several
segments of one stage stay distinct), so a re-scored night overwrites in place.
The segment `dateTime` is local wall-clock ISO without an offset (the night
belongs to the user's local clock) — parsed as local time.
Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary
(one value per calendar day). The externalId carries the `stats:` daily-total
prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched day **overwrites** in
place rather than minting a duplicate, matching the Apple-Health
`stats:<HK>:<YYYY-MM-DD>` daily-total overwrite contract. The running totals
(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data,
not a gap); VO2 max stays strictly positive (daily latest-wins).
Stage map (stages logs + classic logs): `light → CORE` (Fitbit "light" ↔ Apple
"core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `wake`/`awake`/`restless →
AWAKE`, classic `asleep → ASLEEP`, `in_bed → IN_BED`. Unknown labels are skipped,
not mis-bucketed.
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note |
| ------------------------------------- | -------------------------- | ----------- | ----------------- | -------------------------------------------------------------------- |
| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid |
| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) |
| `active-calories` / `active_calories` | `ACTIVE_ENERGY_BURNED` | kcal | `active_calories` | **ACTIVE portion only** — NOT total caloriesOut (which folds in BMR) |
| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid |
| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive |
## Workouts (activity log list)
## Sleep bundle (W5)
Scope: `activity`. `/1/user/-/activities/list.json?afterDate=...&sort=asc&offset&limit`
→ `activities[]`. Each → one `Workout` row (NOT a Measurement), keyed
`(userId, source: "FITBIT", externalId=logId)`. Fields: `sportType`
(`activityName` → canonical `WorkoutSportType`, `other` fallback),
`startedAt`/`endedAt` (`startTime` + `duration` ms), `durationSec`,
`totalEnergyKcal` (`calories`), `totalDistanceM` (`distance` km → m),
`avgHeartRate` (`averageHeartRate`). The classic list endpoint does NOT surface
min/max HR, so those stay null. A Fitbit run and the same run via Apple Health /
WHOOP stay distinct rows; the read-time `pickCanonicalWorkoutRows` picker
collapses the cross-source twin (FITBIT ranks just below WHOOP in the default
ladder).
Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments
(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage
(summed minutes), `measuredAt = the stage's latest END instant`, harmonised onto
the shared `SleepStage` enum the night-total + hypnogram readers consume.
externalId = `<session-anchor>:sleep_<stage>` (session end ISO instant), so a
re-scored night overwrites in place. Stage map: `light → CORE` (Fitbit "light" ↔
Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `awake`/`wake`/
`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`. Unknown stage
labels are skipped, not mis-bucketed.
## Deliberately skipped
## Exercise bundle (W5) — Workouts
Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one
`Workout` row (NOT a Measurement), keyed `(userId, source: "FITBIT",
externalId)` where externalId is the session id (or `exercise:<startISO>` when
absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`,
`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active
session energy), totalDistanceM, avg/max/min HR (optional). A Fitbit run and the
same run via Apple Health / WHOOP stay distinct rows; the read-time
`pickCanonicalWorkoutRows` picker collapses the cross-source twin (FITBIT ranks
just below WHOOP in the default ladder). `active-zone-minutes` is **skipped** at
launch (no slot; not blocking).
- **Intraday heart rate → `PULSE`.** The classic API gates intraday HR + step
series behind a Personal-app type or a per-app intraday access request at
dev.fitbit.com that most self-hosters do not have. Daily resting HR is always
available and is synced; intraday is left out so a missing grant never parks
the connection. (A per-endpoint 403 also soft-skips, as a safety net.)
- **Skin temperature.** `/1/user/-/temp/skin/...` returns
`value.nightlyRelative` — a **delta from the user's personal baseline**, not an
absolute temperature. Mapping a baseline delta into the absolute
`WRIST_TEMPERATURE`/`BODY_TEMPERATURE` slot would write misleading values, so
the metric is **not mapped** (and `temperature` is not requested). Revisit only
if a relative-temperature slot is added.
- **Blood glucose / blood pressure.** No classic self-serve endpoint maps cleanly
to the HealthLog slots at launch.
## Idempotency
`(userId, type, source: "FITBIT", externalId)` unique. Spot/daily metric rows:
`externalId = <anchor>:<fieldTag>`. Daily cumulative activity rows:
`externalId = stats:<fieldTag>:<YYYY-MM-DD>` (Apple-Health overwrite shape).
Workouts: `(userId, source: "FITBIT", externalId)` on the `Workout` table. A
re-fetch of the same window (daily summaries re-roll after the fact, so the
incremental overlap is 24 h) overwrites in place.
`(userId, type, source: "FITBIT", externalId)` unique. Log-anchored metric rows:
`externalId = <logId>:<fieldTag>`. Daily-summary rows: `externalId =
<YYYY-MM-DD>:<fieldTag>`. Daily cumulative activity rows: `externalId =
stats:<fieldTag>:<YYYY-MM-DD>` (Apple-Health overwrite shape). Sleep:
`<logId>:sleep_<stage>:<i>`. Workouts: `(userId, source: "FITBIT", externalId)`
on the `Workout` table. A re-fetch of the same window (daily summaries settle
after the fact, so the incremental overlap is 24 h) overwrites in place.
+5 -3
View File
@@ -1,6 +1,8 @@
/**
* v1.12.0 — shared constants + mint helper for the Fitbit / Google Health OAuth
* state nonce ledger. Mirrors `src/lib/whoop/oauth-state.ts`.
* Shared constants + mint helper for the Fitbit OAuth state nonce ledger.
* Mirrors `src/lib/whoop/oauth-state.ts`. (v1.20.0 — the PKCE `code_verifier`
* lives on the `FitbitOAuthState` row; the connect route mints it via the
* client's `generatePkcePair`.)
*
* The nonce is random + opaque; a short-lived `FitbitOAuthState` row carries
* the `(nonce → userId)` mapping so the user id never leaks into request logs
@@ -19,7 +21,7 @@ export const FITBIT_OAUTH_STATE_COOKIE = "fitbit_state" as const;
/**
* 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to
* cover a user approving the Google consent prompt; short enough that an
* cover a user approving the Fitbit consent prompt; short enough that an
* abandoned handshake doesn't strand a row for hours.
*/
export const FITBIT_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
+55 -48
View File
@@ -1,32 +1,29 @@
/**
* Fitbit / Google Health activity-bundle sync (v1.12.0, W5).
* Fitbit Web API activity-bundle sync.
*
* Reads the daily cumulative-activity data types from the
* `activity_and_fitness.readonly` Restricted bundle and upserts each mapped
* daily total as `source = FITBIT`:
* Reads the daily cumulative-activity time series + VO2 max from the classic
* Fitbit Web API and upserts each mapped daily total as `source = FITBIT`:
*
* - steps → ACTIVITY_STEPS (count)
* - distance → WALKING_RUNNING_DISTANCE (metres)
* - active-calories → ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only)
* - floors → FLIGHTS_CLIMBED (count)
* - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins)
* - activities/steps → ACTIVITY_STEPS (count)
* - activities/distance → WALKING_RUNNING_DISTANCE (metres, km→m)
* - activities/activityCalories → ACTIVE_ENERGY_BURNED (kcal, ACTIVE only)
* - activities/floors → FLIGHTS_CLIMBED (count)
* - cardioscore → VO2_MAX (mL/(kg·min))
*
* These are per-day summaries (one value per calendar day). The externalId is
* minted with the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>`
* — so a re-fetched day OVERWRITES the existing row rather than minting a
* duplicate, matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` overwrite
* contract. A day of rest legitimately records 0 steps / 0 floors / 0 active
* kcal, so the cumulative mappers preserve a zero (dropping it would leave a
* hole the chart misreads as missing data); VO2 max stays strictly positive.
* — so a re-fetched day OVERWRITES the existing row, matching the Apple-Health
* `stats:<HK>:<YYYY-MM-DD>` overwrite contract. A rest day records a real 0
* (dropping it would leave a chart gap); VO2 max stays strictly positive.
*
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
* connected) — the six Restricted bundles are granted independently.
* Each endpoint is a date-RANGE call chunked into ≤30-day windows. A per-endpoint
* 403 soft-skips THAT metric only.
*/
import {
FITBIT_DATA_TYPES,
type FitbitDataType,
FITBIT_RANGE_DAYS,
type FitbitMappedMeasurement,
fetchDataPoints,
fetchActivitySeries,
fetchVo2MaxRange,
mapActiveCalories,
mapDistance,
mapFloors,
@@ -34,6 +31,7 @@ import {
mapVo2Max,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
@@ -42,34 +40,42 @@ import {
} from "./sync";
import { annotate } from "@/lib/logging/context";
/** One mappable activity metric: its data-type encoding + the per-point mapper + a verb. */
/** One mappable activity metric: a range fetcher + a body mapper + a verb. */
interface ActivityResource {
dataType: FitbitDataType;
map: (point: Record<string, unknown>) => FitbitMappedMeasurement[];
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
map: (body: unknown) => FitbitMappedMeasurement[];
verb: string;
}
const ACTIVITY_RESOURCES: ActivityResource[] = [
{ dataType: FITBIT_DATA_TYPES.steps, map: mapSteps, verb: "fetchSteps" },
{
dataType: FITBIT_DATA_TYPES.distance,
fetch: (t, s, e) => fetchActivitySeries("steps", t, s, e),
map: mapSteps,
verb: "fetchSteps",
},
{
fetch: (t, s, e) => fetchActivitySeries("distance", t, s, e),
map: mapDistance,
verb: "fetchDistance",
},
{
dataType: FITBIT_DATA_TYPES.activeCalories,
fetch: (t, s, e) => fetchActivitySeries("activityCalories", t, s, e),
map: mapActiveCalories,
verb: "fetchActiveCalories",
},
{ dataType: FITBIT_DATA_TYPES.floors, map: mapFloors, verb: "fetchFloors" },
{ dataType: FITBIT_DATA_TYPES.vo2Max, map: mapVo2Max, verb: "fetchVo2Max" },
{
fetch: (t, s, e) => fetchActivitySeries("floors", t, s, e),
map: mapFloors,
verb: "fetchFloors",
},
{ fetch: fetchVo2MaxRange, map: mapVo2Max, verb: "fetchVo2Max" },
];
/**
* Assemble the stored externalId for a mapped activity reading. A cumulative
* daily metric carries the `cumulativeDaily` flag from its mapper — its
* externalId gets the `stats:` daily-total prefix so a re-fetched day overwrites
* in place (the mapper's `fieldTag` is already the `<tag>:<YYYY-MM-DD>` form).
* daily metric carries the `cumulativeDaily` flag — its externalId gets the
* `stats:` daily-total prefix so a re-fetched day overwrites in place (the
* mapper's `fieldTag` is already the `<tag>:<YYYY-MM-DD>` form).
*/
function externalIdFor(m: FitbitMappedMeasurement): string {
return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag;
@@ -82,28 +88,28 @@ export async function syncUserActivity(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS);
let imported = 0;
for (const resource of ACTIVITY_RESOURCES) {
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
resource.dataType,
tokenInfo.accessToken,
resource.verb,
{ start },
);
} catch (err) {
imported += await handleCollectionFetchError(resource.verb, userId, err);
continue;
}
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of resource.map(point)) {
let forbidden = false;
for (const w of windows) {
let body: unknown;
try {
body = await resource.fetch(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
@@ -113,6 +119,7 @@ export async function syncUserActivity(
});
}
}
if (forbidden) continue;
imported += (
await upsertFitbitMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
+58 -124
View File
@@ -1,100 +1,74 @@
/**
* Fitbit / Google Health "health metrics & measurements" bundle sync (v1.12.0).
* Fitbit Web API health-metrics sync.
*
* Reads the launch metric data types from the
* `health_metrics_and_measurements.readonly` Restricted bundle and upserts each
* mapped reading as `source = FITBIT`:
* Reads the launch metric endpoints from the classic Fitbit Web API and upserts
* each mapped reading as `source = FITBIT`:
*
* - weight → WEIGHT
* - body-fat → BODY_FAT
* - daily-oxygen-saturation→ OXYGEN_SATURATION
* - daily-HRV → HEART_RATE_VARIABILITY (SDNN slot; see mapper)
* - daily-resting-HR → RESTING_HEART_RATE
* - daily-respiratory-rate → RESPIRATORY_RATE
* - heart-rate (intraday) → PULSE
* - daily sleep-temp deriv.→ WRIST_TEMPERATURE
* - height → User.heightCm (profile seed, NOT a Measurement)
* - body/log/weight → WEIGHT (kg)
* - body/log/fat → BODY_FAT (%)
* - spo2 summary → OXYGEN_SATURATION (%)
* - hrv summary → HEART_RATE_VARIABILITY (ms, dailyRmssd)
* - activities/heart → RESTING_HEART_RATE (bpm)
* - br summary → RESPIRATORY_RATE (breaths/min)
*
* Each data point yields at most one Measurement row, disambiguated by the
* per-point anchor + field-tag in the externalId (`<anchor>:<fieldTag>`) so a
* re-fetch of the same window overwrites in place rather than duplicating.
* Each endpoint is a date-RANGE call (one request per ≤30-day window), so the
* sync chunks `[start, end]` into per-endpoint windows to respect the API range
* caps and the 150 req/h budget. Each metric is fetched independently so a
* per-endpoint 403 (a scope the user did not grant) soft-skips only that metric.
*
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
* connected) rather than parking the whole integration — the six Restricted
* bundles can be granted independently in the Google consent flow.
*
* Activity / sleep / workout land in W5; this is the first (and W3-only) resource.
* NOTE: intraday heart rate (`PULSE`) is intentionally NOT synced — the classic
* API gates intraday series behind a Personal-app / explicit per-app grant most
* self-hosters do not have, and the daily resting HR is always available. Skin
* temperature is also skipped: the classic reading is a baseline DELTA, not an
* absolute value, so it has no honest canonical slot (see mapping.md).
*/
import {
FITBIT_DATA_TYPES,
type FitbitDataType,
FITBIT_RANGE_DAYS,
type FitbitMappedMeasurement,
fetchDataPoints,
fetchBodyFatRange,
fetchHrvRange,
fetchRespiratoryRateRange,
fetchRestingHeartRateRange,
fetchSpo2Range,
fetchWeightRange,
mapBodyFat,
mapHeartRate,
mapHeartRateVariability,
mapHeightCm,
mapOxygenSaturation,
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepTemperature,
mapWeight,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
type FitbitMeasurementUpsert,
type FitbitResourceSyncOptions,
} from "./sync";
import { getEvent } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
/** One mappable metric: its data-type encoding + the per-point mapper + a verb. */
/** One mappable metric: a range fetcher + a body mapper + a verb. */
interface MetricResource {
dataType: FitbitDataType;
map: (point: Record<string, unknown>) => FitbitMappedMeasurement[];
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
map: (body: unknown) => FitbitMappedMeasurement[];
verb: string;
}
/** The launch metric resources (Measurement-producing). Height is handled separately. */
const METRIC_RESOURCES: MetricResource[] = [
{ dataType: FITBIT_DATA_TYPES.weight, map: mapWeight, verb: "fetchWeight" },
{ fetch: fetchWeightRange, map: mapWeight, verb: "fetchWeight" },
{ fetch: fetchBodyFatRange, map: mapBodyFat, verb: "fetchBodyFat" },
{ fetch: fetchSpo2Range, map: mapOxygenSaturation, verb: "fetchSpo2" },
{ fetch: fetchHrvRange, map: mapHeartRateVariability, verb: "fetchHrv" },
{
dataType: FITBIT_DATA_TYPES.bodyFat,
map: mapBodyFat,
verb: "fetchBodyFat",
},
{
dataType: FITBIT_DATA_TYPES.oxygenSaturation,
map: mapOxygenSaturation,
verb: "fetchSpo2",
},
{
dataType: FITBIT_DATA_TYPES.heartRateVariability,
map: mapHeartRateVariability,
verb: "fetchHrv",
},
{
dataType: FITBIT_DATA_TYPES.restingHeartRate,
fetch: fetchRestingHeartRateRange,
map: mapRestingHeartRate,
verb: "fetchRhr",
},
{
dataType: FITBIT_DATA_TYPES.respiratoryRate,
fetch: fetchRespiratoryRateRange,
map: mapRespiratoryRate,
verb: "fetchRespiratoryRate",
},
{
dataType: FITBIT_DATA_TYPES.heartRate,
map: mapHeartRate,
verb: "fetchHeartRate",
},
{
dataType: FITBIT_DATA_TYPES.sleepTemperature,
map: mapSleepTemperature,
verb: "fetchSleepTemperature",
},
];
export async function syncUserMetrics(
@@ -104,32 +78,32 @@ export async function syncUserMetrics(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// The incremental lower bound is the cycle-wide watermark snapshotted once by
// `syncUserFitbit` — never re-read here, so a sibling resource's stamp can't
// shrink this one's window. Undefined on a full/backfill run.
const start = opts.start;
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS);
let imported = 0;
// Each metric data type is fetched + mapped independently so a per-class 403
// soft-skips only that class.
// Each metric is fetched + mapped independently so a per-endpoint 403
// soft-skips only that metric. A 403 short-circuits THAT metric (skips its
// remaining windows) without aborting the others.
for (const resource of METRIC_RESOURCES) {
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
resource.dataType,
tokenInfo.accessToken,
resource.verb,
{ start },
);
} catch (err) {
imported += await handleCollectionFetchError(resource.verb, userId, err);
continue;
}
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of resource.map(point)) {
let forbidden = false;
for (const w of windows) {
let body: unknown;
try {
body = await resource.fetch(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
@@ -139,6 +113,7 @@ export async function syncUserMetrics(
});
}
}
if (forbidden) continue;
imported += (
await upsertFitbitMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
@@ -146,47 +121,6 @@ export async function syncUserMetrics(
).imported;
}
// Height → User.heightCm, only when the user has no height yet. Never mint a
// Measurement; never overwrite a user-set value. Mirrors WHOOP's mapBody seed.
try {
let heightPoints: Record<string, unknown>[];
try {
heightPoints = await fetchDataPoints(
FITBIT_DATA_TYPES.height,
tokenInfo.accessToken,
"fetchHeight",
{ start },
);
} catch (err) {
heightPoints = [];
await handleCollectionFetchError("fetchHeight", userId, err);
}
// Latest non-null parsed height wins (the points are time-ordered ascending).
let heightCm: number | null = null;
for (const point of heightPoints) {
const cm = mapHeightCm(point);
if (cm !== null) heightCm = cm;
}
if (heightCm !== null) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { heightCm: true },
});
if (user && user.heightCm === null) {
await prisma.user.update({
where: { id: userId },
data: { heightCm },
});
}
}
} catch (err) {
getEvent()?.addWarning(
`fitbit: failed to seed heightCm for ${userId}: ${err}`,
);
}
// `markSynced` is owned by the orchestrator (`syncUserFitbit`), stamped once
// at the end of the cycle — never here, so the watermark can't move mid-cycle.
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
return imported;
}
+38 -43
View File
@@ -1,31 +1,30 @@
/**
* Fitbit / Google Health sleep-bundle sync (v1.12.0, W5).
* Fitbit Web API sleep sync.
*
* Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps
* each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row
* per stage segment; `measuredAt = that segment's END instant; harmonised onto
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the
* night-total + hypnogram readers already consume for WHOOP / Apple). Google
* carries a real per-segment series, so the rows lay each block at its true
* clock time (a MEASURED timeline, not reconstructed). Upserts as
* `source = FITBIT`.
* Reads sleep sessions from the classic `1.2/sleep/date` log and maps each into
* per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row per stage
* segment; `measuredAt = segment START + seconds = segment END`; harmonised onto
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP the night-total
* + hypnogram readers already consume for WHOOP / Apple). The 1.2 log carries a
* real per-segment series, so the rows lay each block at its true clock time (a
* MEASURED timeline, not reconstructed). Upserts as `source = FITBIT`.
*
* Each segment carries the `sleepStage` axis and an indexed fieldTag so the
* several segments of one stage stay distinct under the
* `(userId, type, source, externalId)` dedup key. externalId =
* `<session-anchor>:sleep_<stage>:<i>` — a re-scored night overwrites in place.
* A 24 h overlap covers Google's after-the-fact re-score.
* several segments of one stage stay distinct under the dedup key. externalId =
* `<logId>:sleep_<stage>:<i>` — a re-scored night overwrites in place. A 24 h
* overlap covers Fitbit's after-the-fact re-score.
*
* A per-data-class 403 soft-skips the resource — the sleep bundle is granted
* independently of activity / metrics in the Google consent flow.
* A per-endpoint 403 soft-skips the resource — the `sleep` scope is granted
* independently in the consent flow.
*/
import {
FITBIT_ACTIVITY_PAGE_SIZE,
FITBIT_DATA_TYPES,
fetchDataPoints,
FITBIT_SLEEP_RANGE_DAYS,
fetchSleepRange,
mapSleepSession,
readSleepSessions,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
@@ -41,33 +40,29 @@ export async function syncUserSleep(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
FITBIT_DATA_TYPES.sleep,
tokenInfo.accessToken,
"fetchSleep",
{ start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE },
);
} catch (err) {
return handleCollectionFetchError("fetchSleep", userId, err);
}
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_SLEEP_RANGE_DAYS);
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of mapSleepSession(point)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
sleepStage: m.sleepStage ?? null,
});
for (const w of windows) {
let body: unknown;
try {
body = await fetchSleepRange(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
return handleCollectionFetchError("fetchSleep", userId, err);
}
for (const session of readSleepSessions(body)) {
for (const m of mapSleepSession(session)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
sleepStage: m.sleepStage ?? null,
});
}
}
}
+40 -27
View File
@@ -1,24 +1,25 @@
/**
* Fitbit / Google Health exercise-session sync (v1.12.0, W5).
* Fitbit Web API exercise-session sync.
*
* Reads exercise sessions from the `activity_and_fitness.readonly` Restricted
* bundle and upserts each into the `Workout` table as `source = FITBIT`, keyed
* on `(userId, source, externalId)` so a re-fetch overwrites in place.
* Reads exercise sessions from the classic `activities/list` endpoint
* (`afterDate` + ascending offset/limit pagination) and upserts each into the
* `Workout` table as `source = FITBIT`, keyed on `(userId, source, externalId)`
* (externalId = the Fitbit `logId`) so a re-fetch overwrites in place.
*
* A Fitbit run and the same run via Apple Health (or WHOOP) stay distinct
* `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows`
* picker collapses the cross-source twin at read time per the user's source
* ladder (FITBIT is already in the default ladder, ranked just below WHOOP).
* There is no ingest-time collapse for a server-owned source pair.
* picker collapses the cross-source twin per the user's source ladder (FITBIT is
* already in the default ladder, ranked just below WHOOP). No ingest-time
* collapse for a server-owned source pair.
*
* A per-data-class 403 soft-skips the resource — the activity/fitness bundle is
* granted independently in the Google consent flow.
* The page-walk is bounded (the 150 req/h budget is tight): a small page cap
* covers the incremental window comfortably; a deep backfill is bounded by the
* backfill horizon `afterDate`. A per-endpoint 403 soft-skips the resource.
*/
import {
FITBIT_ACTIVITY_PAGE_SIZE,
FITBIT_DATA_TYPES,
fetchDataPoints,
fetchActivityList,
mapWorkout,
readActivityList,
type FitbitMappedWorkout,
} from "./client";
import {
@@ -29,6 +30,11 @@ import {
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
/** Activities per page on the `activities/list` walk. */
const WORKOUT_PAGE_SIZE = 100;
/** Hard ceiling on pages walked per cycle (rate-budget guard). */
const WORKOUT_MAX_PAGES = 12;
export async function syncUserWorkout(
userId: string,
opts: FitbitResourceSyncOptions = {},
@@ -36,25 +42,32 @@ export async function syncUserWorkout(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
const afterDate =
opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
FITBIT_DATA_TYPES.exercise,
tokenInfo.accessToken,
"fetchExercise",
{ start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE },
);
} catch (err) {
return handleCollectionFetchError("fetchExercise", userId, err);
const entries: ReturnType<typeof readActivityList> = [];
let offset = 0;
let pages = 0;
while (pages < WORKOUT_MAX_PAGES) {
let body: unknown;
try {
body = await fetchActivityList(tokenInfo.accessToken, afterDate, {
limit: WORKOUT_PAGE_SIZE,
offset,
});
} catch (err) {
return handleCollectionFetchError("fetchExercise", userId, err);
}
const page = readActivityList(body);
entries.push(...page);
pages += 1;
if (page.length < WORKOUT_PAGE_SIZE) break;
offset += WORKOUT_PAGE_SIZE;
}
let imported = 0;
for (const point of points) {
const w: FitbitMappedWorkout | null = mapWorkout(point);
for (const entry of entries) {
const w: FitbitMappedWorkout | null = mapWorkout(entry);
if (!w) continue; // no usable time span — not a workout
try {
+115 -59
View File
@@ -1,17 +1,19 @@
/**
* Fitbit / Google Health sync service (v1.12.0).
* Fitbit Web API sync service.
*
* This wave (OAuth + credentials) lands the token-management half:
* - `getValidToken` decrypts the stored token, refreshes at
* `tokenExpiresAt - 5 min`, persists the new access token + expiry, and —
* UNLIKE WHOOP — only overwrites the stored refresh token when the response
* carries a fresh one (Google does not rotate refresh tokens).
* `tokenExpiresAt - 5 min`, and persists the new access token + expiry AND
* the rotated refresh token. Classic Fitbit ROTATES refresh tokens
* (one-time use), so the new `refresh_token` MUST replace the stored one —
* the inverse of the prior Google transport, which did not rotate.
* - `recordFitbitSyncFailure` / `classificationToFailureKind` map a
* classified API error onto the shared integration-status ledger.
*
* The per-resource data sync (`upsertFitbitMeasurements`, `syncUserFitbit`, the
* collection walkers, and the 403 soft-skip orchestration) lands in a later
* wave and extends this file.
* - `upsertFitbitMeasurements` + `syncUserFitbit` own the write tail (dedup,
* overwrite contract, rollup fold) — reused unchanged across the transport
* fork; only the per-resource fetch/map differs.
* - `chunkDateRanges` slices a deep backfill into the per-endpoint date-range
* windows the classic Web API caps each call at (30 days), so the per-resource
* syncs issue one valid request per window.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import pLimit from "p-limit";
@@ -43,13 +45,22 @@ import {
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
/**
* Overlap window for the incremental sync, in ms. Google re-rolls daily
* summaries after the fact (a daily SpO2 / HRV / RHR can finalise hours after
* the night), so the default overlap is a full 24 h to make sure the re-rolled
* row is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap.
* Overlap window for the incremental sync, in ms. Fitbit finalises daily
* summaries after the fact (a daily SpO2 / HRV / RHR can settle hours after the
* night), so the default overlap is a full 24 h to make sure the re-rolled row
* is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap.
*/
export const FITBIT_DEFAULT_OVERLAP_MS = 24 * 60 * 60 * 1000;
/**
* How far back a `fullSync` backfill walks. The classic Web API per-endpoint
* range cap (30 days) means a deep backfill issues many requests against the
* tight 150/h budget, so the backfill horizon is bounded to keep one user's
* backfill inside a couple of hourly windows (~12 chunks/resource). One year of
* daily summaries is the launch horizon.
*/
export const FITBIT_BACKFILL_DAYS = 365;
export interface FitbitTokenInfo {
accessToken: string;
connection: { id: string; fitbitUserId: string };
@@ -61,10 +72,12 @@ export interface FitbitTokenInfo {
* credentials, or the refresh fails (the failure is recorded so scheduled syncs
* back off).
*
* KEY DELTA vs WHOOP: Google does not rotate refresh tokens. On refresh, persist
* the new access token + expiry and overwrite the stored `refreshToken` ONLY
* when the response carries a fresh one — otherwise keep the existing refresh
* token so the next refresh still authenticates.
* Classic Fitbit ROTATES refresh tokens (one-time use): every refresh response
* carries a fresh `refresh_token` that invalidates the old one. Persist the new
* access token + expiry AND the new refresh token unconditionally. If a refresh
* response ever omits the new refresh token (a malformed reply), keep the stored
* one rather than wiping it — a blank refresh token would brick every future
* refresh.
*/
export async function getValidToken(
userId: string,
@@ -100,10 +113,11 @@ export async function getValidToken(
const newTokens = await refreshAccessToken(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
// Google does NOT rotate refresh tokens: a routine refresh returns a new
// access token but usually omits `refresh_token`. Persist the new access
// token + expiry and only overwrite the stored refresh token when the
// response actually carries a fresh one — otherwise keep the existing one.
// Classic Fitbit ROTATES refresh tokens: the refresh response carries a
// fresh `refresh_token` that replaces the (now-invalid) stored one. Persist
// it unconditionally. Guard the malformed-reply case: if the response omits
// the new refresh token, keep the existing one rather than writing a blank,
// which would brick every future refresh.
await prisma.fitbitConnection.update({
where: { id: connection.id },
data: {
@@ -141,14 +155,14 @@ export async function getValidToken(
}
/**
* True when a caught error is a per-data-class 403 (forbidden). The six Google
* Health Restricted bundles are granted independently in the consent flow, so a
* 403 on ONE data class is a scope gate on THAT class — soft-skip it and keep
* the connection connected rather than parking the whole integration at
* `error_reauth`. Connection-wide reauth is reserved for a 401 (token rejected)
* and for a 403 on the token-refresh / profile path (a genuine grant revoke),
* which run outside the per-resource catch blocks. Matters more here than for
* WHOOP because partial grants are likely with six independent bundles.
* True when a caught error is a per-endpoint 403 (forbidden). The classic Fitbit
* scopes are granted independently in the consent flow (and intraday needs an
* extra per-app grant), so a 403 on ONE endpoint is a scope gate on THAT metric
* — soft-skip it and keep the connection connected rather than parking the whole
* integration at `error_reauth`. Connection-wide reauth is reserved for a 401
* (token rejected) and a 403 on the token-refresh / profile path (a genuine
* grant revoke), which run outside the per-resource catch blocks. Matters here
* because partial grants are likely across the independent scopes.
*/
export function isCollectionForbidden(err: unknown): boolean {
return err instanceof FitbitApiError && err.httpStatus === 403;
@@ -203,16 +217,18 @@ export async function handleCollectionFetchError(
/**
* Options threaded from `syncUserFitbit` to each per-resource sync. The `start`
* watermark is snapshotted ONCE by the orchestrator (a single `lastSyncedAt`
* read) so every resource fetches from the same lower bound; no resource re-reads
* `lastSyncedAt` or stamps `markSynced` itself. On a full/backfill run `start`
* is undefined (no lower bound). The orchestrator owns the single end-of-cycle
* `markSynced`.
* + `end` window is snapshotted ONCE by the orchestrator (a single `lastSyncedAt`
* read + a single `now`) so every resource fetches the same window; no resource
* re-reads `lastSyncedAt` or stamps `markSynced` itself. The per-resource sync
* chunks `[start, end]` into the per-endpoint date-range cap. The orchestrator
* owns the single end-of-cycle `markSynced`.
*/
export interface FitbitResourceSyncOptions {
fullSync?: boolean;
/** The incremental lower bound, snapshotted once by the orchestrator. */
/** The lower bound of the fetch window, snapshotted once by the orchestrator. */
start?: Date;
/** The upper bound of the fetch window (now), snapshotted once. */
end?: Date;
/**
* When true, `upsertFitbitMeasurements` writes the rows but SKIPS the inline
* per-(type,day) DAY-rollup recompute + status-insight invalidate, returning
@@ -227,19 +243,53 @@ export interface FitbitResourceSyncOptions {
}
/**
* Compute the incremental `start` for a resource sync. `fullSync` returns
* undefined (the backfill walks deep history without a lower bound). Otherwise
* start from `lastSyncedAt - overlap`, or 30 days back on the very first
* incremental tick. Mirrors WHOOP's `incrementalStart`.
* Compute the incremental `start` for a resource sync. `fullSync` walks back the
* bounded backfill horizon (the classic Web API caps each call's date range, so
* an unbounded backfill would blow the 150/h budget). Otherwise start from
* `lastSyncedAt - overlap`, or 30 days back on the very first incremental tick.
*/
export function incrementalStart(
lastSyncedAt: Date | null,
opts: { fullSync?: boolean; overlapMs?: number } = {},
): Date | undefined {
if (opts.fullSync) return undefined;
opts: { fullSync?: boolean; overlapMs?: number; now?: Date } = {},
): Date {
const now = opts.now ?? new Date();
if (opts.fullSync) {
return new Date(now.getTime() - FITBIT_BACKFILL_DAYS * 24 * 60 * 60 * 1000);
}
const overlap = opts.overlapMs ?? FITBIT_DEFAULT_OVERLAP_MS;
if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap);
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
}
/**
* Slice `[start, end]` into consecutive inclusive windows no wider than
* `maxDays` calendar days, the per-endpoint range cap the classic Web API
* enforces. Each window is `{ start, end }` with `end` no later than the overall
* `end`; the per-resource syncs issue one request per window. Returns a single
* window when the span already fits.
*/
export function chunkDateRanges(
start: Date,
end: Date,
maxDays: number,
): Array<{ start: Date; end: Date }> {
const windows: Array<{ start: Date; end: Date }> = [];
if (end.getTime() < start.getTime()) return windows;
const stepMs = maxDays * 24 * 60 * 60 * 1000;
let cursor = start.getTime();
const endMs = end.getTime();
// Guard against a pathological span minting an unbounded loop.
let guard = 0;
const MAX_WINDOWS = 1000;
while (cursor <= endMs && guard < MAX_WINDOWS) {
// Each window spans up to `maxDays` days inclusive, so subtract one day from
// the step to keep the inclusive end inside the cap.
const windowEnd = Math.min(cursor + stepMs - 24 * 60 * 60 * 1000, endMs);
windows.push({ start: new Date(cursor), end: new Date(windowEnd) });
cursor = windowEnd + 24 * 60 * 60 * 1000;
guard += 1;
}
return windows;
}
/**
@@ -486,14 +536,13 @@ export async function markSynced(userId: string): Promise<void> {
* and exercise workouts (W5). Each runs independently so a per-resource 403
* (a Restricted bundle the user did not grant) soft-skips only that resource.
*
* WATERMARK: the incremental `start` is snapshotted ONCE here (from the single
* `lastSyncedAt` read at the top of the cycle) and threaded to every resource,
* and `markSynced` is stamped ONCE at the end. A per-resource read+stamp would
* let the first resource move `lastSyncedAt` to now() so later resources only
* see the last overlap window — silently dropping the gap after an outage longer
* than the overlap. The full/backfill path passes `fullSync: true`, which makes
* `incrementalStart` return undefined regardless of the snapshot, so the deep
* history walk is unaffected.
* WATERMARK: the incremental `[start, end]` window is snapshotted ONCE here
* (from the single `lastSyncedAt` read + a single `now` at the top of the cycle)
* and threaded to every resource, and `markSynced` is stamped ONCE at the end. A
* per-resource read+stamp would let the first resource move `lastSyncedAt` to
* now() so later resources only see the last overlap window — silently dropping
* the gap after an outage longer than the overlap. The full/backfill path passes
* `fullSync: true`, which widens `start` back to the bounded backfill horizon.
*/
export async function syncUserFitbit(
userId: string,
@@ -506,21 +555,23 @@ export async function syncUserFitbit(
return 0;
}
// Snapshot the incremental watermark ONCE for the whole cycle. Every resource
// sees the same `start`; no resource re-reads `lastSyncedAt` mid-cycle. On a
// full/backfill run `incrementalStart` ignores the snapshot and returns
// undefined (no lower bound).
// Snapshot the incremental window ONCE for the whole cycle. Every resource
// sees the same `[start, end]`; no resource re-reads `lastSyncedAt` mid-cycle.
// A full/backfill run widens `start` back to the bounded backfill horizon.
const connection = await prisma.fitbitConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
});
if (!connection) return 0;
const end = new Date();
const start = incrementalStart(connection.lastSyncedAt, {
fullSync: opts.fullSync,
now: end,
});
const resourceOpts: FitbitResourceSyncOptions = {
fullSync: opts.fullSync,
start,
end,
// A backfill walks years of daily summaries — defer the per-(type,day)
// rollup hook on every write and run ONE range-recompute at the end of the
// cycle. The hourly incremental keeps the inline hook (small touched set).
@@ -656,12 +707,17 @@ export function classificationToFailureKind(
/**
* Bounded fan-out width for the hourly Fitbit cohort poll. The cron tick carries
* no `userId`, so the worker resolves every connection and hands the cohort here.
* A small pool (rather than a strict serial loop) means one slow Google response
* A small pool (rather than a strict serial loop) means one slow Fitbit response
* can't stall the whole cohort, while the cap keeps the burst of per-user
* resource fetches + rollup writes from crowding the worker's DB pool — matching
* the `p-limit` ceilings the analytics fan-out + insight warm pass use elsewhere.
* resource fetches + rollup writes from crowding the worker's DB pool.
*
* Tuned to 2 for the classic Web API: each per-user cycle issues ~a dozen
* date-range requests against a tight 150 req/h-per-user budget, so a narrower
* pool keeps the concurrent outbound burst (and the DB-write tail behind it)
* modest. The per-user budget is independent, so the cap is about worker
* pressure, not the API ceiling — but 2 keeps both comfortable.
*/
export const FITBIT_POLL_CONCURRENCY = 4;
export const FITBIT_POLL_CONCURRENCY = 2;
/**
* Run a Fitbit hourly-poll cohort with bounded concurrency + per-user error