feat(fitbit): add the Google Health settings card with connection test and resume

Give the Google Health (Fitbit & Pixel) integration the same settings-card
parity WHOOP and Withings already have. It is a BYO-key provider — the user
pastes their own Google OAuth client id/secret, then connects — so the card
mirrors the WHOOP flow rather than the server-brokered Withings one.

- New POST /api/integrations/fitbit/test does a live, rate-limited
  (5/60s per user) probe against the Google Health profile endpoint via
  the refreshed token from getValidToken, returning { ok, latencyMs,
  lastSyncedAt } and the same error-code union WHOOP uses. The Bearer
  token and upstream host are scrubbed before any annotation.
- New POST /api/integrations/fitbit/resume clears a parked integration
  via resumeIntegrationFromPark, rate-limited to match the test route.
- FitbitCard renders the credentials form, OAuth connect, sync now /
  sync all data / test connection / disconnect action row, the parked
  resume banner, and a backfill-in-progress line. Status reads from the
  existing /api/fitbit/status; the pill/error/parked state comes off the
  cross-integration envelope, which now carries a fitbit entry.
- Card strings added under settings.fitbit* across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 22:37:58 +02:00
1 parent 14469a6de9
commit f44dccb828
12 files changed
+1078 -40

No files matched your search

+25
View File
@@ -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": {
+25
View File
@@ -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": {
+25
View File
@@ -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": {
+25
View File
@@ -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": {
+25
View File
@@ -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": {
+25
View File
@@ -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": {
@@ -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 });
});
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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<T> {
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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/);
});
});
@@ -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 <token>` 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]");
}
+76 -35
View File
@@ -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;
}
@@ -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);
});
});
@@ -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}
/>
<WhoopCard isAuthenticated={isAuthenticated} viewModel={whoopViewModel} />
<FitbitCard
isAuthenticated={isAuthenticated}
viewModel={fitbitViewModel}
/>
{moodLogEnabled && <MoodLogCard viewModel={moodLogViewModel} />}
</section>
);
@@ -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<string | null>(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<string | null>(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 (
<div
data-testid="fitbit-card"
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<HeartPulse className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.fitbit")}</h2>
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.fitbitTag")}
</span>
</div>
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.fitbitDescription")}
</p>
<p className="text-muted-foreground/80 mt-2 text-xs">
{t("settings.fitbitOverlapNote")}
</p>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4">
{errorMessage && <IntegrationErrorMessage message={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" && (
<div
data-testid="fitbit-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="fitbit-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
data-testid="fitbit-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
data-testid="fitbit-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
{t("settings.fitbitCredentials")}
</h3>
<p className="text-muted-foreground text-xs">
{t("settings.fitbitCredentialsHelp")}
</p>
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fitbit-clientid">
{t("settings.fitbitClientId")}
</Label>
<Input
id="fitbit-clientid"
value={clientId}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fitbit-secret">
{t("settings.fitbitClientSecret")}
</Label>
<PasswordInput
id="fitbit-secret"
value={clientSecret}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.fitbitSaveCredentials")}
</Button>
</div>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.fitbitSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.fitbitFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.fitbitSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/fitbit/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.fitbitDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => disconnect.mutate()}
>
{t("settings.fitbitDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.fitbitBackfillInProgress")}
</p>
)}
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{syncMsg}
</p>
)}
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/fitbit/connect";
}}
>
<Link2 className="mr-2 h-4 w-4" />
{t("settings.fitbitConnect")}
</Button>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
{t("settings.fitbitNoCredentials")}
</div>
)}
</div>
</div>
);
}
function MoodLogCard({
viewModel,
}: {