mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
14469a6de9
commit
f44dccb828
12 files changed
+1078
-40
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
Reference in new issue
Block a user