diff --git a/messages/de.json b/messages/de.json index 19818c8d9..bcd2f4415 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3505,6 +3505,31 @@ "whoopConnect": "Mit WHOOP verbinden", "whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.", "whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…", + "fitbit": "Google Health", + "fitbitTag": "Fitbit & Pixel", + "fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.", + "fitbitOverlapNote": "Wenn Google Health und eine andere Quelle denselben Vitalwert liefern — Ruhepuls, Sauerstoffsättigung, Körpertemperatur oder Atemfrequenz —, siehst du möglicherweise beide Werte, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.", + "fitbitCredentials": "API-Zugangsdaten", + "fitbitCredentialsHelp": "Registriere deinen eigenen Google-Cloud-OAuth-Client für die Google-Health-API und füge hier dessen Client-ID und Client-Secret ein.", + "fitbitClientId": "Client-ID", + "fitbitClientSecret": "Client-Secret", + "fitbitCredentialsSaved": "Zugangsdaten gespeichert", + "fitbitCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", + "fitbitCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen", + "fitbitSaveCredentials": "Zugangsdaten speichern", + "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.", + "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…", "integrations": { "withings": { "reconnect": { diff --git a/messages/en.json b/messages/en.json index c8106b970..9a457a19d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3505,6 +3505,31 @@ "whoopConnect": "Connect with WHOOP", "whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.", "whoopBackfillInProgress": "Importing your WHOOP history in the background…", + "fitbit": "Google Health", + "fitbitTag": "Fitbit & Pixel", + "fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.", + "fitbitOverlapNote": "If Google Health and another source both supply a vital — resting heart rate, blood oxygen, body temperature or respiratory rate — you may see both values until a future update settles on a single preferred source.", + "fitbitCredentials": "API Credentials", + "fitbitCredentialsHelp": "Register your own Google Cloud OAuth client for the Google Health API and paste its Client ID and Client Secret here.", + "fitbitClientId": "Client ID", + "fitbitClientSecret": "Client Secret", + "fitbitCredentialsSaved": "Credentials saved", + "fitbitCredentialsSavedPlaceholder": "Saved — enter new to replace", + "fitbitCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "fitbitSaveCredentials": "Save credentials", + "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.", + "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…", "integrations": { "withings": { "reconnect": { diff --git a/messages/es.json b/messages/es.json index 14e6b084b..2d03776f1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3505,6 +3505,31 @@ "whoopConnect": "Conectar con WHOOP", "whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.", "whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…", + "fitbit": "Google Health", + "fitbitTag": "Fitbit y Pixel", + "fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.", + "fitbitOverlapNote": "Si Google Health y otra fuente proporcionan la misma constante vital —frecuencia cardíaca en reposo, saturación de oxígeno, temperatura corporal o frecuencia respiratoria—, es posible que veas ambos valores hasta que una actualización futura defina una única fuente preferida.", + "fitbitCredentials": "Credenciales de API", + "fitbitCredentialsHelp": "Registra tu propio cliente OAuth de Google Cloud para la API de Google Health y pega aquí su Client ID y Client Secret.", + "fitbitClientId": "Client ID", + "fitbitClientSecret": "Client Secret", + "fitbitCredentialsSaved": "Credenciales guardadas", + "fitbitCredentialsSavedPlaceholder": "Guardado: introduce nuevas para reemplazar", + "fitbitCredentialsSavedPlaceholderSecret": "Guardado: introduce nuevas para reemplazar", + "fitbitSaveCredentials": "Guardar credenciales", + "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.", + "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…", "integrations": { "withings": { "reconnect": { diff --git a/messages/fr.json b/messages/fr.json index 03116b03c..9c549e0aa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3505,6 +3505,31 @@ "whoopConnect": "Se connecter avec WHOOP", "whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.", "whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…", + "fitbit": "Google Health", + "fitbitTag": "Fitbit et Pixel", + "fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.", + "fitbitOverlapNote": "Si Google Health et une autre source fournissent la même constante vitale — fréquence cardiaque au repos, saturation en oxygène, température corporelle ou fréquence respiratoire —, vous pourriez voir les deux valeurs jusqu'à ce qu'une future mise à jour retienne une seule source préférée.", + "fitbitCredentials": "Identifiants d'API", + "fitbitCredentialsHelp": "Enregistrez votre propre client OAuth Google Cloud pour l'API Google Health et collez ici son Client ID et son Client Secret.", + "fitbitClientId": "Client ID", + "fitbitClientSecret": "Client Secret", + "fitbitCredentialsSaved": "Identifiants enregistrés", + "fitbitCredentialsSavedPlaceholder": "Enregistré — saisissez-en de nouveaux pour remplacer", + "fitbitCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en de nouveaux pour remplacer", + "fitbitSaveCredentials": "Enregistrer les identifiants", + "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.", + "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…", "integrations": { "withings": { "reconnect": { diff --git a/messages/it.json b/messages/it.json index 841aaa8bb..6c49f7f00 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3505,6 +3505,31 @@ "whoopConnect": "Connetti con WHOOP", "whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.", "whoopBackfillInProgress": "Importazione dello storico WHOOP in background…", + "fitbit": "Google Health", + "fitbitTag": "Fitbit e Pixel", + "fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.", + "fitbitOverlapNote": "Se Google Health e un'altra fonte forniscono lo stesso parametro vitale — frequenza cardiaca a riposo, saturazione di ossigeno, temperatura corporea o frequenza respiratoria —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.", + "fitbitCredentials": "Credenziali API", + "fitbitCredentialsHelp": "Registra il tuo client OAuth di Google Cloud per l'API Google Health e incolla qui il suo Client ID e Client Secret.", + "fitbitClientId": "Client ID", + "fitbitClientSecret": "Client Secret", + "fitbitCredentialsSaved": "Credenziali salvate", + "fitbitCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", + "fitbitCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire", + "fitbitSaveCredentials": "Salva credenziali", + "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.", + "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…", "integrations": { "withings": { "reconnect": { diff --git a/messages/pl.json b/messages/pl.json index 584b5c7b7..0efee5f9c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3505,6 +3505,31 @@ "whoopConnect": "Połącz z WHOOP", "whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.", "whoopBackfillInProgress": "Importowanie historii WHOOP w tle…", + "fitbit": "Google Health", + "fitbitTag": "Fitbit i Pixel", + "fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.", + "fitbitOverlapNote": "Jeśli Google Health i inne źródło dostarczają ten sam parametr życiowy — tętno spoczynkowe, saturację, temperaturę ciała lub częstość oddechów — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.", + "fitbitCredentials": "Dane uwierzytelniające API", + "fitbitCredentialsHelp": "Zarejestruj własny klient OAuth Google Cloud dla interfejsu Google Health API i wklej tutaj jego Client ID oraz Client Secret.", + "fitbitClientId": "Client ID", + "fitbitClientSecret": "Client Secret", + "fitbitCredentialsSaved": "Dane uwierzytelniające zapisane", + "fitbitCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", + "fitbitCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić", + "fitbitSaveCredentials": "Zapisz dane uwierzytelniające", + "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ć.", + "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…", "integrations": { "withings": { "reconnect": { diff --git a/src/app/api/integrations/fitbit/resume/route.ts b/src/app/api/integrations/fitbit/resume/route.ts new file mode 100644 index 000000000..10435516f --- /dev/null +++ b/src/app/api/integrations/fitbit/resume/route.ts @@ -0,0 +1,48 @@ +/** + * Manual resume endpoint for a parked Fitbit / Google Health integration. + * + * Background: the `parked` state lives in + * `src/lib/integrations/status.ts`. Once a Fitbit persistent-failure + * streak has been running for more than 24h, the status writer flips + * the row to `parked` and the sync entry-point short-circuits. The user + * (or an operator) calls this endpoint to clear the park and let the + * next cron tick attempt the sync again. + * + * Rate-limited (5/min per user) to match the existing + * `/api/integrations/fitbit/test` endpoint — there's no upstream call + * here, but a misbehaving client tab loop would still rack up + * audit-log rows if the limit was absent. + * + * Returns 200 with `{ resumed: true, wasParked: boolean }`. The + * `wasParked` flag lets the caller decide whether to surface a "you + * resumed it" toast or treat the action as a no-op (the row was + * already connected / disconnected). Mirrors the WHOOP resume route. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { resumeIntegrationFromPark } from "@/lib/integrations/status"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.fitbit.resume" } }); + + const rl = await checkRateLimit(`fitbit-resume:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many resume requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const result = await resumeIntegrationFromPark(user.id, "fitbit"); + annotate({ + meta: { + fitbit_resume_was_parked: result.wasParked, + }, + }); + + return apiSuccess({ resumed: true, wasParked: result.wasParked }); +}); diff --git a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts new file mode 100644 index 000000000..bef0ba2ba --- /dev/null +++ b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + fitbitConnection: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((x: string) => x), +})); + +vi.mock("@/lib/fitbit/sync", () => ({ + getValidToken: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getValidToken } from "@/lib/fitbit/sync"; + +interface ApiErrorEnvelope { + data: null; + error: string; +} +interface ApiSuccessEnvelope { + data: T; + error: null; +} + +const originalFetch = global.fetch; + +beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 5, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.fitbitConnection.findUnique).mockReset(); + vi.mocked(getValidToken).mockReset(); + global.fetch = vi.fn() as never; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +function emptyRequest(): Request { + return new Request("http://localhost/api/integrations/fitbit/test", { + method: "POST", + headers: { "content-length": "0" }, + }); +} + +describe("POST /api/integrations/fitbit/test", () => { + it("happy path returns ok with shape", async () => { + const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z"); + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "plain-access", + connection: { id: "fc-1", fitbitUserId: "fu-1" }, + }); + vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ name: "users/1" }), { + status: 200, + }), + ); + + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(200); + const body = (await response.json()) as ApiSuccessEnvelope<{ + ok: boolean; + lastSyncedAt: string; + latencyMs: number; + }>; + expect(body.data.ok).toBe(true); + expect(typeof body.data.latencyMs).toBe("number"); + expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString()); + }); + + it("rate-limit denial returns 429 with no upstream call", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(429); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns 422 when no Fitbit connection exists", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce(null); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(422); + const body = (await response.json()) as ApiErrorEnvelope; + expect(body.error).toMatch(/not connected/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("classifies 401 upstream as credentials_rejected", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "sk-secret", + connection: { id: "fc-1", fitbitUserId: "fu-1" }, + }); + vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(502); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("credentials_rejected"); + }); + + it("classifies 403 upstream as credentials_rejected", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "plain-access", + connection: { id: "fc-1", fitbitUserId: "fu-1" }, + }); + vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "forbidden" }), { status: 403 }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(502); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("credentials_rejected"); + }); + + it("classifies 429 upstream as rate_limited", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "plain-access", + connection: { id: "fc-1", fitbitUserId: "fu-1" }, + }); + vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(502); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("rate_limited"); + }); + + it("classifies 500 upstream as upstream_error", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "plain-access", + connection: { id: "fc-1", fitbitUserId: "fu-1" }, + }); + vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response("oops", { status: 503 }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(502); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("upstream_error"); + }); + + it("does not leak Bearer token or upstream URL on failure", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "sk-secret", + connection: { id: "fc-1", fitbitUserId: "fu-1" }, + }); + vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: "Authorization: Bearer sk-secret echoed", + url: "https://health.googleapis.com/v4/users/me/profile", + }), + { status: 401 }, + ), + ); + const response = await POST(emptyRequest() as never); + const text = await response.text(); + expect(response.status).toBe(502); + expect(text).not.toMatch(/sk-/); + expect(text).not.toMatch(/health\.googleapis\.com/); + }); +}); diff --git a/src/app/api/integrations/fitbit/test/route.ts b/src/app/api/integrations/fitbit/test/route.ts new file mode 100644 index 000000000..899576742 --- /dev/null +++ b/src/app/api/integrations/fitbit/test/route.ts @@ -0,0 +1,132 @@ +import type { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { getValidToken } from "@/lib/fitbit/sync"; +import { FITBIT_API_BASE } from "@/lib/fitbit/client"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +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 TIMEOUT_MS = 5_000; + +type CategorisedError = { code: string; message: string }; + +function categoriseHttpStatus(status: number): CategorisedError { + if (status === 401 || status === 403) { + return { + code: "credentials_rejected", + message: "Google Health rejected the credentials", + }; + } + if (status === 429) { + return { + code: "rate_limited", + message: "Google Health rate-limited the request", + }; + } + if (status >= 500) { + return { + code: "upstream_error", + message: "Google Health returned a server error", + }; + } + return { + code: "connection_failed", + message: "Google Health connection failed", + }; +} + +export const POST = apiHandler(async (request: NextRequest) => { + void request; + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.fitbit.test" } }); + + const rl = await checkRateLimit(`fitbit-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const tokenInfo = await getValidToken(user.id); + if (!tokenInfo) { + return apiError("Google Health not connected", 422, { + errorCode: "not_configured", + }); + } + + const connection = await prisma.fitbitConnection.findUnique({ + where: { userId: user.id }, + select: { lastSyncedAt: true }, + }); + + const start = performance.now(); + + try { + const res = await safeFetch( + FITBIT_PROFILE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${tokenInfo.accessToken}`, + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { + fitbit_test_status: res.status, + fitbit_test_code: cat.code, + fitbit_test_latency_ms: latencyMs, + }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ + ok: true, + lastSyncedAt: connection?.lastSyncedAt ?? null, + latencyMs, + }); + } catch (e) { + const err = e as Error; + // safeFetch wraps timeouts into SafeFetchError{kind:"timeout"}; + // legacy AbortError name is preserved for compatibility with any + // future direct-fetch path that still reaches this catch. + const isAbort = + (e instanceof SafeFetchError && e.kind === "timeout") || + err.name === "AbortError"; + const code = isAbort ? "timeout" : "connection_failed"; + annotate({ + meta: { + fitbit_test_code: code, + fitbit_test_error: redact(err.message).slice(0, 300), + }, + }); + return apiError( + isAbort + ? "Google Health request timed out" + : "Google Health connection failed", + 502, + { errorCode: code }, + ); + } +}); + +// Strip any `Bearer ` substring before annotating; the catch path can +// see DNS / TLS errors that include the request init via err.cause in some +// runtimes, and we never want a token in Loki. +function redact(text: string): string { + return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]"); +} diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index dee8ec44b..128ae54d4 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -29,41 +29,60 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "integrations.status" } }); - const [withingsStatus, moodLogStatus, whoopStatus, dbUser, withingsConn, whoopConn] = - await Promise.all([ - getIntegrationStatus(user.id, "withings"), - getIntegrationStatus(user.id, "moodlog"), - getIntegrationStatus(user.id, "whoop"), - prisma.user.findUnique({ - where: { id: user.id }, - select: { - withingsClientIdEncrypted: true, - withingsClientSecretEncrypted: true, - whoopClientIdEncrypted: true, - whoopClientSecretEncrypted: true, - moodLogUrlEncrypted: true, - moodLogApiKeyEncrypted: true, - moodLogEnabled: true, - moodLogLastSyncedAt: true, - }, - }), - prisma.withingsConnection.findUnique({ - where: { userId: user.id }, - select: { - tokenExpiresAt: true, - lastSyncedAt: true, - createdAt: true, - }, - }), - prisma.whoopConnection.findUnique({ - where: { userId: user.id }, - select: { - tokenExpiresAt: true, - lastSyncedAt: true, - createdAt: true, - }, - }), - ]); + const [ + withingsStatus, + moodLogStatus, + whoopStatus, + fitbitStatus, + dbUser, + withingsConn, + whoopConn, + fitbitConn, + ] = await Promise.all([ + getIntegrationStatus(user.id, "withings"), + getIntegrationStatus(user.id, "moodlog"), + getIntegrationStatus(user.id, "whoop"), + getIntegrationStatus(user.id, "fitbit"), + prisma.user.findUnique({ + where: { id: user.id }, + select: { + withingsClientIdEncrypted: true, + withingsClientSecretEncrypted: true, + whoopClientIdEncrypted: true, + whoopClientSecretEncrypted: true, + fitbitClientIdEncrypted: true, + fitbitClientSecretEncrypted: true, + moodLogUrlEncrypted: true, + moodLogApiKeyEncrypted: true, + moodLogEnabled: true, + moodLogLastSyncedAt: true, + }, + }), + prisma.withingsConnection.findUnique({ + where: { userId: user.id }, + select: { + tokenExpiresAt: true, + lastSyncedAt: true, + createdAt: true, + }, + }), + prisma.whoopConnection.findUnique({ + where: { userId: user.id }, + select: { + tokenExpiresAt: true, + lastSyncedAt: true, + createdAt: true, + }, + }), + prisma.fitbitConnection.findUnique({ + where: { userId: user.id }, + select: { + tokenExpiresAt: true, + lastSyncedAt: true, + createdAt: true, + }, + }), + ]); const now = Date.now(); @@ -106,6 +125,19 @@ export const GET = apiHandler(async () => { ? whoopConn.tokenExpiresAt.getTime() <= now : null, } satisfies IntegrationViewModel & WhoopExtras, + { + ...fitbitStatus, + configured: + !!dbUser?.fitbitClientIdEncrypted && + !!dbUser?.fitbitClientSecretEncrypted, + connected: !!fitbitConn, + connectedAt: fitbitConn?.createdAt?.toISOString() ?? null, + legacyLastSyncedAt: fitbitConn?.lastSyncedAt?.toISOString() ?? null, + tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null, + tokenExpired: fitbitConn + ? fitbitConn.tokenExpiresAt.getTime() <= now + : null, + } satisfies IntegrationViewModel & FitbitExtras, ], }); }); @@ -141,3 +173,12 @@ interface WhoopExtras { tokenExpiresAt: string | null; tokenExpired: boolean | null; } + +interface FitbitExtras { + configured: boolean; + connected: boolean; + connectedAt: string | null; + legacyLastSyncedAt: string | null; + tokenExpiresAt: string | null; + tokenExpired: boolean | null; +} diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index f0d38f124..0b82f5539 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -163,8 +163,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }); const html = render(); - // Exactly one pill per card → 3 pills total (Withings, moodLog, WHOOP). - expect(count(html, 'data-testid="integration-status-pill"')).toBe(3); + // Exactly one pill per card → 4 pills total (Withings, moodLog, WHOOP, + // Fitbit / Google Health). + expect(count(html, 'data-testid="integration-status-pill"')).toBe(4); // The redundant banner from v1.4.15 is gone. expect(html).not.toContain('data-testid="integration-status-banner"'); // Card-body "letzter Sync" repetition is gone — no @@ -354,7 +355,8 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { const html = render(); // Every integration card includes the section divider data-testid so the - // header → body separation is visually consistent. - expect(count(html, 'data-testid="integration-card-divider"')).toBe(3); + // header → body separation is visually consistent (Withings, moodLog, + // WHOOP, Fitbit / Google Health). + expect(count(html, 'data-testid="integration-card-divider"')).toBe(4); }); }); diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 710e7a555..f2ba32d85 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -6,6 +6,7 @@ import { Activity, AlertCircle, Download, + HeartPulse, Link2, Loader2, RefreshCw, @@ -60,7 +61,7 @@ interface GlobalServiceAvailability { // v1.4.19 Phase A5: the redundant in-card status banner is gone — the // IntegrationStatusPill now owns state + last-sync presentation, and // the actionable error message is shown inline above the action row. -type IntegrationKey = "withings" | "whoop" | "moodlog"; +type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog"; type IntegrationState = | "connected" | "error_transient" @@ -196,6 +197,7 @@ export function IntegrationsSection() { const moodLogEnabled = globalServices?.moodLogGlobal ?? true; const withingsViewModel = pickStatus(integrationStatus, "withings"); const whoopViewModel = pickStatus(integrationStatus, "whoop"); + const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); const moodLogViewModel = pickStatus(integrationStatus, "moodlog"); return ( @@ -220,6 +222,10 @@ export function IntegrationsSection() { viewModel={withingsViewModel} /> + {moodLogEnabled && } ); @@ -1122,6 +1128,445 @@ function WhoopCard({ ); } +// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a +// BYO-key credentials form first, then an OAuth connect, then the +// sync/test/disconnect action row + parked-resume banner. Status reads from the +// dedicated /api/fitbit/status (queryKeys.fitbitStatus); the pill/error/parked +// state comes off the cross-integration envelope view-model like WHOOP. +function FitbitCard({ + isAuthenticated, + viewModel, +}: { + isAuthenticated: boolean; + viewModel: IntegrationStatusViewModel | undefined; +}) { + const { t } = useTranslations(); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( + null, + ); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [credsSaving, setCredsSaving] = useState(false); + const [credsMsg, setCredsMsg] = useState(null); + const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( + null, + ); + const queryClient = useQueryClient(); + + const { data: status } = useQuery({ + queryKey: queryKeys.fitbitStatus(), + queryFn: async () => { + const res = await fetch("/api/fitbit/status"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as { + connected: boolean; + configured: boolean; + lastSyncedAt?: string | null; + connectedAt?: string; + tokenExpired?: boolean; + backfillCompleted?: boolean; + scope?: string | null; + }; + }, + enabled: isAuthenticated, + }); + + const disconnect = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/fitbit/disconnect", { method: "POST" }); + if (!res.ok) throw new Error("Failed"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + }, + }); + + // Clear a parked integration via the resume endpoint. The CTA is + // rendered inside the parked banner below; success invalidates both + // the per-card status (so the pill flips back to connected + // immediately) and the cross-integration envelope (so any other view + // picks up the change on its next focus). + const resume = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/integrations/fitbit/resume", { + method: "POST", + }); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as { + resumed: boolean; + wasParked: boolean; + }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + }, + }); + + async function handleSync(fullSync = false) { + setSyncing(true); + setSyncMsg(null); + setSyncMsgType(null); + try { + const res = await fetch("/api/fitbit/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + }); + const json = await res.json(); + if (res.ok) { + setSyncMsg( + t("settings.fitbitSyncResult", { count: json.data.imported }), + ); + setSyncMsgType("success"); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncMsg(json.error || t("settings.fitbitSyncFailed")); + setSyncMsgType("error"); + } + } catch { + setSyncMsg(t("settings.fitbitSyncFailed")); + setSyncMsgType("error"); + } finally { + setSyncing(false); + } + } + + async function handleSaveCredentials(e: React.FormEvent) { + e.preventDefault(); + setCredsSaving(true); + setCredsMsg(null); + setCredsMsgType(null); + + try { + const res = await fetch("/api/fitbit/credentials", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + + if (res.ok) { + setCredsMsg(t("settings.fitbitCredentialsSaved")); + setCredsMsgType("success"); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); + } else { + try { + const json = await res.json(); + setCredsMsg(json.error || t("settings.savingError")); + } catch { + setCredsMsg(t("settings.savingError")); + } + setCredsMsgType("error"); + } + } catch { + setCredsMsg(t("common.networkError")); + setCredsMsgType("error"); + } + setCredsSaving(false); + } + + const pillState: IntegrationPillState = status?.connected + ? pillStateFor(viewModel) + : "disconnected"; + const pillLastSyncAt = + status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; + const errorMessage = + (pillState === "error" || pillState === "parked") && viewModel?.lastError + ? viewModel.lastError + : null; + + return ( +
+
+
+ +

{t("settings.fitbit")}

+ + {t("settings.fitbitTag")} + +
+ +
+

+ {t("settings.fitbitDescription")} +

+

+ {t("settings.fitbitOverlapNote")} +

+ +
+ +
+ {errorMessage && } + {/* Parked-integration resume CTA. Surfaces only when the row + state is `parked` (>24h of persistent failures). The button + POSTs to /api/integrations/fitbit/resume which calls + `resumeIntegrationFromPark`; on success the per-card status + invalidates and the pill flips back to connected without a + page refresh. */} + {pillState === "parked" && ( +
+ + {t("settings.integrationPill.parkedReconnect")} + + +
+ )} + {resume.isError && ( +

+ {t("settings.integrationPill.resumeError")} +

+ )} + {resume.isSuccess && resume.data?.wasParked && ( +

+ {t("settings.integrationPill.resumeSuccess")} +

+ )} + +
+

+ {t("settings.fitbitCredentials")} +

+

+ {t("settings.fitbitCredentialsHelp")} +

+
+
+
+ + setClientId(e.target.value)} + placeholder={ + status?.configured + ? t("settings.fitbitCredentialsSavedPlaceholder") + : t("settings.fitbitClientId") + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
+
+ + setClientSecret(e.target.value)} + placeholder={ + status?.configured + ? t("settings.fitbitCredentialsSavedPlaceholderSecret") + : t("settings.fitbitClientSecret") + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +
+
+
+ +
+ {credsMsg && ( +

+ {credsMsg} +

+ )} +
+
+ + {status?.connected ? ( + <> +
+ + + + + + + + + {t("settings.fitbitFullSyncTitle")} + + + {t("settings.fitbitFullSyncDescription")} + + + + {t("common.cancel")} + handleSync(true)}> + {t("settings.fitbitSynchronize")} + + + + + + + + + + + + + {t("settings.fitbitDisconnectTitle")} + + + {t("settings.fitbitDisconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t("settings.fitbitDisconnect")} + + + + +
+ {status?.backfillCompleted === false && ( +

+ {t("settings.fitbitBackfillInProgress")} +

+ )} + {syncMsg && ( +

+ {syncMsg} +

+ )} + + ) : status?.configured ? ( + + ) : ( +
+ {t("settings.fitbitNoCredentials")} +
+ )} +
+
+ ); +} + function MoodLogCard({ viewModel, }: {